feat(blog): جداسازی دوزبانهٔ اسلاگ/کلیدواژه/نویسنده/تگ‌ها/متا + میگریشن ۰۰۰۲، رفع «دقیقه مطالعه دقیقه»، فونت مقاله ۱۵/۱۷px، جدول تیپ‌تپ در ادیتور، مسیر /en/blog با slug_en

This commit is contained in:
aramesh-site deploy
2026-10-06 10:11:55 +00:00
parent 426dc783df
commit 61a2e95278
11 changed files with 862 additions and 197 deletions
+14
View File
@@ -119,10 +119,24 @@ class Article(Base):
faqs: Mapped[list] = mapped_column(JSON, default=list) faqs: Mapped[list] = mapped_column(JSON, default=list)
faqs_en: Mapped[Optional[list]] = mapped_column(JSON) # NULL یعنی بدون ترجمه — برگشت به فارسی faqs_en: Mapped[Optional[list]] = mapped_column(JSON) # NULL یعنی بدون ترجمه — برگشت به فارسی
author_id: Mapped[int | None] = mapped_column(Integer, nullable=True) # نویسندهٔ creator — برای مقاله‌های ادمین NULL author_id: Mapped[int | None] = mapped_column(Integer, nullable=True) # نویسندهٔ creator — برای مقاله‌های ادمین NULL
# نام نویسنده (رشتهٔ آزاد) — سربرگ مقاله و اسکیمای Article (نوع Person برای سیگنال E-E-A-T)
author: Mapped[str] = mapped_column(String(128), default="")
# draft | published — پیش‌نویس فقط برای ادمین دیده می‌شود # draft | published — پیش‌نویس فقط برای ادمین دیده می‌شود
status: Mapped[str] = mapped_column(String(16), default="published") status: Mapped[str] = mapped_column(String(16), default="published")
# کلمه کلیدی هدف چک‌لیست سئو — راهنمای نوشتن، در سایت نمایش داده نمی‌شود # کلمه کلیدی هدف چک‌لیست سئو — راهنمای نوشتن، در سایت نمایش داده نمی‌شود
seo_keyphrase: Mapped[str] = mapped_column(String(128), default="") seo_keyphrase: Mapped[str] = mapped_column(String(128), default="")
# توضیحات سئوی اختصاصی مقاله — در تگ description و اسکیما، در اولویت بر چکیده
meta_description: Mapped[str] = mapped_column(Text, default="")
meta_description_en: Mapped[str] = mapped_column(Text, default="")
# نشانی انگلیسی مقاله — مسیر /en/blog/...؛ خالی یعنی همان اسلاگ فارسی
slug_en: Mapped[str] = mapped_column(String(128), default="", index=True)
# کلمه کلیدی هدف نسخه انگلیسی — چک‌لیست سئو
seo_keyphrase_en: Mapped[str] = mapped_column(String(128), default="")
# نام نویسنده نسخه انگلیسی
author_en: Mapped[str] = mapped_column(String(128), default="")
# هشتگ‌ها/برچسب‌ها — هر زبان جدا
tags: Mapped[list] = mapped_column(JSON, default=list)
tags_en: Mapped[list] = mapped_column(JSON, default=list)
class Testimonial(Base): class Testimonial(Base):
+25
View File
@@ -98,6 +98,15 @@ class ArticleOut(ORMModel):
faqs_en: Optional[list[dict]] = None faqs_en: Optional[list[dict]] = None
status: str status: str
seo_keyphrase: str seo_keyphrase: str
# نام نویسنده و توضیحات سئوی اختصاصی — سربرگ مقاله، تگ description و اسکیمای Article
author: str = ""
meta_description: str = ""
meta_description_en: str = ""
slug_en: str = ""
seo_keyphrase_en: str = ""
author_en: str = ""
tags: list[str] = []
tags_en: list[str] = []
class TestimonialOut(ORMModel): class TestimonialOut(ORMModel):
@@ -517,6 +526,14 @@ class ArticleIn(BaseModel):
faqs_en: list[dict] = [] faqs_en: list[dict] = []
status: Literal["draft", "published"] = "draft" status: Literal["draft", "published"] = "draft"
seo_keyphrase: str = Field(default="", max_length=128) seo_keyphrase: str = Field(default="", max_length=128)
seo_keyphrase_en: str = Field(default="", max_length=128)
slug_en: str = Field(default="", max_length=128)
author: str = Field(default="", max_length=128)
author_en: str = Field(default="", max_length=128)
tags: list[str] = []
tags_en: list[str] = []
meta_description: str = ""
meta_description_en: str = ""
_validate_body = field_validator("body")(validate_doc_body) _validate_body = field_validator("body")(validate_doc_body)
_validate_body_en = field_validator("body_en")(validate_doc_body) _validate_body_en = field_validator("body_en")(validate_doc_body)
@@ -823,6 +840,14 @@ class ArticlePatch(BaseModel):
faqs_en: Optional[list[dict]] = None faqs_en: Optional[list[dict]] = None
status: Optional[Literal["draft", "published"]] = None status: Optional[Literal["draft", "published"]] = None
seo_keyphrase: Optional[str] = None seo_keyphrase: Optional[str] = None
seo_keyphrase_en: Optional[str] = None
slug_en: Optional[str] = None
author: Optional[str] = None
author_en: Optional[str] = None
tags: Optional[list[str]] = None
tags_en: Optional[list[str]] = None
meta_description: Optional[str] = None
meta_description_en: Optional[str] = None
_validate_body = field_validator("body")(validate_doc_body) _validate_body = field_validator("body")(validate_doc_body)
_validate_body_en = field_validator("body_en")(validate_doc_body) _validate_body_en = field_validator("body_en")(validate_doc_body)
TestimonialPatch = _make_patch("TestimonialPatch", TestimonialIn) TestimonialPatch = _make_patch("TestimonialPatch", TestimonialIn)
+638 -161
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,43 @@
"""ستون‌های چندزبانه مقاله — اسلاگ/کلیدواژه/نویسنده انگلیسی و تگ‌های دو زبانه.
فیلدهای «نویسنده» و «متا دیسکریپشن» در ادیتور تا پیش از این نسخه یا مشترک
بودند یا بی‌اثر؛ از این نسخه هر زبان ستون خودش را دارد.
Revision ID: 0002
Revises: 0001
Create Date: 2026-10-01
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0002"
down_revision: Union[str, None] = "0001"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
articles = sa.table("articles")
columns = sa.inspect(op.get_bind()).get_columns("articles")
existing = {c["name"] for c in columns}
if "slug_en" not in existing:
op.add_column("articles", sa.Column("slug_en", sa.String(128), nullable=False, server_default=""))
if "seo_keyphrase_en" not in existing:
op.add_column("articles", sa.Column("seo_keyphrase_en", sa.String(128), nullable=False, server_default=""))
if "author_en" not in existing:
op.add_column("articles", sa.Column("author_en", sa.String(128), nullable=False, server_default=""))
if "tags" not in existing:
op.add_column("articles", sa.Column("tags", sa.JSON(), nullable=False, server_default="[]"))
if "tags_en" not in existing:
op.add_column("articles", sa.Column("tags_en", sa.JSON(), nullable=False, server_default="[]"))
_ = articles
def downgrade() -> None:
op.drop_column("articles", "tags_en")
op.drop_column("articles", "tags")
op.drop_column("articles", "author_en")
op.drop_column("articles", "seo_keyphrase_en")
op.drop_column("articles", "slug_en")
+57
View File
@@ -11,6 +11,10 @@
"@emotion/react": "^11.13.3", "@emotion/react": "^11.13.3",
"@tiptap/extension-image": "^3.31.3", "@tiptap/extension-image": "^3.31.3",
"@tiptap/extension-link": "^3.31.3", "@tiptap/extension-link": "^3.31.3",
"@tiptap/extension-table": "^3.31.3",
"@tiptap/extension-table-cell": "^3.31.3",
"@tiptap/extension-table-header": "^3.31.3",
"@tiptap/extension-table-row": "^3.31.3",
"@tiptap/pm": "^3.31.3", "@tiptap/pm": "^3.31.3",
"@tiptap/react": "^3.31.3", "@tiptap/react": "^3.31.3",
"@tiptap/starter-kit": "^3.31.3", "@tiptap/starter-kit": "^3.31.3",
@@ -1866,6 +1870,59 @@
"@tiptap/core": "3.31.3" "@tiptap/core": "3.31.3"
} }
}, },
"node_modules/@tiptap/extension-table": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-3.31.3.tgz",
"integrity": "sha512-7cnVPHhdiGGeauYqca6JVyPLTqZbqFEEk9nn2e2E8+fBo6zVtV07AktJBqth/XEzjZxcUmGxjoeuWYAisWjUHg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.31.3",
"@tiptap/pm": "3.31.3"
}
},
"node_modules/@tiptap/extension-table-cell": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-cell/-/extension-table-cell-3.31.3.tgz",
"integrity": "sha512-5nueKR/p/IX6B4etWqHjyRsjNfDf6dJZaRgfw1/1l90CJe2h3tTZk4JbUKWN3Mo4FzZuYKzmU3fxcZ8pSVOeJQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-table": "3.31.3"
}
},
"node_modules/@tiptap/extension-table-header": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-header/-/extension-table-header-3.31.3.tgz",
"integrity": "sha512-sstVtNQiBYX4P16vlhwBYPsthxDnodHWQfGq0EUQM40Z7H796Lgh9t1qYtu8yARQuHKzvINRwJCSy6DZ8YTDtQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-table": "3.31.3"
}
},
"node_modules/@tiptap/extension-table-row": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-row/-/extension-table-row-3.31.3.tgz",
"integrity": "sha512-up6tDK+hYVTFDeJ3XqKO0WJqBzyXNfGxMoimo5h83jrq9dcNh0oPCOzglZ7rdo8GH9I3VJ2fNKxxU2Eg2HcMoQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-table": "3.31.3"
}
},
"node_modules/@tiptap/extension-text": { "node_modules/@tiptap/extension-text": {
"version": "3.31.3", "version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.31.3.tgz", "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.31.3.tgz",
+4
View File
@@ -14,6 +14,10 @@
"@emotion/react": "^11.13.3", "@emotion/react": "^11.13.3",
"@tiptap/extension-image": "^3.31.3", "@tiptap/extension-image": "^3.31.3",
"@tiptap/extension-link": "^3.31.3", "@tiptap/extension-link": "^3.31.3",
"@tiptap/extension-table": "^3.31.3",
"@tiptap/extension-table-cell": "^3.31.3",
"@tiptap/extension-table-header": "^3.31.3",
"@tiptap/extension-table-row": "^3.31.3",
"@tiptap/pm": "^3.31.3", "@tiptap/pm": "^3.31.3",
"@tiptap/react": "^3.31.3", "@tiptap/react": "^3.31.3",
"@tiptap/starter-kit": "^3.31.3", "@tiptap/starter-kit": "^3.31.3",
+21 -16
View File
@@ -14,7 +14,7 @@ import { CategorySelect, LuxeTagsSelect } from '../components/admin/LuxeSelect'
import type { CategoryOption } from '../components/admin/LuxeSelect' import type { CategoryOption } from '../components/admin/LuxeSelect'
import { ARTICLE_CATEGORIES } from './entities' import { ARTICLE_CATEGORIES } from './entities'
import { FEATURED_IMAGE_ALT_SUFFIX } from '../data/constants' import { FEATURED_IMAGE_ALT_SUFFIX } from '../data/constants'
import { formatReadingTime } from '../utils/readingTime' import { formatReadingTime, readingTimeMinutes } from '../utils/readingTime'
import { toArticleBodyDoc } from '../utils/tiptapDoc' import { toArticleBodyDoc } from '../utils/tiptapDoc'
import type { Article, ArticleBodyDoc } from '../data/site' import type { Article, ArticleBodyDoc } from '../data/site'
@@ -341,6 +341,9 @@ export function ArticleWritePage() {
const metaLength = (draft.meta_description ?? '').trim().length const metaLength = (draft.meta_description ?? '').trim().length
// در زبان انگلیسی، فیلدهای متنی به ستون‌های _en می‌روند؛ اسلاگ/تصویر/وضعیت مشترک‌اند // در زبان انگلیسی، فیلدهای متنی به ستون‌های _en می‌روند؛ اسلاگ/تصویر/وضعیت مشترک‌اند
const suf = <N extends string>(name: N) => `${name}${editLang === 'en' ? '_en' : ''}` as keyof Draft const suf = <N extends string>(name: N) => `${name}${editLang === 'en' ? '_en' : ''}` as keyof Draft
// مقدار فعال فیلد دوزبانه — برای value ورودی‌ها و چک‌لیست سئو
const activeVal = (name: 'title' | 'slug' | 'author' | 'seo_keyphrase' | 'meta_description' | 'excerpt'): string =>
String((editLang === 'en' ? draft[`${name}_en` as keyof Draft] : draft[name]) ?? '')
// متن جایگزین تصویر شاخص — با watch('title') به‌صورت زنده ساخته می‌شود // متن جایگزین تصویر شاخص — با watch('title') به‌صورت زنده ساخته می‌شود
const imageAltFallback = [watchedTitle.trim(), FEATURED_IMAGE_ALT_SUFFIX] const imageAltFallback = [watchedTitle.trim(), FEATURED_IMAGE_ALT_SUFFIX]
@@ -410,7 +413,7 @@ export function ArticleWritePage() {
</button> </button>
)} )}
<Link <Link
to={`${editLang === 'en' ? '/en' : ''}/blog/${draft.slug}`} to={`${editLang === 'en' ? '/en' : ''}/blog/${editLang === 'en' ? draft.slug_en || draft.slug : draft.slug}`}
data-admin-link 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" 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"
> >
@@ -496,11 +499,12 @@ export function ArticleWritePage() {
doc={(editLang === 'en' ? draft.body_en : draft.body) as unknown as Record<string, unknown>} doc={(editLang === 'en' ? draft.body_en : draft.body) as unknown as Record<string, unknown>}
onChange={(json) => { onChange={(json) => {
// زمان مطالعه به‌صورت پویا از متن سند محاسبه می‌شود — به زبان جاری // زمان مطالعه به‌صورت پویا از متن سند محاسبه می‌شود — به زبان جاری
const minutes = formatReadingTime(json) const minutes = readingTimeMinutes(json)
if (editLang === 'en') { if (editLang === 'en') {
queueSave({ [suf('body')]: json, [suf('read')]: `${minutes} min read` }) queueSave({ [suf('body')]: json, [suf('read')]: `${minutes} min read` })
} else { } else {
queueSave({ body: json, read: `${minutes} دقیقه` }) // formatReadingTime خودش برچسب «مطالعه» را دارد — پسوند اضافه ممنوع
queueSave({ body: json, read: formatReadingTime(json) })
} }
}} }}
/> />
@@ -524,8 +528,8 @@ export function ArticleWritePage() {
<textarea <textarea
id="meta-description" id="meta-description"
rows={3} rows={3}
value={draft.meta_description ?? ''} value={activeVal('meta_description')}
onChange={(e) => queueSave({ meta_description: e.target.value })} onChange={(e) => queueSave({ [suf('meta_description')]: e.target.value })}
placeholder="توضیح جذابی برای گوگل بنویسید که شامل کلمه کلیدی باشد…" placeholder="توضیح جذابی برای گوگل بنویسید که شامل کلمه کلیدی باشد…"
className="mt-3 w-full resize-y border border-ivory/15 bg-ink px-4 py-3 text-sm font-light leading-8 text-ivory focus:border-copper focus:outline-none" className="mt-3 w-full resize-y border border-ivory/15 bg-ink px-4 py-3 text-sm font-light leading-8 text-ivory focus:border-copper focus:outline-none"
/> />
@@ -548,11 +552,12 @@ export function ArticleWritePage() {
{/* چکیده و متادیتا */} {/* چکیده و متادیتا */}
<SeoChecklist <SeoChecklist
input={{ input={{
title: (editLang === 'en' ? draft.title_en : draft.title) ?? '', title: activeVal('title'),
slug: draft.slug, slug: activeVal('slug'),
excerpt: draft.meta_description || (editLang === 'en' ? draft.excerpt_en : draft.excerpt) || '', excerpt: activeVal('meta_description') || activeVal('excerpt'),
body: (editLang === 'en' ? draft.body_en : draft.body) ?? draft.body, body: (editLang === 'en' ? draft.body_en : draft.body) ?? draft.body,
keyphrase: draft.seo_keyphrase, keyphrase: activeVal('seo_keyphrase'),
author: draft.author,
}} }}
/> />
@@ -564,8 +569,8 @@ export function ArticleWritePage() {
<input <input
id="seo-keyphrase" id="seo-keyphrase"
type="text" type="text"
value={draft.seo_keyphrase} value={activeVal('seo_keyphrase')}
onChange={(e) => queueSave({ seo_keyphrase: e.target.value })} onChange={(e) => queueSave({ [suf('seo_keyphrase')]: e.target.value })}
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" 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> </div>
@@ -585,7 +590,7 @@ export function ArticleWritePage() {
<input <input
id="article-author" id="article-author"
type="text" type="text"
{...textField('author')} {...textField(suf('author'))}
placeholder="نام و نام خانوادگی نویسنده" placeholder="نام و نام خانوادگی نویسنده"
className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory placeholder:text-ivory/30 focus:border-copper focus:outline-none" className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory placeholder:text-ivory/30 focus:border-copper focus:outline-none"
/> />
@@ -628,8 +633,8 @@ export function ArticleWritePage() {
<label className="text-xs font-light text-ivory/50">هشتگ‌ها / برچسب‌ها</label> <label className="text-xs font-light text-ivory/50">هشتگ‌ها / برچسب‌ها</label>
<div className="mt-2"> <div className="mt-2">
<LuxeTagsSelect <LuxeTagsSelect
value={normalizeTags(draft.tags)} value={normalizeTags(editLang === 'en' ? draft.tags_en : draft.tags)}
onChange={(tags) => queueSave({ tags })} onChange={(tags) => queueSave({ [suf('tags')]: tags })}
/> />
</div> </div>
</div> </div>
@@ -664,7 +669,7 @@ export function ArticleWritePage() {
<input <input
type="text" type="text"
dir="ltr" dir="ltr"
{...textField('slug')} {...textField(suf('slug'))}
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" 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> </div>
@@ -1,6 +1,10 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { EditorContent, useEditor } from '@tiptap/react' import { EditorContent, useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit' import StarterKit from '@tiptap/starter-kit'
import { Table } from '@tiptap/extension-table'
import TableRow from '@tiptap/extension-table-row'
import TableHeader from '@tiptap/extension-table-header'
import TableCell from '@tiptap/extension-table-cell'
import Link from '@tiptap/extension-link' import Link from '@tiptap/extension-link'
import { Mark, mergeAttributes } from '@tiptap/core' import { Mark, mergeAttributes } from '@tiptap/core'
import { import {
@@ -20,6 +24,7 @@ import {
QuoteIcon, QuoteIcon,
Redo2Icon, Redo2Icon,
StrikethroughIcon, StrikethroughIcon,
TableIcon,
UnderlineIcon, UnderlineIcon,
Undo2Icon, Undo2Icon,
} from 'lucide-react' } from 'lucide-react'
@@ -60,6 +65,10 @@ export const ARTICLE_EXTENSIONS = [
HTMLAttributes: { rel: 'noopener noreferrer', target: '_blank' }, HTMLAttributes: { rel: 'noopener noreferrer', target: '_blank' },
isAllowedUri: (uri: string) => isAllowedHref(uri), isAllowedUri: (uri: string) => isAllowedHref(uri),
}), }),
Table.configure({ resizable: true }),
TableRow,
TableHeader,
TableCell,
LegacySmallSizeMark, LegacySmallSizeMark,
EnglishText, EnglishText,
BidiTypography, BidiTypography,
@@ -429,6 +438,16 @@ export function ArticleEditor({ doc, onChange, contentKey }: ArticleEditorProps)
<Divider /> <Divider />
<ToolButton
label="درج جدول"
active={editor.isActive('table')}
onClick={() => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()}
>
<TableIcon className="h-4 w-4" />
</ToolButton>
<ToolButton label="حذف جدول" onClick={() => editor.chain().focus().deleteTable().run()}>
<TableIcon className="h-4 w-4 rotate-45" />
</ToolButton>
<ToolButton label="بولد" active={editor.isActive('bold')} onClick={() => editor.chain().focus().toggleBold().run()}> <ToolButton label="بولد" active={editor.isActive('bold')} onClick={() => editor.chain().focus().toggleBold().run()}>
<BoldIcon className="h-4 w-4" /> <BoldIcon className="h-4 w-4" />
</ToolButton> </ToolButton>
+5 -1
View File
@@ -49,7 +49,7 @@ export type ArticleStatus = 'draft' | 'published';
export type Article = { export type Article = {
id: number; id: number;
slug: string; title: string; title_en?: string; date: string; date_en?: string; category: string; category_en?: string; slug: string; slug_en?: string; title: string; title_en?: string; date: string; date_en?: string; category: string; category_en?: string;
image: string; image: string;
read: string; read_en?: string; excerpt: string; excerpt_en?: string; read: string; read_en?: string; excerpt: string; excerpt_en?: string;
/** پرسش‌وپاسخ‌های پایان مقاله — سئوی FAQPage (faqs_en خالی → برگشت به فارسی) */ /** پرسش‌وپاسخ‌های پایان مقاله — سئوی FAQPage (faqs_en خالی → برگشت به فارسی) */
@@ -63,16 +63,20 @@ export type Article = {
status: ArticleStatus; status: ArticleStatus;
/** کلمه کلیدی هدف چک‌لیست سئو — راهنمای نوشتن، در سایت نمایش داده نمی‌شود */ /** کلمه کلیدی هدف چک‌لیست سئو — راهنمای نوشتن، در سایت نمایش داده نمی‌شود */
seo_keyphrase: string; seo_keyphrase: string;
seo_keyphrase_en?: string;
/** توضیحات متای سئو — در اولویت بر چکیده برای تگ description */ /** توضیحات متای سئو — در اولویت بر چکیده برای تگ description */
meta_description?: string; meta_description?: string;
meta_description_en?: string;
/** متن جایگزین تصویر شاخص (Alt) */ /** متن جایگزین تصویر شاخص (Alt) */
image_alt?: string; image_alt?: string;
/** عنوان تصویر شاخص (Title) */ /** عنوان تصویر شاخص (Title) */
image_title?: string; image_title?: string;
/** نویسنده مقاله — در سربرگ مقاله زیر عنوان نمایش داده می‌شود */ /** نویسنده مقاله — در سربرگ مقاله زیر عنوان نمایش داده می‌شود */
author?: string; author?: string;
author_en?: string;
/** هشتگ‌ها/برچسب‌های مقاله */ /** هشتگ‌ها/برچسب‌های مقاله */
tags?: string[]; tags?: string[];
tags_en?: string[];
}; };
/** /**
+6 -17
View File
@@ -2,9 +2,10 @@
// فرانت‌اند هر دو زبان را در یک درخواست از GET /api/site می‌گیرد و سوییچ زبان // فرانت‌اند هر دو زبان را در یک درخواست از GET /api/site می‌گیرد و سوییچ زبان
// بی‌درنگ و بدون درخواست تازه انجام می‌شود. داده خام برای پنل مدیریت دست‌نخورده می‌ماند. // بی‌درنگ و بدون درخواست تازه انجام می‌شود. داده خام برای پنل مدیریت دست‌نخورده می‌ماند.
import type { Article, ArticleBodyDoc } from '../data/site' import type { Article } from '../data/site'
import type { SiteData } from '../api' import type { SiteData } from '../api'
import type { Lang } from './lang' import type { Lang } from './lang'
import { docHasText } from '../utils/tiptapDoc'
/** متن انگلیسی فقط وقتی جایگزین می‌شود که واقعاً چیزی نوشته شده باشد */ /** متن انگلیسی فقط وقتی جایگزین می‌شود که واقعاً چیزی نوشته شده باشد */
const s = (fa: string, en?: string | null): string => (en && en.trim() ? en : fa) const s = (fa: string, en?: string | null): string => (en && en.trim() ? en : fa)
@@ -12,22 +13,6 @@ const s = (fa: string, en?: string | null): string => (en && en.trim() ? en : fa
/** لیست انگلیسی فقط وقتی جایگزین می‌شود که غیرخالی باشد */ /** لیست انگلیسی فقط وقتی جایگزین می‌شود که غیرخالی باشد */
const list = <T,>(fa: T[], en?: T[] | null): T[] => (en && en.length ? en : fa) const list = <T,>(fa: T[], en?: T[] | null): T[] => (en && en.length ? en : fa)
/** سند TipTap انگلیسی فقط وقتی متن واقعی دارد جایگزین بدنه فارسی می‌شود */
function docHasText(doc: unknown): boolean {
if (!doc || typeof doc !== 'object') return false
const content = (doc as ArticleBodyDoc).content
if (!Array.isArray(content)) return false
let found = false
const walk = (node: unknown): void => {
if (!node || typeof node !== 'object' || found) return
const n = node as { type?: string; text?: string; content?: unknown[] }
if (n.type === 'text' && typeof n.text === 'string' && n.text.trim()) found = true
n.content?.forEach(walk)
}
content.forEach(walk)
return found
}
export function localizeSite(data: SiteData, lang: Lang): SiteData { export function localizeSite(data: SiteData, lang: Lang): SiteData {
if (lang === 'fa') return data if (lang === 'fa') return data
return { return {
@@ -93,13 +78,17 @@ export function localizeSite(data: SiteData, lang: Lang): SiteData {
})), })),
articles: data.articles.map((a: Article) => ({ articles: data.articles.map((a: Article) => ({
...a, ...a,
slug: s(a.slug, a.slug_en),
title: s(a.title, a.title_en), title: s(a.title, a.title_en),
date: s(a.date, a.date_en), date: s(a.date, a.date_en),
category: s(a.category, a.category_en), category: s(a.category, a.category_en),
read: s(a.read, a.read_en), read: s(a.read, a.read_en),
author: s(a.author ?? '', a.author_en),
excerpt: s(a.excerpt, a.excerpt_en), excerpt: s(a.excerpt, a.excerpt_en),
meta_description: s(a.meta_description ?? '', a.meta_description_en),
body: docHasText(a.body_en) ? a.body_en! : a.body, body: docHasText(a.body_en) ? a.body_en! : a.body,
faqs: list(a.faqs ?? [], a.faqs_en), faqs: list(a.faqs ?? [], a.faqs_en),
tags: list(a.tags ?? [], a.tags_en),
})), })),
testimonials: data.testimonials.map((tm) => ({ testimonials: data.testimonials.map((tm) => ({
...tm, ...tm,
+30 -2
View File
@@ -84,6 +84,7 @@ html[lang='en'] .font-pinar {
} }
html[lang='en'] .article-body { html[lang='en'] .article-body {
line-height: 1.8; line-height: 1.8;
font-size: 17px; /* وبلاگ انگلیسی دو سه درجه بزرگ‌تر از فارسی */
} }
html[lang='en'] .article-body p { html[lang='en'] .article-body p {
margin-top: 1.25rem; margin-top: 1.25rem;
@@ -106,7 +107,7 @@ html[lang='en'] .article-body h3 {
.article-body { .article-body {
line-height: 2.4; line-height: 2.4;
word-wrap: break-word; word-wrap: break-word;
font-size: 14px; /* متن اصلی ۱۴ تنظیم شد */ font-size: 15px; /* متن اصلی — یک درجه بزرگ‌تر از قبل */
} }
.article-body p { .article-body p {
margin-top: 1.75rem; margin-top: 1.75rem;
@@ -476,4 +477,31 @@ html[lang='en'] .article-body h3 {
to { to {
opacity: 1; opacity: 1;
} }
} }
/* ---------- جدول در بدنه مقاله و ویرایشگر ---------- */
.article-body table,
.ProseMirror table {
width: 100%;
border-collapse: collapse;
margin-top: 1.5rem;
font-size: 0.92em;
}
.article-body th,
.article-body td,
.ProseMirror th,
.ProseMirror td {
border: 1px solid rgba(245, 243, 239, 0.18);
padding: 0.5rem 0.75rem;
text-align: start;
vertical-align: top;
}
.article-body th,
.ProseMirror th {
background: rgba(213, 135, 76, 0.12);
font-weight: 700;
color: #f5f3ef;
}
.ProseMirror td > p,
.ProseMirror th > p {
margin: 0;
}