Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a0defc3d4 | ||
|
|
049291c109 | ||
|
|
35c03ef35e |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
@@ -0,0 +1,47 @@
|
||||
// تست موقت رندر استاتیک و ابزارها — پس از اجرا حذف میشود
|
||||
import { renderToHTMLString } from '@tiptap/static-renderer'
|
||||
import { ARTICLE_EXTENSIONS } from './src/components/admin/ArticleEditor'
|
||||
import { formatReadingTime, readingTimeMinutes } from './src/utils/readingTime'
|
||||
import { buildLinkRel, normalizeHref, parseLinkRel } from './src/utils/link'
|
||||
import { docHeadings, docImages, docToText } from './src/utils/tiptapDoc'
|
||||
|
||||
const doc = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'متن نمونه مقاله برای تست رندر' }] },
|
||||
{
|
||||
type: 'image',
|
||||
attrs: { src: '/media/a.jpg', alt: 'تصویر تست', title: 'Photo 1', caption: 'کپشن تصویر تست' },
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'لینک خارجی',
|
||||
marks: [{ type: 'link', attrs: { href: 'https://example.com', target: '_blank', rel: 'noopener noreferrer nofollow' } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'تیتر دوم' }] },
|
||||
],
|
||||
}
|
||||
|
||||
const html = renderToHTMLString({ extensions: ARTICLE_EXTENSIONS, content: doc as never })
|
||||
|
||||
console.log('--- rendered html ---')
|
||||
console.log(html)
|
||||
console.log('--- checks ---')
|
||||
console.log('has figure:', html.includes('<figure'))
|
||||
console.log('has figcaption:', html.includes('<figcaption') && html.includes('کپشن تصویر تست'))
|
||||
console.log('has img src:', html.includes('src="/media/a.jpg"'))
|
||||
console.log('has alt:', html.includes('alt="تصویر تست"'))
|
||||
console.log('has title:', html.includes('title="Photo 1"'))
|
||||
console.log('link rel/target:', html.includes('rel="noopener noreferrer nofollow"'), html.includes('target="_blank"'))
|
||||
console.log('--- text utils ---')
|
||||
console.log('text:', docToText(doc))
|
||||
console.log('h2:', docHeadings(doc, 2), '| images:', docImages(doc).length)
|
||||
console.log('reading time:', formatReadingTime(doc), '| minutes:', readingTimeMinutes(doc))
|
||||
console.log('--- link helpers ---')
|
||||
console.log('normalize:', normalizeHref('example.com'), '|', normalizeHref('/contact'), '|', normalizeHref('javascript:alert(1)'))
|
||||
console.log('rel:', buildLinkRel({ newTab: true, nofollow: true }), '|', buildLinkRel({ newTab: false, nofollow: false }), '|', parseLinkRel('noopener noreferrer').newTab)
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
lib: { entry: '.tmp-render-test.ts', formats: ['cjs'], fileName: () => 'tmp-render-test.cjs' },
|
||||
outDir: '.tmp-render-out',
|
||||
minify: false,
|
||||
emptyOutDir: true,
|
||||
},
|
||||
})
|
||||
+18
-9
@@ -3,20 +3,31 @@
|
||||
|
||||
A modern React + Vite + TypeScript frontend application with a luxurious dark theme (Background: `#27221c`, Accent: `#d5874c`). All text content and assets are fetched dynamically from the backend API (`GET /api/site`).
|
||||
|
||||
## Typography
|
||||
## Typography & Smart Bidi Text
|
||||
The **Pinar** font is installed and served locally. The application no longer relies on any external servers or CDNs for font rendering, and the UI is fully localized for Persian (Farsi) users.
|
||||
|
||||
**Smart Bidi Typography:** To maintain perfect visual weight and baseline alignment when mixing English words within Persian text, we utilize a dynamic CSS calculation for Latin characters: `calc(1em - 2px)`. This ensures that English text automatically scales down slightly to match the natural density and baseline of the Persian font, preventing any visual imbalance in mixed-language sentences.
|
||||
|
||||
## Recent UI/UX Improvements
|
||||
* **Tiptap Editor Fixes**:
|
||||
* Resolved the initialization bug where the editor body remained empty during edit mode.
|
||||
* Fixed Tiptap block boundaries so formatting a new line (e.g., changing it to H2) no longer alters the formatting of the previous line.
|
||||
* **Dynamic TOC & Layout**:
|
||||
* The Table of Contents (TOC) is now strictly `position: sticky` and extracts all H2/H3 tags flawlessly.
|
||||
* The entire article layout (including Author, Read Time, and TOC) dynamically flips RTL/LTR based on the article's primary language.
|
||||
|
||||
## CMS, SEO & Editor Features
|
||||
|
||||
The project includes a robust, custom-built Digital Twin (Admin Panel) with advanced content management capabilities:
|
||||
The project includes a robust, custom-built Digital Twin (Admin Panel) with advanced content management capabilities, emphasizing a premium UI/UX:
|
||||
|
||||
* **Advanced SEO Management:** Dynamic `meta_description` fields (150-160 characters) and fully managed Image `Alt` texts (with auto-fallback to titles/filenames) ensure high search engine visibility.
|
||||
* **Tags & Categorization:** Integrated `react-select` for modern category selection and creatable dynamic tags.
|
||||
* **Article Metadata UI:** A beautifully designed metadata component displaying the author's avatar, name, and publication date right below the H1 title.
|
||||
* **Advanced SEO & Featured Media:** Dynamic `meta_description` fields (150-160 characters). The Featured Image component features a compact layout with smart data-binding: the `Alt` text auto-fills based on the title (e.g., `[Title] - تصویر شاخص`) as a smart fallback to ensure SEO compliance, while still allowing **full manual overrides** for precise custom keyword targeting.
|
||||
* **Precise Category Management:** A flawlessly integrated, single-select category dropdown using `react-select` (Creatable) and `react-hook-form`. It allows creating new categories on the fly while retaining them in the global list, seamlessly matching the strict single-category backend schema.
|
||||
* **Article Metadata & Reading Time:** A beautifully designed metadata component displaying the author's avatar, name, publication date, and an auto-calculated dynamic reading time (displayed dynamically with Persian numerals, e.g., "۵ دقیقه مطالعه") directly below the H1 title.
|
||||
* **Dynamic Table of Contents (TOC):** Auto-generates a clickable TOC from `h2` and `h3` tags within the article, using a custom Persian-supported `slugify` logic for anchor links.
|
||||
* **Custom Tiptap WYSIWYG Editor:**
|
||||
* Specific block-level toggling for Headings (H1: 18px, H2: 16px, H3: 14px) and Paragraphs (14px).
|
||||
* A custom feature to wrap English text blocks in LTR styling (`<span dir="ltr" class="english-text">` at 12px) for perfect bi-directional text alignment.
|
||||
* **Fixed Toolbar & Typography:** Dedicated LTR/RTL toggles (`data-dir="ltr|rtl"`) mapped to a fixed toolbar that ensures layout stability.
|
||||
* **Advanced Image & Caption Nodes:** Built via `ReactNodeViewRenderer`, rendering HTML wrapped in `<figure><figcaption>`. Features strictly isolated UI deletion controls.
|
||||
* **Animated Link Modal:** A premium, auto-opening modal with smooth fade/scale transition effects. Includes seamless checkbox toggles for "Open in new tab" (`target="_blank" rel="noopener noreferrer"`) and SEO link attributes (`nofollow/dofollow`).
|
||||
* **Full Jalali (Shamsi) Calendar Integration:**
|
||||
* Admin panel utilizes `react-multi-date-picker` for a visual Persian date selection experience.
|
||||
* Frontend and text displays rely on native `Intl.DateTimeFormat` to render dates and numerals perfectly in Persian.
|
||||
@@ -49,5 +60,3 @@ npm run build
|
||||
* `src/admin/` — Live editing layer (Digital Twin): includes the admin toolbar, entity drawers, and the article editor.
|
||||
* `src/seo.ts` — Manages page-level SEO tags (Title, Description, Open Graph, Canonical, JSON-LD).
|
||||
* `src/data/site.ts` — Shared TypeScript data models and interfaces.
|
||||
|
||||
|
||||
|
||||
Generated
+17
@@ -19,6 +19,7 @@
|
||||
"lucide-react": "0.522.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.88.0",
|
||||
"react-multi-date-picker": "^4.5.2",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"react-select": "^5.10.2",
|
||||
@@ -4484,6 +4485,22 @@
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-hook-form": {
|
||||
"version": "7.88.0",
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.88.0.tgz",
|
||||
"integrity": "sha512-QRaLOWhX93YCnMiRfnOFRSwWXZNt8qhm2JTZwypoDvKpSffTJHmpzMXt8U6PV5UThvL3IiiLDUWe2nHMtz6Mmw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/react-hook-form"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"lucide-react": "0.522.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.88.0",
|
||||
"react-multi-date-picker": "^4.5.2",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"react-select": "^5.10.2",
|
||||
|
||||
@@ -97,8 +97,10 @@ function AppChrome() {
|
||||
function AppShell() {
|
||||
const { data, error, reload } = useSiteData();
|
||||
|
||||
// overflow-x-clip جای overflow-x-hidden: اسکرولافزار نمیسازد، پس position: sticky
|
||||
// (فهرست مطالب مقاله) به صفحه میچسبد و با محتوا پایین نمیرود؛ برش افقی هم حفظ میشود.
|
||||
return (
|
||||
<div dir="rtl" lang="fa" className="min-h-screen w-full overflow-x-hidden bg-ink font-pinar text-ivory">
|
||||
<div dir="rtl" lang="fa" className="min-h-screen w-full overflow-x-clip bg-ink font-pinar text-ivory">
|
||||
<Preloader />
|
||||
{error ? (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center px-6 text-center">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Controller, useController, useForm } from 'react-hook-form'
|
||||
import { Link, Navigate, useNavigate, useParams } from 'react-router-dom'
|
||||
import { ArrowRightIcon, CheckIcon, EyeIcon, Trash2Icon } from 'lucide-react'
|
||||
import Swal from 'sweetalert2'
|
||||
@@ -9,8 +10,12 @@ import { ArticleEditor } from '../components/admin/ArticleEditor'
|
||||
import { SeoChecklist } from '../components/admin/SeoChecklist'
|
||||
import { ImageField } from '../components/admin/ImageField'
|
||||
import { JalaliDatePicker } from '../components/admin/JalaliDatePicker'
|
||||
import { LuxeSelect, LuxeTagsSelect } from '../components/admin/LuxeSelect'
|
||||
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 { toArticleBodyDoc } from '../utils/tiptapDoc'
|
||||
import type { Article, ArticleBodyDoc } from '../data/site'
|
||||
|
||||
const EMPTY_DOC = { type: 'doc', content: [{ type: 'paragraph' }] } as unknown as ArticleBodyDoc
|
||||
@@ -18,6 +23,30 @@ const EMPTY_DOC = { type: 'doc', content: [{ type: 'paragraph' }] } as unknown a
|
||||
/** پیشنویس صفحه نوشتن — همان شکل Article با بدنه ساختاریافته */
|
||||
type Draft = Omit<Article, 'body'> & { body: ArticleBodyDoc }
|
||||
|
||||
/**
|
||||
* فیلدهای متادیتای مقاله که با React Hook Form مدیریت میشوند:
|
||||
* عنوان (مبنای Alt پویا)، دستهبندی و متادیتای تصویر شاخص.
|
||||
* `category` در فرم یک شیء تکمقداری {value,label} یا null است
|
||||
* که هنگام ذخیره به رشته (ستون category در بکاند) نگاشت میشود.
|
||||
*/
|
||||
type ArticleMetaForm = {
|
||||
title: string
|
||||
category: CategoryOption | null
|
||||
image_alt: string
|
||||
image_title: string
|
||||
}
|
||||
|
||||
/** مقدار رشتهای ذخیرهشده → گزینه react-select (یا null وقتی خالی است) */
|
||||
function toCategoryOption(value: string): CategoryOption | null {
|
||||
const category = (value ?? '').trim()
|
||||
return category ? { label: category, value: category } : null
|
||||
}
|
||||
|
||||
/** گزینه react-select → مقدار رشتهای ذخیرهسازی در بکاند */
|
||||
function categoryValue(option: CategoryOption | null): string {
|
||||
return option?.value.trim() ?? ''
|
||||
}
|
||||
|
||||
/** دادههای قدیمی ممکن است هشتگ را رشته کاماشده داشته باشند */
|
||||
function normalizeTags(value: unknown): string[] {
|
||||
if (Array.isArray(value)) return value.map((t) => String(t).trim()).filter(Boolean)
|
||||
@@ -54,6 +83,7 @@ export function ArticleWritePage() {
|
||||
tags: [],
|
||||
image: '',
|
||||
image_alt: '',
|
||||
image_title: '',
|
||||
author: '',
|
||||
meta_description: '',
|
||||
read: '۵ دقیقه',
|
||||
@@ -62,7 +92,31 @@ export function ArticleWritePage() {
|
||||
status: 'draft',
|
||||
seo_keyphrase: '',
|
||||
})
|
||||
|
||||
/**
|
||||
* فرم متادیتا با React Hook Form — منبع نمایش فیلدها.
|
||||
* هر تغییر، هم فرم و هم draft (مبنای ذخیرهسازی خودکار) را بهروز میکند.
|
||||
*/
|
||||
const { control, watch, reset } = useForm<ArticleMetaForm>({
|
||||
defaultValues: {
|
||||
title: '',
|
||||
category: toCategoryOption(ARTICLE_CATEGORIES[0]),
|
||||
image_alt: '',
|
||||
image_title: '',
|
||||
},
|
||||
mode: 'onChange',
|
||||
})
|
||||
/** عنوان زنده مقاله — مبنای متن جایگزین (Alt) تصویر شاخص */
|
||||
const watchedTitle = watch('title')
|
||||
const { field: imageAltField } = useController({ name: 'image_alt', control })
|
||||
const { field: imageTitleField } = useController({ name: 'image_title', control })
|
||||
|
||||
const [createdId, setCreatedId] = useState<number | null>(null)
|
||||
/**
|
||||
* شناسه محتوای بارگذاریشده — با رسیدن مقاله از سرور عوض میشود تا
|
||||
* بدنه ویرایشگر TipTap با متن واقعی مقاله مقداردهی شود.
|
||||
*/
|
||||
const [loadedContentKey, setLoadedContentKey] = useState<string | number>('new')
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const draftRef = useRef(draft)
|
||||
draftRef.current = draft
|
||||
@@ -96,9 +150,19 @@ export function ArticleWritePage() {
|
||||
setDraft({
|
||||
...article,
|
||||
tags: normalizeTags(article.tags),
|
||||
body: (Array.isArray(article.body) ? EMPTY_DOC : article.body) as ArticleBodyDoc,
|
||||
// بدنه ذخیرهشده (سند جدید یا پاراگرافهای قدیمی) به سند قابل ویرایش تبدیل میشود
|
||||
body: toArticleBodyDoc(article.body),
|
||||
})
|
||||
setCreatedId(article.id)
|
||||
// بدنه ویرایشگر با محتوای بارگذاریشده همگام میشود
|
||||
setLoadedContentKey(article.id)
|
||||
// فرم متادیتا هم با مقادیر مقاله بارگذاریشده همگام میشود
|
||||
reset({
|
||||
title: article.title ?? '',
|
||||
category: toCategoryOption(article.category ?? ''),
|
||||
image_alt: article.image_alt ?? '',
|
||||
image_title: article.image_title ?? '',
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [article?.id])
|
||||
|
||||
@@ -196,6 +260,11 @@ export function ArticleWritePage() {
|
||||
|
||||
const metaLength = (draft.meta_description ?? '').trim().length
|
||||
|
||||
// متن جایگزین تصویر شاخص — با watch('title') بهصورت زنده ساخته میشود
|
||||
const imageAltFallback = [watchedTitle.trim(), FEATURED_IMAGE_ALT_SUFFIX]
|
||||
.filter(Boolean)
|
||||
.join(' - ')
|
||||
|
||||
return (
|
||||
<div dir="rtl" className="min-h-screen bg-ink font-pinar text-ivory">
|
||||
{/* نوار بالا */}
|
||||
@@ -276,44 +345,59 @@ export function ArticleWritePage() {
|
||||
: 'مقاله منتشرشده — تغییرها همان لحظه در سایت اعمال میشود'}
|
||||
</p>
|
||||
|
||||
{/* عنوان */}
|
||||
<input
|
||||
type="text"
|
||||
{...textField('title')}
|
||||
placeholder="عنوان مقاله…"
|
||||
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"
|
||||
{/* عنوان — با Controller به فرم وصل است تا watch('title') زنده بماند */}
|
||||
<Controller
|
||||
name="title"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<input
|
||||
type="text"
|
||||
{...field}
|
||||
onChange={(event) => {
|
||||
field.onChange(event.target.value)
|
||||
queueSave({ title: event.target.value })
|
||||
}}
|
||||
placeholder="عنوان مقاله…"
|
||||
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"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div className="mt-4 h-px w-28 bg-copper" />
|
||||
|
||||
{/* تصویر شاخص + متن جایگزین */}
|
||||
{/* تصویر شاخص + متن جایگزین و عنوان (هر دو به فرم وصلاند) */}
|
||||
<div className="mt-8">
|
||||
<ImageField
|
||||
label="تصویر شاخص مقاله"
|
||||
value={draft.image}
|
||||
alt={draft.image_alt ?? ''}
|
||||
altFallback={draft.title}
|
||||
alt={imageAltField.value}
|
||||
altFallback={imageAltFallback}
|
||||
title={imageTitleField.value}
|
||||
onSave={async (url) => {
|
||||
queueSave({ image: url })
|
||||
return true
|
||||
}}
|
||||
onSaveAlt={async (alt) => {
|
||||
imageAltField.onChange(alt)
|
||||
queueSave({ image_alt: alt })
|
||||
return true
|
||||
}}
|
||||
onSaveTitle={async (imageTitle) => {
|
||||
imageTitleField.onChange(imageTitle)
|
||||
queueSave({ image_title: imageTitle })
|
||||
return true
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* متن */}
|
||||
<div className="mt-8">
|
||||
<ArticleEditor
|
||||
contentKey={loadedContentKey}
|
||||
doc={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 })
|
||||
// زمان مطالعه بهصورت پویا از متن سند محاسبه میشود (۲۰۰ واژه در دقیقه)
|
||||
queueSave({ body: json, read: formatReadingTime(json) })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -394,11 +478,20 @@ export function ArticleWritePage() {
|
||||
<div>
|
||||
<label className="text-xs font-light text-ivory/50">دستهبندی مقاله (Category)</label>
|
||||
<div className="mt-2">
|
||||
<LuxeSelect
|
||||
value={draft.category}
|
||||
options={ARTICLE_CATEGORIES}
|
||||
onChange={(category) => queueSave({ category })}
|
||||
isClearable={false}
|
||||
{/* تکانتخابی: ورودی/خروجی Controller یک {value,label} یا null است */}
|
||||
<Controller
|
||||
name="category"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<CategorySelect
|
||||
value={field.value}
|
||||
options={ARTICLE_CATEGORIES}
|
||||
onChange={(option) => {
|
||||
field.onChange(option)
|
||||
queueSave({ category: categoryValue(option) })
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,10 +5,11 @@ import Swal from 'sweetalert2'
|
||||
import { useSiteData } from '../api'
|
||||
import { useAdmin, useFieldSaver } from './AdminContext'
|
||||
import { adminApi } from './adminClient'
|
||||
import { toArticleBodyDoc } from '../utils/tiptapDoc'
|
||||
import type { EntityConfig, FieldConfig } from './entities'
|
||||
import { ImageField } from '../components/admin/ImageField'
|
||||
import { JalaliDatePicker } from '../components/admin/JalaliDatePicker'
|
||||
import { LuxeSelect, LuxeTagsSelect } from '../components/admin/LuxeSelect'
|
||||
import { LuxeCreatableSelect, LuxeSelect, LuxeTagsSelect } from '../components/admin/LuxeSelect'
|
||||
import { ArticleEditor } from '../components/admin/ArticleEditor'
|
||||
|
||||
type EntityDrawerProps = {
|
||||
@@ -114,22 +115,34 @@ 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) => {
|
||||
const isImage = field.type === 'image'
|
||||
// فیلد Alt تصویر: برای گالری همان ستون alt و برای بقیه <نام فیلد>_alt
|
||||
const altKey =
|
||||
field.type === 'image'
|
||||
? field.name === 'src' && 'alt' in item
|
||||
? 'alt'
|
||||
: `${field.name}_alt`
|
||||
: ''
|
||||
const altKey = isImage
|
||||
? field.name === 'src' && 'alt' in item
|
||||
? 'alt'
|
||||
: `${field.name}_alt`
|
||||
: ''
|
||||
const imageTitleKey = isImage ? `${field.name}_title` : ''
|
||||
// پیشنهاد Alt از عنوان رکورد ساخته میشود (هرگز از URL)
|
||||
const imageAltFallback = [
|
||||
String(item[config.titleKey] ?? '').trim(),
|
||||
field.altFallbackSuffix,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' - ')
|
||||
return (
|
||||
<FieldControl
|
||||
key={field.name}
|
||||
field={field}
|
||||
value={item[field.name]}
|
||||
altValue={altKey ? String(item[altKey] ?? '') : undefined}
|
||||
altFallback={String(item[config.titleKey] ?? '')}
|
||||
altFallback={imageAltFallback}
|
||||
imageTitleValue={imageTitleKey ? String(item[imageTitleKey] ?? '') : undefined}
|
||||
onSave={(value) => saveField(field.name, value)}
|
||||
onSaveAlt={altKey ? (alt) => saveField(altKey, alt) : undefined}
|
||||
onSaveImageTitle={
|
||||
imageTitleKey ? (title) => saveField(imageTitleKey, title) : undefined
|
||||
}
|
||||
registerFlush={
|
||||
field.type === 'wysiwyg'
|
||||
? (fn) => {
|
||||
@@ -166,8 +179,10 @@ function FieldControl({
|
||||
value,
|
||||
altValue,
|
||||
altFallback,
|
||||
imageTitleValue,
|
||||
onSave,
|
||||
onSaveAlt,
|
||||
onSaveImageTitle,
|
||||
registerFlush,
|
||||
}: {
|
||||
field: FieldConfig
|
||||
@@ -175,12 +190,20 @@ function FieldControl({
|
||||
/** مقدار Alt تصویر — فقط برای فیلدهای image */
|
||||
altValue?: string
|
||||
altFallback?: string
|
||||
/** مقدار Title تصویر — فقط برای فیلدهای image */
|
||||
imageTitleValue?: string
|
||||
onSave: (value: unknown) => Promise<boolean>
|
||||
onSaveAlt?: (alt: string) => Promise<boolean>
|
||||
onSaveImageTitle?: (title: string) => Promise<boolean>
|
||||
registerFlush?: (fn: () => void) => void
|
||||
}) {
|
||||
const [text, setText] = useState<string>(String(value ?? ''))
|
||||
|
||||
// همگامسازی مقدار محلی با دادهای که از بیرون میآید (بارگذاری/بهروزرسانی خوشبینانه)
|
||||
useEffect(() => {
|
||||
setText(String(value ?? ''))
|
||||
}, [value])
|
||||
|
||||
const commitText = async () => {
|
||||
const next = field.type === 'number' ? Number(text) || 0 : text
|
||||
if (String(value ?? '') === String(next)) return
|
||||
@@ -194,26 +217,34 @@ function FieldControl({
|
||||
value={String(value ?? '')}
|
||||
alt={altValue}
|
||||
altFallback={altFallback}
|
||||
title={imageTitleValue}
|
||||
onSave={(url) => onSave(url)}
|
||||
onSaveAlt={onSaveAlt}
|
||||
onSaveTitle={onSaveImageTitle}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === 'select') {
|
||||
// مقدار نمایشی از وضعیت محلی میآید تا افزودن/حذف گزینه فوراً دیده شود
|
||||
const selectProps = {
|
||||
value: text,
|
||||
options: field.options ?? [],
|
||||
onChange: (next: string) => {
|
||||
setText(next)
|
||||
void onSave(next)
|
||||
},
|
||||
}
|
||||
return (
|
||||
<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>}
|
||||
<div className="mt-2">
|
||||
<LuxeSelect
|
||||
value={String(value ?? '')}
|
||||
options={field.options ?? []}
|
||||
onChange={(next) => {
|
||||
setText(next)
|
||||
void onSave(next)
|
||||
}}
|
||||
/>
|
||||
{field.creatable ? (
|
||||
<LuxeCreatableSelect {...selectProps} />
|
||||
) : (
|
||||
<LuxeSelect {...selectProps} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -383,10 +414,9 @@ function WysiwygField({
|
||||
onSave: (value: unknown) => Promise<boolean>
|
||||
registerFlush?: (fn: () => void) => void
|
||||
}) {
|
||||
const [doc, setDoc] = useState<Record<string, unknown>>(() =>
|
||||
value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: { type: 'doc', content: [{ type: 'paragraph' }] },
|
||||
const [doc, setDoc] = useState<Record<string, unknown>>(
|
||||
// بدنه ذخیرهشده (سند جدید، رشته JSON یا پاراگرافهای قدیمی) به سند قابل ویرایش تبدیل میشود
|
||||
() => toArticleBodyDoc(value),
|
||||
)
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const pending = useRef<Record<string, unknown> | null>(null)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { SiteData } from '../api'
|
||||
import { FEATURED_IMAGE_ALT_SUFFIX } from '../data/constants'
|
||||
|
||||
export type FieldType = 'text' | 'textarea' | 'number' | 'image' | 'list' | 'wysiwyg' | 'select' | 'date'
|
||||
|
||||
@@ -9,8 +10,12 @@ export type FieldConfig = {
|
||||
hint?: string
|
||||
/** فقط برای type === 'select' — گزینههای ثابت react-select */
|
||||
options?: string[]
|
||||
/** فقط برای type === 'select' — امکان ساخت گزینه جدید توسط کاربر */
|
||||
creatable?: boolean
|
||||
/** ویجت سفارشی نوع list — 'tags' یعنی react-select قابل ساخت (Creatable) */
|
||||
ui?: 'lines' | 'tags'
|
||||
/** پسوند پیشنهاد Alt تصویر — مثل «تصویر شاخص» در کنار عنوان رکورد */
|
||||
altFallbackSuffix?: string
|
||||
}
|
||||
|
||||
export type EntityConfig = {
|
||||
@@ -73,8 +78,8 @@ export const ENTITIES: Record<string, EntityConfig> = {
|
||||
F('body', 'متن مقاله', 'wysiwyg'),
|
||||
F('excerpt', 'چکیده (روی کارت مقاله)'),
|
||||
F('meta_description', 'توضیحات سئو (Meta Description)', 'textarea', '۱۵۰ تا ۱۶۰ کاراکتر — زیر عنوان در نتایج گوگل نمایش داده میشود'),
|
||||
F('image', 'تصویر شاخص', 'image'),
|
||||
F('category', 'دستهبندی (Category)', 'select', undefined, { options: ARTICLE_CATEGORIES }),
|
||||
F('image', 'تصویر شاخص', 'image', undefined, { altFallbackSuffix: FEATURED_IMAGE_ALT_SUFFIX }),
|
||||
F('category', 'دستهبندی (Category)', 'select', 'میتوانید دسته جدید هم بسازید', { options: ARTICLE_CATEGORIES, creatable: true }),
|
||||
F('tags', 'هشتگها / برچسبها', 'list', 'با Enter هر هشتگ جدید را اضافه کنید', { ui: 'tags' }),
|
||||
F('author', 'نویسنده'),
|
||||
F('date', 'تاریخ انتشار (شمسی)', 'date'),
|
||||
@@ -89,6 +94,7 @@ export const ENTITIES: Record<string, EntityConfig> = {
|
||||
tags: ['مدیریت پروژه', 'ساختوساز'],
|
||||
image: '',
|
||||
image_alt: '',
|
||||
image_title: '',
|
||||
author: '',
|
||||
meta_description: '',
|
||||
read: '۵ دقیقه',
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom'
|
||||
import { ArrowLeftIcon, ClockIcon } from 'lucide-react'
|
||||
import type { Article } from '../data/site'
|
||||
import { formatJalali } from '../utils/date'
|
||||
import { toPersianDigits } from '../utils/digits'
|
||||
|
||||
export function ArticleCard({ article }: { article: Article }) {
|
||||
return (
|
||||
@@ -37,7 +38,7 @@ export function ArticleCard({ article }: { article: Article }) {
|
||||
<span>{formatJalali(article.date)}</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<ClockIcon className="h-3.5 w-3.5 text-copper/80" />
|
||||
{article.read}
|
||||
{toPersianDigits(article.read)}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { CalendarIcon, ClockIcon } from 'lucide-react'
|
||||
import type { Article } from '../data/site'
|
||||
import { JALALI_TIME_FORMATTER, formatJalali } from '../utils/date'
|
||||
import { toPersianDigits } from '../utils/digits'
|
||||
|
||||
type ArticleMetaProps = {
|
||||
article: Article
|
||||
@@ -41,11 +42,11 @@ export function ArticleMeta({ article, className = '' }: ArticleMetaProps) {
|
||||
{dateLabel}
|
||||
</span>
|
||||
|
||||
{/* زمان مطالعه */}
|
||||
{/* زمان مطالعه — با ارقام فارسی */}
|
||||
{article.read && (
|
||||
<span className="flex items-center gap-2 text-xs font-light text-ivory/60">
|
||||
<ClockIcon className="h-3.5 w-3.5 text-copper" aria-hidden="true" />
|
||||
{article.read}
|
||||
{toPersianDigits(article.read)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -6,23 +6,49 @@ import { slugifyFa, uniqueSlug } from '../utils/slug'
|
||||
export type TocItem = { id: string; text: string; level: 2 | 3 }
|
||||
|
||||
/**
|
||||
* استخراج فهرست مطالب از HTML ویرایشگر — تیترهای H2 و H3 شناسه لنگر میگیرند
|
||||
* (با slugify و پشتیبانی فارسی) و HTML برگشتی همان است که باید رندر شود.
|
||||
* متن یک تیتر از HTML — همه گرههای تودرتو پیمایش میشوند و <br> به فاصله تبدیل
|
||||
* میشود تا تیترهای چندخطی یا شامل مارک (مثل متن انگلیسی) کامل خوانده شوند.
|
||||
*/
|
||||
export function extractToc(html: string): { items: TocItem[]; html: string } {
|
||||
function headingText(element: Element): string {
|
||||
const parts: string[] = []
|
||||
|
||||
element.childNodes.forEach((node) => {
|
||||
if (node.nodeType === Node.TEXT_NODE) parts.push(node.textContent ?? '')
|
||||
else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
const child = node as Element
|
||||
parts.push(child.tagName === 'BR' ? ' ' : headingText(child))
|
||||
}
|
||||
})
|
||||
|
||||
return parts.join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* استخراج فهرست مطالب از HTML ویرایشگر — همه تیترهای H2 و H3 در هر عمقی از سند
|
||||
* (حتی داخل نقلقول) شناسه لنگر میگیرند؛ متن فارسی در شناسه حفظ میشود و تیترهای
|
||||
* تکراری پسوند عددی میگیرند. HTML برگشتی همان است که باید رندر شود.
|
||||
*/
|
||||
export function extractToc(
|
||||
html: string,
|
||||
transform?: (root: HTMLElement) => void,
|
||||
): { items: TocItem[]; html: string } {
|
||||
if (!html || typeof window === 'undefined') return { items: [], html }
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html')
|
||||
const used = new Map<string, number>()
|
||||
const items: TocItem[] = []
|
||||
|
||||
doc.body.querySelectorAll('h2, h3').forEach((el) => {
|
||||
const text = (el.textContent ?? '').replace(/\s+/g, ' ').trim()
|
||||
doc.body.querySelectorAll('h2, h3').forEach((element, index) => {
|
||||
const text = headingText(element).replace(/\s+/g, ' ').trim()
|
||||
if (!text) return
|
||||
const id = uniqueSlug(slugifyFa(text), used)
|
||||
el.setAttribute('id', id)
|
||||
items.push({ id, text, level: el.tagName === 'H3' ? 3 : 2 })
|
||||
// تیتر بدون حرف و رقم (فقط نشانه) شناسه ترتیبی میگیرد تا لنگرش بینام نماند
|
||||
const id = uniqueSlug(slugifyFa(text) || `heading-${index + 1}`, used)
|
||||
element.setAttribute('id', id)
|
||||
items.push({ id, text, level: element.tagName === 'H3' ? 3 : 2 })
|
||||
})
|
||||
|
||||
// تبدیل اختیاری (بستهبندی متن لاتین/فارسی برای اندازه دوزبانه) روی همان DOM اجرا میشود
|
||||
transform?.(doc.body)
|
||||
|
||||
return { items, html: doc.body.innerHTML }
|
||||
}
|
||||
|
||||
@@ -32,7 +58,11 @@ type TableOfContentsProps = {
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** فهرست مطالب کلیکپذیر با هایلایت خودکار تیتر فعال (IntersectionObserver) */
|
||||
/**
|
||||
* فهرست مطالب کلیکپذیر با هایلایت خودکار تیتر فعال (IntersectionObserver).
|
||||
* همه اندازهها و ترازبندیها منطقیاند (start/end) تا در چیدمان راستبهچپ و
|
||||
* چپبهراست — بر اساس جهت مقاله — درست بنشیند.
|
||||
*/
|
||||
export function TableOfContents({ items, title = 'فهرست مطالب', className = '' }: TableOfContentsProps) {
|
||||
const [activeId, setActiveId] = useState<string>(items[0]?.id ?? '')
|
||||
|
||||
@@ -79,7 +109,8 @@ export function TableOfContents({ items, title = 'فهرست مطالب', classN
|
||||
{title}
|
||||
</p>
|
||||
<div className="mt-5 h-px w-12 bg-copper/50" aria-hidden="true" />
|
||||
<ul className="mt-4 max-h-[60vh] space-y-0.5 overflow-y-auto border-r border-ivory/10 pr-3">
|
||||
{/* ارتفاع فهرست به ارتفاع در دسترس صفحه (زیر نوار چسبان) محدود میشود تا بریده نشود */}
|
||||
<ul className="mt-4 max-h-[60vh] space-y-0.5 overflow-y-auto border-s border-ivory/10 ps-3 lg:max-h-[calc(100vh_-_15rem)]">
|
||||
{items.map((item) => {
|
||||
const isActive = item.id === activeId
|
||||
return (
|
||||
@@ -88,8 +119,8 @@ export function TableOfContents({ items, title = 'فهرست مطالب', classN
|
||||
type="button"
|
||||
onClick={(event) => go(event, item.id)}
|
||||
aria-current={isActive ? 'location' : undefined}
|
||||
className={`block w-full text-right text-sm font-light leading-8 transition-colors duration-200 ease-lux ${
|
||||
item.level === 3 ? 'pr-4 text-[13px]' : ''
|
||||
className={`block w-full text-start text-sm font-light leading-8 transition-colors duration-200 ease-lux ${
|
||||
item.level === 3 ? 'ps-4 text-[13px]' : ''
|
||||
} ${
|
||||
isActive
|
||||
? 'font-normal text-copper'
|
||||
|
||||
@@ -1,61 +1,107 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { EditorContent, useEditor } from '@tiptap/react'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import Image from '@tiptap/extension-image'
|
||||
import Link from '@tiptap/extension-link'
|
||||
import { Mark, mergeAttributes } from '@tiptap/core'
|
||||
import {
|
||||
AlignLeftIcon,
|
||||
AlignRightIcon,
|
||||
BoldIcon,
|
||||
ItalicIcon,
|
||||
Heading1Icon,
|
||||
Heading2Icon,
|
||||
Heading3Icon,
|
||||
ImagePlusIcon,
|
||||
LanguagesIcon,
|
||||
ItalicIcon,
|
||||
Link2Icon,
|
||||
ListIcon,
|
||||
ListOrderedIcon,
|
||||
Loader2Icon,
|
||||
PilcrowIcon,
|
||||
QuoteIcon,
|
||||
Redo2Icon,
|
||||
StrikethroughIcon,
|
||||
UnderlineIcon,
|
||||
Undo2Icon,
|
||||
Loader2Icon,
|
||||
} from 'lucide-react'
|
||||
import Swal from 'sweetalert2'
|
||||
import { useAdmin } from '../../admin/AdminContext'
|
||||
import { adminApi } from '../../admin/adminClient'
|
||||
import { ENGLISH_TEXT_MARK, EnglishText } from './EnglishText'
|
||||
import { DEFAULT_IMAGE_ALT } from '../../data/constants'
|
||||
import { buildLinkRel, isAllowedHref, isUrlLike, normalizeHref, parseLinkRel } from '../../utils/link'
|
||||
import { ArticleImage } from './ArticleImage'
|
||||
import { BidiTypography, bidiPluginKey } from './BidiTypography'
|
||||
import { EnglishText } from './EnglishText'
|
||||
import { LinkModal } from './LinkModal'
|
||||
import { applyBlockFormat, handleHeadingEnter } from './blockFormat'
|
||||
import type { LinkModalValue } from './LinkModal'
|
||||
|
||||
const SmallSizeMark = Mark.create({
|
||||
/**
|
||||
* مارکهای قدیمی (سایز ۱۲ و متن انگلیسی) — کنترل آنها از نوار ابزار برداشته شده و
|
||||
* فقط برای اینکه محتوای قبلی از دست نرود در شِما نگه داشته شدهاند.
|
||||
*/
|
||||
const LegacySmallSizeMark = Mark.create({
|
||||
name: 'smallSize',
|
||||
parseHTML() { return [{ tag: 'span[data-small]' }] },
|
||||
parseHTML() {
|
||||
return [{ tag: 'span[data-small]' }]
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['span', mergeAttributes(HTMLAttributes, { 'data-small': 'true' }), 0]
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
/** اکستنشنهای مشترک ویرایشگر و رندر مقاله — هر دو باید یکسان باشند */
|
||||
export const ARTICLE_EXTENSIONS = [
|
||||
StarterKit.configure({
|
||||
link: false,
|
||||
heading: { levels: [1, 2, 3] },
|
||||
}),
|
||||
Image.configure({ allowBase64: false }),
|
||||
ArticleImage.configure({ allowBase64: false }),
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
HTMLAttributes: { rel: 'noopener noreferrer', target: '_blank' },
|
||||
isAllowedUri: (uri: string) => /^(https?:\/\/|\/|#|mailto:|tel:)/i.test(uri),
|
||||
isAllowedUri: (uri: string) => isAllowedHref(uri),
|
||||
}),
|
||||
SmallSizeMark,
|
||||
LegacySmallSizeMark,
|
||||
EnglishText,
|
||||
BidiTypography,
|
||||
]
|
||||
|
||||
type ArticleEditorProps = {
|
||||
doc: Record<string, unknown>
|
||||
onChange: (json: Record<string, unknown>) => void
|
||||
/**
|
||||
* شناسه محتوا — با هر تغییر آن، بدنه ویرایشگر از `doc` جاری دوباره بارگذاری میشود
|
||||
* (مثلاً وقتی مقاله از سرور میرسد). بدون این prop محتوا فقط یکبار مقداردهی میشود.
|
||||
*/
|
||||
contentKey?: string | number
|
||||
}
|
||||
|
||||
/** نمونه ویرایشگر TipTap — همان چیزی که useEditor برمیگرداند */
|
||||
type EditorInstance = NonNullable<ReturnType<typeof useEditor>>
|
||||
|
||||
type TextDirection = 'rtl' | 'ltr'
|
||||
|
||||
/** نحوه اعمال لینک: روی انتخاب فعلی، درج متن تازه یا تبدیل محدوده تایپشده */
|
||||
type LinkApplyMode = 'selection' | 'insert' | 'range'
|
||||
|
||||
type LinkModalState = {
|
||||
open: boolean
|
||||
editing: boolean
|
||||
mode: LinkApplyMode
|
||||
range: { from: number; to: number } | null
|
||||
initial: LinkModalValue
|
||||
}
|
||||
|
||||
const CLOSED_LINK_MODAL: LinkModalState = {
|
||||
open: false,
|
||||
editing: false,
|
||||
mode: 'selection',
|
||||
range: null,
|
||||
initial: { href: '', newTab: true, nofollow: false },
|
||||
}
|
||||
|
||||
/** چند نویسه پیش از نشانگر برای تشخیص نشانیِ تایپشده بررسی میشود */
|
||||
const URL_LOOKBACK = 300
|
||||
|
||||
function ToolButton({
|
||||
onClick,
|
||||
active = false,
|
||||
@@ -72,6 +118,7 @@ function ToolButton({
|
||||
type="button"
|
||||
title={label}
|
||||
aria-label={label}
|
||||
aria-pressed={active}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={onClick}
|
||||
className={`flex h-8 w-8 items-center justify-center border transition-colors duration-150 ${
|
||||
@@ -87,10 +134,16 @@ function ToolButton({
|
||||
|
||||
const Divider = () => <span className="mx-1 h-5 w-px bg-ivory/10" aria-hidden="true" />
|
||||
|
||||
export function ArticleEditor({ doc, onChange }: ArticleEditorProps) {
|
||||
/**
|
||||
* ویرایشگر WYSIWYG مقاله (TipTap) — نود تصویر کپشندار، مودال لینک سئو-محور،
|
||||
* کلید راستچین/چپچین (فقط روی متن اثر دارد، نه نوار ابزار) و آپلود تصویر به MinIO.
|
||||
*/
|
||||
export function ArticleEditor({ doc, onChange, contentKey }: ArticleEditorProps) {
|
||||
const { runSave } = useAdmin()
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [direction, setDirection] = useState<TextDirection>('rtl')
|
||||
const [linkModal, setLinkModal] = useState<LinkModalState>(CLOSED_LINK_MODAL)
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: ARTICLE_EXTENSIONS,
|
||||
@@ -99,16 +152,30 @@ export function ArticleEditor({ doc, onChange }: ArticleEditorProps) {
|
||||
editorProps: {
|
||||
attributes: {
|
||||
dir: 'rtl',
|
||||
lang: 'fa',
|
||||
class: 'article-body min-h-[300px] px-4 py-4 focus:outline-none',
|
||||
'data-wysiwyg': 'true',
|
||||
},
|
||||
handlePaste: (_event_ignored, event) => {
|
||||
handlePaste: (_view, event, _slice) => {
|
||||
const file = event.clipboardData?.files?.[0]
|
||||
if (file && file.type.startsWith('image/')) {
|
||||
event.preventDefault()
|
||||
void uploadAndInsert(file)
|
||||
return true
|
||||
}
|
||||
// چسباندن یک نشانی → مودال لینک بلافاصله باز میشود
|
||||
const text = event.clipboardData?.getData('text/plain')?.trim() ?? ''
|
||||
if (isUrlLike(text)) {
|
||||
event.preventDefault()
|
||||
setLinkModal({
|
||||
open: true,
|
||||
editing: false,
|
||||
mode: 'insert',
|
||||
range: null,
|
||||
initial: { href: normalizeHref(text) ?? text, newTab: true, nofollow: false },
|
||||
})
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
handleDrop: (_view, event, _slice, moved) => {
|
||||
@@ -121,176 +188,245 @@ export function ArticleEditor({ doc, onChange }: ArticleEditorProps) {
|
||||
}
|
||||
return false
|
||||
},
|
||||
// پایان تایپ یک نشانی (فاصله بعد از آن) → مودال لینک باز میشود
|
||||
handleTextInput: (view, from, to, text) => {
|
||||
if (text !== ' ' || from !== to) return false
|
||||
const start = Math.max(0, from - URL_LOOKBACK)
|
||||
const before = view.state.doc.textBetween(start, from, ' ', ' ').trimEnd()
|
||||
const lastToken = before.slice(before.lastIndexOf(' ') + 1)
|
||||
if (!isUrlLike(lastToken)) return false
|
||||
setLinkModal({
|
||||
open: true,
|
||||
editing: false,
|
||||
mode: 'range',
|
||||
range: { from: from - lastToken.length, to: from },
|
||||
initial: { href: normalizeHref(lastToken) ?? lastToken, newTab: true, nofollow: false },
|
||||
})
|
||||
return false
|
||||
},
|
||||
// Enter داخل تیتر، بلوک تازه را پاراگراف میکند تا قالب تیتر سرایت نکند
|
||||
handleKeyDown: (view, event) => handleHeadingEnter(view, event),
|
||||
},
|
||||
onUpdate: ({ editor: instance }) => onChange(instance.getJSON()),
|
||||
})
|
||||
|
||||
// جهت متن فقط روی ناحیه ویرایش اعمال میشود؛ نوار ابزار همیشه راستچین میماند
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
editor.view.dom.setAttribute('dir', direction)
|
||||
editor.view.dom.setAttribute('lang', direction === 'rtl' ? 'fa' : 'en')
|
||||
// تزئینهای دوزبانه (اندازه ۲ پیکسل کوچکتر متن لاتین) با جهت تازه بازمحاسبه میشوند
|
||||
editor.view.dispatch(editor.state.tr.setMeta(bidiPluginKey, direction))
|
||||
}, [editor, direction])
|
||||
|
||||
/**
|
||||
* ترکیب (نمونه ویرایشگر، شناسه محتوا) که آخرین بار بارگذاری شده است.
|
||||
* تغییرهای معمول سند (تایپ کاربر) نباید بدنه را از نو بارگذاری کنند.
|
||||
*/
|
||||
const loadedContentRef = useRef<{ editor: EditorInstance; key: string | number } | null>(null)
|
||||
|
||||
// بارگذاری بدنه با تغییر شناسه محتوا (رسیدن مقاله از سرور) یا ساختهشدن نمونه ویرایشگر
|
||||
useEffect(() => {
|
||||
if (!editor || contentKey === undefined) return
|
||||
const loaded = loadedContentRef.current
|
||||
if (loaded && loaded.editor === editor && loaded.key === contentKey) return
|
||||
const incoming = doc && Object.keys(doc).length ? doc : null
|
||||
if (!incoming) return
|
||||
loadedContentRef.current = { editor, key: contentKey }
|
||||
// emitUpdate=false تا بارگذاری، ذخیره خودکارِ بیمورد را فعال نکند
|
||||
editor.commands.setContent(incoming, { emitUpdate: false })
|
||||
}, [editor, contentKey, doc])
|
||||
|
||||
async function uploadAndInsert(file: File) {
|
||||
if (!editor) return
|
||||
setUploading(true)
|
||||
|
||||
// استفاده از ریگکس یونیکد اصلاحشده برای نام فایل و Alt
|
||||
const originalName = file.name.replace(/\.[^.]+$/, '')
|
||||
const cleanAlt = originalName.replace(/[^\w\u0600-\u06FF\s-]/g, '-').replace(/\s+/g, '-').toLowerCase()
|
||||
|
||||
await runSave(async () => {
|
||||
const { url } = await adminApi.upload(file)
|
||||
editor.chain().focus().setImage({ src: url, alt: cleanAlt }).run()
|
||||
// نام پیشفرض از نام فایل ساخته میشود و در نبود آن، مقدار پیشفرض ثابت میماند
|
||||
const defaultAlt = file.name.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ').trim()
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setImage({ src: url, alt: defaultAlt || DEFAULT_IMAGE_ALT })
|
||||
.run()
|
||||
})
|
||||
setUploading(false)
|
||||
}
|
||||
|
||||
const setLink = () => {
|
||||
/** باز کردن مودال برای لینک انتخاب فعلی (یا ویرایش لینک زیر نشانگر) */
|
||||
const openLinkModalForSelection = () => {
|
||||
if (!editor) return
|
||||
if (editor.isActive('link')) {
|
||||
editor.chain().focus().unsetLink().run()
|
||||
return
|
||||
const attrs = editor.getAttributes('link') as {
|
||||
href?: string | null
|
||||
target?: string | null
|
||||
rel?: string | null
|
||||
}
|
||||
void Swal.fire({
|
||||
title: 'نشانی لینک',
|
||||
input: 'text',
|
||||
inputValue: 'https://',
|
||||
background: '#1e1a16',
|
||||
color: '#F5F3EF',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'ثبت',
|
||||
cancelButtonText: 'انصراف',
|
||||
buttonsStyling: false,
|
||||
customClass: {
|
||||
popup: 'font-pinar border border-ivory/10',
|
||||
title: 'font-pinar font-bold text-ivory',
|
||||
input: 'font-pinar',
|
||||
confirmButton: 'bg-copper text-char font-bold px-8 py-2.5 mx-2',
|
||||
cancelButton: 'border border-ivory/30 text-ivory font-light px-8 py-2.5 mx-2',
|
||||
const editing = Boolean(attrs.href)
|
||||
setLinkModal({
|
||||
open: true,
|
||||
editing,
|
||||
mode: 'selection',
|
||||
range: null,
|
||||
initial: {
|
||||
href: attrs.href ?? '',
|
||||
newTab: editing ? attrs.target === '_blank' : true,
|
||||
nofollow: editing ? parseLinkRel(attrs.rel).nofollow : false,
|
||||
},
|
||||
}).then((result) => {
|
||||
if (!result.isConfirmed || !result.value) return
|
||||
editor.chain().focus().extendMarkRange('link').setLink({ href: result.value }).run()
|
||||
})
|
||||
}
|
||||
|
||||
const applyLink = ({ href, newTab, nofollow }: LinkModalValue) => {
|
||||
if (!editor) return
|
||||
const linkAttrs = { href, target: newTab ? '_blank' : null, rel: buildLinkRel({ newTab, nofollow }) }
|
||||
const chain = editor.chain().focus()
|
||||
|
||||
if (linkModal.mode === 'insert') {
|
||||
// نشانی چسباندهشده: متن لینک تازه درج میشود
|
||||
chain.insertContent({ type: 'text', text: href, marks: [{ type: 'link', attrs: linkAttrs }] }).run()
|
||||
} else if (linkModal.mode === 'range' && linkModal.range) {
|
||||
// نشانی تایپشده: همان محدوده به لینک تبدیل میشود
|
||||
chain.setTextSelection(linkModal.range).extendMarkRange('link').setLink(linkAttrs).run()
|
||||
} else {
|
||||
chain.extendMarkRange('link').setLink(linkAttrs).run()
|
||||
}
|
||||
|
||||
setLinkModal((state) => ({ ...state, open: false }))
|
||||
}
|
||||
|
||||
const removeLink = () => {
|
||||
editor?.chain().focus().extendMarkRange('link').unsetLink().run()
|
||||
setLinkModal((state) => ({ ...state, open: false }))
|
||||
}
|
||||
|
||||
if (!editor) return null
|
||||
|
||||
return (
|
||||
<div className="border border-ivory/15 bg-char">
|
||||
<div className="flex flex-wrap items-center gap-1 border-b border-ivory/10 px-2 py-1.5">
|
||||
|
||||
{/* قالببندی بلوک — پاراگراف و تیترها با سایز مشخص */}
|
||||
<ToolButton
|
||||
label="پاراگراف (۱۴ پیکسل)"
|
||||
active={editor.isActive('paragraph')}
|
||||
onClick={() => editor.chain().focus().setParagraph().run()}
|
||||
<>
|
||||
<div className="border border-ivory/15 bg-char" data-dir={direction}>
|
||||
{/* نوار ابزار: همیشه راستچین و در بالای ویرایشگر ثابت میماند */}
|
||||
<div
|
||||
dir="rtl"
|
||||
className="sticky top-14 z-20 flex flex-wrap items-center gap-1 border-b border-ivory/10 bg-char px-2 py-1.5"
|
||||
>
|
||||
<PilcrowIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
label="تیتر ۱ (۱۸ پیکسل)"
|
||||
active={editor.isActive('heading', { level: 1 })}
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
||||
>
|
||||
<Heading1Icon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
label="تیتر ۲ (۱۶ پیکسل)"
|
||||
active={editor.isActive('heading', { level: 2 })}
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||
>
|
||||
<Heading2Icon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
label="تیتر ۳ (۱۴ پیکسل)"
|
||||
active={editor.isActive('heading', { level: 3 })}
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
||||
>
|
||||
<Heading3Icon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
label="پاراگراف"
|
||||
active={editor.isActive('paragraph')}
|
||||
onClick={() => applyBlockFormat(editor.view, 'paragraph')}
|
||||
>
|
||||
<PilcrowIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
label="تیتر ۱"
|
||||
active={editor.isActive('heading', { level: 1 })}
|
||||
onClick={() => applyBlockFormat(editor.view, 1)}
|
||||
>
|
||||
<Heading1Icon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
label="تیتر ۲"
|
||||
active={editor.isActive('heading', { level: 2 })}
|
||||
onClick={() => applyBlockFormat(editor.view, 2)}
|
||||
>
|
||||
<Heading2Icon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
label="تیتر ۳"
|
||||
active={editor.isActive('heading', { level: 3 })}
|
||||
onClick={() => applyBlockFormat(editor.view, 3)}
|
||||
>
|
||||
<Heading3Icon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
|
||||
<Divider />
|
||||
<Divider />
|
||||
|
||||
<select
|
||||
className="h-8 bg-ink border border-ivory/15 text-ivory/90 text-xs px-2 outline-none focus:border-copper cursor-pointer transition-colors"
|
||||
value={editor.isActive('smallSize') ? '12' : '14'}
|
||||
{/* جهت متن — فقط محتوای ویرایشگر را عوض میکند */}
|
||||
<ToolButton
|
||||
label="راستچین (فارسی)"
|
||||
active={direction === 'rtl'}
|
||||
onClick={() => setDirection('rtl')}
|
||||
>
|
||||
<AlignRightIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
label="چپچین (انگلیسی)"
|
||||
active={direction === 'ltr'}
|
||||
onClick={() => setDirection('ltr')}
|
||||
>
|
||||
<AlignLeftIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
|
||||
<Divider />
|
||||
|
||||
<ToolButton label="بولد" active={editor.isActive('bold')} onClick={() => editor.chain().focus().toggleBold().run()}>
|
||||
<BoldIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton label="ایتالیک" active={editor.isActive('italic')} onClick={() => editor.chain().focus().toggleItalic().run()}>
|
||||
<ItalicIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton label="زیرخط" active={editor.isActive('underline')} onClick={() => editor.chain().focus().toggleUnderline().run()}>
|
||||
<UnderlineIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton label="خطخورده" active={editor.isActive('strike')} onClick={() => editor.chain().focus().toggleStrike().run()}>
|
||||
<StrikethroughIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
|
||||
<Divider />
|
||||
|
||||
<ToolButton label="لیست نقطهای" active={editor.isActive('bulletList')} onClick={() => editor.chain().focus().toggleBulletList().run()}>
|
||||
<ListIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton label="لیست شمارهای" active={editor.isActive('orderedList')} onClick={() => editor.chain().focus().toggleOrderedList().run()}>
|
||||
<ListOrderedIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton label="نقلقول" active={editor.isActive('blockquote')} onClick={() => editor.chain().focus().toggleBlockquote().run()}>
|
||||
<QuoteIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
|
||||
<Divider />
|
||||
|
||||
<ToolButton label="افزودن لینک" active={editor.isActive('link')} onClick={openLinkModalForSelection}>
|
||||
<Link2Icon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton label="درج تصویر" onClick={() => fileRef.current?.click()}>
|
||||
{uploading ? <Loader2Icon className="h-4 w-4 animate-spin" /> : <ImagePlusIcon className="h-4 w-4" />}
|
||||
</ToolButton>
|
||||
|
||||
<Divider />
|
||||
|
||||
<ToolButton label="واگرد" onClick={() => editor.chain().focus().undo().run()}>
|
||||
<Undo2Icon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton label="ازنو" onClick={() => editor.chain().focus().redo().run()}>
|
||||
<Redo2Icon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
</div>
|
||||
|
||||
<EditorContent editor={editor} />
|
||||
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
data-editor-file-input
|
||||
accept="image/jpeg,image/png,image/webp,image/gif,image/avif"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
if (e.target.value === '12') {
|
||||
editor.chain().focus().setMark('smallSize').run()
|
||||
} else {
|
||||
editor.chain().focus().unsetMark('smallSize').run()
|
||||
}
|
||||
const file = e.target.files?.[0]
|
||||
if (file) void uploadAndInsert(file)
|
||||
e.target.value = ''
|
||||
}}
|
||||
>
|
||||
<option value="14">سایز ۱۴ (فارسی)</option>
|
||||
<option value="12">سایز ۱۲ (انگلیسی)</option>
|
||||
</select>
|
||||
|
||||
{/* متن انگلیسی — span با dir=ltr و سایز ۱۲ پیکسل */}
|
||||
<ToolButton
|
||||
label="متن انگلیسی (۱۲ پیکسل، چپبهراست)"
|
||||
active={editor.isActive(ENGLISH_TEXT_MARK)}
|
||||
onClick={() => editor.chain().focus().toggleMark(ENGLISH_TEXT_MARK).run()}
|
||||
>
|
||||
<LanguagesIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
|
||||
<Divider />
|
||||
|
||||
<ToolButton label="بولد" active={editor.isActive('bold')} onClick={() => editor.chain().focus().toggleBold().run()}>
|
||||
<BoldIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton label="ایتالیک" active={editor.isActive('italic')} onClick={() => editor.chain().focus().toggleItalic().run()}>
|
||||
<ItalicIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton label="زیرخط" active={editor.isActive('underline')} onClick={() => editor.chain().focus().toggleUnderline().run()}>
|
||||
<UnderlineIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton label="خطخورده" active={editor.isActive('strike')} onClick={() => editor.chain().focus().toggleStrike().run()}>
|
||||
<StrikethroughIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
|
||||
<Divider />
|
||||
|
||||
<ToolButton label="لیست نقطهای" active={editor.isActive('bulletList')} onClick={() => editor.chain().focus().toggleBulletList().run()}>
|
||||
<ListIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton label="لیست شمارهای" active={editor.isActive('orderedList')} onClick={() => editor.chain().focus().toggleOrderedList().run()}>
|
||||
<ListOrderedIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton label="نقلقول" active={editor.isActive('blockquote')} onClick={() => editor.chain().focus().toggleBlockquote().run()}>
|
||||
<QuoteIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
|
||||
<Divider />
|
||||
|
||||
<ToolButton label="لینک" active={editor.isActive('link')} onClick={setLink}>
|
||||
<Link2Icon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton label="درج تصویر" onClick={() => fileRef.current?.click()}>
|
||||
{uploading ? <Loader2Icon className="h-4 w-4 animate-spin" /> : <ImagePlusIcon className="h-4 w-4" />}
|
||||
</ToolButton>
|
||||
|
||||
<Divider />
|
||||
|
||||
<ToolButton label="واگرد" onClick={() => editor.chain().focus().undo().run()}>
|
||||
<Undo2Icon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton label="ازنو" onClick={() => editor.chain().focus().redo().run()}>
|
||||
<Redo2Icon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
/>
|
||||
</div>
|
||||
|
||||
<EditorContent editor={editor} />
|
||||
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
data-editor-file-input
|
||||
accept="image/jpeg,image/png,image/webp,image/gif,image/avif"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) void uploadAndInsert(file)
|
||||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* مودال لینک — انیمیشن ورود/خروج را خودش با کلاسهای Tailwind مدیریت میکند */}
|
||||
{linkModal.open && (
|
||||
<LinkModal
|
||||
initial={linkModal.initial}
|
||||
editing={linkModal.editing}
|
||||
onApply={applyLink}
|
||||
onRemove={removeLink}
|
||||
onClose={() => setLinkModal((state) => ({ ...state, open: false }))}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import Image from '@tiptap/extension-image'
|
||||
import { mergeAttributes } from '@tiptap/core'
|
||||
import { ReactNodeViewRenderer } from '@tiptap/react'
|
||||
import { ImageNodeView } from './ImageNodeView'
|
||||
|
||||
/**
|
||||
* تصویر مقاله با کپشن.
|
||||
* خروجی HTML در سایت: <figure><img><figcaption>کپشن</figcaption></figure>
|
||||
* و در ویرایشگر با NodeView سفارشی (ورودی کپشن + دکمه حذف) نمایش داده میشود.
|
||||
*/
|
||||
export const ArticleImage = Image.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
// ویژگیهای استاندارد تصویر (src/alt/title/width/height) از افزونه پایه
|
||||
...this.parent?.(),
|
||||
caption: {
|
||||
default: '',
|
||||
parseHTML: (element) =>
|
||||
element.closest('figure')?.querySelector('figcaption')?.textContent?.trim() ?? '',
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'figure',
|
||||
priority: 60,
|
||||
getAttrs: (element) => {
|
||||
if (!(element instanceof HTMLElement)) return false
|
||||
const img = element.querySelector('img')
|
||||
if (!img) return false
|
||||
return {
|
||||
src: img.getAttribute('src'),
|
||||
alt: img.getAttribute('alt'),
|
||||
title: img.getAttribute('title'),
|
||||
caption: element.querySelector('figcaption')?.textContent?.trim() ?? '',
|
||||
}
|
||||
},
|
||||
},
|
||||
// تصاویر بدون figure (محتوای قدیمی یا چسباندهشده)
|
||||
{ tag: 'img[src]:not([src^="data:"])' },
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
const { caption, ...imageAttributes } = HTMLAttributes
|
||||
const image: [string, Record<string, unknown>] = [
|
||||
'img',
|
||||
mergeAttributes(this.options.HTMLAttributes, imageAttributes),
|
||||
]
|
||||
|
||||
// کپشن خالی → عنصر figcaption اصلاً رندر نمیشود (فقط تصویر میماند)
|
||||
return caption
|
||||
? [
|
||||
'figure',
|
||||
{ class: 'article-figure' },
|
||||
image,
|
||||
['figcaption', { class: 'article-figure__caption' }, caption],
|
||||
]
|
||||
: ['figure', { class: 'article-figure' }, image]
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(ImageNodeView)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Extension } from '@tiptap/core'
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
||||
import type { EditorState } from '@tiptap/pm/state'
|
||||
import { Decoration, DecorationSet } from '@tiptap/pm/view'
|
||||
import { LATIN_CLASS, RTL_CLASS, findScriptRuns } from '../../utils/bidiText'
|
||||
|
||||
/** نام مارکهای قدیمی که خودشان اندازه/جهت متن را تعیین میکنند */
|
||||
const LEGACY_MARKS = new Set(['englishText', 'smallSize'])
|
||||
|
||||
type BidiState = { dir: 'rtl' | 'ltr' }
|
||||
|
||||
/** کلید پلاگین — جهت متن با متای همین کلید بهروز میشود */
|
||||
export const bidiPluginKey = new PluginKey<BidiState>('bidiTypography')
|
||||
|
||||
/**
|
||||
* تزئین دوزبانه متن ویرایشگر — اجرای لاتین (و در متن انگلیسی، اجرای فارسی) با یک
|
||||
* span جهتدار نشانهگذاری میشود تا ویرایشگر هم دقیقاً همان اندازه «۲ پیکسل کوچکتر»
|
||||
* خروجی سایت را نشان دهد. تزئینها سند را تغییر نمیدهند و ذخیره نمیشوند.
|
||||
*/
|
||||
export const BidiTypography = Extension.create({
|
||||
name: 'bidiTypography',
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin<BidiState>({
|
||||
key: bidiPluginKey,
|
||||
state: {
|
||||
init: () => ({ dir: 'rtl' }),
|
||||
apply: (tr, value) => {
|
||||
const dir = tr.getMeta(bidiPluginKey) as BidiState['dir'] | undefined
|
||||
return dir && dir !== value.dir ? { dir } : value
|
||||
},
|
||||
},
|
||||
props: {
|
||||
decorations: (state) =>
|
||||
buildDecorations(state, bidiPluginKey.getState(state)?.dir ?? 'rtl'),
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
|
||||
function buildDecorations(state: EditorState, dir: 'rtl' | 'ltr'): DecorationSet {
|
||||
const className = dir === 'rtl' ? LATIN_CLASS : RTL_CLASS
|
||||
const runDir = dir === 'rtl' ? 'ltr' : 'rtl'
|
||||
const decorations: Decoration[] = []
|
||||
const { doc, schema } = state
|
||||
|
||||
doc.descendants((node, pos, parent) => {
|
||||
if (node.type.name === 'codeBlock' || parent?.type.name === 'codeBlock') return false
|
||||
if (!node.isText || !node.text) return
|
||||
if (node.marks.some((mark) => mark.type === schema.marks.code || LEGACY_MARKS.has(mark.type.name))) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const run of findScriptRuns(node.text, dir)) {
|
||||
decorations.push(
|
||||
Decoration.inline(pos + run.from, pos + run.to, { class: className, dir: runDir }),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
return DecorationSet.create(doc, decorations)
|
||||
}
|
||||
@@ -1,126 +1,168 @@
|
||||
import { useEffect, useId, useRef, useState } from 'react'
|
||||
import { ImagePlusIcon, Loader2Icon } from 'lucide-react'
|
||||
import { useId, useRef, useState } from 'react'
|
||||
import { ImageIcon, ImagePlusIcon, Loader2Icon } from 'lucide-react'
|
||||
import { DEFAULT_IMAGE_TITLE } from '../../data/constants'
|
||||
import { adminApi } from '../../admin/adminClient'
|
||||
import { useAdmin } from '../../admin/AdminContext'
|
||||
import { useSmartTextField } from './useSmartTextField'
|
||||
|
||||
type ImageFieldProps = {
|
||||
label: string
|
||||
value: string
|
||||
onSave: (url: string) => Promise<boolean>
|
||||
/** متن جایگزین (Alt) — با ارسال این prop ورودی Alt نمایش داده میشود */
|
||||
/** متن جایگزین (Alt) */
|
||||
alt?: string
|
||||
/** ذخیره متن جایگزین — بدون آن، Alt فقط برای پیشنمایش و پیشنهاد استفاده میشود */
|
||||
onSaveAlt?: (alt: string) => Promise<boolean>
|
||||
/** پیشنهاد Alt وقتی خالی است (مثل عنوان مقاله یا نام موجودیت) */
|
||||
/** عنوان تصویر (Title) */
|
||||
title?: string
|
||||
onSaveTitle?: (title: string) => Promise<boolean>
|
||||
/**
|
||||
* پیشنهاد Alt وقتی کاربر چیزی ننوشته — مثل «{عنوان مقاله} - تصویر شاخص».
|
||||
* هرگز از URL یا نام فایل ساخته نمیشود.
|
||||
*/
|
||||
altFallback?: string
|
||||
/** پیشنهاد عنوان تصویر — پیشفرض «Photo 1» */
|
||||
titleFallback?: string
|
||||
}
|
||||
|
||||
const META_FIELD_CLASS =
|
||||
'mt-1.5 w-full border border-ivory/15 bg-char px-3 py-2 text-xs font-light text-ivory placeholder:text-ivory/30 focus:border-copper focus:outline-none'
|
||||
|
||||
/**
|
||||
* انتخاب و آپلود تصویر به MinIO — در کشوهای ویرایش و صفحه نوشتن مقاله.
|
||||
* پس از آپلود موفق، URL نسبی /media/... ذخیره میشود.
|
||||
* اگر Alt خالی باشد، پیش از فراخوانی API از عنوان موجودیت یا نام فایل ساخته میشود.
|
||||
* انتخاب و آپلود تصویر به MinIO با پیشنمایش بزرگ.
|
||||
* ترتیب فیلدها: ۱) عنوان تصویر (پیشفرض «Photo 1») ۲) متن جایگزین (پیشفرض «{عنوان} - تصویر شاخص»).
|
||||
* هر دو «دستی یا پیشفرض» هستند و Alt هرگز از URL ساخته نمیشود.
|
||||
* پیشفرض خودکار با فوکوس پاک میشود تا کاربر بلافاصله تایپ کند و با بلورِ خالی برمیگردد.
|
||||
*/
|
||||
export function ImageField({ label, value, onSave, alt, onSaveAlt, altFallback = '' }: ImageFieldProps) {
|
||||
export function ImageField({
|
||||
label,
|
||||
value,
|
||||
onSave,
|
||||
alt,
|
||||
onSaveAlt,
|
||||
title,
|
||||
onSaveTitle,
|
||||
altFallback = '',
|
||||
titleFallback = '',
|
||||
}: ImageFieldProps) {
|
||||
const { runSave, isAuthenticated, isEditMode } = useAdmin()
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const altInputId = useId()
|
||||
const titleId = useId()
|
||||
const altId = useId()
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [altText, setAltText] = useState(alt ?? '')
|
||||
|
||||
const showTitle = title !== undefined || !!onSaveTitle
|
||||
const showAlt = alt !== undefined || !!onSaveAlt
|
||||
|
||||
// همگامسازی با مقدار ذخیرهشده (پس از بهروزرسانی خوشبینانه در درخت داده)
|
||||
useEffect(() => {
|
||||
setAltText(alt ?? '')
|
||||
}, [alt])
|
||||
const titleField = useSmartTextField({
|
||||
value: title ?? '',
|
||||
fallback: titleFallback,
|
||||
hardFallback: DEFAULT_IMAGE_TITLE,
|
||||
onCommit: onSaveTitle,
|
||||
})
|
||||
const altField = useSmartTextField({
|
||||
value: alt ?? '',
|
||||
fallback: altFallback,
|
||||
hardFallback: label,
|
||||
onCommit: onSaveAlt,
|
||||
})
|
||||
|
||||
if (!isAuthenticated || !isEditMode) return null
|
||||
|
||||
/** متن جایگزین نهایی: ورودی کاربر → عنوان موجودیت → نام فایل */
|
||||
const resolveAlt = (fileName: string): string => {
|
||||
const typed = altText.trim()
|
||||
if (typed) return typed
|
||||
const fromEntity = altFallback.trim()
|
||||
if (fromEntity) return fromEntity
|
||||
return fileName.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ').trim() || label
|
||||
}
|
||||
|
||||
const pick = async (file: File | undefined) => {
|
||||
if (!file) return
|
||||
const generatedAlt = resolveAlt(file.name)
|
||||
setUploading(true)
|
||||
try {
|
||||
await runSave(async () => {
|
||||
const { url } = await adminApi.upload(file)
|
||||
const ok = await onSave(url)
|
||||
if (!ok) throw new Error('ذخیره نشد')
|
||||
// Alt ساختهشده هم ذخیره میشود تا تصویر بدون متن جایگزین نماند
|
||||
if (showAlt && generatedAlt !== (alt ?? '').trim()) {
|
||||
setAltText(generatedAlt)
|
||||
if (onSaveAlt) await onSaveAlt(generatedAlt)
|
||||
}
|
||||
// پیشفرضهای Alt/Title همان لحظه ثبت میشوند تا تصویر بدون متادیتا نماند
|
||||
if (showTitle) await titleField.commit()
|
||||
if (showAlt) await altField.commit()
|
||||
})
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const commitAlt = () => {
|
||||
const next = altText.trim()
|
||||
if (!onSaveAlt || next === (alt ?? '').trim()) return
|
||||
void onSaveAlt(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-bold text-ivory/80">{label}</p>
|
||||
<div className="mt-2 flex items-center gap-3">
|
||||
|
||||
{/* پیشنمایش بزرگ تصویر */}
|
||||
<div className="mt-2 overflow-hidden border border-ivory/15 bg-char">
|
||||
{value ? (
|
||||
<img
|
||||
src={value}
|
||||
alt={altText || label}
|
||||
className="h-16 w-24 border border-ivory/15 object-cover"
|
||||
alt={altField.display || label}
|
||||
title={titleField.display || undefined}
|
||||
className="h-[27rem] w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-16 w-24 items-center justify-center border border-dashed border-ivory/20 text-ivory/30">
|
||||
—
|
||||
<div className="flex h-[27rem] w-full flex-col items-center justify-center gap-2 border border-dashed border-ivory/20 text-ivory/30">
|
||||
<ImageIcon className="h-7 w-7" aria-hidden="true" />
|
||||
<span className="text-[11px] font-light">تصویری انتخاب نشده است</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="flex items-center gap-2 border border-ivory/20 px-4 py-2 text-xs font-light text-ivory/80 transition-colors duration-200 hover:border-copper hover:text-copper disabled:opacity-50"
|
||||
>
|
||||
{uploading ? (
|
||||
<Loader2Icon className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<ImagePlusIcon className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{uploading ? 'در حال آپلود…' : 'انتخاب تصویر'}
|
||||
</button>
|
||||
{value && <p className="mt-1.5 truncate text-[10px] font-light text-ivory/35" dir="ltr">{value}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showAlt && (
|
||||
<div className="mt-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label htmlFor={altInputId} className="text-[11px] font-light text-ivory/50">
|
||||
متن جایگزین (Alt)
|
||||
</label>
|
||||
<span className="text-[10px] font-light text-ivory/30">برای سئو و صفحهخوانها</span>
|
||||
</div>
|
||||
<input
|
||||
id={altInputId}
|
||||
type="text"
|
||||
dir="auto"
|
||||
value={altText}
|
||||
onChange={(e) => setAltText(e.target.value)}
|
||||
onBlur={commitAlt}
|
||||
placeholder={altFallback ? `پیشفرض: ${altFallback}` : 'توضیح کوتاه تصویر…'}
|
||||
className="mt-1.5 w-full border border-ivory/15 bg-char px-3 py-2 text-xs font-light text-ivory placeholder:text-ivory/30 focus:border-copper focus:outline-none"
|
||||
/>
|
||||
<div className="mt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="flex items-center gap-2 border border-ivory/20 px-4 py-2 text-xs font-light text-ivory/80 transition-colors duration-200 hover:border-copper hover:text-copper disabled:opacity-50"
|
||||
>
|
||||
{uploading ? (
|
||||
<Loader2Icon className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<ImagePlusIcon className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{uploading ? 'در حال آپلود…' : value ? 'تغییر تصویر' : 'انتخاب تصویر'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{(showTitle || showAlt) && (
|
||||
<div className="mt-4 grid max-w-md gap-4 border-t border-ivory/10 pt-4 sm:grid-cols-2">
|
||||
{showTitle && (
|
||||
<div>
|
||||
<label htmlFor={titleId} className="text-[11px] font-light text-ivory/50">
|
||||
عنوان تصویر (Title)
|
||||
</label>
|
||||
<input
|
||||
id={titleId}
|
||||
type="text"
|
||||
dir="auto"
|
||||
value={titleField.display}
|
||||
onChange={(event) => titleField.onChange(event.target.value)}
|
||||
onFocus={titleField.onFocus}
|
||||
onBlur={titleField.onBlur}
|
||||
placeholder={titleFallback.trim() || DEFAULT_IMAGE_TITLE}
|
||||
className={META_FIELD_CLASS}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAlt && (
|
||||
<div>
|
||||
<label htmlFor={altId} className="text-[11px] font-light text-ivory/50">
|
||||
متن جایگزین (Alt)
|
||||
</label>
|
||||
<input
|
||||
id={altId}
|
||||
type="text"
|
||||
dir="auto"
|
||||
value={altField.display}
|
||||
onChange={(event) => altField.onChange(event.target.value)}
|
||||
onFocus={altField.onFocus}
|
||||
onBlur={altField.onBlur}
|
||||
placeholder={
|
||||
altFallback.trim() || 'توضیح کوتاه تصویر برای سئو و صفحهخوانها'
|
||||
}
|
||||
className={META_FIELD_CLASS}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -129,9 +171,9 @@ export function ImageField({ label, value, onSave, alt, onSaveAlt, altFallback =
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif,image/avif"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
void pick(e.target.files?.[0])
|
||||
e.target.value = ''
|
||||
onChange={(event) => {
|
||||
void pick(event.target.files?.[0])
|
||||
event.target.value = ''
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { NodeViewWrapper } from '@tiptap/react'
|
||||
import type { NodeViewProps } from '@tiptap/react'
|
||||
import { PlusIcon, XIcon } from 'lucide-react'
|
||||
|
||||
type ImageAttrs = {
|
||||
src: string
|
||||
alt: string | null
|
||||
title: string | null
|
||||
caption: string | null
|
||||
}
|
||||
|
||||
const DELETE_BUTTON_CLASS =
|
||||
'flex h-7 w-7 shrink-0 items-center justify-center border border-red-400/50 bg-ink/85 text-red-300 backdrop-blur-sm transition-colors duration-150 hover:bg-red-500/20'
|
||||
|
||||
const INLINE_FIELD_CLASS =
|
||||
'w-full border border-ivory/15 bg-ink px-3 py-1.5 text-xs font-light text-ivory placeholder:text-ivory/30 focus:border-copper focus:outline-none'
|
||||
|
||||
const ADD_CAPTION_CLASS =
|
||||
'mt-1.5 inline-flex items-center gap-1.5 border border-dashed border-ivory/25 px-3 py-1 text-[11px] font-light text-ivory/50 transition-colors duration-150 hover:border-copper hover:text-copper'
|
||||
|
||||
/**
|
||||
* نمایش تصویر مقاله در ویرایشگر — دقیقاً با همان ساختار خروجی سایت:
|
||||
* <figure><img><figcaption>کپشن</figcaption></figure>
|
||||
*
|
||||
* ویرایش کاملاً درونخطی است (بدون پاپاور/مودال):
|
||||
* - هر تصویر یک «جعبه توضیح/کپشن» همراه خود دارد و متن آن در figcaption ذخیره میشود.
|
||||
* - دکمه X کپشن را کامل حذف میکند: صفت کپشن به مقدار پیشفرض برمیگردد و عنصر
|
||||
* figcaption با ورودیاش از DOM ویرایشگر (و از خروجی ذخیرهشده) حذف میشود؛
|
||||
* فقط تصویر میماند و «افزودن کپشن» دوباره جعبه را باز میکند.
|
||||
* - ویرایش Alt در ویرایشگر وجود ندارد؛ Alt هنگام درج از نام فایل ثبت میشود.
|
||||
* - دکمه X روی تصویر کل تصویر را حذف میکند.
|
||||
*/
|
||||
export function ImageNodeView({ node, updateAttributes, deleteNode, selected }: NodeViewProps) {
|
||||
const { src, alt, title, caption } = node.attrs as ImageAttrs
|
||||
const hasCaption = Boolean((caption ?? '').trim())
|
||||
const [captionOpen, setCaptionOpen] = useState(true)
|
||||
const captionInputRef = useRef<HTMLInputElement>(null)
|
||||
const focusOnOpen = useRef(false)
|
||||
const showCaption = captionOpen || hasCaption
|
||||
|
||||
// فقط وقتی کاربر «افزودن کپشن» را میزند ورودی فوکوس میگیرد (نه هنگام بارگذاری مقاله)
|
||||
useEffect(() => {
|
||||
if (!focusOnOpen.current) return
|
||||
focusOnOpen.current = false
|
||||
captionInputRef.current?.focus()
|
||||
}, [captionOpen])
|
||||
|
||||
/** حذف کامل کپشن: عنصر figcaption و ورودیاش از DOM ویرایشگر بیرون میرود */
|
||||
const removeCaption = () => {
|
||||
updateAttributes({ caption: '' })
|
||||
setCaptionOpen(false)
|
||||
}
|
||||
|
||||
const addCaption = () => {
|
||||
focusOnOpen.current = true
|
||||
setCaptionOpen(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeViewWrapper
|
||||
className={`article-figure-node ${selected ? 'is-selected' : ''}`}
|
||||
data-drag-handle
|
||||
>
|
||||
<figure className="article-figure relative">
|
||||
<img src={src} alt={alt ?? ''} title={title ?? ''} draggable={false} />
|
||||
|
||||
{/* حذف کل تصویر */}
|
||||
<button
|
||||
type="button"
|
||||
contentEditable={false}
|
||||
onClick={() => deleteNode()}
|
||||
aria-label="حذف تصویر"
|
||||
title="حذف تصویر"
|
||||
className={`absolute left-2 top-2 z-10 ${DELETE_BUTTON_CLASS}`}
|
||||
>
|
||||
<XIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
{showCaption ? (
|
||||
<figcaption className="article-figure__caption" contentEditable={false}>
|
||||
<input
|
||||
ref={captionInputRef}
|
||||
type="text"
|
||||
value={caption ?? ''}
|
||||
onChange={(event) => updateAttributes({ caption: event.target.value })}
|
||||
placeholder="توضیح تصویر (کپشن)…"
|
||||
aria-label="کپشن تصویر"
|
||||
className={INLINE_FIELD_CLASS}
|
||||
/>
|
||||
{/* حذف کامل کپشن — تصویر دستنخورده میماند */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={removeCaption}
|
||||
aria-label="حذف کپشن"
|
||||
title="حذف کپشن (تصویر حذف نمیشود)"
|
||||
className={DELETE_BUTTON_CLASS}
|
||||
>
|
||||
<XIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</figcaption>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
contentEditable={false}
|
||||
onClick={addCaption}
|
||||
className={ADD_CAPTION_CLASS}
|
||||
>
|
||||
<PlusIcon className="h-3 w-3" aria-hidden="true" />
|
||||
افزودن کپشن
|
||||
</button>
|
||||
)}
|
||||
</figure>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { FormEvent } from 'react'
|
||||
import { Link2Icon, Trash2Icon, XIcon } from 'lucide-react'
|
||||
import { buildLinkRel, normalizeHref } from '../../utils/link'
|
||||
|
||||
export type LinkModalValue = {
|
||||
href: string
|
||||
newTab: boolean
|
||||
nofollow: boolean
|
||||
}
|
||||
|
||||
type LinkModalProps = {
|
||||
/** مقادیر اولیه — از لینک فعال زیر نشانگر یا خالی برای لینک تازه */
|
||||
initial: LinkModalValue
|
||||
/** ویرایش لینک موجود (دکمه «حذف لینک» نمایش داده میشود) */
|
||||
editing: boolean
|
||||
onApply: (value: LinkModalValue) => void
|
||||
onRemove: () => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const FIELD_CLASS =
|
||||
'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'
|
||||
|
||||
/** مدت انیمیشن ورود/خروج — با duration-300 کلاسها هماهنگ است */
|
||||
const TRANSITION_MS = 300
|
||||
|
||||
/**
|
||||
* مودال ثبت و ویرایش لینک — انیمیشن نرم fade و scale با کلاسهای Tailwind.
|
||||
* ورود با یک فریم تاخیر (تا کلاسهای بسته اعمال شوند) و خروج پیش از بستن مودال اجرا میشود.
|
||||
* جایگزین پنجره پیشفرض مرورگر برای لینک شده است.
|
||||
*/
|
||||
export function LinkModal({ initial, editing, onApply, onRemove, onClose }: LinkModalProps) {
|
||||
const [href, setHref] = useState(initial.href)
|
||||
const [newTab, setNewTab] = useState(initial.newTab)
|
||||
const [nofollow, setNofollow] = useState(initial.nofollow)
|
||||
const [error, setError] = useState('')
|
||||
const [visible, setVisible] = useState(false)
|
||||
const [closing, setClosing] = useState(false)
|
||||
|
||||
// انیمیشن ورود: ابتدا حالت بسته رندر میشود، سپس به حالت باز میرود
|
||||
useEffect(() => {
|
||||
const frame = window.requestAnimationFrame(() => setVisible(true))
|
||||
return () => window.cancelAnimationFrame(frame)
|
||||
}, [])
|
||||
|
||||
/** بستن نرم: اول انیمیشن خروج کامل میشود، بعد عملیات و unmount */
|
||||
const closeWith = (action?: () => void) => {
|
||||
if (closing) return
|
||||
setClosing(true)
|
||||
setVisible(false)
|
||||
window.setTimeout(() => {
|
||||
action?.()
|
||||
onClose()
|
||||
}, TRANSITION_MS)
|
||||
}
|
||||
|
||||
const submit = (event: FormEvent) => {
|
||||
event.preventDefault()
|
||||
const normalized = normalizeHref(href)
|
||||
if (!normalized) {
|
||||
setError('نشانی معتبر نیست — نمونه: https://example.com یا /contact')
|
||||
return
|
||||
}
|
||||
closeWith(() => onApply({ href: normalized, newTab, nofollow }))
|
||||
}
|
||||
|
||||
const relPreview = buildLinkRel({ newTab, nofollow })
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[97] flex items-center justify-center p-6"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="ثبت لینک"
|
||||
>
|
||||
{/* پسزمینه تار — fade نرم */}
|
||||
<div
|
||||
className={`absolute inset-0 bg-char/70 backdrop-blur-sm transition-opacity duration-300 ease-out ${
|
||||
visible ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
onClick={() => closeWith()}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* کارت مودال — fade + scale نرم */}
|
||||
<form
|
||||
onSubmit={submit}
|
||||
dir="rtl"
|
||||
className={`relative w-full max-w-md border border-ivory/10 bg-ink p-7 shadow-2xl transition-all duration-300 ease-out ${
|
||||
visible ? 'translate-y-0 scale-100 opacity-100' : 'translate-y-2 scale-95 opacity-0'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="flex items-center gap-2 text-sm font-bold text-copper">
|
||||
<Link2Icon className="h-4 w-4" aria-hidden="true" />
|
||||
{editing ? 'ویرایش لینک' : 'افزودن لینک'}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => closeWith()}
|
||||
aria-label="بستن"
|
||||
className="text-ivory/50 transition-colors hover:text-ivory"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* نشانی */}
|
||||
<label htmlFor="link-href" className="mt-6 block text-xs font-light text-ivory/60">
|
||||
نشانی پیوند (URL)
|
||||
</label>
|
||||
<input
|
||||
id="link-href"
|
||||
type="text"
|
||||
dir="ltr"
|
||||
autoFocus
|
||||
value={href}
|
||||
onChange={(event) => {
|
||||
setHref(event.target.value)
|
||||
if (error) setError('')
|
||||
}}
|
||||
placeholder="https://example.com"
|
||||
className={FIELD_CLASS}
|
||||
/>
|
||||
{error && (
|
||||
<p className="mt-2 border-r-2 border-red-400/70 pr-3 text-[11px] font-light leading-6 text-red-300">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* باز شدن در تب جدید */}
|
||||
<label className="mt-5 flex cursor-pointer items-center gap-3 text-sm font-light text-ivory/80">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={newTab}
|
||||
onChange={(event) => setNewTab(event.target.checked)}
|
||||
className="h-4 w-4 accent-copper"
|
||||
/>
|
||||
باز شدن در تب جدید
|
||||
<span className="text-[10px] font-light text-ivory/35" dir="ltr">target="_blank"</span>
|
||||
</label>
|
||||
|
||||
{/* نوع لینک از نظر سئو */}
|
||||
<fieldset className="mt-5">
|
||||
<legend className="text-xs font-light text-ivory/60">انتقال اعتبار سئو (rel)</legend>
|
||||
<div className="mt-3 grid gap-2.5">
|
||||
{[
|
||||
{ key: 'dofollow', label: 'Dofollow', hint: 'اعتبار سئو به مقصد منتقل میشود', value: false },
|
||||
{ key: 'nofollow', label: 'Nofollow', hint: 'گوگل این لینک را دنبال نکند', value: true },
|
||||
].map((option) => (
|
||||
<label
|
||||
key={option.key}
|
||||
className={`flex cursor-pointer items-start gap-3 border px-4 py-3 transition-colors duration-150 ${
|
||||
nofollow === option.value
|
||||
? 'border-copper/60 bg-copper/10'
|
||||
: 'border-ivory/12 hover:border-ivory/25'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="link-rel"
|
||||
checked={nofollow === option.value}
|
||||
onChange={() => setNofollow(option.value)}
|
||||
className="mt-0.5 h-4 w-4 accent-copper"
|
||||
/>
|
||||
<span className="flex flex-col">
|
||||
<span className="text-sm font-light text-ivory">{option.label}</span>
|
||||
<span className="text-[11px] font-light text-ivory/45">{option.hint}</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{relPreview && (
|
||||
<p className="mt-4 text-[10px] font-light text-ivory/35" dir="ltr">
|
||||
rel="{relPreview}"
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* دکمهها */}
|
||||
<div className="mt-7 flex items-center justify-between gap-3">
|
||||
{editing ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => closeWith(onRemove)}
|
||||
className="flex items-center gap-1.5 border border-red-400/40 px-4 py-2.5 text-xs font-light text-red-300 transition-colors hover:bg-red-500/10"
|
||||
>
|
||||
<Trash2Icon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
حذف لینک
|
||||
</button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => closeWith()}
|
||||
className="border border-ivory/20 px-5 py-2.5 text-xs font-light text-ivory/70 transition-colors hover:border-ivory/40 hover:text-ivory"
|
||||
>
|
||||
انصراف
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-copper px-6 py-2.5 text-xs font-bold text-char transition-transform duration-200 hover:scale-105"
|
||||
>
|
||||
{editing ? 'ذخیره لینک' : 'افزودن لینک'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,89 +1,82 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import Select from 'react-select'
|
||||
import CreatableSelect from 'react-select/creatable'
|
||||
import type { StylesConfig } from 'react-select'
|
||||
import type { ClassNamesConfig, GroupBase, MultiValueRemoveProps } from 'react-select'
|
||||
import { XIcon } from 'lucide-react'
|
||||
|
||||
export type LuxeOption = { label: string; value: string }
|
||||
|
||||
const COPPER = '#d5874c'
|
||||
const IVORY = '#F5F3EF'
|
||||
const SURFACE = '#1e1a16'
|
||||
const BORDER = 'rgba(245,243,239,0.15)'
|
||||
type LuxeClassNames<IsMulti extends boolean> = ClassNamesConfig<
|
||||
LuxeOption,
|
||||
IsMulti,
|
||||
GroupBase<LuxeOption>
|
||||
>
|
||||
|
||||
/** استایل تیره و لوکس react-select هماهنگ با پالت سایت (مسی روی زغالی) */
|
||||
function makeStyles<IsMulti extends boolean>(): StylesConfig<LuxeOption, IsMulti> {
|
||||
const CONTROL_CLASS_NAMES = (isFocused: boolean): string =>
|
||||
`!min-h-[42px] !rounded-none !border !bg-char !transition-colors ${
|
||||
isFocused
|
||||
? '!border-copper !shadow-[0_0_0_1px_rgba(213,135,76,0.35)]'
|
||||
: '!border-ivory/15 !shadow-none hover:!border-copper/50'
|
||||
}`
|
||||
|
||||
const OPTION_CLASS_NAMES = (isSelected: boolean, isFocused: boolean): string =>
|
||||
`!cursor-pointer !rounded-none !text-[13px] ${
|
||||
isSelected
|
||||
? '!bg-copper/25 !font-bold !text-copper'
|
||||
: isFocused
|
||||
? '!bg-copper/15 !text-ivory'
|
||||
: '!bg-transparent !text-ivory/85'
|
||||
}`
|
||||
|
||||
/**
|
||||
* کلاسهای Tailwind برای همه اسلاتهای react-select — تم تیره پروژه.
|
||||
* react-select برای هر اسلات تابع میخواهد، پس همه با تابع برگردانده میشوند.
|
||||
*/
|
||||
function makeClassNames<IsMulti extends boolean>(
|
||||
extras?: Partial<LuxeClassNames<IsMulti>>,
|
||||
): LuxeClassNames<IsMulti> {
|
||||
return {
|
||||
control: (base, state) => ({
|
||||
...base,
|
||||
minHeight: 42,
|
||||
backgroundColor: SURFACE,
|
||||
borderColor: state.isFocused ? COPPER : BORDER,
|
||||
borderRadius: 0,
|
||||
boxShadow: state.isFocused ? '0 0 0 1px rgba(213,135,76,0.35)' : 'none',
|
||||
transition: 'border-color 150ms ease, box-shadow 150ms ease',
|
||||
':hover': { borderColor: state.isFocused ? COPPER : 'rgba(213,135,76,0.5)' },
|
||||
}),
|
||||
valueContainer: (base) => ({ ...base, padding: '2px 12px' }),
|
||||
placeholder: (base) => ({ ...base, color: 'rgba(245,243,239,0.35)', fontSize: 13 }),
|
||||
singleValue: (base) => ({ ...base, color: IVORY, fontSize: 13 }),
|
||||
input: (base) => ({ ...base, color: IVORY, fontSize: 13 }),
|
||||
menu: (base) => ({
|
||||
...base,
|
||||
backgroundColor: SURFACE,
|
||||
border: `1px solid ${BORDER}`,
|
||||
borderRadius: 0,
|
||||
boxShadow: '0 24px 50px -18px rgba(0,0,0,0.75)',
|
||||
overflow: 'hidden',
|
||||
zIndex: 60,
|
||||
}),
|
||||
menuList: (base) => ({ ...base, padding: 4 }),
|
||||
menuPortal: (base) => ({ ...base, zIndex: 200 }),
|
||||
option: (base, state) => ({
|
||||
...base,
|
||||
backgroundColor: state.isSelected
|
||||
? 'rgba(213,135,76,0.25)'
|
||||
: state.isFocused
|
||||
? 'rgba(213,135,76,0.12)'
|
||||
: 'transparent',
|
||||
color: state.isSelected ? COPPER : 'rgba(245,243,239,0.85)',
|
||||
fontSize: 13,
|
||||
fontWeight: state.isSelected ? 700 : 400,
|
||||
cursor: 'pointer',
|
||||
borderRadius: 0,
|
||||
':active': { backgroundColor: 'rgba(213,135,76,0.3)' },
|
||||
}),
|
||||
noOptionsMessage: (base) => ({ ...base, color: 'rgba(245,243,239,0.45)', fontSize: 12 }),
|
||||
indicatorSeparator: (base) => ({ ...base, backgroundColor: BORDER }),
|
||||
dropdownIndicator: (base, state) => ({
|
||||
...base,
|
||||
color: state.isFocused ? COPPER : 'rgba(245,243,239,0.4)',
|
||||
':hover': { color: COPPER },
|
||||
}),
|
||||
clearIndicator: (base) => ({
|
||||
...base,
|
||||
color: 'rgba(245,243,239,0.4)',
|
||||
':hover': { color: COPPER },
|
||||
}),
|
||||
multiValue: (base) => ({
|
||||
...base,
|
||||
backgroundColor: 'rgba(213,135,76,0.15)',
|
||||
border: '1px solid rgba(213,135,76,0.4)',
|
||||
borderRadius: 999,
|
||||
paddingInline: 2,
|
||||
}),
|
||||
multiValueLabel: (base) => ({ ...base, color: COPPER, fontSize: 12 }),
|
||||
multiValueRemove: (base) => ({
|
||||
...base,
|
||||
color: COPPER,
|
||||
borderRadius: 999,
|
||||
':hover': { backgroundColor: COPPER, color: SURFACE },
|
||||
}),
|
||||
control: (state) => CONTROL_CLASS_NAMES(state.isFocused),
|
||||
option: (state) => OPTION_CLASS_NAMES(state.isSelected, state.isFocused),
|
||||
valueContainer: () => '!gap-1 !px-3 !py-0.5',
|
||||
placeholder: () => '!text-[13px] !text-ivory/35',
|
||||
singleValue: () => '!text-[13px] !text-ivory',
|
||||
input: () => '!text-[13px] !text-ivory',
|
||||
indicatorsContainer: () => '!text-ivory/40',
|
||||
dropdownIndicator: () => '!text-ivory/40 hover:!text-copper',
|
||||
clearIndicator: () => '!text-ivory/40 hover:!text-copper',
|
||||
indicatorSeparator: () => '!bg-ivory/15',
|
||||
menu: () => '!mt-1 !rounded-none !border !border-ivory/10 !bg-char !shadow-lift',
|
||||
menuList: () => '!p-1',
|
||||
noOptionsMessage: () => '!text-xs !text-ivory/45',
|
||||
loadingMessage: () => '!text-xs !text-ivory/45',
|
||||
multiValue: () => '!m-0.5 !rounded-full !border !border-copper/40 !bg-copper/15 !py-0.5 !pe-0 !ps-2',
|
||||
multiValueLabel: () => '!text-xs !text-copper',
|
||||
...extras,
|
||||
}
|
||||
}
|
||||
|
||||
/** دکمه ضربدر حذف برچسب انتخابشده — همیشه واضح و قابل کلیک */
|
||||
function TagRemoveButton({ innerProps }: MultiValueRemoveProps<LuxeOption, true>) {
|
||||
return (
|
||||
<div
|
||||
{...innerProps}
|
||||
role="button"
|
||||
aria-label="حذف برچسب"
|
||||
title="حذف برچسب"
|
||||
className="mx-1 flex h-5 w-5 cursor-pointer items-center justify-center rounded-full text-copper transition-colors duration-150 hover:bg-copper hover:text-char"
|
||||
>
|
||||
<XIcon className="h-3 w-3" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const portalTarget = (): HTMLElement | undefined =>
|
||||
typeof document === 'undefined' ? undefined : document.body
|
||||
|
||||
const uniqueOptions = (values: string[]): LuxeOption[] =>
|
||||
[...new Set(values.filter(Boolean))].map((item) => ({ label: item, value: item }))
|
||||
|
||||
type LuxeSelectProps = {
|
||||
value: string
|
||||
options: string[]
|
||||
@@ -94,7 +87,7 @@ type LuxeSelectProps = {
|
||||
inputId?: string
|
||||
}
|
||||
|
||||
/** react-select تکی با ظاهر تیره پروژه — گزینه فعلی حتی اگر در لیست نباشد حفظ میشود */
|
||||
/** react-select تکی با تم تیره — گزینه فعلی حتی اگر در لیست نباشد حفظ میشود */
|
||||
export function LuxeSelect({
|
||||
value,
|
||||
options,
|
||||
@@ -104,9 +97,10 @@ export function LuxeSelect({
|
||||
isDisabled = false,
|
||||
inputId,
|
||||
}: LuxeSelectProps) {
|
||||
const selectOptions = useMemo(() => [...new Set([...options, ...(value ? [value] : [])])].map(
|
||||
(item) => ({ label: item, value: item }),
|
||||
), [options, value])
|
||||
const selectOptions = useMemo(
|
||||
() => uniqueOptions([...options, ...(value ? [value] : [])]),
|
||||
[options, value],
|
||||
)
|
||||
|
||||
return (
|
||||
<Select<LuxeOption, false>
|
||||
@@ -119,7 +113,141 @@ export function LuxeSelect({
|
||||
isClearable={isClearable}
|
||||
isDisabled={isDisabled}
|
||||
noOptionsMessage={() => 'موردی یافت نشد'}
|
||||
styles={makeStyles<false>()}
|
||||
classNames={makeClassNames<false>()}
|
||||
styles={{ menuPortal: (base) => ({ ...base, zIndex: 200 }) }}
|
||||
menuPosition="fixed"
|
||||
menuPortalTarget={portalTarget()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type LuxeCreatableSelectProps = {
|
||||
value: string
|
||||
options: string[]
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
inputId?: string
|
||||
/** متن گزینه «ساخت مقدار جدید» در فهرست بازشو */
|
||||
createLabel?: (input: string) => string
|
||||
}
|
||||
|
||||
/** react-select تکی و قابل ساخت — کاربر با کلیک روی گزینه ساخت، مقدار تازه میسازد */
|
||||
export function LuxeCreatableSelect({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
placeholder = 'انتخاب یا نوشتن مورد جدید…',
|
||||
inputId,
|
||||
createLabel = (input) => `افزودن «${input}»`,
|
||||
}: LuxeCreatableSelectProps) {
|
||||
const selectOptions = useMemo(
|
||||
() => uniqueOptions([...options, ...(value ? [value] : [])]),
|
||||
[options, value],
|
||||
)
|
||||
|
||||
return (
|
||||
<CreatableSelect<LuxeOption, false>
|
||||
inputId={inputId}
|
||||
classNamePrefix="luxe"
|
||||
options={selectOptions}
|
||||
value={selectOptions.find((option) => option.value === value) ?? null}
|
||||
onChange={(option) => onChange(option?.value?.trim() ?? '')}
|
||||
placeholder={placeholder}
|
||||
formatCreateLabel={(input) => createLabel(input)}
|
||||
noOptionsMessage={() => 'موردی یافت نشد'}
|
||||
classNames={makeClassNames<false>()}
|
||||
styles={{ menuPortal: (base) => ({ ...base, zIndex: 200 }) }}
|
||||
menuPosition="fixed"
|
||||
menuPortalTarget={portalTarget()}
|
||||
isClearable
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type CategoryOption = { label: string; value: string }
|
||||
|
||||
/** کلید نگهداری دستههای ساختهشده در مرورگر */
|
||||
const CREATED_CATEGORIES_KEY = 'aramesh_article_categories'
|
||||
|
||||
/** دستههایی که ادمین ساخته است — برای مقالههای بعدی هم در فهرست میمانند */
|
||||
function readCreatedCategories(): string[] {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(CREATED_CATEGORIES_KEY)
|
||||
const parsed: unknown = raw ? JSON.parse(raw) : []
|
||||
return Array.isArray(parsed)
|
||||
? parsed.filter((item): item is string => typeof item === 'string')
|
||||
: []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function storeCreatedCategory(category: string): void {
|
||||
try {
|
||||
const current = readCreatedCategories()
|
||||
if (current.includes(category)) return
|
||||
window.localStorage.setItem(CREATED_CATEGORIES_KEY, JSON.stringify([...current, category]))
|
||||
} catch {
|
||||
/* حافظه مرورگر در دسترس نیست — بیصدا رد شو */
|
||||
}
|
||||
}
|
||||
|
||||
type CategorySelectProps = {
|
||||
/** مقدار انتخابشده — یک شیء {value,label} یا null */
|
||||
value: CategoryOption | null
|
||||
/** تغییر انتخاب — null یعنی فقط انتخاب پاک شده است (دکمه X) */
|
||||
onChange: (option: CategoryOption | null) => void
|
||||
/** دستههای پایه */
|
||||
options: string[]
|
||||
/** متن گزینه «ساخت دسته جدید» در فهرست بازشو */
|
||||
createLabel?: (input: string) => string
|
||||
}
|
||||
|
||||
/**
|
||||
* انتخابگر تکمقداری دستهبندی (react-select Creatable).
|
||||
* - isMulti=false و isClearable: دکمه X فقط انتخاب جاری را پاک میکند و گزینهها دستنخورده میمانند.
|
||||
* - ساخت دسته جدید: به انتهای فهرست گزینهها اضافه و برای مقاله جاری انتخاب میشود و
|
||||
* در همین مرورگر برای مقالههای بعدی نگه داشته میشود.
|
||||
*/
|
||||
export function CategorySelect({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
createLabel = (input) => `ایجاد دسته جدید: ${input}`,
|
||||
}: CategorySelectProps) {
|
||||
const [createdOptions, setCreatedOptions] = useState<string[]>(() => readCreatedCategories())
|
||||
|
||||
// گزینههای پایه + دستههای ساختهشده (همیشه در انتهای فهرست)
|
||||
const selectOptions = useMemo(
|
||||
() => uniqueOptions([...options, ...createdOptions.filter((item) => !options.includes(item))]),
|
||||
[options, createdOptions],
|
||||
)
|
||||
|
||||
/** ساخت دسته جدید: افزودن به انتهای فهرست + انتخاب خودکار برای مقاله جاری */
|
||||
const handleCreate = (input: string) => {
|
||||
const category = input.trim()
|
||||
if (!category) return
|
||||
setCreatedOptions((previous) =>
|
||||
previous.includes(category) ? previous : [...previous, category],
|
||||
)
|
||||
storeCreatedCategory(category)
|
||||
onChange({ label: category, value: category })
|
||||
}
|
||||
|
||||
return (
|
||||
<CreatableSelect<CategoryOption, false>
|
||||
classNamePrefix="luxe"
|
||||
isMulti={false}
|
||||
isClearable
|
||||
options={selectOptions}
|
||||
value={value}
|
||||
onChange={(option) => onChange(option ? { label: option.label, value: option.value } : null)}
|
||||
onCreateOption={handleCreate}
|
||||
formatCreateLabel={(input) => createLabel(input)}
|
||||
placeholder="انتخاب یا نوشتن دسته جدید…"
|
||||
noOptionsMessage={() => 'موردی یافت نشد'}
|
||||
classNames={makeClassNames<false>()}
|
||||
styles={{ menuPortal: (base) => ({ ...base, zIndex: 200 }) }}
|
||||
menuPosition="fixed"
|
||||
menuPortalTarget={portalTarget()}
|
||||
/>
|
||||
@@ -130,18 +258,19 @@ type LuxeTagsSelectProps = {
|
||||
value: string[]
|
||||
onChange: (value: string[]) => void
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
/** هشتگها با react-select قابل ساخت — هر تایپ جدید با Enter به فهرست اضافه میشود */
|
||||
}/** هشتگها با react-select قابل ساخت — هر برچسب دکمه ضربدر حذف مستقل دارد */
|
||||
export function LuxeTagsSelect({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'هشتگ بنویسید و Enter بزنید…',
|
||||
}: LuxeTagsSelectProps) {
|
||||
const options = useMemo(
|
||||
() => [...new Set(value.filter(Boolean))].map((item) => ({ label: item, value: item })),
|
||||
[value],
|
||||
)
|
||||
const options = useMemo(() => uniqueOptions(value), [value])
|
||||
|
||||
/** پاکسازی و حذف تکراریها هنگام افزودن یا حذف برچسب */
|
||||
const applyTags = (next: readonly LuxeOption[]) => {
|
||||
const tags = next.map((option) => option.value.trim()).filter(Boolean)
|
||||
onChange([...new Set(tags)])
|
||||
}
|
||||
|
||||
return (
|
||||
<CreatableSelect<LuxeOption, true>
|
||||
@@ -149,11 +278,14 @@ export function LuxeTagsSelect({
|
||||
isMulti
|
||||
options={options}
|
||||
value={options}
|
||||
onChange={(next) => onChange(next.map((option) => option.value))}
|
||||
onChange={applyTags}
|
||||
placeholder={placeholder}
|
||||
formatCreateLabel={(input) => `افزودن «${input}»`}
|
||||
formatCreateLabel={(input) => `افزودن هشتگ «${input}»`}
|
||||
noOptionsMessage={() => 'هشتگی یافت نشد'}
|
||||
styles={makeStyles<true>()}
|
||||
closeMenuOnSelect={false}
|
||||
components={{ MultiValueRemove: TagRemoveButton }}
|
||||
classNames={makeClassNames<true>({ multiValueRemove: () => '!bg-transparent' })}
|
||||
styles={{ menuPortal: (base) => ({ ...base, zIndex: 200 }) }}
|
||||
menuPosition="fixed"
|
||||
menuPortalTarget={portalTarget()}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo } from 'react'
|
||||
import { AlertCircleIcon, CheckCircle2Icon, XCircleIcon } from 'lucide-react'
|
||||
import type { ArticleBodyDoc } from '../../data/site'
|
||||
import { docHeadings, docImages, docLinks, docToText, firstParagraph } from '../../utils/tiptapDoc'
|
||||
|
||||
export type SeoInput = {
|
||||
title: string
|
||||
@@ -13,78 +14,6 @@ export type SeoInput = {
|
||||
type Status = 'good' | 'ok' | 'bad'
|
||||
type Check = { label: string; status: Status; hint: string }
|
||||
|
||||
// ---------- استخراج متن/ساختار از سند TipTap ----------
|
||||
|
||||
function docToText(body: SeoInput['body']): string {
|
||||
if (Array.isArray(body)) return body.join(' ')
|
||||
const out: string[] = []
|
||||
const walk = (node: unknown) => {
|
||||
if (!node || typeof node !== 'object') return
|
||||
const n = node as { type?: string; text?: string; content?: unknown[] }
|
||||
if (n.type === 'text' && n.text) out.push(n.text)
|
||||
for (const c of n.content ?? []) walk(c)
|
||||
}
|
||||
walk(body)
|
||||
return out.join(' ')
|
||||
}
|
||||
|
||||
function docHeadings(body: SeoInput['body']): string[] {
|
||||
if (Array.isArray(body)) return []
|
||||
const out: string[] = []
|
||||
const walk = (node: unknown) => {
|
||||
if (!node || typeof node !== 'object') return
|
||||
const n = node as { type?: string; text?: string; attrs?: { level?: number }; content?: unknown[] }
|
||||
if (n.type === 'heading' && n.attrs?.level === 2) {
|
||||
const texts: string[] = []
|
||||
const collect = (inner: unknown) => {
|
||||
if (!inner || typeof inner !== 'object') return
|
||||
const t = inner as { type?: string; text?: string; content?: unknown[] }
|
||||
if (t.type === 'text' && t.text) texts.push(t.text)
|
||||
for (const c of t.content ?? []) collect(c)
|
||||
}
|
||||
collect(n)
|
||||
out.push(texts.join(''))
|
||||
}
|
||||
for (const c of n.content ?? []) walk(c)
|
||||
}
|
||||
walk(body)
|
||||
return out
|
||||
}
|
||||
|
||||
function docImages(body: SeoInput['body']): { src: string; alt: string }[] {
|
||||
if (Array.isArray(body)) return []
|
||||
const out: { src: string; alt: string }[] = []
|
||||
const walk = (node: unknown) => {
|
||||
if (!node || typeof node !== 'object') return
|
||||
const n = node as { type?: string; attrs?: { src?: string; alt?: string }; content?: unknown[] }
|
||||
if (n.type === 'image') out.push({ src: n.attrs?.src ?? '', alt: n.attrs?.alt ?? '' })
|
||||
for (const c of n.content ?? []) walk(c)
|
||||
}
|
||||
walk(body)
|
||||
return out
|
||||
}
|
||||
|
||||
function docLinks(body: SeoInput['body']): string[] {
|
||||
if (Array.isArray(body)) return []
|
||||
const out: string[] = []
|
||||
const walk = (node: unknown) => {
|
||||
if (!node || typeof node !== 'object') return
|
||||
const n = node as { type?: string; marks?: { type?: string; attrs?: { href?: string } }[]; content?: unknown[] }
|
||||
for (const m of n.marks ?? []) if (m.type === 'link' && m.attrs?.href) out.push(m.attrs.href)
|
||||
for (const c of n.content ?? []) walk(c)
|
||||
}
|
||||
walk(body)
|
||||
return out
|
||||
}
|
||||
|
||||
function firstParagraph(body: SeoInput['body']): string {
|
||||
if (Array.isArray(body)) return body[0] ?? ''
|
||||
const p = (body as { content?: { type?: string; content?: unknown[] }[] }).content?.find(
|
||||
(c) => c.type === 'paragraph',
|
||||
)
|
||||
return docToText({ type: 'doc', content: p ? [p] : [] } as never)
|
||||
}
|
||||
|
||||
// ---------- محاسبه بررسیها ----------
|
||||
|
||||
export function useSeoChecks(input: SeoInput): Check[] {
|
||||
@@ -117,7 +46,7 @@ export function useSeoChecks(input: SeoInput): Check[] {
|
||||
? { label: 'کلمه کلیدی در پاراگراف اول', status: 'good', hint: 'کلمه کلیدی در ابتدای متن هست.' }
|
||||
: { label: 'کلمه کلیدی در پاراگراف اول', status: 'ok', hint: 'بهتر است کلمه کلیدی در پاراگراف اول بیاید.' },
|
||||
)
|
||||
const h2s = docHeadings(input.body)
|
||||
const h2s = docHeadings(input.body, 2)
|
||||
if (h2s.length === 0) checks.push({ label: 'کلمه کلیدی در تیترها', status: 'ok', hint: 'هنوز تیتر H2 در متن نیست.' })
|
||||
else
|
||||
checks.push(
|
||||
@@ -140,7 +69,7 @@ export function useSeoChecks(input: SeoInput): Check[] {
|
||||
else checks.push({ label: `طول متن (${words.length} واژه)`, status: 'bad', hint: 'متن خیلی کوتاه است — حداقل ۸۰ و ترجیحاً ۱۵۰ واژه بنویسید.' })
|
||||
|
||||
// تیتر H2
|
||||
const h2s = docHeadings(input.body)
|
||||
const h2s = docHeadings(input.body, 2)
|
||||
if (h2s.length > 0) checks.push({ label: `تیتربندی (${h2s.length} تیتر H2)`, status: 'good', hint: 'متن تیتربندی شده است.' })
|
||||
else checks.push({ label: 'تیتربندی', status: 'bad', hint: 'برای خوانایی، متن را با تیتر H2 بخشبندی کنید.' })
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { setBlockType, splitBlock } from '@tiptap/pm/commands'
|
||||
import type { EditorView } from '@tiptap/pm/view'
|
||||
import type { ResolvedPos } from '@tiptap/pm/model'
|
||||
|
||||
/** قالب بلوک جاری — پاراگراف یا یکی از سطوح تیتر */
|
||||
export type BlockFormat = 'paragraph' | 1 | 2 | 3
|
||||
|
||||
type LineBounds = {
|
||||
/** ابتدای بلوک جاری */
|
||||
start: number
|
||||
/** پایان بلوک جاری */
|
||||
end: number
|
||||
/** ابتدای خط جاری (پس از نزدیکترین شکست نرم قبل از نشانگر) */
|
||||
lineStart: number
|
||||
/** پایان خط جاری (نزدیکترین شکست نرم پس از نشانگر) */
|
||||
lineEnd: number
|
||||
/** موقعیت شکست نرم قبل از خط جاری — null یعنی خط، ابتدای بلوک است */
|
||||
before: number | null
|
||||
/** موقعیت شکست نرم بعد از خط جاری — null یعنی خط، انتهای بلوک است */
|
||||
after: number | null
|
||||
}
|
||||
|
||||
/** مرزهای خط جاری داخل بلوک جاری — خطوطی که با شکست نرم (<br>) از هم جدا شدهاند */
|
||||
function lineBounds($from: ResolvedPos): LineBounds {
|
||||
const start = $from.start()
|
||||
const end = $from.end()
|
||||
let lineStart = start
|
||||
let lineEnd = end
|
||||
let before: number | null = null
|
||||
let after: number | null = null
|
||||
|
||||
$from.parent.forEach((node, offset) => {
|
||||
if (node.type.name !== 'hardBreak') return
|
||||
const pos = start + offset
|
||||
if (pos < $from.pos) {
|
||||
lineStart = pos + 1
|
||||
before = pos
|
||||
} else if (after === null) {
|
||||
lineEnd = pos
|
||||
after = pos
|
||||
}
|
||||
})
|
||||
|
||||
return { start, end, lineStart, lineEnd, before, after }
|
||||
}
|
||||
|
||||
/**
|
||||
* جدا کردن خط جاری از خطوط چسبیده با شکست نرم (Shift+Enter یا متن چسباندهشده از Word).
|
||||
* بدون این جداسازی، عوض کردن قالب یک خط، قالب همه خطوط همان بلوک را با هم عوض میکند.
|
||||
* اگر گزینش از خط عبور کند (قالببندی چندخطیِ عمدی) دستنخورده میماند.
|
||||
*/
|
||||
function isolateCurrentLine(view: EditorView): boolean {
|
||||
const { state } = view
|
||||
const { selection } = state
|
||||
const bounds = lineBounds(selection.$from)
|
||||
|
||||
if (bounds.before === null && bounds.after === null) return false
|
||||
|
||||
const selectionInsideLine =
|
||||
selection.empty ||
|
||||
(selection.from >= bounds.lineStart && selection.to <= bounds.lineEnd)
|
||||
if (!selectionInsideLine) return false
|
||||
|
||||
const { tr } = state
|
||||
if (bounds.after !== null) {
|
||||
tr.delete(bounds.after, bounds.after + 1)
|
||||
tr.split(bounds.after)
|
||||
}
|
||||
if (bounds.before !== null) {
|
||||
tr.delete(bounds.before, bounds.before + 1)
|
||||
tr.split(bounds.before)
|
||||
}
|
||||
if (!tr.docChanged) return false
|
||||
|
||||
view.dispatch(tr)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* قالببندی بلوک جاری (تیتر ۱/۲/۳ یا پاراگراف) — فقط همان خطی که نشانگر در آن است
|
||||
* و به خطوط بالا و پایین کاری ندارد. کلیک دوباره روی همان سطح تیتر، آن را به
|
||||
* پاراگراف برمیگرداند (رفتار toggle).
|
||||
*/
|
||||
export function applyBlockFormat(view: EditorView, format: BlockFormat): boolean {
|
||||
view.focus()
|
||||
isolateCurrentLine(view)
|
||||
|
||||
const { state } = view
|
||||
const paragraph = state.schema.nodes.paragraph
|
||||
const heading = state.schema.nodes.heading
|
||||
if (!paragraph || !heading) return false
|
||||
|
||||
if (format === 'paragraph') return setBlockType(paragraph)(state, view.dispatch, view)
|
||||
|
||||
const current = state.selection.$from.parent
|
||||
const sameHeading = current.type === heading && current.attrs.level === format
|
||||
return sameHeading
|
||||
? setBlockType(paragraph)(state, view.dispatch, view)
|
||||
: setBlockType(heading, { level: format })(state, view.dispatch, view)
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter داخل تیتر: بلوک تازه پاراگراف میشود (نه تیتر تازه) تا قالب تیتر به خط
|
||||
* بعدی سرایت نکند و مرز بلوکها تمیز بماند.
|
||||
* این هندلر پیش از keymap پیشفرض اجرا میشود و فقط همین حالت را میگیرد.
|
||||
*/
|
||||
export function handleHeadingEnter(view: EditorView, event: KeyboardEvent): boolean {
|
||||
if (event.key !== 'Enter' || event.shiftKey || event.altKey || event.ctrlKey || event.metaKey) {
|
||||
return false
|
||||
}
|
||||
|
||||
const { state } = view
|
||||
const { selection } = state
|
||||
const paragraph = state.schema.nodes.paragraph
|
||||
if (!paragraph || !selection.empty || selection.$from.parent.type.name !== 'heading') return false
|
||||
|
||||
// خط تازه با splitBlock ساخته و بعد به پاراگراف تبدیل میشود
|
||||
if (!splitBlock(state, view.dispatch, view)) return false
|
||||
setBlockType(paragraph)(view.state, view.dispatch, view)
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
type SmartTextFieldOptions = {
|
||||
/** مقدار ذخیرهشده در بیرون (منبع حقیقت) */
|
||||
value: string
|
||||
/** پیشنهاد خودکار — وقتی کاربر چیزی ننوشته، همین متن نمایش داده و ذخیره میشود */
|
||||
fallback?: string
|
||||
/** آخرین گزینه وقتی پیشنهادی هم وجود ندارد (مثل «Photo 1») */
|
||||
hardFallback?: string
|
||||
/** ذخیره مقدار نهایی — فقط وقتی نسبت به مقدار ذخیرهشده تغییر کرده باشد */
|
||||
onCommit?: (next: string) => void | Promise<unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* فیلد متنی با «پیشفرض هوشمند» برای Alt/Title تصویر:
|
||||
* - متن کاربر تا لحظه ذخیره فقط در حالت محلی میماند؛ در حین تایپ نه trim میشود و نه از
|
||||
* بیرون بازنویسی میشود، پس فاصله انتهای متن و جای نشانگر حفظ میشود.
|
||||
* - با فوکوس، پیشفرض خودکار پاک میشود تا کاربر بلافاصله تایپ کند.
|
||||
* - با بلور، اگر فیلد خالی بماند پیشفرض خودکار برمیگردد و مقدار نهایی ذخیره میشود.
|
||||
*/
|
||||
export function useSmartTextField({
|
||||
value,
|
||||
fallback = '',
|
||||
hardFallback = '',
|
||||
onCommit,
|
||||
}: SmartTextFieldOptions) {
|
||||
const [text, setText] = useState(value)
|
||||
const [focused, setFocused] = useState(false)
|
||||
|
||||
// تازهترین مقادیر در ref نگه داشته میشوند تا هندلرها به closure قدیمی وابسته نباشند
|
||||
const textRef = useRef(text)
|
||||
textRef.current = text
|
||||
const focusedRef = useRef(focused)
|
||||
focusedRef.current = focused
|
||||
const fallbackRef = useRef(fallback)
|
||||
fallbackRef.current = fallback
|
||||
const hardFallbackRef = useRef(hardFallback)
|
||||
hardFallbackRef.current = hardFallback
|
||||
const valueRef = useRef(value)
|
||||
valueRef.current = value
|
||||
|
||||
// همگامسازی با مقدار ذخیرهشده از بیرون (بارگذاری مقاله، بهروزرسانی خوشبینانه)
|
||||
// اما هرگز در حین ویرایش کاربر — وگرنه نشانگر میپرد و فاصلهها میخورند
|
||||
useEffect(() => {
|
||||
if (focusedRef.current) return
|
||||
setText(value)
|
||||
}, [value])
|
||||
|
||||
/** مقدار نهایی فیلد: متن کاربر → پیشنهاد خودکار → پیشفرض ثابت */
|
||||
const resolve = useCallback((): string => {
|
||||
const current = textRef.current.trim()
|
||||
if (current) return current
|
||||
return fallbackRef.current.trim() || hardFallbackRef.current.trim()
|
||||
}, [])
|
||||
|
||||
const commit = useCallback(async (): Promise<void> => {
|
||||
const next = resolve()
|
||||
setText(next)
|
||||
if (onCommit && next !== valueRef.current.trim()) await onCommit(next)
|
||||
}, [onCommit, resolve])
|
||||
|
||||
const onFocus = useCallback(() => {
|
||||
setFocused(true)
|
||||
// پیشفرض خودکار متن واقعی نیست؛ با فوکوس پاک میشود تا کاربر بلافاصله تایپ کند
|
||||
const current = textRef.current.trim()
|
||||
const auto = fallbackRef.current.trim() || hardFallbackRef.current.trim()
|
||||
if (!current || current === auto) setText('')
|
||||
}, [])
|
||||
|
||||
const onBlur = useCallback(() => {
|
||||
setFocused(false)
|
||||
void commit()
|
||||
}, [commit])
|
||||
|
||||
const onChange = useCallback((next: string) => {
|
||||
setText(next)
|
||||
}, [])
|
||||
|
||||
/** متن نمایشی — در نبود متن کاربر، پیشنهاد خودکار نشان داده میشود */
|
||||
const display = focused ? text : text !== '' ? text : fallback
|
||||
|
||||
return { display, focused, onFocus, onBlur, onChange, commit, resolve }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* ثابتهای مشترک رابط کاربری — تنها منبع مقادیر پیشفرض (بدون متن پراکنده در کامپوننتها).
|
||||
*/
|
||||
|
||||
/** عنوان پیشفرض تصویر، وقتی کاربر عنوانی وارد نکرده باشد */
|
||||
export const DEFAULT_IMAGE_TITLE = 'Photo 1'
|
||||
|
||||
/** متن جایگزین پیشفرض تصویرهای داخل متن (وقتی نام فایل چیزی به دست نمیدهد) */
|
||||
export const DEFAULT_IMAGE_ALT = 'تصویر مقاله'
|
||||
|
||||
/** پسوند پیشنهاد Alt برای تصویر شاخص — در کنار عنوان رکورد قرار میگیرد */
|
||||
export const FEATURED_IMAGE_ALT_SUFFIX = 'تصویر شاخص'
|
||||
|
||||
/** سرعت مطالعه مبنای محاسبه زمان مطالعه — واژه در دقیقه */
|
||||
export const WORDS_PER_MINUTE = 200
|
||||
|
||||
/** برچسب زمان مطالعه در خروجی */
|
||||
export const READING_TIME_LABEL = 'دقیقه مطالعه'
|
||||
@@ -44,6 +44,8 @@ export type Article = {
|
||||
meta_description?: string;
|
||||
/** متن جایگزین تصویر شاخص (Alt) */
|
||||
image_alt?: string;
|
||||
/** عنوان تصویر شاخص (Title) */
|
||||
image_title?: string;
|
||||
/** نویسنده مقاله — در سربرگ مقاله زیر عنوان نمایش داده میشود */
|
||||
author?: string;
|
||||
/** هشتگها/برچسبهای مقاله */
|
||||
|
||||
+86
-4
@@ -92,6 +92,10 @@ body {
|
||||
.article-body > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
/* ---------- تایپوگرافی مقاله ----------
|
||||
راستچین (فارسی): پایه ۱۴ / تیتر۱ ۱۸ / تیتر۲ ۱۶ / تیتر۳ ۱۴
|
||||
چپچین (انگلیسی): پایه ۱۲ / تیتر۱ ۱۶ / تیتر۲ ۱۴ / تیتر۳ ۱۲
|
||||
جهت با data-dir روی پوسته ویرایشگر یا مقاله تعیین میشود. */
|
||||
.article-body h1,
|
||||
.article-body h2,
|
||||
.article-body h3 {
|
||||
@@ -104,13 +108,62 @@ body {
|
||||
scroll-margin-top: 7rem;
|
||||
}
|
||||
.article-body h1 {
|
||||
font-size: 18px; /* تیتر ۱ سایز ۱۸ */
|
||||
font-size: 18px;
|
||||
}
|
||||
.article-body h2 {
|
||||
font-size: 16px; /* تیتر ۲ سایز ۱۶ */
|
||||
font-size: 16px;
|
||||
}
|
||||
.article-body h3 {
|
||||
font-size: 14px; /* تیتر ۳ سایز ۱۴ */
|
||||
font-size: 14px;
|
||||
}
|
||||
[data-dir='rtl'] .article-body {
|
||||
direction: rtl;
|
||||
text-align: right;
|
||||
}
|
||||
[data-dir='ltr'] .article-body {
|
||||
direction: ltr;
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
}
|
||||
[data-dir='ltr'] .article-body h1 {
|
||||
font-size: 16px;
|
||||
}
|
||||
[data-dir='ltr'] .article-body h2 {
|
||||
font-size: 14px;
|
||||
}
|
||||
[data-dir='ltr'] .article-body h3 {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ---------- تایپوگرافی دوزبانه (فارسی و لاتین در یک خط) ----------
|
||||
متن لاتین همیشه ۲ پیکسل کوچکتر از متن فارسیِ همان بلوک است:
|
||||
تیتر۱ ۱۸→۱۶، تیتر۲ ۱۶→۱۴، تیتر۳ و پاراگراف ۱۴→۱۲.
|
||||
calc(1em - 2px) نسبت به اندازه همان بلوک حساب میشود، پس قاعده در همه سطحها درست
|
||||
میماند و خط پایه هم جابهجا نمیشود (line-height نسبی است).
|
||||
در مقاله انگلیسی (data-dir=ltr) پایه همان اندازه کوچکتر است و واژههای فارسی
|
||||
۲ پیکسل بزرگتر میشوند تا تفاضل همیشه ۲ پیکسل بماند. */
|
||||
.article-body .latin,
|
||||
.article-body .persian,
|
||||
.article-body .english-text {
|
||||
unicode-bidi: isolate;
|
||||
}
|
||||
|
||||
.article-body .latin,
|
||||
.article-body .english-text {
|
||||
font-size: calc(1em - 2px);
|
||||
}
|
||||
|
||||
.article-body .persian {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
[data-dir='ltr'] .article-body .latin,
|
||||
[data-dir='ltr'] .article-body .english-text {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
[data-dir='ltr'] .article-body .persian {
|
||||
font-size: calc(1em + 2px);
|
||||
}
|
||||
.article-body span[data-small="true"] {
|
||||
font-size: 12px; /* مارک اختصاصی برای سایز ۱۲ - انگلیسی */
|
||||
@@ -119,7 +172,7 @@ body {
|
||||
.english-text {
|
||||
direction: ltr;
|
||||
unicode-bidi: isolate;
|
||||
font-size: 12px;
|
||||
/* اندازه از قواعد دوزبانه بالا میآید: ۲ پیکسل کوچکتر از متن فارسی همان بلوک */
|
||||
}
|
||||
.article-body ul,
|
||||
.article-body ol {
|
||||
@@ -163,6 +216,35 @@ body {
|
||||
border: 1px solid rgba(245, 243, 239, 0.1);
|
||||
object-fit: cover;
|
||||
}
|
||||
/* ---------- تصویر کپشندار: <figure><img><figcaption> ---------- */
|
||||
.article-body figure {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
.article-body figure img {
|
||||
margin-top: 0;
|
||||
}
|
||||
.article-body figcaption {
|
||||
margin-top: 0.7rem;
|
||||
color: rgba(245, 243, 239, 0.5);
|
||||
font-size: 0.85em;
|
||||
line-height: 1.9;
|
||||
text-align: center;
|
||||
}
|
||||
/* ویرایشگر: کپشن ورودی و دکمه حذف زیر تصویر */
|
||||
[data-wysiwyg] .article-figure-node {
|
||||
margin: 1.25rem 0;
|
||||
}
|
||||
[data-wysiwyg] .article-figure__caption {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.6rem;
|
||||
text-align: right;
|
||||
}
|
||||
[data-wysiwyg] .article-figure-node.is-selected .article-figure {
|
||||
outline: 1px dashed rgba(213, 135, 76, 0.8);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.article-body strong {
|
||||
color: #f5f3ef;
|
||||
font-weight: 700;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { ArrowLeftIcon, ChevronLeftIcon } from 'lucide-react';
|
||||
import { ArrowLeftIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
|
||||
import { renderToHTMLString } from '@tiptap/static-renderer';
|
||||
import { Reveal } from '../components/Reveal';
|
||||
import { ButtonLink } from '../components/Button';
|
||||
@@ -12,17 +12,23 @@ import { useSite } from '../api';
|
||||
import { applySeo } from '../seo';
|
||||
import { useEffect } from 'react';
|
||||
import { ARTICLE_EXTENSIONS } from '../components/admin/ArticleEditor';
|
||||
import { docToText } from '../utils/tiptapDoc';
|
||||
import { detectDirection } from '../utils/direction';
|
||||
import { wrapScriptRuns } from '../utils/bidiText';
|
||||
import type { Article } from '../data/site';
|
||||
|
||||
/** بدنه مقاله را به HTML و فهرست مطالب تبدیل میکند — سند TipTap جدید یا پاراگرافهای قدیمی */
|
||||
function renderBody(article: Article): { html: string | null; toc: TocItem[]; legacy: string[] } {
|
||||
function renderBody(
|
||||
article: Article,
|
||||
dir: 'rtl' | 'ltr',
|
||||
): { html: string | null; toc: TocItem[]; legacy: string[] } {
|
||||
const body = article.body as unknown;
|
||||
if (Array.isArray(body)) return { html: null, toc: [], legacy: body };
|
||||
if (body && typeof body === 'object' && (body as { type?: string }).type === 'doc') {
|
||||
try {
|
||||
const html = renderToHTMLString({ extensions: ARTICLE_EXTENSIONS, content: body as never });
|
||||
// تیترهای H2/H3 شناسه لنگر میگیرند تا فهرست مطالب به آنها بپرد
|
||||
const { items, html: htmlWithIds } = extractToc(html);
|
||||
// تیترها شناسه لنگر میگیرند و متن لاتین/فارسی برای قاعده «۲ پیکسل کوچکتر» بستهبندی میشود
|
||||
const { items, html: htmlWithIds } = extractToc(html, (root) => wrapScriptRuns(root, dir));
|
||||
return { html: htmlWithIds, toc: items, legacy: [] };
|
||||
} catch {
|
||||
return { html: null, toc: [], legacy: [] };
|
||||
@@ -40,7 +46,7 @@ export function ArticleDetail() {
|
||||
useEffect(() => {
|
||||
if (!article) return;
|
||||
applySeo({
|
||||
title: `${article.title} | ${brand}`,
|
||||
title: `${article.title} | ${brand.name}`,
|
||||
// توضیحات سئوی اختصاصی در اولویت است؛ در نبود آن، چکیده
|
||||
description: article.meta_description || article.excerpt,
|
||||
path: `/blog/${article.slug}`,
|
||||
@@ -73,15 +79,27 @@ export function ArticleDetail() {
|
||||
|
||||
}
|
||||
|
||||
const { html, toc, legacy } = renderBody(article);
|
||||
const dir = detectDirection(article.title, article.excerpt, docToText(article.body));
|
||||
const { html, toc, legacy } = renderBody(article, dir);
|
||||
const related = articles.filter((a) => a.slug !== article.slug && a.status !== 'draft').slice(0, 3);
|
||||
|
||||
/**
|
||||
* جهت غالب متن مقاله — بدنه (با data-dir، طبق قاعده CSS مقاله) و سمت فهرست مطالب
|
||||
* از همین پیروی میکنند: فارسی راستبهچپ (فهرست سمت راست)، انگلیسی چپبهراست (فهرست سمت چپ).
|
||||
*/
|
||||
const hasToc = toc.length > 0;
|
||||
// در چیدمان راستبهچپ ستون شماره ۱ سمت راست است؛ پس ستونها صریح شماره میخورند
|
||||
const gridColumns = hasToc ? 'lg:grid-cols-[17rem_minmax(0,1fr)]' : '';
|
||||
const bodyColumns = 'lg:col-start-2';
|
||||
const tocColumns = 'lg:col-start-1';
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="relative flex h-[56vh] min-h-[400px] items-end overflow-hidden">
|
||||
<section dir={dir} className="relative flex h-[56vh] min-h-[400px] items-end overflow-hidden">
|
||||
<img
|
||||
src={article.image}
|
||||
alt={article.image_alt || article.title}
|
||||
title={article.image_title || undefined}
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-ink/75" aria-hidden="true" />
|
||||
@@ -95,11 +113,11 @@ export function ArticleDetail() {
|
||||
<Link to="/" className="transition-colors hover:text-copper">
|
||||
صفحه اصلی
|
||||
</Link>
|
||||
<ChevronLeftIcon className="h-3.5 w-3.5" />
|
||||
{dir === 'ltr' ? <ChevronRightIcon className="h-3.5 w-3.5" /> : <ChevronLeftIcon className="h-3.5 w-3.5" />}
|
||||
<Link to="/blog" className="transition-colors hover:text-copper">
|
||||
وبلاگ
|
||||
</Link>
|
||||
<ChevronLeftIcon className="h-3.5 w-3.5" />
|
||||
{dir === 'ltr' ? <ChevronRightIcon className="h-3.5 w-3.5" /> : <ChevronLeftIcon className="h-3.5 w-3.5" />}
|
||||
<span className="text-copper">{article.category}</span>
|
||||
</nav>
|
||||
<h1 className="mt-5 max-w-[700px] text-hero font-bold text-ivory">{article.title}</h1>
|
||||
@@ -111,10 +129,14 @@ export function ArticleDetail() {
|
||||
|
||||
<article className="bg-ink py-20 lg:py-28">
|
||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||
<div className="mx-auto grid max-w-5xl gap-12 lg:grid-cols-[minmax(0,1fr)_17rem] lg:gap-14">
|
||||
<div className="min-w-0">
|
||||
<div
|
||||
dir={dir}
|
||||
data-dir={dir}
|
||||
className={`mx-auto grid max-w-5xl gap-12 lg:gap-14 ${gridColumns}`}
|
||||
>
|
||||
<div className={`min-w-0 lg:row-start-1 ${hasToc ? bodyColumns : ''}`}>
|
||||
<Reveal>
|
||||
<p className="border-r-2 border-copper pr-6 text-lg font-light leading-10 text-ivory/80">
|
||||
<p className="border-s-2 border-copper ps-6 text-lg font-light leading-10 text-ivory/80">
|
||||
{article.excerpt}
|
||||
</p>
|
||||
</Reveal>
|
||||
@@ -133,9 +155,12 @@ export function ArticleDetail() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* فهرست مطالب — در دسکتاپ کنار متن چسبان میماند */}
|
||||
{toc.length > 0 && (
|
||||
<aside className="order-first lg:order-none">
|
||||
{/* فهرست مطالب — در دسکتاپ کنار متن میچسبد و همراه مقاله پایین میآید */}
|
||||
{hasToc && (
|
||||
<aside
|
||||
dir={dir}
|
||||
className={`order-first lg:order-none lg:row-start-1 ${tocColumns}`}
|
||||
>
|
||||
<TableOfContents items={toc} className="lg:sticky lg:top-28" />
|
||||
</aside>
|
||||
)}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useSite } from '../api'
|
||||
import { EditableItem } from '../admin/Editable'
|
||||
import { useAdmin } from '../admin/AdminContext'
|
||||
import { formatJalali } from '../utils/date'
|
||||
import { toPersianDigits } from '../utils/digits'
|
||||
import { PlusIcon } from 'lucide-react'
|
||||
|
||||
function DraftBadge({ className = '' }: { className?: string }) {
|
||||
@@ -95,7 +96,7 @@ export function Blog() {
|
||||
<span>{formatJalali(featured.date)}</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<ClockIcon className="h-3.5 w-3.5" />
|
||||
{featured.read}
|
||||
{toPersianDigits(featured.read)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-2 text-copper">
|
||||
|
||||
@@ -21,7 +21,7 @@ export function ProjectDetail() {
|
||||
useEffect(() => {
|
||||
if (!project) return;
|
||||
applySeo({
|
||||
title: `${project.title} | ${brand}`,
|
||||
title: `${project.title} | ${brand.name}`,
|
||||
description: project.summary,
|
||||
path: `/projects/${project.slug}`,
|
||||
image: project.image,
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* دوزبانهسازی خودکار متن مقاله — قاعده: متن لاتین همیشه «۲ پیکسل کوچکتر» از متن
|
||||
* فارسیِ همان بلوک است (تیتر۱ ۱۸→۱۶، تیتر۲ ۱۶→۱۴، تیتر۳ و پاراگراف ۱۴→۱۲) و در
|
||||
* مقاله انگلیسی برعکس، واژههای فارسی ۲ پیکسل بزرگتر میشوند.
|
||||
*
|
||||
* اجرای غیرغالب متن با یک span جهتدار جدا میشود تا نه جهت متن بههم بریزد
|
||||
* (unicode-bidi: isolate) و نه خط پایه جابهجا شود؛ اندازه در CSS با
|
||||
* calc(1em - 2px) نسبت به همان بلوک حساب میشود.
|
||||
*/
|
||||
|
||||
/** اجرای متن لاتین — حروف/ارقام لاتین بههمراه فاصله و نشانههای داخلی */
|
||||
const LATIN_PATTERN =
|
||||
"[A-Za-z0-9\\u00c0-\\u024f]+(?:[ \\t.,:;'\"()&/+%\\u2013\\u2014-]+[A-Za-z0-9\\u00c0-\\u024f]+)*"
|
||||
|
||||
/** اجرای متن فارسی/عربی — با نیمفاصله و ارقام فارسی */
|
||||
const RTL_PATTERN = '[\\u0590-\\u08ff\\u200c]+(?:[ \\t\\u200c]+[\\u0590-\\u08ff\\u200c]+)*'
|
||||
|
||||
/** کلاس span متن لاتین و متن فارسی — همینها در CSS اندازه ۲ پیکسل تفاضلی میگیرند */
|
||||
export const LATIN_CLASS = 'latin'
|
||||
export const RTL_CLASS = 'persian'
|
||||
|
||||
/** عناصری که متنشان دستنخورده میماند (کد، مارکهای قدیمی و اجرای بستهبندیشده) */
|
||||
const SKIP_SELECTOR = 'code, pre, kbd, samp, script, style, .english-text, [data-small], .latin, .persian'
|
||||
|
||||
/** همه اجراهای غیرغالب یک متن — با بازه نسبت به ابتدای همان متن */
|
||||
export function findScriptRuns(text: string, dir: 'rtl' | 'ltr'): { from: number; to: number }[] {
|
||||
const scan = new RegExp(dir === 'rtl' ? LATIN_PATTERN : RTL_PATTERN, 'g')
|
||||
const runs: { from: number; to: number }[] = []
|
||||
|
||||
for (const match of text.matchAll(scan)) {
|
||||
const from = match.index ?? 0
|
||||
runs.push({ from, to: from + match[0].length })
|
||||
}
|
||||
|
||||
return runs
|
||||
}
|
||||
|
||||
/**
|
||||
* بستهبندی اجرای غیرغالب همه گرههای متنی داخل یک ریشه HTML.
|
||||
* در مقاله فارسی اجرای لاتین و در مقاله انگلیسی اجرای فارسی جدا میشود.
|
||||
*/
|
||||
export function wrapScriptRuns(root: HTMLElement, dir: 'rtl' | 'ltr'): void {
|
||||
const document = root.ownerDocument
|
||||
const className = dir === 'rtl' ? LATIN_CLASS : RTL_CLASS
|
||||
const runDir = dir === 'rtl' ? 'ltr' : 'rtl'
|
||||
|
||||
const texts: Text[] = []
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
|
||||
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
||||
const text = node as Text
|
||||
if (!text.parentElement || text.parentElement.closest(SKIP_SELECTOR)) continue
|
||||
if (!text.data.trim()) continue
|
||||
if (findScriptRuns(text.data, dir).length) texts.push(text)
|
||||
}
|
||||
|
||||
for (const text of texts) {
|
||||
const value = text.data
|
||||
const fragment = document.createDocumentFragment()
|
||||
let cursor = 0
|
||||
|
||||
for (const run of findScriptRuns(value, dir)) {
|
||||
if (run.from > cursor) fragment.append(value.slice(cursor, run.from))
|
||||
const span = document.createElement('span')
|
||||
span.className = className
|
||||
span.setAttribute('dir', runDir)
|
||||
span.textContent = value.slice(run.from, run.to)
|
||||
fragment.append(span)
|
||||
cursor = run.to
|
||||
}
|
||||
|
||||
if (cursor < value.length) fragment.append(value.slice(cursor))
|
||||
text.replaceWith(fragment)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
const PERSIAN_DIGITS = '۰۱۲۳۴۵۶۷۸۹'
|
||||
|
||||
/** تبدیل ارقام لاتین به ارقام فارسی — برای نمایش یکدست فارسی */
|
||||
export function toPersianDigits(value: string | number): string {
|
||||
return String(value).replace(/[0-9]/g, (digit) => PERSIAN_DIGITS[Number(digit)])
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* تشخیص جهت غالب متن — پایه چیدمان مقاله و فهرست مطالب.
|
||||
* سایت فارسی است؛ اما مقالهای که متنش لاتین است باید چپبهراست خوانده شود.
|
||||
*/
|
||||
|
||||
/** خطهای راستبهچپ: عبری، عربی و فارسی */
|
||||
const RTL_CHARS = /[\u0590-\u05ff\u0600-\u06ff\u0700-\u074f\ufb50-\ufdff\ufe70-\ufeff]/
|
||||
/** ارقام فارسی/عربی — در شمارش جهت بیطرفاند */
|
||||
const RTL_DIGITS = /[\u0660-\u0669\u06f0-\u06f9]/
|
||||
/** حروف لاتین (با علامتها) */
|
||||
const LTR_CHARS = /[A-Za-z\u00c0-\u024f]/
|
||||
|
||||
/**
|
||||
* جهت غالب نمونههای متن: فارسی/عربی → rtl و لاتین → ltr.
|
||||
* در حالت مساوی (مثلاً متن کوتاه یا فقط رقم) پیشفرض سایت، راستبهچپ، برمیگردد.
|
||||
*/
|
||||
export function detectDirection(...samples: (string | null | undefined)[]): 'rtl' | 'ltr' {
|
||||
let rtl = 0
|
||||
let ltr = 0
|
||||
|
||||
for (const sample of samples) {
|
||||
for (const char of sample ?? '') {
|
||||
if (RTL_DIGITS.test(char)) continue
|
||||
if (RTL_CHARS.test(char)) rtl += 1
|
||||
else if (LTR_CHARS.test(char)) ltr += 1
|
||||
}
|
||||
}
|
||||
|
||||
return rtl >= ltr ? 'rtl' : 'ltr'
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/** پروتکلها و الگوهای مجاز برای نشانی پیوند — ویرایشگر و مودال لینک هر دو از اینها استفاده میکنند */
|
||||
const ALLOWED_URI = /^(https?:\/\/|\/|#|mailto:|tel:)/i
|
||||
const BARE_DOMAIN = /^[\w-]+(\.[\w-]+)+([/?#].*)?$/i
|
||||
/** نشانی کامل بدون فاصله — برای تشخیص متن چسباندهشده یا تایپشده */
|
||||
const URL_LIKE = /^(https?:\/\/\S+|www\.\S+|[a-z0-9-]+(\.[a-z0-9-]+)+(\/\S*)?)$/i
|
||||
|
||||
/** آیا متن یک نشانی کامل است؟ (برای بازکردن خودکار مودال لینک) */
|
||||
export function isUrlLike(value: string): boolean {
|
||||
return URL_LIKE.test(value.trim())
|
||||
}
|
||||
|
||||
/** آیا نشانی برای درج در لینک مجاز است؟ */
|
||||
export function isAllowedHref(uri: string): boolean {
|
||||
return ALLOWED_URI.test(uri.trim())
|
||||
}
|
||||
|
||||
/**
|
||||
* نرمالسازی نشانی ورودی کاربر:
|
||||
* «example.com» → «https://example.com» و ورودی خالی/نامعتبر → null
|
||||
*/
|
||||
export function normalizeHref(raw: string): string | null {
|
||||
const value = raw.trim()
|
||||
if (!value) return null
|
||||
if (isAllowedHref(value)) return value
|
||||
if (BARE_DOMAIN.test(value)) return `https://${value}`
|
||||
return null
|
||||
}
|
||||
|
||||
export type LinkOptions = {
|
||||
/** باز شدن در تب جدید */
|
||||
newTab: boolean
|
||||
/** عدم انتقال اعتبار سئو (nofollow) */
|
||||
nofollow: boolean
|
||||
}
|
||||
|
||||
/** ساخت مقدار rel بر اساس گزینههای انتخابی کاربر */
|
||||
export function buildLinkRel({ newTab, nofollow }: LinkOptions): string | null {
|
||||
const tokens: string[] = []
|
||||
if (newTab) tokens.push('noopener', 'noreferrer')
|
||||
if (nofollow) tokens.push('nofollow')
|
||||
return tokens.length ? tokens.join(' ') : null
|
||||
}
|
||||
|
||||
/** خواندن گزینه nofollow از مقدار rel لینک موجود */
|
||||
export function parseLinkRel(rel?: string | null): LinkOptions {
|
||||
const tokens = (rel ?? '').split(/\s+/).filter(Boolean)
|
||||
return { newTab: tokens.includes('noopener') || tokens.includes('noreferrer'), nofollow: tokens.includes('nofollow') }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { READING_TIME_LABEL, WORDS_PER_MINUTE } from '../data/constants'
|
||||
import { toPersianDigits } from './digits'
|
||||
import { docToText } from './tiptapDoc'
|
||||
|
||||
export { WORDS_PER_MINUTE, READING_TIME_LABEL }
|
||||
|
||||
/** شمارش واژههای متن سند (بدون شمارش نویسههای قالببندی) */
|
||||
export function countWords(body: unknown): number {
|
||||
return docToText(body)
|
||||
.split(/\s+/)
|
||||
.filter(Boolean).length
|
||||
}
|
||||
|
||||
/** زمان مطالعه بر حسب دقیقه — سقف واژهها تقسیم بر سرعت مطالعه، حداقل یک دقیقه */
|
||||
export function readingTimeMinutes(body: unknown): number {
|
||||
return Math.max(1, Math.ceil(countWords(body) / WORDS_PER_MINUTE))
|
||||
}
|
||||
|
||||
/** زمان مطالعه آماده نمایش با ارقام فارسی — مثل «۵ دقیقه مطالعه» */
|
||||
export function formatReadingTime(body: unknown): string {
|
||||
return `${toPersianDigits(readingTimeMinutes(body))} ${READING_TIME_LABEL}`
|
||||
}
|
||||
+14
-18
@@ -1,31 +1,27 @@
|
||||
import slugify from 'slugify'
|
||||
|
||||
/**
|
||||
* تبدیل متن (فارسی یا انگلیسی) به شناسه/لنگر امن.
|
||||
* slugify حروف فارسی و عربی را به معادل لاتین ترانویسی میکند تا لنگرهای
|
||||
* فهرست مطالب در همه مرورگرها ASCII، پایدار و قابل اشتراکگذاری بمانند.
|
||||
* تبدیل متن (فارسی یا انگلیسی) به شناسه/لنگر امن برای فهرست مطالب.
|
||||
* حروف و اعداد فارسی/عربی و لاتین در شناسه میمانند (در URL بهصورت percent-encoded
|
||||
* نوشته میشوند و در همه مرورگرها کار میکنند)؛ فقط نشانهها و فاصلهها به خط تیره
|
||||
* تبدیل میشوند. نیمفاصله و نویسههای نامرئی جهت هم حذف میشوند تا تیترهای تکراری
|
||||
* شناسه یکتا بگیرند.
|
||||
*/
|
||||
export function slugifyFa(input: string): string {
|
||||
const normalized = (input ?? '')
|
||||
return (input ?? '')
|
||||
.toString()
|
||||
.normalize('NFC')
|
||||
// نیمفاصله، اتصالدهنده و نویسههای نامرئی جهت → فاصله
|
||||
.replace(/[\u200b-\u200f\u202a-\u202e]/g, ' ')
|
||||
// نیمفاصله، اتصالدهنده و نویسههای نامرئی جهت → حذف
|
||||
.replace(/[\u200b-\u200f\u202a-\u202e\ufeff]/g, ' ')
|
||||
// یکسانسازی حروف عربی با معادل فارسی
|
||||
.replace(/[يى]/g, 'ی')
|
||||
.replace(/ك/g, 'ک')
|
||||
.replace(/[ۀة]/g, 'ه')
|
||||
.toLowerCase()
|
||||
// فقط حروف/اعداد یونیکد (فارسی، عربی، لاتین و...) و فاصله/خط تیره بماند
|
||||
.replace(/[^\p{L}\p{N}\s-]/gu, '')
|
||||
.trim()
|
||||
|
||||
const slug = slugify(normalized, {
|
||||
lower: true,
|
||||
trim: true,
|
||||
replacement: '-',
|
||||
// فقط حروف/اعداد/فاصله/خط تیره بماند — بقیه نشانهها حذف شوند
|
||||
remove: /[^\w\s-]/g,
|
||||
})
|
||||
|
||||
return slug.replace(/-{2,}/g, '-').replace(/^-+|-+$/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-{2,}/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
}
|
||||
|
||||
/** ساخت شناسه یکتا — تیترهای تکراری پسوند عددی میگیرند (مثل about، about-2) */
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* کمکتابعهای مشترک سند TipTap — استخراج متن و ساختار از JSON مقاله.
|
||||
* هم چکلیست سئو و هم محاسبه زمان مطالعه از همینجا استفاده میکنند (DRY).
|
||||
*/
|
||||
|
||||
import type { ArticleBodyDoc } from '../data/site'
|
||||
|
||||
type DocNode = {
|
||||
type?: string
|
||||
text?: string
|
||||
attrs?: Record<string, unknown>
|
||||
marks?: { type?: string; attrs?: Record<string, unknown> }[]
|
||||
content?: unknown[]
|
||||
}
|
||||
|
||||
function walk(node: unknown, visit: (node: DocNode) => void): void {
|
||||
if (!node || typeof node !== 'object') return
|
||||
const current = node as DocNode
|
||||
visit(current)
|
||||
for (const child of current.content ?? []) walk(child, visit)
|
||||
}
|
||||
|
||||
/** متن یک سند TipTap (یا آرایه پاراگرافهای قدیمی) */
|
||||
export function docToText(body: unknown): string {
|
||||
if (Array.isArray(body)) return body.join(' ')
|
||||
const parts: string[] = []
|
||||
walk(body, (node) => {
|
||||
if (node.type === 'text' && node.text) parts.push(node.text)
|
||||
})
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
/** متن تیترهای یک سطح مشخص (پیشفرض H2) */
|
||||
export function docHeadings(body: unknown, level = 2): string[] {
|
||||
if (Array.isArray(body)) return []
|
||||
const headings: string[] = []
|
||||
walk(body, (node) => {
|
||||
if (node.type !== 'heading' || node.attrs?.level !== level) return
|
||||
const texts: string[] = []
|
||||
walk(node, (inner) => {
|
||||
if (inner.type === 'text' && inner.text) texts.push(inner.text)
|
||||
})
|
||||
headings.push(texts.join(''))
|
||||
})
|
||||
return headings
|
||||
}
|
||||
|
||||
/** تصاویر سند با متن جایگزین */
|
||||
export function docImages(body: unknown): { src: string; alt: string }[] {
|
||||
if (Array.isArray(body)) return []
|
||||
const images: { src: string; alt: string }[] = []
|
||||
walk(body, (node) => {
|
||||
if (node.type !== 'image') return
|
||||
images.push({ src: String(node.attrs?.src ?? ''), alt: String(node.attrs?.alt ?? '') })
|
||||
})
|
||||
return images
|
||||
}
|
||||
|
||||
/** نشانی همه پیوندهای سند */
|
||||
export function docLinks(body: unknown): string[] {
|
||||
if (Array.isArray(body)) return []
|
||||
const links: string[] = []
|
||||
walk(body, (node) => {
|
||||
for (const mark of node.marks ?? []) {
|
||||
if (mark.type === 'link' && mark.attrs?.href) links.push(String(mark.attrs.href))
|
||||
}
|
||||
})
|
||||
return links
|
||||
}
|
||||
|
||||
/** متن نخستین پاراگراف — برای بررسی کلمه کلیدی در ابتدای متن */
|
||||
export function firstParagraph(body: unknown): string {
|
||||
if (Array.isArray(body)) return body[0] ?? ''
|
||||
const content = (body as { content?: { type?: string }[] } | null)?.content
|
||||
const paragraph = content?.find((child) => child?.type === 'paragraph')
|
||||
return paragraph ? docToText({ type: 'doc', content: [paragraph] }) : ''
|
||||
}
|
||||
|
||||
const EMPTY_DOC = { type: 'doc', content: [{ type: 'paragraph' }] } as ArticleBodyDoc
|
||||
|
||||
/**
|
||||
* تبدیل بدنه ذخیرهشده مقاله به سند TipTap برای بارگذاری در ویرایشگر.
|
||||
* ردیفهای قدیمی متن را بهصورت آرایه پاراگراف نگه داشتهاند و ممکن است رشته JSON هم
|
||||
* برگردد؛ هر دو شکل به سند تبدیل میشوند تا هنگام ویرایش، متن قبلی از دست نرود.
|
||||
*/
|
||||
export function toArticleBodyDoc(body: unknown): ArticleBodyDoc {
|
||||
if (Array.isArray(body)) {
|
||||
const paragraphs = body
|
||||
.map((paragraph) => String(paragraph ?? '').trim())
|
||||
.filter(Boolean)
|
||||
.map((text) => ({ type: 'paragraph', content: [{ type: 'text', text }] }))
|
||||
return paragraphs.length ? { type: 'doc', content: paragraphs } : { ...EMPTY_DOC }
|
||||
}
|
||||
if (typeof body === 'string') {
|
||||
try {
|
||||
return toArticleBodyDoc(JSON.parse(body))
|
||||
} catch {
|
||||
return { ...EMPTY_DOC }
|
||||
}
|
||||
}
|
||||
if (body && typeof body === 'object') return body as ArticleBodyDoc
|
||||
return { ...EMPTY_DOC }
|
||||
}
|
||||
@@ -5,10 +5,12 @@ import react from '@vitejs/plugin-react'
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
// در حالت توسعه، /api و /media به Traefik پروکسی میشوند (پورت ۵۱۷۳ روی هاست)
|
||||
port: 5174, // سرور توسعه فرانتاند روی این پورت اجرا میشود تا تداخل نکند
|
||||
strictPort: true, // جلوگیری از تغییر خودکار پورت
|
||||
proxy: {
|
||||
// در حالت توسعه، درخواستهای API و Media به Traefik (پورت ۵۱۷۳) فرستاده میشوند
|
||||
'/api': 'http://localhost:5173',
|
||||
'/media': 'http://localhost:5173',
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user