Author SHA1 Message Date
fatemeh sane 7a0defc3d4 fix(admin): resolve Tiptap editor bugs, dynamic RTL/LTR layout, sticky TOC, and equalize Bidi typography 2026-09-17 10:10:50 +03:30
fatemeh sane 049291c109 feat(admin): enhance editor UI (links, image captions) & fix categories
Added animated auto-opening link modal, custom image node with isolated caption deletion, fixed single-select category data binding, improved featured image layout, and updated README.
2026-09-15 11:33:04 +03:30
49 changed files with 56352 additions and 506 deletions
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
+47
View File
@@ -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)
+10
View File
@@ -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
View File
@@ -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`). 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. 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 ## 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. * **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.
* **Tags & Categorization:** Integrated `react-select` for modern category selection and creatable dynamic tags. * **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 UI:** A beautifully designed metadata component displaying the author's avatar, name, and publication date right below the H1 title. * **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. * **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:** * **Custom Tiptap WYSIWYG Editor:**
* Specific block-level toggling for Headings (H1: 18px, H2: 16px, H3: 14px) and Paragraphs (14px). * **Fixed Toolbar & Typography:** Dedicated LTR/RTL toggles (`data-dir="ltr|rtl"`) mapped to a fixed toolbar that ensures layout stability.
* 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. * **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:** * **Full Jalali (Shamsi) Calendar Integration:**
* Admin panel utilizes `react-multi-date-picker` for a visual Persian date selection experience. * 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. * 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/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/seo.ts` — Manages page-level SEO tags (Title, Description, Open Graph, Canonical, JSON-LD).
* `src/data/site.ts` — Shared TypeScript data models and interfaces. * `src/data/site.ts` — Shared TypeScript data models and interfaces.
+17
View File
@@ -19,6 +19,7 @@
"lucide-react": "0.522.0", "lucide-react": "0.522.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-hook-form": "^7.88.0",
"react-multi-date-picker": "^4.5.2", "react-multi-date-picker": "^4.5.2",
"react-router-dom": "^6.26.2", "react-router-dom": "^6.26.2",
"react-select": "^5.10.2", "react-select": "^5.10.2",
@@ -4484,6 +4485,22 @@
"react-dom": ">=16.8.0" "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": { "node_modules/react-is": {
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+1
View File
@@ -21,6 +21,7 @@
"lucide-react": "0.522.0", "lucide-react": "0.522.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-hook-form": "^7.88.0",
"react-multi-date-picker": "^4.5.2", "react-multi-date-picker": "^4.5.2",
"react-router-dom": "^6.26.2", "react-router-dom": "^6.26.2",
"react-select": "^5.10.2", "react-select": "^5.10.2",
+3 -1
View File
@@ -97,8 +97,10 @@ function AppChrome() {
function AppShell() { function AppShell() {
const { data, error, reload } = useSiteData(); const { data, error, reload } = useSiteData();
// overflow-x-clip جای overflow-x-hidden: اسکرول‌افزار نمی‌سازد، پس position: sticky
// (فهرست مطالب مقاله) به صفحه می‌چسبد و با محتوا پایین نمی‌رود؛ برش افقی هم حفظ می‌شود.
return ( 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 /> <Preloader />
{error ? ( {error ? (
<div className="flex min-h-screen flex-col items-center justify-center px-6 text-center"> <div className="flex min-h-screen flex-col items-center justify-center px-6 text-center">
+115 -22
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react' 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 { Link, Navigate, useNavigate, useParams } from 'react-router-dom'
import { ArrowRightIcon, CheckIcon, EyeIcon, Trash2Icon } from 'lucide-react' import { ArrowRightIcon, CheckIcon, EyeIcon, Trash2Icon } from 'lucide-react'
import Swal from 'sweetalert2' import Swal from 'sweetalert2'
@@ -9,8 +10,12 @@ import { ArticleEditor } from '../components/admin/ArticleEditor'
import { SeoChecklist } from '../components/admin/SeoChecklist' import { SeoChecklist } from '../components/admin/SeoChecklist'
import { ImageField } from '../components/admin/ImageField' import { ImageField } from '../components/admin/ImageField'
import { JalaliDatePicker } from '../components/admin/JalaliDatePicker' 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 { 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' import type { Article, ArticleBodyDoc } from '../data/site'
const EMPTY_DOC = { type: 'doc', content: [{ type: 'paragraph' }] } as unknown as ArticleBodyDoc 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 با بدنه ساختاریافته */ /** پیش‌نویس صفحه نوشتن — همان شکل Article با بدنه ساختاریافته */
type Draft = Omit<Article, 'body'> & { body: ArticleBodyDoc } 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[] { function normalizeTags(value: unknown): string[] {
if (Array.isArray(value)) return value.map((t) => String(t).trim()).filter(Boolean) if (Array.isArray(value)) return value.map((t) => String(t).trim()).filter(Boolean)
@@ -54,6 +83,7 @@ export function ArticleWritePage() {
tags: [], tags: [],
image: '', image: '',
image_alt: '', image_alt: '',
image_title: '',
author: '', author: '',
meta_description: '', meta_description: '',
read: '۵ دقیقه', read: '۵ دقیقه',
@@ -62,7 +92,31 @@ export function ArticleWritePage() {
status: 'draft', status: 'draft',
seo_keyphrase: '', 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) const [createdId, setCreatedId] = useState<number | null>(null)
/**
* شناسه محتوای بارگذاری‌شده — با رسیدن مقاله از سرور عوض می‌شود تا
* بدنه ویرایشگر TipTap با متن واقعی مقاله مقداردهی شود.
*/
const [loadedContentKey, setLoadedContentKey] = useState<string | number>('new')
const [deleting, setDeleting] = useState(false) const [deleting, setDeleting] = useState(false)
const draftRef = useRef(draft) const draftRef = useRef(draft)
draftRef.current = draft draftRef.current = draft
@@ -96,9 +150,19 @@ export function ArticleWritePage() {
setDraft({ setDraft({
...article, ...article,
tags: normalizeTags(article.tags), tags: normalizeTags(article.tags),
body: (Array.isArray(article.body) ? EMPTY_DOC : article.body) as ArticleBodyDoc, // بدنه ذخیره‌شده (سند جدید یا پاراگراف‌های قدیمی) به سند قابل ویرایش تبدیل می‌شود
body: toArticleBodyDoc(article.body),
}) })
setCreatedId(article.id) 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 // eslint-disable-next-line react-hooks/exhaustive-deps
}, [article?.id]) }, [article?.id])
@@ -196,6 +260,11 @@ export function ArticleWritePage() {
const metaLength = (draft.meta_description ?? '').trim().length const metaLength = (draft.meta_description ?? '').trim().length
// متن جایگزین تصویر شاخص — با watch('title') به‌صورت زنده ساخته می‌شود
const imageAltFallback = [watchedTitle.trim(), FEATURED_IMAGE_ALT_SUFFIX]
.filter(Boolean)
.join(' - ')
return ( return (
<div dir="rtl" className="min-h-screen bg-ink font-pinar text-ivory"> <div dir="rtl" className="min-h-screen bg-ink font-pinar text-ivory">
{/* نوار بالا */} {/* نوار بالا */}
@@ -276,44 +345,59 @@ export function ArticleWritePage() {
: 'مقاله منتشرشده — تغییرها همان لحظه در سایت اعمال می‌شود'} : 'مقاله منتشرشده — تغییرها همان لحظه در سایت اعمال می‌شود'}
</p> </p>
{/* عنوان */} {/* عنوان — با Controller به فرم وصل است تا watch('title') زنده بماند */}
<input <Controller
type="text" name="title"
{...textField('title')} control={control}
placeholder="عنوان مقاله…" render={({ field }) => (
aria-label="عنوان مقاله" <input
className="mt-4 w-full bg-transparent text-4xl font-bold leading-[1.6] text-ivory placeholder:text-ivory/25 focus:outline-none" 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-4 h-px w-28 bg-copper" />
{/* تصویر شاخص + متن جایگزین */} {/* تصویر شاخص + متن جایگزین و عنوان (هر دو به فرم وصل‌اند) */}
<div className="mt-8"> <div className="mt-8">
<ImageField <ImageField
label="تصویر شاخص مقاله" label="تصویر شاخص مقاله"
value={draft.image} value={draft.image}
alt={draft.image_alt ?? ''} alt={imageAltField.value}
altFallback={draft.title} altFallback={imageAltFallback}
title={imageTitleField.value}
onSave={async (url) => { onSave={async (url) => {
queueSave({ image: url }) queueSave({ image: url })
return true return true
}} }}
onSaveAlt={async (alt) => { onSaveAlt={async (alt) => {
imageAltField.onChange(alt)
queueSave({ image_alt: alt }) queueSave({ image_alt: alt })
return true return true
}} }}
onSaveTitle={async (imageTitle) => {
imageTitleField.onChange(imageTitle)
queueSave({ image_title: imageTitle })
return true
}}
/> />
</div> </div>
{/* متن */} {/* متن */}
<div className="mt-8"> <div className="mt-8">
<ArticleEditor <ArticleEditor
contentKey={loadedContentKey}
doc={draft.body as unknown as Record<string, unknown>} doc={draft.body as unknown as Record<string, unknown>}
onChange={(json) => { onChange={(json) => {
// محاسبه خودکار زمان مطالعه بر اساس تعداد کلمات متن // زمان مطالعه بهصورت پویا از متن سند محاسبه میشود (۲۰۰ واژه در دقیقه)
const textContent = JSON.stringify(json) queueSave({ body: json, read: formatReadingTime(json) })
const wordCount = textContent.split(/\s+/).length
const calculatedReadTime = Math.max(1, Math.ceil(wordCount / 200)) + ' دقیقه'
queueSave({ body: json, read: calculatedReadTime })
}} }}
/> />
</div> </div>
@@ -394,11 +478,20 @@ export function ArticleWritePage() {
<div> <div>
<label className="text-xs font-light text-ivory/50">دسته‌بندی مقاله (Category)</label> <label className="text-xs font-light text-ivory/50">دسته‌بندی مقاله (Category)</label>
<div className="mt-2"> <div className="mt-2">
<LuxeSelect {/* تک‌انتخابی: ورودی/خروجی Controller یک {value,label} یا null است */}
value={draft.category} <Controller
options={ARTICLE_CATEGORIES} name="category"
onChange={(category) => queueSave({ category })} control={control}
isClearable={false} render={({ field }) => (
<CategorySelect
value={field.value}
options={ARTICLE_CATEGORIES}
onChange={(option) => {
field.onChange(option)
queueSave({ category: categoryValue(option) })
}}
/>
)}
/> />
</div> </div>
</div> </div>
+50 -20
View File
@@ -5,10 +5,11 @@ import Swal from 'sweetalert2'
import { useSiteData } from '../api' import { useSiteData } from '../api'
import { useAdmin, useFieldSaver } from './AdminContext' import { useAdmin, useFieldSaver } from './AdminContext'
import { adminApi } from './adminClient' import { adminApi } from './adminClient'
import { toArticleBodyDoc } from '../utils/tiptapDoc'
import type { EntityConfig, FieldConfig } from './entities' import type { EntityConfig, FieldConfig } from './entities'
import { ImageField } from '../components/admin/ImageField' import { ImageField } from '../components/admin/ImageField'
import { JalaliDatePicker } from '../components/admin/JalaliDatePicker' 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' import { ArticleEditor } from '../components/admin/ArticleEditor'
type EntityDrawerProps = { 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="flex-1 overflow-y-auto px-6 py-6">
<div className="grid gap-6"> <div className="grid gap-6">
{config.fields.map((field) => { {config.fields.map((field) => {
const isImage = field.type === 'image'
// فیلد Alt تصویر: برای گالری همان ستون alt و برای بقیه <نام فیلد>_alt // فیلد Alt تصویر: برای گالری همان ستون alt و برای بقیه <نام فیلد>_alt
const altKey = const altKey = isImage
field.type === 'image' ? field.name === 'src' && 'alt' in item
? field.name === 'src' && 'alt' in item ? 'alt'
? 'alt' : `${field.name}_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 ( return (
<FieldControl <FieldControl
key={field.name} key={field.name}
field={field} field={field}
value={item[field.name]} value={item[field.name]}
altValue={altKey ? String(item[altKey] ?? '') : undefined} 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)} onSave={(value) => saveField(field.name, value)}
onSaveAlt={altKey ? (alt) => saveField(altKey, alt) : undefined} onSaveAlt={altKey ? (alt) => saveField(altKey, alt) : undefined}
onSaveImageTitle={
imageTitleKey ? (title) => saveField(imageTitleKey, title) : undefined
}
registerFlush={ registerFlush={
field.type === 'wysiwyg' field.type === 'wysiwyg'
? (fn) => { ? (fn) => {
@@ -166,8 +179,10 @@ function FieldControl({
value, value,
altValue, altValue,
altFallback, altFallback,
imageTitleValue,
onSave, onSave,
onSaveAlt, onSaveAlt,
onSaveImageTitle,
registerFlush, registerFlush,
}: { }: {
field: FieldConfig field: FieldConfig
@@ -175,12 +190,20 @@ function FieldControl({
/** مقدار Alt تصویر — فقط برای فیلدهای image */ /** مقدار Alt تصویر — فقط برای فیلدهای image */
altValue?: string altValue?: string
altFallback?: string altFallback?: string
/** مقدار Title تصویر — فقط برای فیلدهای image */
imageTitleValue?: string
onSave: (value: unknown) => Promise<boolean> onSave: (value: unknown) => Promise<boolean>
onSaveAlt?: (alt: string) => Promise<boolean> onSaveAlt?: (alt: string) => Promise<boolean>
onSaveImageTitle?: (title: string) => Promise<boolean>
registerFlush?: (fn: () => void) => void registerFlush?: (fn: () => void) => void
}) { }) {
const [text, setText] = useState<string>(String(value ?? '')) const [text, setText] = useState<string>(String(value ?? ''))
// همگام‌سازی مقدار محلی با داده‌ای که از بیرون می‌آید (بارگذاری/به‌روزرسانی خوش‌بینانه)
useEffect(() => {
setText(String(value ?? ''))
}, [value])
const commitText = async () => { const commitText = async () => {
const next = field.type === 'number' ? Number(text) || 0 : text const next = field.type === 'number' ? Number(text) || 0 : text
if (String(value ?? '') === String(next)) return if (String(value ?? '') === String(next)) return
@@ -194,26 +217,34 @@ function FieldControl({
value={String(value ?? '')} value={String(value ?? '')}
alt={altValue} alt={altValue}
altFallback={altFallback} altFallback={altFallback}
title={imageTitleValue}
onSave={(url) => onSave(url)} onSave={(url) => onSave(url)}
onSaveAlt={onSaveAlt} onSaveAlt={onSaveAlt}
onSaveTitle={onSaveImageTitle}
/> />
) )
} }
if (field.type === 'select') { if (field.type === 'select') {
// مقدار نمایشی از وضعیت محلی می‌آید تا افزودن/حذف گزینه فوراً دیده شود
const selectProps = {
value: text,
options: field.options ?? [],
onChange: (next: string) => {
setText(next)
void onSave(next)
},
}
return ( return (
<div> <div>
<p className="text-xs font-bold text-ivory/80">{field.label}</p> <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>} {field.hint && <p className="mt-1 text-[10px] font-light text-ivory/40">{field.hint}</p>}
<div className="mt-2"> <div className="mt-2">
<LuxeSelect {field.creatable ? (
value={String(value ?? '')} <LuxeCreatableSelect {...selectProps} />
options={field.options ?? []} ) : (
onChange={(next) => { <LuxeSelect {...selectProps} />
setText(next) )}
void onSave(next)
}}
/>
</div> </div>
</div> </div>
) )
@@ -383,10 +414,9 @@ function WysiwygField({
onSave: (value: unknown) => Promise<boolean> onSave: (value: unknown) => Promise<boolean>
registerFlush?: (fn: () => void) => void registerFlush?: (fn: () => void) => void
}) { }) {
const [doc, setDoc] = useState<Record<string, unknown>>(() => const [doc, setDoc] = useState<Record<string, unknown>>(
value && typeof value === 'object' && !Array.isArray(value) // بدنه ذخیره‌شده (سند جدید، رشته JSON یا پاراگراف‌های قدیمی) به سند قابل ویرایش تبدیل می‌شود
? (value as Record<string, unknown>) () => toArticleBodyDoc(value),
: { type: 'doc', content: [{ type: 'paragraph' }] },
) )
const timer = useRef<ReturnType<typeof setTimeout> | null>(null) const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
const pending = useRef<Record<string, unknown> | null>(null) const pending = useRef<Record<string, unknown> | null>(null)
+8 -2
View File
@@ -1,4 +1,5 @@
import type { SiteData } from '../api' import type { SiteData } from '../api'
import { FEATURED_IMAGE_ALT_SUFFIX } from '../data/constants'
export type FieldType = 'text' | 'textarea' | 'number' | 'image' | 'list' | 'wysiwyg' | 'select' | 'date' export type FieldType = 'text' | 'textarea' | 'number' | 'image' | 'list' | 'wysiwyg' | 'select' | 'date'
@@ -9,8 +10,12 @@ export type FieldConfig = {
hint?: string hint?: string
/** فقط برای type === 'select' — گزینه‌های ثابت react-select */ /** فقط برای type === 'select' — گزینه‌های ثابت react-select */
options?: string[] options?: string[]
/** فقط برای type === 'select' — امکان ساخت گزینه جدید توسط کاربر */
creatable?: boolean
/** ویجت سفارشی نوع list — 'tags' یعنی react-select قابل ساخت (Creatable) */ /** ویجت سفارشی نوع list — 'tags' یعنی react-select قابل ساخت (Creatable) */
ui?: 'lines' | 'tags' ui?: 'lines' | 'tags'
/** پسوند پیشنهاد Alt تصویر — مثل «تصویر شاخص» در کنار عنوان رکورد */
altFallbackSuffix?: string
} }
export type EntityConfig = { export type EntityConfig = {
@@ -73,8 +78,8 @@ export const ENTITIES: Record<string, EntityConfig> = {
F('body', 'متن مقاله', 'wysiwyg'), F('body', 'متن مقاله', 'wysiwyg'),
F('excerpt', 'چکیده (روی کارت مقاله)'), F('excerpt', 'چکیده (روی کارت مقاله)'),
F('meta_description', 'توضیحات سئو (Meta Description)', 'textarea', '۱۵۰ تا ۱۶۰ کاراکتر — زیر عنوان در نتایج گوگل نمایش داده می‌شود'), F('meta_description', 'توضیحات سئو (Meta Description)', 'textarea', '۱۵۰ تا ۱۶۰ کاراکتر — زیر عنوان در نتایج گوگل نمایش داده می‌شود'),
F('image', 'تصویر شاخص', 'image'), F('image', 'تصویر شاخص', 'image', undefined, { altFallbackSuffix: FEATURED_IMAGE_ALT_SUFFIX }),
F('category', 'دسته‌بندی (Category)', 'select', undefined, { options: ARTICLE_CATEGORIES }), F('category', 'دسته‌بندی (Category)', 'select', 'می‌توانید دسته جدید هم بسازید', { options: ARTICLE_CATEGORIES, creatable: true }),
F('tags', 'هشتگ‌ها / برچسب‌ها', 'list', 'با Enter هر هشتگ جدید را اضافه کنید', { ui: 'tags' }), F('tags', 'هشتگ‌ها / برچسب‌ها', 'list', 'با Enter هر هشتگ جدید را اضافه کنید', { ui: 'tags' }),
F('author', 'نویسنده'), F('author', 'نویسنده'),
F('date', 'تاریخ انتشار (شمسی)', 'date'), F('date', 'تاریخ انتشار (شمسی)', 'date'),
@@ -89,6 +94,7 @@ export const ENTITIES: Record<string, EntityConfig> = {
tags: ['مدیریت پروژه', 'ساخت‌وساز'], tags: ['مدیریت پروژه', 'ساخت‌وساز'],
image: '', image: '',
image_alt: '', image_alt: '',
image_title: '',
author: '', author: '',
meta_description: '', meta_description: '',
read: '۵ دقیقه', read: '۵ دقیقه',
+2 -1
View File
@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom'
import { ArrowLeftIcon, ClockIcon } from 'lucide-react' import { ArrowLeftIcon, ClockIcon } from 'lucide-react'
import type { Article } from '../data/site' import type { Article } from '../data/site'
import { formatJalali } from '../utils/date' import { formatJalali } from '../utils/date'
import { toPersianDigits } from '../utils/digits'
export function ArticleCard({ article }: { article: Article }) { export function ArticleCard({ article }: { article: Article }) {
return ( return (
@@ -37,7 +38,7 @@ export function ArticleCard({ article }: { article: Article }) {
<span>{formatJalali(article.date)}</span> <span>{formatJalali(article.date)}</span>
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<ClockIcon className="h-3.5 w-3.5 text-copper/80" /> <ClockIcon className="h-3.5 w-3.5 text-copper/80" />
{article.read} {toPersianDigits(article.read)}
</span> </span>
</span> </span>
+3 -2
View File
@@ -1,6 +1,7 @@
import { CalendarIcon, ClockIcon } from 'lucide-react' import { CalendarIcon, ClockIcon } from 'lucide-react'
import type { Article } from '../data/site' import type { Article } from '../data/site'
import { JALALI_TIME_FORMATTER, formatJalali } from '../utils/date' import { JALALI_TIME_FORMATTER, formatJalali } from '../utils/date'
import { toPersianDigits } from '../utils/digits'
type ArticleMetaProps = { type ArticleMetaProps = {
article: Article article: Article
@@ -41,11 +42,11 @@ export function ArticleMeta({ article, className = '' }: ArticleMetaProps) {
{dateLabel} {dateLabel}
</span> </span>
{/* زمان مطالعه */} {/* زمان مطالعه — با ارقام فارسی */}
{article.read && ( {article.read && (
<span className="flex items-center gap-2 text-xs font-light text-ivory/60"> <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" /> <ClockIcon className="h-3.5 w-3.5 text-copper" aria-hidden="true" />
{article.read} {toPersianDigits(article.read)}
</span> </span>
)} )}
</div> </div>
+43 -12
View File
@@ -6,23 +6,49 @@ import { slugifyFa, uniqueSlug } from '../utils/slug'
export type TocItem = { id: string; text: string; level: 2 | 3 } export type TocItem = { id: string; text: string; level: 2 | 3 }
/** /**
* استخراج فهرست مطالب از HTML ویرایشگر — تیترهای H2 و H3 شناسه لنگر می‌گیرند * متن یک تیتر از HTML — همه گره‌های تودرتو پیمایش می‌شوند و <br> به فاصله تبدیل
* (با slugify و پشتیبانی فارسی) و HTML برگشتی همان است که باید رندر شود. * می‌شود تا تیترهای چندخطی یا شامل مارک (مثل متن انگلیسی) کامل خوانده شوند.
*/ */
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 } if (!html || typeof window === 'undefined') return { items: [], html }
const doc = new DOMParser().parseFromString(html, 'text/html') const doc = new DOMParser().parseFromString(html, 'text/html')
const used = new Map<string, number>() const used = new Map<string, number>()
const items: TocItem[] = [] const items: TocItem[] = []
doc.body.querySelectorAll('h2, h3').forEach((el) => { doc.body.querySelectorAll('h2, h3').forEach((element, index) => {
const text = (el.textContent ?? '').replace(/\s+/g, ' ').trim() const text = headingText(element).replace(/\s+/g, ' ').trim()
if (!text) return if (!text) return
const id = uniqueSlug(slugifyFa(text), used) // تیتر بدون حرف و رقم (فقط نشانه) شناسه ترتیبی می‌گیرد تا لنگرش بی‌نام نماند
el.setAttribute('id', id) const id = uniqueSlug(slugifyFa(text) || `heading-${index + 1}`, used)
items.push({ id, text, level: el.tagName === 'H3' ? 3 : 2 }) element.setAttribute('id', id)
items.push({ id, text, level: element.tagName === 'H3' ? 3 : 2 })
}) })
// تبدیل اختیاری (بسته‌بندی متن لاتین/فارسی برای اندازه دوزبانه) روی همان DOM اجرا می‌شود
transform?.(doc.body)
return { items, html: doc.body.innerHTML } return { items, html: doc.body.innerHTML }
} }
@@ -32,7 +58,11 @@ type TableOfContentsProps = {
className?: string className?: string
} }
/** فهرست مطالب کلیک‌پذیر با هایلایت خودکار تیتر فعال (IntersectionObserver) */ /**
* فهرست مطالب کلیک‌پذیر با هایلایت خودکار تیتر فعال (IntersectionObserver).
* همه اندازه‌ها و ترازبندی‌ها منطقی‌اند (start/end) تا در چیدمان راست‌به‌چپ و
* چپ‌به‌راست — بر اساس جهت مقاله — درست بنشیند.
*/
export function TableOfContents({ items, title = 'فهرست مطالب', className = '' }: TableOfContentsProps) { export function TableOfContents({ items, title = 'فهرست مطالب', className = '' }: TableOfContentsProps) {
const [activeId, setActiveId] = useState<string>(items[0]?.id ?? '') const [activeId, setActiveId] = useState<string>(items[0]?.id ?? '')
@@ -79,7 +109,8 @@ export function TableOfContents({ items, title = 'فهرست مطالب', classN
{title} {title}
</p> </p>
<div className="mt-5 h-px w-12 bg-copper/50" aria-hidden="true" /> <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) => { {items.map((item) => {
const isActive = item.id === activeId const isActive = item.id === activeId
return ( return (
@@ -88,8 +119,8 @@ export function TableOfContents({ items, title = 'فهرست مطالب', classN
type="button" type="button"
onClick={(event) => go(event, item.id)} onClick={(event) => go(event, item.id)}
aria-current={isActive ? 'location' : undefined} aria-current={isActive ? 'location' : undefined}
className={`block w-full text-right text-sm font-light leading-8 transition-colors duration-200 ease-lux ${ className={`block w-full text-start text-sm font-light leading-8 transition-colors duration-200 ease-lux ${
item.level === 3 ? 'pr-4 text-[13px]' : '' item.level === 3 ? 'ps-4 text-[13px]' : ''
} ${ } ${
isActive isActive
? 'font-normal text-copper' ? 'font-normal text-copper'
+294 -158
View File
@@ -1,61 +1,107 @@
import { useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { EditorContent, useEditor } from '@tiptap/react' import { EditorContent, useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit' import StarterKit from '@tiptap/starter-kit'
import Image from '@tiptap/extension-image'
import Link from '@tiptap/extension-link' import Link from '@tiptap/extension-link'
import { Mark, mergeAttributes } from '@tiptap/core' import { Mark, mergeAttributes } from '@tiptap/core'
import { import {
AlignLeftIcon,
AlignRightIcon,
BoldIcon, BoldIcon,
ItalicIcon,
Heading1Icon, Heading1Icon,
Heading2Icon, Heading2Icon,
Heading3Icon, Heading3Icon,
ImagePlusIcon, ImagePlusIcon,
LanguagesIcon, ItalicIcon,
Link2Icon, Link2Icon,
ListIcon, ListIcon,
ListOrderedIcon, ListOrderedIcon,
Loader2Icon,
PilcrowIcon, PilcrowIcon,
QuoteIcon, QuoteIcon,
Redo2Icon, Redo2Icon,
StrikethroughIcon, StrikethroughIcon,
UnderlineIcon, UnderlineIcon,
Undo2Icon, Undo2Icon,
Loader2Icon,
} from 'lucide-react' } from 'lucide-react'
import Swal from 'sweetalert2'
import { useAdmin } from '../../admin/AdminContext' import { useAdmin } from '../../admin/AdminContext'
import { adminApi } from '../../admin/adminClient' 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', name: 'smallSize',
parseHTML() { return [{ tag: 'span[data-small]' }] }, parseHTML() {
return [{ tag: 'span[data-small]' }]
},
renderHTML({ HTMLAttributes }) { renderHTML({ HTMLAttributes }) {
return ['span', mergeAttributes(HTMLAttributes, { 'data-small': 'true' }), 0] return ['span', mergeAttributes(HTMLAttributes, { 'data-small': 'true' }), 0]
} },
}) })
/** اکستنشن‌های مشترک ویرایشگر و رندر مقاله — هر دو باید یکسان باشند */
export const ARTICLE_EXTENSIONS = [ export const ARTICLE_EXTENSIONS = [
StarterKit.configure({ StarterKit.configure({
link: false, link: false,
heading: { levels: [1, 2, 3] }, heading: { levels: [1, 2, 3] },
}), }),
Image.configure({ allowBase64: false }), ArticleImage.configure({ allowBase64: false }),
Link.configure({ Link.configure({
openOnClick: false, openOnClick: false,
HTMLAttributes: { rel: 'noopener noreferrer', target: '_blank' }, HTMLAttributes: { rel: 'noopener noreferrer', target: '_blank' },
isAllowedUri: (uri: string) => /^(https?:\/\/|\/|#|mailto:|tel:)/i.test(uri), isAllowedUri: (uri: string) => isAllowedHref(uri),
}), }),
SmallSizeMark, LegacySmallSizeMark,
EnglishText, EnglishText,
BidiTypography,
] ]
type ArticleEditorProps = { type ArticleEditorProps = {
doc: Record<string, unknown> doc: Record<string, unknown>
onChange: (json: Record<string, unknown>) => void 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({ function ToolButton({
onClick, onClick,
active = false, active = false,
@@ -72,6 +118,7 @@ function ToolButton({
type="button" type="button"
title={label} title={label}
aria-label={label} aria-label={label}
aria-pressed={active}
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
onClick={onClick} onClick={onClick}
className={`flex h-8 w-8 items-center justify-center border transition-colors duration-150 ${ 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" /> 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 { runSave } = useAdmin()
const fileRef = useRef<HTMLInputElement>(null) const fileRef = useRef<HTMLInputElement>(null)
const [uploading, setUploading] = useState(false) const [uploading, setUploading] = useState(false)
const [direction, setDirection] = useState<TextDirection>('rtl')
const [linkModal, setLinkModal] = useState<LinkModalState>(CLOSED_LINK_MODAL)
const editor = useEditor({ const editor = useEditor({
extensions: ARTICLE_EXTENSIONS, extensions: ARTICLE_EXTENSIONS,
@@ -99,16 +152,30 @@ export function ArticleEditor({ doc, onChange }: ArticleEditorProps) {
editorProps: { editorProps: {
attributes: { attributes: {
dir: 'rtl', dir: 'rtl',
lang: 'fa',
class: 'article-body min-h-[300px] px-4 py-4 focus:outline-none', class: 'article-body min-h-[300px] px-4 py-4 focus:outline-none',
'data-wysiwyg': 'true', 'data-wysiwyg': 'true',
}, },
handlePaste: (_event_ignored, event) => { handlePaste: (_view, event, _slice) => {
const file = event.clipboardData?.files?.[0] const file = event.clipboardData?.files?.[0]
if (file && file.type.startsWith('image/')) { if (file && file.type.startsWith('image/')) {
event.preventDefault() event.preventDefault()
void uploadAndInsert(file) void uploadAndInsert(file)
return true 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 return false
}, },
handleDrop: (_view, event, _slice, moved) => { handleDrop: (_view, event, _slice, moved) => {
@@ -121,176 +188,245 @@ export function ArticleEditor({ doc, onChange }: ArticleEditorProps) {
} }
return false 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()), 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) { async function uploadAndInsert(file: File) {
if (!editor) return if (!editor) return
setUploading(true) setUploading(true)
// استفاده از ریگکس یونیکد اصلاح‌شده برای نام فایل و Alt
const originalName = file.name.replace(/\.[^.]+$/, '')
const cleanAlt = originalName.replace(/[^\w\u0600-\u06FF\s-]/g, '-').replace(/\s+/g, '-').toLowerCase()
await runSave(async () => { await runSave(async () => {
const { url } = await adminApi.upload(file) 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) setUploading(false)
} }
const setLink = () => { /** باز کردن مودال برای لینک انتخاب فعلی (یا ویرایش لینک زیر نشانگر) */
const openLinkModalForSelection = () => {
if (!editor) return if (!editor) return
if (editor.isActive('link')) { const attrs = editor.getAttributes('link') as {
editor.chain().focus().unsetLink().run() href?: string | null
return target?: string | null
rel?: string | null
} }
void Swal.fire({ const editing = Boolean(attrs.href)
title: 'نشانی لینک', setLinkModal({
input: 'text', open: true,
inputValue: 'https://', editing,
background: '#1e1a16', mode: 'selection',
color: '#F5F3EF', range: null,
showCancelButton: true, initial: {
confirmButtonText: 'ثبت', href: attrs.href ?? '',
cancelButtonText: 'انصراف', newTab: editing ? attrs.target === '_blank' : true,
buttonsStyling: false, nofollow: editing ? parseLinkRel(attrs.rel).nofollow : 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',
}, },
}).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 if (!editor) return null
return ( 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"> <div className="border border-ivory/15 bg-char" data-dir={direction}>
{/* نوار ابزار: همیشه راست‌چین و در بالای ویرایشگر ثابت می‌ماند */}
{/* قالب‌بندی بلوک — پاراگراف و تیترها با سایز مشخص */} <div
<ToolButton dir="rtl"
label="پاراگراف (۱۴ پیکسل)" 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"
active={editor.isActive('paragraph')}
onClick={() => editor.chain().focus().setParagraph().run()}
> >
<PilcrowIcon className="h-4 w-4" /> <ToolButton
</ToolButton> label="پاراگراف"
<ToolButton active={editor.isActive('paragraph')}
label="تیتر ۱ (۱۸ پیکسل)" onClick={() => applyBlockFormat(editor.view, 'paragraph')}
active={editor.isActive('heading', { level: 1 })} >
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()} <PilcrowIcon className="h-4 w-4" />
> </ToolButton>
<Heading1Icon className="h-4 w-4" /> <ToolButton
</ToolButton> label="تیتر ۱"
<ToolButton active={editor.isActive('heading', { level: 1 })}
label="تیتر ۲ (۱۶ پیکسل)" onClick={() => applyBlockFormat(editor.view, 1)}
active={editor.isActive('heading', { level: 2 })} >
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()} <Heading1Icon className="h-4 w-4" />
> </ToolButton>
<Heading2Icon className="h-4 w-4" /> <ToolButton
</ToolButton> label="تیتر ۲"
<ToolButton active={editor.isActive('heading', { level: 2 })}
label="تیتر ۳ (۱۴ پیکسل)" onClick={() => applyBlockFormat(editor.view, 2)}
active={editor.isActive('heading', { level: 3 })} >
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()} <Heading2Icon className="h-4 w-4" />
> </ToolButton>
<Heading3Icon 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" <ToolButton
value={editor.isActive('smallSize') ? '12' : '14'} 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) => { onChange={(e) => {
if (e.target.value === '12') { const file = e.target.files?.[0]
editor.chain().focus().setMark('smallSize').run() if (file) void uploadAndInsert(file)
} else { e.target.value = ''
editor.chain().focus().unsetMark('smallSize').run()
}
}} }}
> />
<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> </div>
<EditorContent editor={editor} /> {/* مودال لینک — انیمیشن ورود/خروج را خودش با کلاس‌های Tailwind مدیریت می‌کند */}
{linkModal.open && (
<input <LinkModal
ref={fileRef} initial={linkModal.initial}
type="file" editing={linkModal.editing}
data-editor-file-input onApply={applyLink}
accept="image/jpeg,image/png,image/webp,image/gif,image/avif" onRemove={removeLink}
className="hidden" onClose={() => setLinkModal((state) => ({ ...state, open: false }))}
onChange={(e) => { />
const file = e.target.files?.[0] )}
if (file) void uploadAndInsert(file) </>
e.target.value = ''
}}
/>
</div>
) )
} }
@@ -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)
}
+120 -78
View File
@@ -1,126 +1,168 @@
import { useEffect, useId, useRef, useState } from 'react' import { useId, useRef, useState } from 'react'
import { ImagePlusIcon, Loader2Icon } from 'lucide-react' import { ImageIcon, ImagePlusIcon, Loader2Icon } from 'lucide-react'
import { DEFAULT_IMAGE_TITLE } from '../../data/constants'
import { adminApi } from '../../admin/adminClient' import { adminApi } from '../../admin/adminClient'
import { useAdmin } from '../../admin/AdminContext' import { useAdmin } from '../../admin/AdminContext'
import { useSmartTextField } from './useSmartTextField'
type ImageFieldProps = { type ImageFieldProps = {
label: string label: string
value: string value: string
onSave: (url: string) => Promise<boolean> onSave: (url: string) => Promise<boolean>
/** متن جایگزین (Alt) — با ارسال این prop ورودی Alt نمایش داده می‌شود */ /** متن جایگزین (Alt) */
alt?: string alt?: string
/** ذخیره متن جایگزین — بدون آن، Alt فقط برای پیش‌نمایش و پیشنهاد استفاده می‌شود */
onSaveAlt?: (alt: string) => Promise<boolean> onSaveAlt?: (alt: string) => Promise<boolean>
/** پیشنهاد Alt وقتی خالی است (مثل عنوان مقاله یا نام موجودیت) */ /** عنوان تصویر (Title) */
title?: string
onSaveTitle?: (title: string) => Promise<boolean>
/**
* پیشنهاد Alt وقتی کاربر چیزی ننوشته — مثل «{عنوان مقاله} - تصویر شاخص».
* هرگز از URL یا نام فایل ساخته نمی‌شود.
*/
altFallback?: string 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 — در کشوهای ویرایش و صفحه نوشتن مقاله. * انتخاب و آپلود تصویر به MinIO با پیش‌نمایش بزرگ.
* پس از آپلود موفق، URL نسبی /media/... ذخیره می‌شود. * ترتیب فیلدها: ۱) عنوان تصویر (پیش‌فرض «Photo 1») ۲) متن جایگزین (پیش‌فرض «{عنوان} - تصویر شاخص»).
* اگر Alt خالی باشد، پیش از فراخوانی API از عنوان موجودیت یا نام فایل ساخته می‌شود. * هر دو «دستی یا پیش‌فرض» هستند و 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 { runSave, isAuthenticated, isEditMode } = useAdmin()
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
const altInputId = useId() const titleId = useId()
const altId = useId()
const [uploading, setUploading] = useState(false) const [uploading, setUploading] = useState(false)
const [altText, setAltText] = useState(alt ?? '')
const showTitle = title !== undefined || !!onSaveTitle
const showAlt = alt !== undefined || !!onSaveAlt const showAlt = alt !== undefined || !!onSaveAlt
// همگام‌سازی با مقدار ذخیره‌شده (پس از به‌روزرسانی خوش‌بینانه در درخت داده) const titleField = useSmartTextField({
useEffect(() => { value: title ?? '',
setAltText(alt ?? '') fallback: titleFallback,
}, [alt]) hardFallback: DEFAULT_IMAGE_TITLE,
onCommit: onSaveTitle,
})
const altField = useSmartTextField({
value: alt ?? '',
fallback: altFallback,
hardFallback: label,
onCommit: onSaveAlt,
})
if (!isAuthenticated || !isEditMode) return null 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) => { const pick = async (file: File | undefined) => {
if (!file) return if (!file) return
const generatedAlt = resolveAlt(file.name)
setUploading(true) setUploading(true)
try { try {
await runSave(async () => { await runSave(async () => {
const { url } = await adminApi.upload(file) const { url } = await adminApi.upload(file)
const ok = await onSave(url) const ok = await onSave(url)
if (!ok) throw new Error('ذخیره نشد') if (!ok) throw new Error('ذخیره نشد')
// Alt ساخته‌شده هم ذخیره می‌شود تا تصویر بدون متن جایگزین نماند // پیش‌فرض‌های Alt/Title همان لحظه ثبت می‌شوند تا تصویر بدون متادیتا نماند
if (showAlt && generatedAlt !== (alt ?? '').trim()) { if (showTitle) await titleField.commit()
setAltText(generatedAlt) if (showAlt) await altField.commit()
if (onSaveAlt) await onSaveAlt(generatedAlt)
}
}) })
} finally { } finally {
setUploading(false) setUploading(false)
} }
} }
const commitAlt = () => {
const next = altText.trim()
if (!onSaveAlt || next === (alt ?? '').trim()) return
void onSaveAlt(next)
}
return ( return (
<div> <div>
<p className="text-xs font-bold text-ivory/80">{label}</p> <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 ? ( {value ? (
<img <img
src={value} src={value}
alt={altText || label} alt={altField.display || label}
className="h-16 w-24 border border-ivory/15 object-cover" 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>
)} )}
<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> </div>
{showAlt && ( <div className="mt-3">
<div className="mt-3"> <button
<div className="flex items-center justify-between gap-3"> type="button"
<label htmlFor={altInputId} className="text-[11px] font-light text-ivory/50"> onClick={() => inputRef.current?.click()}
متن جایگزین (Alt) disabled={uploading}
</label> 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"
<span className="text-[10px] font-light text-ivory/30">برای سئو و صفحه‌خوان‌ها</span> >
</div> {uploading ? (
<input <Loader2Icon className="h-3.5 w-3.5 animate-spin" />
id={altInputId} ) : (
type="text" <ImagePlusIcon className="h-3.5 w-3.5" />
dir="auto" )}
value={altText} {uploading ? 'در حال آپلود…' : value ? 'تغییر تصویر' : 'انتخاب تصویر'}
onChange={(e) => setAltText(e.target.value)} </button>
onBlur={commitAlt} </div>
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" {(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> </div>
)} )}
@@ -129,9 +171,9 @@ export function ImageField({ label, value, onSave, alt, onSaveAlt, altFallback =
type="file" type="file"
accept="image/jpeg,image/png,image/webp,image/gif,image/avif" accept="image/jpeg,image/png,image/webp,image/gif,image/avif"
className="hidden" className="hidden"
onChange={(e) => { onChange={(event) => {
void pick(e.target.files?.[0]) void pick(event.target.files?.[0])
e.target.value = '' event.target.value = ''
}} }}
/> />
</div> </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>
)
}
+215
View File
@@ -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=&quot;_blank&quot;</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=&quot;{relPreview}&quot;
</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>
)
}
+220 -88
View File
@@ -1,89 +1,82 @@
import { useMemo } from 'react' import { useMemo, useState } from 'react'
import Select from 'react-select' import Select from 'react-select'
import CreatableSelect from 'react-select/creatable' 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 } export type LuxeOption = { label: string; value: string }
const COPPER = '#d5874c' type LuxeClassNames<IsMulti extends boolean> = ClassNamesConfig<
const IVORY = '#F5F3EF' LuxeOption,
const SURFACE = '#1e1a16' IsMulti,
const BORDER = 'rgba(245,243,239,0.15)' GroupBase<LuxeOption>
>
/** استایل تیره و لوکس react-select هماهنگ با پالت سایت (مسی روی زغالی) */ const CONTROL_CLASS_NAMES = (isFocused: boolean): string =>
function makeStyles<IsMulti extends boolean>(): StylesConfig<LuxeOption, IsMulti> { `!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 { return {
control: (base, state) => ({ control: (state) => CONTROL_CLASS_NAMES(state.isFocused),
...base, option: (state) => OPTION_CLASS_NAMES(state.isSelected, state.isFocused),
minHeight: 42, valueContainer: () => '!gap-1 !px-3 !py-0.5',
backgroundColor: SURFACE, placeholder: () => '!text-[13px] !text-ivory/35',
borderColor: state.isFocused ? COPPER : BORDER, singleValue: () => '!text-[13px] !text-ivory',
borderRadius: 0, input: () => '!text-[13px] !text-ivory',
boxShadow: state.isFocused ? '0 0 0 1px rgba(213,135,76,0.35)' : 'none', indicatorsContainer: () => '!text-ivory/40',
transition: 'border-color 150ms ease, box-shadow 150ms ease', dropdownIndicator: () => '!text-ivory/40 hover:!text-copper',
':hover': { borderColor: state.isFocused ? COPPER : 'rgba(213,135,76,0.5)' }, clearIndicator: () => '!text-ivory/40 hover:!text-copper',
}), indicatorSeparator: () => '!bg-ivory/15',
valueContainer: (base) => ({ ...base, padding: '2px 12px' }), menu: () => '!mt-1 !rounded-none !border !border-ivory/10 !bg-char !shadow-lift',
placeholder: (base) => ({ ...base, color: 'rgba(245,243,239,0.35)', fontSize: 13 }), menuList: () => '!p-1',
singleValue: (base) => ({ ...base, color: IVORY, fontSize: 13 }), noOptionsMessage: () => '!text-xs !text-ivory/45',
input: (base) => ({ ...base, color: IVORY, fontSize: 13 }), loadingMessage: () => '!text-xs !text-ivory/45',
menu: (base) => ({ multiValue: () => '!m-0.5 !rounded-full !border !border-copper/40 !bg-copper/15 !py-0.5 !pe-0 !ps-2',
...base, multiValueLabel: () => '!text-xs !text-copper',
backgroundColor: SURFACE, ...extras,
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 },
}),
} }
} }
/** دکمه ضربدر حذف برچسب انتخاب‌شده — همیشه واضح و قابل کلیک */
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 => const portalTarget = (): HTMLElement | undefined =>
typeof document === 'undefined' ? undefined : document.body typeof document === 'undefined' ? undefined : document.body
const uniqueOptions = (values: string[]): LuxeOption[] =>
[...new Set(values.filter(Boolean))].map((item) => ({ label: item, value: item }))
type LuxeSelectProps = { type LuxeSelectProps = {
value: string value: string
options: string[] options: string[]
@@ -94,7 +87,7 @@ type LuxeSelectProps = {
inputId?: string inputId?: string
} }
/** react-select تکی با ظاهر تیره پروژه — گزینه فعلی حتی اگر در لیست نباشد حفظ می‌شود */ /** react-select تکی با تم تیره — گزینه فعلی حتی اگر در لیست نباشد حفظ می‌شود */
export function LuxeSelect({ export function LuxeSelect({
value, value,
options, options,
@@ -104,9 +97,10 @@ export function LuxeSelect({
isDisabled = false, isDisabled = false,
inputId, inputId,
}: LuxeSelectProps) { }: LuxeSelectProps) {
const selectOptions = useMemo(() => [...new Set([...options, ...(value ? [value] : [])])].map( const selectOptions = useMemo(
(item) => ({ label: item, value: item }), () => uniqueOptions([...options, ...(value ? [value] : [])]),
), [options, value]) [options, value],
)
return ( return (
<Select<LuxeOption, false> <Select<LuxeOption, false>
@@ -119,7 +113,141 @@ export function LuxeSelect({
isClearable={isClearable} isClearable={isClearable}
isDisabled={isDisabled} isDisabled={isDisabled}
noOptionsMessage={() => 'موردی یافت نشد'} 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" menuPosition="fixed"
menuPortalTarget={portalTarget()} menuPortalTarget={portalTarget()}
/> />
@@ -130,18 +258,19 @@ type LuxeTagsSelectProps = {
value: string[] value: string[]
onChange: (value: string[]) => void onChange: (value: string[]) => void
placeholder?: string placeholder?: string
} }/** هشتگ‌ها با react-select قابل ساخت — هر برچسب دکمه ضربدر حذف مستقل دارد */
/** هشتگ‌ها با react-select قابل ساخت — هر تایپ جدید با Enter به فهرست اضافه می‌شود */
export function LuxeTagsSelect({ export function LuxeTagsSelect({
value, value,
onChange, onChange,
placeholder = 'هشتگ بنویسید و Enter بزنید…', placeholder = 'هشتگ بنویسید و Enter بزنید…',
}: LuxeTagsSelectProps) { }: LuxeTagsSelectProps) {
const options = useMemo( const options = useMemo(() => uniqueOptions(value), [value])
() => [...new Set(value.filter(Boolean))].map((item) => ({ label: item, value: item })),
[value], /** پاک‌سازی و حذف تکراری‌ها هنگام افزودن یا حذف برچسب */
) const applyTags = (next: readonly LuxeOption[]) => {
const tags = next.map((option) => option.value.trim()).filter(Boolean)
onChange([...new Set(tags)])
}
return ( return (
<CreatableSelect<LuxeOption, true> <CreatableSelect<LuxeOption, true>
@@ -149,11 +278,14 @@ export function LuxeTagsSelect({
isMulti isMulti
options={options} options={options}
value={options} value={options}
onChange={(next) => onChange(next.map((option) => option.value))} onChange={applyTags}
placeholder={placeholder} placeholder={placeholder}
formatCreateLabel={(input) => `افزودن «${input}»`} formatCreateLabel={(input) => `افزودن هشتگ «${input}»`}
noOptionsMessage={() => 'هشتگی یافت نشد'} noOptionsMessage={() => 'هشتگی یافت نشد'}
styles={makeStyles<true>()} closeMenuOnSelect={false}
components={{ MultiValueRemove: TagRemoveButton }}
classNames={makeClassNames<true>({ multiValueRemove: () => '!bg-transparent' })}
styles={{ menuPortal: (base) => ({ ...base, zIndex: 200 }) }}
menuPosition="fixed" menuPosition="fixed"
menuPortalTarget={portalTarget()} menuPortalTarget={portalTarget()}
/> />
+3 -74
View File
@@ -1,6 +1,7 @@
import { useMemo } from 'react' import { useMemo } from 'react'
import { AlertCircleIcon, CheckCircle2Icon, XCircleIcon } from 'lucide-react' import { AlertCircleIcon, CheckCircle2Icon, XCircleIcon } from 'lucide-react'
import type { ArticleBodyDoc } from '../../data/site' import type { ArticleBodyDoc } from '../../data/site'
import { docHeadings, docImages, docLinks, docToText, firstParagraph } from '../../utils/tiptapDoc'
export type SeoInput = { export type SeoInput = {
title: string title: string
@@ -13,78 +14,6 @@ export type SeoInput = {
type Status = 'good' | 'ok' | 'bad' type Status = 'good' | 'ok' | 'bad'
type Check = { label: string; status: Status; hint: string } 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[] { export function useSeoChecks(input: SeoInput): Check[] {
@@ -117,7 +46,7 @@ export function useSeoChecks(input: SeoInput): Check[] {
? { label: 'کلمه کلیدی در پاراگراف اول', status: 'good', hint: 'کلمه کلیدی در ابتدای متن هست.' } ? { label: 'کلمه کلیدی در پاراگراف اول', status: 'good', hint: 'کلمه کلیدی در ابتدای متن هست.' }
: { label: 'کلمه کلیدی در پاراگراف اول', status: 'ok', 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 در متن نیست.' }) if (h2s.length === 0) checks.push({ label: 'کلمه کلیدی در تیترها', status: 'ok', hint: 'هنوز تیتر H2 در متن نیست.' })
else else
checks.push( checks.push(
@@ -140,7 +69,7 @@ export function useSeoChecks(input: SeoInput): Check[] {
else checks.push({ label: `طول متن (${words.length} واژه)`, status: 'bad', hint: 'متن خیلی کوتاه است — حداقل ۸۰ و ترجیحاً ۱۵۰ واژه بنویسید.' }) else checks.push({ label: `طول متن (${words.length} واژه)`, status: 'bad', hint: 'متن خیلی کوتاه است — حداقل ۸۰ و ترجیحاً ۱۵۰ واژه بنویسید.' })
// تیتر H2 // تیتر 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: 'متن تیتربندی شده است.' }) if (h2s.length > 0) checks.push({ label: `تیتربندی (${h2s.length} تیتر H2)`, status: 'good', hint: 'متن تیتربندی شده است.' })
else checks.push({ label: 'تیتربندی', status: 'bad', hint: 'برای خوانایی، متن را با تیتر H2 بخش‌بندی کنید.' }) 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 }
}
+18
View File
@@ -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 = 'دقیقه مطالعه'
+2
View File
@@ -44,6 +44,8 @@ export type Article = {
meta_description?: string; meta_description?: string;
/** متن جایگزین تصویر شاخص (Alt) */ /** متن جایگزین تصویر شاخص (Alt) */
image_alt?: string; image_alt?: string;
/** عنوان تصویر شاخص (Title) */
image_title?: string;
/** نویسنده مقاله — در سربرگ مقاله زیر عنوان نمایش داده می‌شود */ /** نویسنده مقاله — در سربرگ مقاله زیر عنوان نمایش داده می‌شود */
author?: string; author?: string;
/** هشتگ‌ها/برچسب‌های مقاله */ /** هشتگ‌ها/برچسب‌های مقاله */
+86 -4
View File
@@ -92,6 +92,10 @@ body {
.article-body > :first-child { .article-body > :first-child {
margin-top: 0; margin-top: 0;
} }
/* ---------- تایپوگرافی مقاله ----------
راست‌چین (فارسی): پایه ۱۴ / تیتر۱ ۱۸ / تیتر۲ ۱۶ / تیتر۳ ۱۴
چپ‌چین (انگلیسی): پایه ۱۲ / تیتر۱ ۱۶ / تیتر۲ ۱۴ / تیتر۳ ۱۲
جهت با data-dir روی پوسته ویرایشگر یا مقاله تعیین می‌شود. */
.article-body h1, .article-body h1,
.article-body h2, .article-body h2,
.article-body h3 { .article-body h3 {
@@ -104,13 +108,62 @@ body {
scroll-margin-top: 7rem; scroll-margin-top: 7rem;
} }
.article-body h1 { .article-body h1 {
font-size: 18px; /* تیتر ۱ سایز ۱۸ */ font-size: 18px;
} }
.article-body h2 { .article-body h2 {
font-size: 16px; /* تیتر ۲ سایز ۱۶ */ font-size: 16px;
} }
.article-body h3 { .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"] { .article-body span[data-small="true"] {
font-size: 12px; /* مارک اختصاصی برای سایز ۱۲ - انگلیسی */ font-size: 12px; /* مارک اختصاصی برای سایز ۱۲ - انگلیسی */
@@ -119,7 +172,7 @@ body {
.english-text { .english-text {
direction: ltr; direction: ltr;
unicode-bidi: isolate; unicode-bidi: isolate;
font-size: 12px; /* اندازه از قواعد دوزبانه بالا می‌آید: ۲ پیکسل کوچک‌تر از متن فارسی همان بلوک */
} }
.article-body ul, .article-body ul,
.article-body ol { .article-body ol {
@@ -163,6 +216,35 @@ body {
border: 1px solid rgba(245, 243, 239, 0.1); border: 1px solid rgba(245, 243, 239, 0.1);
object-fit: cover; 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 { .article-body strong {
color: #f5f3ef; color: #f5f3ef;
font-weight: 700; font-weight: 700;
+39 -14
View File
@@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { Link, useParams } from 'react-router-dom'; 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 { renderToHTMLString } from '@tiptap/static-renderer';
import { Reveal } from '../components/Reveal'; import { Reveal } from '../components/Reveal';
import { ButtonLink } from '../components/Button'; import { ButtonLink } from '../components/Button';
@@ -12,17 +12,23 @@ import { useSite } from '../api';
import { applySeo } from '../seo'; import { applySeo } from '../seo';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { ARTICLE_EXTENSIONS } from '../components/admin/ArticleEditor'; 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'; import type { Article } from '../data/site';
/** بدنه مقاله را به HTML و فهرست مطالب تبدیل می‌کند — سند TipTap جدید یا پاراگراف‌های قدیمی */ /** بدنه مقاله را به 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; const body = article.body as unknown;
if (Array.isArray(body)) return { html: null, toc: [], legacy: body }; if (Array.isArray(body)) return { html: null, toc: [], legacy: body };
if (body && typeof body === 'object' && (body as { type?: string }).type === 'doc') { if (body && typeof body === 'object' && (body as { type?: string }).type === 'doc') {
try { try {
const html = renderToHTMLString({ extensions: ARTICLE_EXTENSIONS, content: body as never }); 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: [] }; return { html: htmlWithIds, toc: items, legacy: [] };
} catch { } catch {
return { html: null, toc: [], legacy: [] }; return { html: null, toc: [], legacy: [] };
@@ -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); 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 ( 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 <img
src={article.image} src={article.image}
alt={article.image_alt || article.title} alt={article.image_alt || article.title}
title={article.image_title || undefined}
className="absolute inset-0 h-full w-full object-cover" className="absolute inset-0 h-full w-full object-cover"
/> />
<div className="absolute inset-0 bg-ink/75" aria-hidden="true" /> <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 to="/" className="transition-colors hover:text-copper">
صفحه اصلی صفحه اصلی
</Link> </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 to="/blog" className="transition-colors hover:text-copper">
وبلاگ وبلاگ
</Link> </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> <span className="text-copper">{article.category}</span>
</nav> </nav>
<h1 className="mt-5 max-w-[700px] text-hero font-bold text-ivory">{article.title}</h1> <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"> <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 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
<div className="min-w-0"> 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> <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} {article.excerpt}
</p> </p>
</Reveal> </Reveal>
@@ -133,9 +155,12 @@ export function ArticleDetail() {
)} )}
</div> </div>
{/* فهرست مطالب — در دسکتاپ کنار متن چسبان می‌ماند */} {/* فهرست مطالب — در دسکتاپ کنار متن می‌چسبد و همراه مقاله پایین می‌آید */}
{toc.length > 0 && ( {hasToc && (
<aside className="order-first lg:order-none"> <aside
dir={dir}
className={`order-first lg:order-none lg:row-start-1 ${tocColumns}`}
>
<TableOfContents items={toc} className="lg:sticky lg:top-28" /> <TableOfContents items={toc} className="lg:sticky lg:top-28" />
</aside> </aside>
)} )}
+2 -1
View File
@@ -9,6 +9,7 @@ import { useSite } from '../api'
import { EditableItem } from '../admin/Editable' import { EditableItem } from '../admin/Editable'
import { useAdmin } from '../admin/AdminContext' import { useAdmin } from '../admin/AdminContext'
import { formatJalali } from '../utils/date' import { formatJalali } from '../utils/date'
import { toPersianDigits } from '../utils/digits'
import { PlusIcon } from 'lucide-react' import { PlusIcon } from 'lucide-react'
function DraftBadge({ className = '' }: { className?: string }) { function DraftBadge({ className = '' }: { className?: string }) {
@@ -95,7 +96,7 @@ export function Blog() {
<span>{formatJalali(featured.date)}</span> <span>{formatJalali(featured.date)}</span>
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<ClockIcon className="h-3.5 w-3.5" /> <ClockIcon className="h-3.5 w-3.5" />
{featured.read} {toPersianDigits(featured.read)}
</span> </span>
</span> </span>
<span className="flex items-center gap-2 text-copper"> <span className="flex items-center gap-2 text-copper">
+74
View File
@@ -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)
}
}
+6
View File
@@ -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)])
}
+30
View File
@@ -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'
}
+48
View File
@@ -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') }
}
+22
View File
@@ -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
View File
@@ -1,31 +1,27 @@
import slugify from 'slugify'
/** /**
* تبدیل متن (فارسی یا انگلیسی) به شناسه/لنگر امن. * تبدیل متن (فارسی یا انگلیسی) به شناسه/لنگر امن برای فهرست مطالب.
* slugify حروف فارسی و عربی را به معادل لاتین ترانویسی می‌کند تا لنگرهای * حروف و اعداد فارسی/عربی و لاتین در شناسه می‌مانند (در URL به‌صورت percent-encoded
* فهرست مطالب در همه مرورگرها ASCII، پایدار و قابل اشتراک‌گذاری بمانند. * نوشته می‌شوند و در همه مرورگرها کار می‌کنند)؛ فقط نشانه‌ها و فاصله‌ها به خط تیره
* تبدیل می‌شوند. نیم‌فاصله و نویسه‌های نامرئی جهت هم حذف می‌شوند تا تیترهای تکراری
* شناسه یکتا بگیرند.
*/ */
export function slugifyFa(input: string): string { export function slugifyFa(input: string): string {
const normalized = (input ?? '') return (input ?? '')
.toString() .toString()
.normalize('NFC') .normalize('NFC')
// نیم‌فاصله، اتصال‌دهنده و نویسه‌های نامرئی جهت → فاصله // نیم‌فاصله، اتصال‌دهنده و نویسه‌های نامرئی جهت → حذف
.replace(/[\u200b-\u200f\u202a-\u202e]/g, ' ') .replace(/[\u200b-\u200f\u202a-\u202e\ufeff]/g, ' ')
// یکسان‌سازی حروف عربی با معادل فارسی // یکسان‌سازی حروف عربی با معادل فارسی
.replace(/[يى]/g, 'ی') .replace(/[يى]/g, 'ی')
.replace(/ك/g, 'ک') .replace(/ك/g, 'ک')
.replace(/[ۀة]/g, 'ه') .replace(/[ۀة]/g, 'ه')
.toLowerCase()
// فقط حروف/اعداد یونیکد (فارسی، عربی، لاتین و...) و فاصله/خط تیره بماند
.replace(/[^\p{L}\p{N}\s-]/gu, '')
.trim() .trim()
.replace(/\s+/g, '-')
const slug = slugify(normalized, { .replace(/-{2,}/g, '-')
lower: true, .replace(/^-+|-+$/g, '')
trim: true,
replacement: '-',
// فقط حروف/اعداد/فاصله/خط تیره بماند — بقیه نشانه‌ها حذف شوند
remove: /[^\w\s-]/g,
})
return slug.replace(/-{2,}/g, '-').replace(/^-+|-+$/g, '')
} }
/** ساخت شناسه یکتا — تیترهای تکراری پسوند عددی می‌گیرند (مثل about، about-2) */ /** ساخت شناسه یکتا — تیترهای تکراری پسوند عددی می‌گیرند (مثل about، about-2) */
+103
View File
@@ -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 }
}
+4 -2
View File
@@ -5,10 +5,12 @@ import react from '@vitejs/plugin-react'
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
server: { server: {
// در حالت توسعه، /api و /media به Traefik پروکسی می‌شوند (پورت ۵۱۷۳ روی هاست) port: 5174, // سرور توسعه فرانت‌اند روی این پورت اجرا می‌شود تا تداخل نکند
strictPort: true, // جلوگیری از تغییر خودکار پورت
proxy: { proxy: {
// در حالت توسعه، درخواست‌های API و Media به Traefik (پورت ۵۱۷۳) فرستاده می‌شوند
'/api': 'http://localhost:5173', '/api': 'http://localhost:5173',
'/media': 'http://localhost:5173', '/media': 'http://localhost:5173',
}, },
}, },
}) })