feat(blog): جداسازی دوزبانهٔ اسلاگ/کلیدواژه/نویسنده/تگها/متا + میگریشن ۰۰۰۲، رفع «دقیقه مطالعه دقیقه»، فونت مقاله ۱۵/۱۷px، جدول تیپتپ در ادیتور، مسیر /en/blog با slug_en
This commit is contained in:
@@ -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):
|
||||||
|
|||||||
@@ -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
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")
|
||||||
Generated
+57
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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[];
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user