feat(blog): جداسازی دوزبانهٔ اسلاگ/کلیدواژه/نویسنده/تگها/متا + میگریشن ۰۰۰۲، رفع «دقیقه مطالعه دقیقه»، فونت مقاله ۱۵/۱۷px، جدول تیپتپ در ادیتور، مسیر /en/blog با slug_en
This commit is contained in:
Generated
+57
@@ -11,6 +11,10 @@
|
||||
"@emotion/react": "^11.13.3",
|
||||
"@tiptap/extension-image": "^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/react": "^3.31.3",
|
||||
"@tiptap/starter-kit": "^3.31.3",
|
||||
@@ -1866,6 +1870,59 @@
|
||||
"@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": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.31.3.tgz",
|
||||
|
||||
@@ -14,6 +14,10 @@
|
||||
"@emotion/react": "^11.13.3",
|
||||
"@tiptap/extension-image": "^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/react": "^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 { ARTICLE_CATEGORIES } from './entities'
|
||||
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 type { Article, ArticleBodyDoc } from '../data/site'
|
||||
|
||||
@@ -341,6 +341,9 @@ 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
|
||||
// مقدار فعال فیلد دوزبانه — برای 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') بهصورت زنده ساخته میشود
|
||||
const imageAltFallback = [watchedTitle.trim(), FEATURED_IMAGE_ALT_SUFFIX]
|
||||
@@ -410,7 +413,7 @@ export function ArticleWritePage() {
|
||||
</button>
|
||||
)}
|
||||
<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
|
||||
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>}
|
||||
onChange={(json) => {
|
||||
// زمان مطالعه بهصورت پویا از متن سند محاسبه میشود — به زبان جاری
|
||||
const minutes = formatReadingTime(json)
|
||||
const minutes = readingTimeMinutes(json)
|
||||
if (editLang === 'en') {
|
||||
queueSave({ [suf('body')]: json, [suf('read')]: `${minutes} min read` })
|
||||
} else {
|
||||
queueSave({ body: json, read: `${minutes} دقیقه` })
|
||||
// formatReadingTime خودش برچسب «مطالعه» را دارد — پسوند اضافه ممنوع
|
||||
queueSave({ body: json, read: formatReadingTime(json) })
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -524,8 +528,8 @@ export function ArticleWritePage() {
|
||||
<textarea
|
||||
id="meta-description"
|
||||
rows={3}
|
||||
value={draft.meta_description ?? ''}
|
||||
onChange={(e) => queueSave({ meta_description: e.target.value })}
|
||||
value={activeVal('meta_description')}
|
||||
onChange={(e) => queueSave({ [suf('meta_description')]: e.target.value })}
|
||||
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"
|
||||
/>
|
||||
@@ -548,11 +552,12 @@ export function ArticleWritePage() {
|
||||
{/* چکیده و متادیتا */}
|
||||
<SeoChecklist
|
||||
input={{
|
||||
title: (editLang === 'en' ? draft.title_en : draft.title) ?? '',
|
||||
slug: draft.slug,
|
||||
excerpt: draft.meta_description || (editLang === 'en' ? draft.excerpt_en : draft.excerpt) || '',
|
||||
title: activeVal('title'),
|
||||
slug: activeVal('slug'),
|
||||
excerpt: activeVal('meta_description') || activeVal('excerpt'),
|
||||
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
|
||||
id="seo-keyphrase"
|
||||
type="text"
|
||||
value={draft.seo_keyphrase}
|
||||
onChange={(e) => queueSave({ seo_keyphrase: e.target.value })}
|
||||
value={activeVal('seo_keyphrase')}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
@@ -585,7 +590,7 @@ export function ArticleWritePage() {
|
||||
<input
|
||||
id="article-author"
|
||||
type="text"
|
||||
{...textField('author')}
|
||||
{...textField(suf('author'))}
|
||||
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"
|
||||
/>
|
||||
@@ -628,8 +633,8 @@ export function ArticleWritePage() {
|
||||
<label className="text-xs font-light text-ivory/50">هشتگها / برچسبها</label>
|
||||
<div className="mt-2">
|
||||
<LuxeTagsSelect
|
||||
value={normalizeTags(draft.tags)}
|
||||
onChange={(tags) => queueSave({ tags })}
|
||||
value={normalizeTags(editLang === 'en' ? draft.tags_en : draft.tags)}
|
||||
onChange={(tags) => queueSave({ [suf('tags')]: tags })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -664,7 +669,7 @@ export function ArticleWritePage() {
|
||||
<input
|
||||
type="text"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { EditorContent, useEditor } from '@tiptap/react'
|
||||
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 { Mark, mergeAttributes } from '@tiptap/core'
|
||||
import {
|
||||
@@ -20,6 +24,7 @@ import {
|
||||
QuoteIcon,
|
||||
Redo2Icon,
|
||||
StrikethroughIcon,
|
||||
TableIcon,
|
||||
UnderlineIcon,
|
||||
Undo2Icon,
|
||||
} from 'lucide-react'
|
||||
@@ -60,6 +65,10 @@ export const ARTICLE_EXTENSIONS = [
|
||||
HTMLAttributes: { rel: 'noopener noreferrer', target: '_blank' },
|
||||
isAllowedUri: (uri: string) => isAllowedHref(uri),
|
||||
}),
|
||||
Table.configure({ resizable: true }),
|
||||
TableRow,
|
||||
TableHeader,
|
||||
TableCell,
|
||||
LegacySmallSizeMark,
|
||||
EnglishText,
|
||||
BidiTypography,
|
||||
@@ -429,6 +438,16 @@ export function ArticleEditor({ doc, onChange, contentKey }: ArticleEditorProps)
|
||||
|
||||
<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()}>
|
||||
<BoldIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
|
||||
@@ -49,7 +49,7 @@ export type ArticleStatus = 'draft' | 'published';
|
||||
|
||||
export type Article = {
|
||||
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;
|
||||
read: string; read_en?: string; excerpt: string; excerpt_en?: string;
|
||||
/** پرسشوپاسخهای پایان مقاله — سئوی FAQPage (faqs_en خالی → برگشت به فارسی) */
|
||||
@@ -63,16 +63,20 @@ export type Article = {
|
||||
status: ArticleStatus;
|
||||
/** کلمه کلیدی هدف چکلیست سئو — راهنمای نوشتن، در سایت نمایش داده نمیشود */
|
||||
seo_keyphrase: string;
|
||||
seo_keyphrase_en?: string;
|
||||
/** توضیحات متای سئو — در اولویت بر چکیده برای تگ description */
|
||||
meta_description?: string;
|
||||
meta_description_en?: string;
|
||||
/** متن جایگزین تصویر شاخص (Alt) */
|
||||
image_alt?: string;
|
||||
/** عنوان تصویر شاخص (Title) */
|
||||
image_title?: string;
|
||||
/** نویسنده مقاله — در سربرگ مقاله زیر عنوان نمایش داده میشود */
|
||||
author?: string;
|
||||
author_en?: string;
|
||||
/** هشتگها/برچسبهای مقاله */
|
||||
tags?: string[];
|
||||
tags_en?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
// فرانتاند هر دو زبان را در یک درخواست از GET /api/site میگیرد و سوییچ زبان
|
||||
// بیدرنگ و بدون درخواست تازه انجام میشود. داده خام برای پنل مدیریت دستنخورده میماند.
|
||||
|
||||
import type { Article, ArticleBodyDoc } from '../data/site'
|
||||
import type { Article } from '../data/site'
|
||||
import type { SiteData } from '../api'
|
||||
import type { Lang } from './lang'
|
||||
import { docHasText } from '../utils/tiptapDoc'
|
||||
|
||||
/** متن انگلیسی فقط وقتی جایگزین میشود که واقعاً چیزی نوشته شده باشد */
|
||||
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)
|
||||
|
||||
/** سند 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 {
|
||||
if (lang === 'fa') return data
|
||||
return {
|
||||
@@ -93,13 +78,17 @@ export function localizeSite(data: SiteData, lang: Lang): SiteData {
|
||||
})),
|
||||
articles: data.articles.map((a: Article) => ({
|
||||
...a,
|
||||
slug: s(a.slug, a.slug_en),
|
||||
title: s(a.title, a.title_en),
|
||||
date: s(a.date, a.date_en),
|
||||
category: s(a.category, a.category_en),
|
||||
read: s(a.read, a.read_en),
|
||||
author: s(a.author ?? '', a.author_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,
|
||||
faqs: list(a.faqs ?? [], a.faqs_en),
|
||||
tags: list(a.tags ?? [], a.tags_en),
|
||||
})),
|
||||
testimonials: data.testimonials.map((tm) => ({
|
||||
...tm,
|
||||
|
||||
+30
-2
@@ -84,6 +84,7 @@ html[lang='en'] .font-pinar {
|
||||
}
|
||||
html[lang='en'] .article-body {
|
||||
line-height: 1.8;
|
||||
font-size: 17px; /* وبلاگ انگلیسی دو سه درجه بزرگتر از فارسی */
|
||||
}
|
||||
html[lang='en'] .article-body p {
|
||||
margin-top: 1.25rem;
|
||||
@@ -106,7 +107,7 @@ html[lang='en'] .article-body h3 {
|
||||
.article-body {
|
||||
line-height: 2.4;
|
||||
word-wrap: break-word;
|
||||
font-size: 14px; /* متن اصلی ۱۴ تنظیم شد */
|
||||
font-size: 15px; /* متن اصلی — یک درجه بزرگتر از قبل */
|
||||
}
|
||||
.article-body p {
|
||||
margin-top: 1.75rem;
|
||||
@@ -476,4 +477,31 @@ html[lang='en'] .article-body h3 {
|
||||
to {
|
||||
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