2 Commits
Author SHA1 Message Date
MMD fe6957bf83 Merge remote-tracking branch 'origin/dashboard/blog-changes'
# Conflicts:
#	frontend/src/admin/ArticleWritePage.tsx
#	frontend/src/admin/EntityDrawer.tsx
#	frontend/src/admin/entities.ts
#	frontend/src/components/ArticleCard.tsx
#	frontend/src/index.css
2026-09-15 14:41:46 +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
24 changed files with 1398 additions and 441 deletions
+7 -7
View File
@@ -8,15 +8,16 @@ The **Pinar** font is installed and served locally. The application no longer re
## CMS, SEO & Editor Features
The project includes a robust, custom-built Digital Twin (Admin Panel) with advanced content management capabilities:
The project includes a robust, custom-built Digital Twin (Admin Panel) with advanced content management capabilities, emphasizing a premium UI/UX:
* **Advanced SEO Management:** Dynamic `meta_description` fields (150-160 characters) and fully managed Image `Alt` texts (with auto-fallback to titles/filenames) ensure high search engine visibility.
* **Tags & Categorization:** Integrated `react-select` for modern category selection and creatable dynamic tags.
* **Article Metadata UI:** A beautifully designed metadata component displaying the author's avatar, name, and publication date right below the H1 title.
* **Advanced SEO & Featured Media:** Dynamic `meta_description` fields (150-160 characters). The Featured Image component features a compact layout with smart data-binding: the `Alt` text auto-fills based on the title (e.g., `[Title] - تصویر شاخص`) as a smart fallback to ensure SEO compliance, while still allowing **full manual overrides** for precise custom keyword targeting.
* **Precise Category Management:** A flawlessly integrated, single-select category dropdown using `react-select` (Creatable) and `react-hook-form`. It allows creating new categories on the fly while retaining them in the global list, seamlessly matching the strict single-category backend schema.
* **Article Metadata & Reading Time:** A beautifully designed metadata component displaying the author's avatar, name, publication date, and an auto-calculated dynamic reading time (displayed dynamically with Persian numerals, e.g., "۵ دقیقه مطالعه") directly below the H1 title.
* **Dynamic Table of Contents (TOC):** Auto-generates a clickable TOC from `h2` and `h3` tags within the article, using a custom Persian-supported `slugify` logic for anchor links.
* **Custom Tiptap WYSIWYG Editor:**
* Specific block-level toggling for Headings (H1: 18px, H2: 16px, H3: 14px) and Paragraphs (14px).
* A custom feature to wrap English text blocks in LTR styling (`<span dir="ltr" class="english-text">` at 12px) for perfect bi-directional text alignment.
* **Fixed Toolbar & Typography:** Dedicated LTR/RTL toggles (`data-dir="ltr|rtl"`) with specific font scaling (RTL Base: 14px, LTR Base: 12px) mapped to a fixed toolbar that maintains layout stability.
* **Advanced Image & Caption Nodes:** Built via `ReactNodeViewRenderer`, rendering HTML wrapped in `<figure><figcaption>`. Features strictly isolated UI deletion controls (separate 'X' buttons to delete either the image entirely or just the caption text).
* **Animated Link Modal:** A premium, auto-opening modal with smooth fade/scale transition effects. Includes seamless checkbox toggles for "Open in new tab" (`target="_blank" rel="noopener noreferrer"`) and SEO link attributes (`nofollow/dofollow`).
* **Full Jalali (Shamsi) Calendar Integration:**
* Admin panel utilizes `react-multi-date-picker` for a visual Persian date selection experience.
* Frontend and text displays rely on native `Intl.DateTimeFormat` to render dates and numerals perfectly in Persian.
@@ -50,4 +51,3 @@ npm run build
* `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.
+17
View File
@@ -19,6 +19,7 @@
"lucide-react": "0.522.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.88.0",
"react-multi-date-picker": "^4.5.2",
"react-router-dom": "^6.26.2",
"react-select": "^5.10.2",
@@ -4484,6 +4485,22 @@
"react-dom": ">=16.8.0"
}
},
"node_modules/react-hook-form": {
"version": "7.88.0",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.88.0.tgz",
"integrity": "sha512-QRaLOWhX93YCnMiRfnOFRSwWXZNt8qhm2JTZwypoDvKpSffTJHmpzMXt8U6PV5UThvL3IiiLDUWe2nHMtz6Mmw==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/react-hook-form"
},
"peerDependencies": {
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+1
View File
@@ -21,6 +21,7 @@
"lucide-react": "0.522.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.88.0",
"react-multi-date-picker": "^4.5.2",
"react-router-dom": "^6.26.2",
"react-select": "^5.10.2",
+106 -22
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Controller, useController, useForm } from 'react-hook-form'
import { Link, Navigate, useNavigate, useParams } from 'react-router-dom'
import { ArrowRightIcon, CheckIcon, EyeIcon, Trash2Icon } from 'lucide-react'
import Swal from 'sweetalert2'
@@ -9,8 +10,11 @@ import { ArticleEditor } from '../components/admin/ArticleEditor'
import { SeoChecklist } from '../components/admin/SeoChecklist'
import { ImageField } from '../components/admin/ImageField'
import { JalaliDatePicker } from '../components/admin/JalaliDatePicker'
import { LuxeSelect, LuxeTagsSelect } from '../components/admin/LuxeSelect'
import { CategorySelect, LuxeTagsSelect } from '../components/admin/LuxeSelect'
import type { CategoryOption } from '../components/admin/LuxeSelect'
import { ARTICLE_CATEGORIES } from './entities'
import { FEATURED_IMAGE_ALT_SUFFIX } from '../data/constants'
import { formatReadingTime } from '../utils/readingTime'
import type { Article, ArticleBodyDoc } from '../data/site'
const EMPTY_DOC = { type: 'doc', content: [{ type: 'paragraph' }] } as unknown as ArticleBodyDoc
@@ -18,6 +22,30 @@ const EMPTY_DOC = { type: 'doc', content: [{ type: 'paragraph' }] } as unknown a
/** پیش‌نویس صفحه نوشتن — همان شکل Article با بدنه ساختاریافته دو زبانه */
type Draft = Omit<Article, 'body'> & { body: ArticleBodyDoc; body_en: ArticleBodyDoc }
/**
* فیلدهای متادیتای مقاله که با React Hook Form مدیریت می‌شوند:
* عنوان (مبنای Alt پویا)، دسته‌بندی و متادیتای تصویر شاخص.
* `category` در فرم یک شیء تک‌مقداری {value,label} یا null است
* که هنگام ذخیره به رشته (ستون category در بک‌اند) نگاشت می‌شود.
*/
type ArticleMetaForm = {
title: string
category: CategoryOption | null
image_alt: string
image_title: string
}
/** مقدار رشته‌ای ذخیره‌شده → گزینه react-select (یا null وقتی خالی است) */
function toCategoryOption(value: string): CategoryOption | null {
const category = (value ?? '').trim()
return category ? { label: category, value: category } : null
}
/** گزینه react-select → مقدار رشته‌ای ذخیره‌سازی در بک‌اند */
function categoryValue(option: CategoryOption | null): string {
return option?.value.trim() ?? ''
}
/** داده‌های قدیمی ممکن است هشتگ را رشته کاماشده داشته باشند */
function normalizeTags(value: unknown): string[] {
if (Array.isArray(value)) return value.map((t) => String(t).trim()).filter(Boolean)
@@ -57,6 +85,7 @@ export function ArticleWritePage() {
tags: [],
image: '',
image_alt: '',
image_title: '',
author: '',
meta_description: '',
read: '۵ دقیقه',
@@ -68,6 +97,25 @@ export function ArticleWritePage() {
status: 'draft',
seo_keyphrase: '',
})
/**
* فرم متادیتا با React Hook Form — منبع نمایش فیلدها.
* هر تغییر، هم فرم و هم draft (مبنای ذخیره‌سازی خودکار) را به‌روز می‌کند.
*/
const { control, watch, reset } = useForm<ArticleMetaForm>({
defaultValues: {
title: '',
category: toCategoryOption(ARTICLE_CATEGORIES[0]),
image_alt: '',
image_title: '',
},
mode: 'onChange',
})
/** عنوان زنده مقاله — مبنای متن جایگزین (Alt) تصویر شاخص */
const watchedTitle = watch('title')
const { field: imageAltField } = useController({ name: 'image_alt', control })
const { field: imageTitleField } = useController({ name: 'image_title', control })
const [createdId, setCreatedId] = useState<number | null>(null)
const [deleting, setDeleting] = useState(false)
const draftRef = useRef(draft)
@@ -110,6 +158,13 @@ export function ArticleWritePage() {
: EMPTY_DOC,
})
setCreatedId(article.id)
// فرم متادیتا هم با مقادیر مقاله بارگذاری‌شده همگام می‌شود
reset({
title: article.title ?? '',
category: toCategoryOption(article.category ?? ''),
image_alt: article.image_alt ?? '',
image_title: article.image_title ?? '',
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [article?.id])
@@ -209,6 +264,11 @@ export function ArticleWritePage() {
// در زبان انگلیسی، فیلدهای متنی به ستون‌های _en می‌روند؛ اسلاگ/تصویر/وضعیت مشترک‌اند
const suf = <N extends string>(name: N) => `${name}${editLang === 'en' ? '_en' : ''}` as keyof Draft
// متن جایگزین تصویر شاخص — با watch('title') به‌صورت زنده ساخته می‌شود
const imageAltFallback = [watchedTitle.trim(), FEATURED_IMAGE_ALT_SUFFIX]
.filter(Boolean)
.join(' - ')
return (
<div dir="rtl" className="min-h-screen bg-ink font-pinar text-ivory">
{/* نوار بالا */}
@@ -301,31 +361,49 @@ export function ArticleWritePage() {
: 'مقاله منتشرشده — تغییرها همان لحظه در سایت اعمال می‌شود'}
</p>
{/* عنوان — در ویرایش انگلیسی، متن فارسی فعلی جای‌نگهدار می‌ماند */}
<input
type="text"
{...textField(suf('title'))}
placeholder={editLang === 'en' ? draft.title || 'English article title…' : 'عنوان مقاله…'}
aria-label="عنوان مقاله"
className="mt-4 w-full bg-transparent text-4xl font-bold leading-[1.6] text-ivory placeholder:text-ivory/25 focus:outline-none"
{/* عنوان — با Controller به فرم وصل است تا watch('title') زنده بماند؛
در ویرایش انگلیسی همان مقدار در ستون title_en ذخیره می‌شود */}
<Controller
name="title"
control={control}
render={({ field }) => (
<input
type="text"
{...field}
onChange={(event) => {
field.onChange(event.target.value)
queueSave({ [suf('title')]: event.target.value })
}}
placeholder={editLang === 'en' ? draft.title || 'English article title…' : 'عنوان مقاله…'}
aria-label="عنوان مقاله"
className="mt-4 w-full bg-transparent text-4xl font-bold leading-[1.6] text-ivory placeholder:text-ivory/25 focus:outline-none"
/>
)}
/>
<div className="mt-4 h-px w-28 bg-copper" />
{/* تصویر شاخص + متن جایگزین */}
{/* تصویر شاخص + متن جایگزین و عنوان (هر دو به فرم وصل‌اند) */}
<div className="mt-8">
<ImageField
label="تصویر شاخص مقاله"
value={draft.image}
alt={draft.image_alt ?? ''}
altFallback={draft.title}
alt={imageAltField.value}
altFallback={imageAltFallback}
title={imageTitleField.value}
onSave={async (url) => {
queueSave({ image: url })
return true
}}
onSaveAlt={async (alt) => {
imageAltField.onChange(alt)
queueSave({ image_alt: alt })
return true
}}
onSaveTitle={async (imageTitle) => {
imageTitleField.onChange(imageTitle)
queueSave({ image_title: imageTitle })
return true
}}
/>
</div>
@@ -334,15 +412,12 @@ export function ArticleWritePage() {
<ArticleEditor
doc={(editLang === 'en' ? draft.body_en : draft.body) as unknown as Record<string, unknown>}
onChange={(json) => {
// محاسبه خودکار زمان مطالعه بر اساس تعداد کلمات متن — به زبان جاری
const textContent = JSON.stringify(json)
const wordCount = textContent.split(/\s+/).length
// زمان مطالعه به‌صورت پویا از متن سند محاسبه می‌شود — به زبان جاری
const minutes = formatReadingTime(json)
if (editLang === 'en') {
const minutes = Math.max(1, Math.ceil(wordCount / 200))
queueSave({ [suf('body')]: json, [suf('read')]: `${minutes} min read` })
} else {
const calculatedReadTime = Math.max(1, Math.ceil(wordCount / 200)) + ' دقیقه'
queueSave({ body: json, read: calculatedReadTime })
queueSave({ body: json, read: `${minutes} دقیقه` })
}
}}
/>
@@ -424,11 +499,20 @@ export function ArticleWritePage() {
<div>
<label className="text-xs font-light text-ivory/50">دسته‌بندی مقاله (Category)</label>
<div className="mt-2">
<LuxeSelect
value={draft.category}
options={ARTICLE_CATEGORIES}
onChange={(category) => queueSave({ category })}
isClearable={false}
{/* تک‌انتخابی: ورودی/خروجی Controller یک {value,label} یا null است */}
<Controller
name="category"
control={control}
render={({ field }) => (
<CategorySelect
value={field.value}
options={ARTICLE_CATEGORIES}
onChange={(option) => {
field.onChange(option)
queueSave({ category: categoryValue(option) })
}}
/>
)}
/>
</div>
</div>
+46 -16
View File
@@ -9,7 +9,7 @@ import { editableFields } from './entities'
import type { EntityConfig, FieldConfig } from './entities'
import { ImageField } from '../components/admin/ImageField'
import { JalaliDatePicker } from '../components/admin/JalaliDatePicker'
import { LuxeSelect, LuxeTagsSelect } from '../components/admin/LuxeSelect'
import { LuxeCreatableSelect, LuxeSelect, LuxeTagsSelect } from '../components/admin/LuxeSelect'
import { ArticleEditor } from '../components/admin/ArticleEditor'
type EntityDrawerProps = {
@@ -120,13 +120,21 @@ export function EntityDrawer({ config, item, onClose, deletable = false }: Entit
<div className="flex-1 overflow-y-auto px-6 py-6">
<div className="grid gap-6">
{fields.map((field) => {
const isImage = field.type === 'image'
// فیلد Alt تصویر: برای گالری همان ستون alt و برای بقیه <نام فیلد>_alt
const altKey =
field.type === 'image'
? field.name === 'src' && 'alt' in item
? 'alt'
: `${field.name}_alt`
: ''
const altKey = isImage
? field.name === 'src' && 'alt' in item
? 'alt'
: `${field.name}_alt`
: ''
const imageTitleKey = isImage ? `${field.name}_title` : ''
// پیشنهاد Alt از عنوان رکورد ساخته می‌شود (هرگز از URL)
const imageAltFallback = [
String(item[config.titleKey] ?? '').trim(),
field.altFallbackSuffix,
]
.filter(Boolean)
.join(' - ')
return (
<FieldControl
key={field.name}
@@ -134,9 +142,13 @@ export function EntityDrawer({ config, item, onClose, deletable = false }: Entit
value={item[field.name]}
placeholder={editLang === 'en' ? item[faBase(field.name)] : 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)}
onSaveAlt={altKey ? (alt) => saveField(altKey, alt) : undefined}
onSaveImageTitle={
imageTitleKey ? (title) => saveField(imageTitleKey, title) : undefined
}
registerFlush={
field.type === 'wysiwyg'
? (fn) => {
@@ -174,8 +186,10 @@ function FieldControl({
altValue,
altFallback,
placeholder,
imageTitleValue,
onSave,
onSaveAlt,
onSaveImageTitle,
registerFlush,
}: {
field: FieldConfig
@@ -185,12 +199,20 @@ function FieldControl({
altFallback?: string
/** مقدار فارسی همان فیلد — فقط در ویرایش انگلیسی به‌عنوان جای‌نگهدار نمایش داده می‌شود */
placeholder?: unknown
/** مقدار Title تصویر — فقط برای فیلدهای image */
imageTitleValue?: string
onSave: (value: unknown) => Promise<boolean>
onSaveAlt?: (alt: string) => Promise<boolean>
onSaveImageTitle?: (title: string) => Promise<boolean>
registerFlush?: (fn: () => void) => void
}) {
const [text, setText] = useState<string>(String(value ?? ''))
// همگام‌سازی مقدار محلی با داده‌ای که از بیرون می‌آید (بارگذاری/به‌روزرسانی خوش‌بینانه)
useEffect(() => {
setText(String(value ?? ''))
}, [value])
const commitText = async () => {
const next = field.type === 'number' ? Number(text) || 0 : text
if (String(value ?? '') === String(next)) return
@@ -204,26 +226,34 @@ function FieldControl({
value={String(value ?? '')}
alt={altValue}
altFallback={altFallback}
title={imageTitleValue}
onSave={(url) => onSave(url)}
onSaveAlt={onSaveAlt}
onSaveTitle={onSaveImageTitle}
/>
)
}
if (field.type === 'select') {
// مقدار نمایشی از وضعیت محلی می‌آید تا افزودن/حذف گزینه فوراً دیده شود
const selectProps = {
value: text,
options: field.options ?? [],
onChange: (next: string) => {
setText(next)
void onSave(next)
},
}
return (
<div>
<p className="text-xs font-bold text-ivory/80">{field.label}</p>
{field.hint && <p className="mt-1 text-[10px] font-light text-ivory/40">{field.hint}</p>}
<div className="mt-2">
<LuxeSelect
value={String(value ?? '')}
options={field.options ?? []}
onChange={(next) => {
setText(next)
void onSave(next)
}}
/>
{field.creatable ? (
<LuxeCreatableSelect {...selectProps} />
) : (
<LuxeSelect {...selectProps} />
)}
</div>
</div>
)
+9 -2
View File
@@ -1,4 +1,5 @@
import type { SiteData } from '../api'
import { FEATURED_IMAGE_ALT_SUFFIX } from '../data/constants'
export type FieldType = 'text' | 'textarea' | 'number' | 'image' | 'list' | 'wysiwyg' | 'select' | 'date'
@@ -9,10 +10,14 @@ export type FieldConfig = {
hint?: string
/** فقط برای type === 'select' — گزینه‌های ثابت react-select */
options?: string[]
/** فقط برای type === 'select' — امکان ساخت گزینه جدید توسط کاربر */
creatable?: boolean
/** ویجت سفارشی نوع list — 'tags' یعنی react-select قابل ساخت (Creatable) */
ui?: 'lines' | 'tags'
/** فیلدهای مشترک بین دو زبان (تصویر، لینک، اسلاگ، وضعیت، آیکون، عدد) ترجمه نمی‌شوند */
shared?: boolean
/** پسوند پیشنهاد Alt تصویر — مثل «تصویر شاخص» در کنار عنوان رکورد */
altFallbackSuffix?: string
}
export type EntityConfig = {
@@ -77,8 +82,8 @@ export const ENTITIES: Record<string, EntityConfig> = {
F('body', 'متن مقاله', 'wysiwyg'),
F('excerpt', 'چکیده (روی کارت مقاله)', 'textarea'),
F('meta_description', 'توضیحات سئو (Meta Description)', 'textarea', '۱۵۰ تا ۱۶۰ کاراکتر — زیر عنوان در نتایج گوگل نمایش داده می‌شود', true),
F('image', 'تصویر شاخص', 'image', undefined, true),
F('category', 'دسته‌بندی (Category)', 'select', undefined, { options: ARTICLE_CATEGORIES, shared: true }),
F('image', 'تصویر شاخص', 'image', undefined, { altFallbackSuffix: FEATURED_IMAGE_ALT_SUFFIX, shared: true }),
F('category', 'دسته‌بندی (Category)', 'select', 'می‌توانید دسته جدید هم بسازید', { options: ARTICLE_CATEGORIES, creatable: true, shared: true }),
F('tags', 'هشتگ‌ها / برچسب‌ها', 'list', 'با Enter هر هشتگ جدید را اضافه کنید', { ui: 'tags', shared: true }),
F('author', 'نویسنده', 'text', undefined, true),
F('date', 'تاریخ انتشار (شمسی)', 'date', undefined, true),
@@ -92,6 +97,8 @@ export const ENTITIES: Record<string, EntityConfig> = {
category: ARTICLE_CATEGORIES[0],
tags: ['مدیریت پروژه', 'ساخت‌وساز'],
image: '',
image_alt: '',
image_title: '',
author: '',
meta_description: '',
read: '۵ دقیقه',
+2 -1
View File
@@ -4,6 +4,7 @@ import { ArrowLeftIcon, ClockIcon } from 'lucide-react'
import type { Article } from '../data/site'
import { formatJalali } from '../utils/date'
import { useLang } from '../i18n/lang'
import { toPersianDigits } from '../utils/digits'
export function ArticleCard({ article }: { article: Article }) {
const { t, href } = useLang()
@@ -39,7 +40,7 @@ export function ArticleCard({ article }: { article: Article }) {
<span>{formatJalali(article.date)}</span>
<span className="flex items-center gap-1.5">
<ClockIcon className="h-3.5 w-3.5 text-copper/80" />
{article.read}
{toPersianDigits(article.read)}
</span>
</span>
+3 -2
View File
@@ -1,6 +1,7 @@
import { CalendarIcon, ClockIcon } from 'lucide-react'
import type { Article } from '../data/site'
import { JALALI_TIME_FORMATTER, formatJalali } from '../utils/date'
import { toPersianDigits } from '../utils/digits'
type ArticleMetaProps = {
article: Article
@@ -41,11 +42,11 @@ export function ArticleMeta({ article, className = '' }: ArticleMetaProps) {
{dateLabel}
</span>
{/* زمان مطالعه */}
{/* زمان مطالعه — با ارقام فارسی */}
{article.read && (
<span className="flex items-center gap-2 text-xs font-light text-ivory/60">
<ClockIcon className="h-3.5 w-3.5 text-copper" aria-hidden="true" />
{article.read}
{toPersianDigits(article.read)}
</span>
)}
</div>
+253 -157
View File
@@ -1,53 +1,63 @@
import { useRef, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { EditorContent, useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Image from '@tiptap/extension-image'
import Link from '@tiptap/extension-link'
import { Mark, mergeAttributes } from '@tiptap/core'
import {
AlignLeftIcon,
AlignRightIcon,
BoldIcon,
ItalicIcon,
Heading1Icon,
Heading2Icon,
Heading3Icon,
ImagePlusIcon,
LanguagesIcon,
ItalicIcon,
Link2Icon,
ListIcon,
ListOrderedIcon,
Loader2Icon,
PilcrowIcon,
QuoteIcon,
Redo2Icon,
StrikethroughIcon,
UnderlineIcon,
Undo2Icon,
Loader2Icon,
} from 'lucide-react'
import Swal from 'sweetalert2'
import { useAdmin } from '../../admin/AdminContext'
import { adminApi } from '../../admin/adminClient'
import { ENGLISH_TEXT_MARK, EnglishText } from './EnglishText'
import { buildLinkRel, isAllowedHref, isUrlLike, normalizeHref, parseLinkRel } from '../../utils/link'
import { ArticleImage } from './ArticleImage'
import { EnglishText } from './EnglishText'
import { LinkModal } from './LinkModal'
import type { LinkModalValue } from './LinkModal'
const SmallSizeMark = Mark.create({
/**
* مارک‌های قدیمی (سایز ۱۲ و متن انگلیسی) — کنترل آن‌ها از نوار ابزار برداشته شده و
* فقط برای اینکه محتوای قبلی از دست نرود در شِما نگه داشته شده‌اند.
*/
const LegacySmallSizeMark = Mark.create({
name: 'smallSize',
parseHTML() { return [{ tag: 'span[data-small]' }] },
parseHTML() {
return [{ tag: 'span[data-small]' }]
},
renderHTML({ HTMLAttributes }) {
return ['span', mergeAttributes(HTMLAttributes, { 'data-small': 'true' }), 0]
}
},
})
/** اکستنشن‌های مشترک ویرایشگر و رندر مقاله — هر دو باید یکسان باشند */
export const ARTICLE_EXTENSIONS = [
StarterKit.configure({
link: false,
heading: { levels: [1, 2, 3] },
}),
Image.configure({ allowBase64: false }),
ArticleImage.configure({ allowBase64: false }),
Link.configure({
openOnClick: false,
HTMLAttributes: { rel: 'noopener noreferrer', target: '_blank' },
isAllowedUri: (uri: string) => /^(https?:\/\/|\/|#|mailto:|tel:)/i.test(uri),
isAllowedUri: (uri: string) => isAllowedHref(uri),
}),
SmallSizeMark,
LegacySmallSizeMark,
EnglishText,
]
@@ -56,6 +66,30 @@ type ArticleEditorProps = {
onChange: (json: Record<string, unknown>) => void
}
type TextDirection = 'rtl' | 'ltr'
/** نحوه اعمال لینک: روی انتخاب فعلی، درج متن تازه یا تبدیل محدوده تایپ‌شده */
type LinkApplyMode = 'selection' | 'insert' | 'range'
type LinkModalState = {
open: boolean
editing: boolean
mode: LinkApplyMode
range: { from: number; to: number } | null
initial: LinkModalValue
}
const CLOSED_LINK_MODAL: LinkModalState = {
open: false,
editing: false,
mode: 'selection',
range: null,
initial: { href: '', newTab: true, nofollow: false },
}
/** چند نویسه پیش از نشانگر برای تشخیص نشانیِ تایپ‌شده بررسی می‌شود */
const URL_LOOKBACK = 300
function ToolButton({
onClick,
active = false,
@@ -72,6 +106,7 @@ function ToolButton({
type="button"
title={label}
aria-label={label}
aria-pressed={active}
onMouseDown={(e) => e.preventDefault()}
onClick={onClick}
className={`flex h-8 w-8 items-center justify-center border transition-colors duration-150 ${
@@ -87,10 +122,16 @@ function ToolButton({
const Divider = () => <span className="mx-1 h-5 w-px bg-ivory/10" aria-hidden="true" />
/**
* ویرایشگر WYSIWYG مقاله (TipTap) — نود تصویر کپشن‌دار، مودال لینک سئو-محور،
* کلید راست‌چین/چپ‌چین (فقط روی متن اثر دارد، نه نوار ابزار) و آپلود تصویر به MinIO.
*/
export function ArticleEditor({ doc, onChange }: ArticleEditorProps) {
const { runSave } = useAdmin()
const fileRef = useRef<HTMLInputElement>(null)
const [uploading, setUploading] = useState(false)
const [direction, setDirection] = useState<TextDirection>('rtl')
const [linkModal, setLinkModal] = useState<LinkModalState>(CLOSED_LINK_MODAL)
const editor = useEditor({
extensions: ARTICLE_EXTENSIONS,
@@ -99,16 +140,30 @@ export function ArticleEditor({ doc, onChange }: ArticleEditorProps) {
editorProps: {
attributes: {
dir: 'rtl',
lang: 'fa',
class: 'article-body min-h-[300px] px-4 py-4 focus:outline-none',
'data-wysiwyg': 'true',
},
handlePaste: (_event_ignored, event) => {
handlePaste: (_view, event, _slice) => {
const file = event.clipboardData?.files?.[0]
if (file && file.type.startsWith('image/')) {
event.preventDefault()
void uploadAndInsert(file)
return true
}
// چسباندن یک نشانی → مودال لینک بلافاصله باز می‌شود
const text = event.clipboardData?.getData('text/plain')?.trim() ?? ''
if (isUrlLike(text)) {
event.preventDefault()
setLinkModal({
open: true,
editing: false,
mode: 'insert',
range: null,
initial: { href: normalizeHref(text) ?? text, newTab: true, nofollow: false },
})
return true
}
return false
},
handleDrop: (_view, event, _slice, moved) => {
@@ -121,176 +176,217 @@ export function ArticleEditor({ doc, onChange }: ArticleEditorProps) {
}
return false
},
// پایان تایپ یک نشانی (فاصله بعد از آن) → مودال لینک باز می‌شود
handleTextInput: (view, from, to, text) => {
if (text !== ' ' || from !== to) return false
const start = Math.max(0, from - URL_LOOKBACK)
const before = view.state.doc.textBetween(start, from, ' ', ' ').trimEnd()
const lastToken = before.slice(before.lastIndexOf(' ') + 1)
if (!isUrlLike(lastToken)) return false
setLinkModal({
open: true,
editing: false,
mode: 'range',
range: { from: from - lastToken.length, to: from },
initial: { href: normalizeHref(lastToken) ?? lastToken, newTab: true, nofollow: false },
})
return false
},
},
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, direction])
async function uploadAndInsert(file: File) {
if (!editor) return
setUploading(true)
// استفاده از ریگکس یونیکد اصلاح‌شده برای نام فایل و Alt
const originalName = file.name.replace(/\.[^.]+$/, '')
const cleanAlt = originalName.replace(/[^\w\u0600-\u06FF\s-]/g, '-').replace(/\s+/g, '-').toLowerCase()
await runSave(async () => {
const { url } = await adminApi.upload(file)
editor.chain().focus().setImage({ src: url, alt: cleanAlt }).run()
editor.chain().focus().setImage({ src: url, alt: file.name.replace(/\.[^.]+$/, '') }).run()
})
setUploading(false)
}
const setLink = () => {
/** باز کردن مودال برای لینک انتخاب فعلی (یا ویرایش لینک زیر نشانگر) */
const openLinkModalForSelection = () => {
if (!editor) return
if (editor.isActive('link')) {
editor.chain().focus().unsetLink().run()
return
const attrs = editor.getAttributes('link') as {
href?: string | null
target?: string | null
rel?: string | null
}
void Swal.fire({
title: 'نشانی لینک',
input: 'text',
inputValue: 'https://',
background: '#1e1a16',
color: '#F5F3EF',
showCancelButton: true,
confirmButtonText: 'ثبت',
cancelButtonText: 'انصراف',
buttonsStyling: false,
customClass: {
popup: 'font-pinar border border-ivory/10',
title: 'font-pinar font-bold text-ivory',
input: 'font-pinar',
confirmButton: 'bg-copper text-char font-bold px-8 py-2.5 mx-2',
cancelButton: 'border border-ivory/30 text-ivory font-light px-8 py-2.5 mx-2',
const editing = Boolean(attrs.href)
setLinkModal({
open: true,
editing,
mode: 'selection',
range: null,
initial: {
href: attrs.href ?? '',
newTab: editing ? attrs.target === '_blank' : true,
nofollow: editing ? parseLinkRel(attrs.rel).nofollow : false,
},
}).then((result) => {
if (!result.isConfirmed || !result.value) return
editor.chain().focus().extendMarkRange('link').setLink({ href: result.value }).run()
})
}
const applyLink = ({ href, newTab, nofollow }: LinkModalValue) => {
if (!editor) return
const linkAttrs = { href, target: newTab ? '_blank' : null, rel: buildLinkRel({ newTab, nofollow }) }
const chain = editor.chain().focus()
if (linkModal.mode === 'insert') {
// نشانی چسبانده‌شده: متن لینک تازه درج می‌شود
chain.insertContent({ type: 'text', text: href, marks: [{ type: 'link', attrs: linkAttrs }] }).run()
} else if (linkModal.mode === 'range' && linkModal.range) {
// نشانی تایپ‌شده: همان محدوده به لینک تبدیل می‌شود
chain.setTextSelection(linkModal.range).extendMarkRange('link').setLink(linkAttrs).run()
} else {
chain.extendMarkRange('link').setLink(linkAttrs).run()
}
setLinkModal((state) => ({ ...state, open: false }))
}
const removeLink = () => {
editor?.chain().focus().extendMarkRange('link').unsetLink().run()
setLinkModal((state) => ({ ...state, open: false }))
}
if (!editor) return null
return (
<div className="border border-ivory/15 bg-char">
<div className="flex flex-wrap items-center gap-1 border-b border-ivory/10 px-2 py-1.5">
{/* قالب‌بندی بلوک — پاراگراف و تیترها با سایز مشخص */}
<ToolButton
label="پاراگراف (۱۴ پیکسل)"
active={editor.isActive('paragraph')}
onClick={() => editor.chain().focus().setParagraph().run()}
<>
<div className="border border-ivory/15 bg-char" data-dir={direction}>
{/* نوار ابزار: همیشه راست‌چین و در بالای ویرایشگر ثابت می‌ماند */}
<div
dir="rtl"
className="sticky top-14 z-20 flex flex-wrap items-center gap-1 border-b border-ivory/10 bg-char px-2 py-1.5"
>
<PilcrowIcon className="h-4 w-4" />
</ToolButton>
<ToolButton
label="تیتر ۱ (۱۸ پیکسل)"
active={editor.isActive('heading', { level: 1 })}
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
>
<Heading1Icon className="h-4 w-4" />
</ToolButton>
<ToolButton
label="تیتر ۲ (۱۶ پیکسل)"
active={editor.isActive('heading', { level: 2 })}
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
>
<Heading2Icon className="h-4 w-4" />
</ToolButton>
<ToolButton
label="تیتر ۳ (۱۴ پیکسل)"
active={editor.isActive('heading', { level: 3 })}
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
>
<Heading3Icon className="h-4 w-4" />
</ToolButton>
<ToolButton
label="پاراگراف"
active={editor.isActive('paragraph')}
onClick={() => editor.chain().focus().setParagraph().run()}
>
<PilcrowIcon className="h-4 w-4" />
</ToolButton>
<ToolButton
label="تیتر ۱"
active={editor.isActive('heading', { level: 1 })}
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
>
<Heading1Icon className="h-4 w-4" />
</ToolButton>
<ToolButton
label="تیتر ۲"
active={editor.isActive('heading', { level: 2 })}
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
>
<Heading2Icon className="h-4 w-4" />
</ToolButton>
<ToolButton
label="تیتر ۳"
active={editor.isActive('heading', { level: 3 })}
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
>
<Heading3Icon className="h-4 w-4" />
</ToolButton>
<Divider />
<Divider />
<select
className="h-8 bg-ink border border-ivory/15 text-ivory/90 text-xs px-2 outline-none focus:border-copper cursor-pointer transition-colors"
value={editor.isActive('smallSize') ? '12' : '14'}
{/* جهت متن — فقط محتوای ویرایشگر را عوض می‌کند */}
<ToolButton
label="راست‌چین (فارسی)"
active={direction === 'rtl'}
onClick={() => setDirection('rtl')}
>
<AlignRightIcon className="h-4 w-4" />
</ToolButton>
<ToolButton
label="چپ‌چین (انگلیسی)"
active={direction === 'ltr'}
onClick={() => setDirection('ltr')}
>
<AlignLeftIcon className="h-4 w-4" />
</ToolButton>
<Divider />
<ToolButton label="بولد" active={editor.isActive('bold')} onClick={() => editor.chain().focus().toggleBold().run()}>
<BoldIcon className="h-4 w-4" />
</ToolButton>
<ToolButton label="ایتالیک" active={editor.isActive('italic')} onClick={() => editor.chain().focus().toggleItalic().run()}>
<ItalicIcon className="h-4 w-4" />
</ToolButton>
<ToolButton label="زیرخط" active={editor.isActive('underline')} onClick={() => editor.chain().focus().toggleUnderline().run()}>
<UnderlineIcon className="h-4 w-4" />
</ToolButton>
<ToolButton label="خط‌خورده" active={editor.isActive('strike')} onClick={() => editor.chain().focus().toggleStrike().run()}>
<StrikethroughIcon className="h-4 w-4" />
</ToolButton>
<Divider />
<ToolButton label="لیست نقطه‌ای" active={editor.isActive('bulletList')} onClick={() => editor.chain().focus().toggleBulletList().run()}>
<ListIcon className="h-4 w-4" />
</ToolButton>
<ToolButton label="لیست شماره‌ای" active={editor.isActive('orderedList')} onClick={() => editor.chain().focus().toggleOrderedList().run()}>
<ListOrderedIcon className="h-4 w-4" />
</ToolButton>
<ToolButton label="نقل‌قول" active={editor.isActive('blockquote')} onClick={() => editor.chain().focus().toggleBlockquote().run()}>
<QuoteIcon className="h-4 w-4" />
</ToolButton>
<Divider />
<ToolButton label="افزودن لینک" active={editor.isActive('link')} onClick={openLinkModalForSelection}>
<Link2Icon className="h-4 w-4" />
</ToolButton>
<ToolButton label="درج تصویر" onClick={() => fileRef.current?.click()}>
{uploading ? <Loader2Icon className="h-4 w-4 animate-spin" /> : <ImagePlusIcon className="h-4 w-4" />}
</ToolButton>
<Divider />
<ToolButton label="واگرد" onClick={() => editor.chain().focus().undo().run()}>
<Undo2Icon className="h-4 w-4" />
</ToolButton>
<ToolButton label="ازنو" onClick={() => editor.chain().focus().redo().run()}>
<Redo2Icon className="h-4 w-4" />
</ToolButton>
</div>
<EditorContent editor={editor} />
<input
ref={fileRef}
type="file"
data-editor-file-input
accept="image/jpeg,image/png,image/webp,image/gif,image/avif"
className="hidden"
onChange={(e) => {
if (e.target.value === '12') {
editor.chain().focus().setMark('smallSize').run()
} else {
editor.chain().focus().unsetMark('smallSize').run()
}
const file = e.target.files?.[0]
if (file) void uploadAndInsert(file)
e.target.value = ''
}}
>
<option value="14">سایز ۱۴ (فارسی)</option>
<option value="12">سایز ۱۲ (انگلیسی)</option>
</select>
{/* متن انگلیسی — span با dir=ltr و سایز ۱۲ پیکسل */}
<ToolButton
label="متن انگلیسی (۱۲ پیکسل، چپ‌به‌راست)"
active={editor.isActive(ENGLISH_TEXT_MARK)}
onClick={() => editor.chain().focus().toggleMark(ENGLISH_TEXT_MARK).run()}
>
<LanguagesIcon className="h-4 w-4" />
</ToolButton>
<Divider />
<ToolButton label="بولد" active={editor.isActive('bold')} onClick={() => editor.chain().focus().toggleBold().run()}>
<BoldIcon className="h-4 w-4" />
</ToolButton>
<ToolButton label="ایتالیک" active={editor.isActive('italic')} onClick={() => editor.chain().focus().toggleItalic().run()}>
<ItalicIcon className="h-4 w-4" />
</ToolButton>
<ToolButton label="زیرخط" active={editor.isActive('underline')} onClick={() => editor.chain().focus().toggleUnderline().run()}>
<UnderlineIcon className="h-4 w-4" />
</ToolButton>
<ToolButton label="خط‌خورده" active={editor.isActive('strike')} onClick={() => editor.chain().focus().toggleStrike().run()}>
<StrikethroughIcon className="h-4 w-4" />
</ToolButton>
<Divider />
<ToolButton label="لیست نقطه‌ای" active={editor.isActive('bulletList')} onClick={() => editor.chain().focus().toggleBulletList().run()}>
<ListIcon className="h-4 w-4" />
</ToolButton>
<ToolButton label="لیست شماره‌ای" active={editor.isActive('orderedList')} onClick={() => editor.chain().focus().toggleOrderedList().run()}>
<ListOrderedIcon className="h-4 w-4" />
</ToolButton>
<ToolButton label="نقل‌قول" active={editor.isActive('blockquote')} onClick={() => editor.chain().focus().toggleBlockquote().run()}>
<QuoteIcon className="h-4 w-4" />
</ToolButton>
<Divider />
<ToolButton label="لینک" active={editor.isActive('link')} onClick={setLink}>
<Link2Icon className="h-4 w-4" />
</ToolButton>
<ToolButton label="درج تصویر" onClick={() => fileRef.current?.click()}>
{uploading ? <Loader2Icon className="h-4 w-4 animate-spin" /> : <ImagePlusIcon className="h-4 w-4" />}
</ToolButton>
<Divider />
<ToolButton label="واگرد" onClick={() => editor.chain().focus().undo().run()}>
<Undo2Icon className="h-4 w-4" />
</ToolButton>
<ToolButton label="ازنو" onClick={() => editor.chain().focus().redo().run()}>
<Redo2Icon className="h-4 w-4" />
</ToolButton>
/>
</div>
<EditorContent editor={editor} />
<input
ref={fileRef}
type="file"
data-editor-file-input
accept="image/jpeg,image/png,image/webp,image/gif,image/avif"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) void uploadAndInsert(file)
e.target.value = ''
}}
/>
</div>
{/* مودال لینک — انیمیشن ورود/خروج را خودش با کلاس‌های Tailwind مدیریت می‌کند */}
{linkModal.open && (
<LinkModal
initial={linkModal.initial}
editing={linkModal.editing}
onApply={applyLink}
onRemove={removeLink}
onClose={() => setLinkModal((state) => ({ ...state, open: false }))}
/>
)}
</>
)
}
}
@@ -0,0 +1,67 @@
import Image from '@tiptap/extension-image'
import { mergeAttributes } from '@tiptap/core'
import { ReactNodeViewRenderer } from '@tiptap/react'
import { ImageNodeView } from './ImageNodeView'
/**
* تصویر مقاله با کپشن.
* خروجی HTML در سایت: <figure><img><figcaption>کپشن</figcaption></figure>
* و در ویرایشگر با NodeView سفارشی (ورودی کپشن + دکمه حذف) نمایش داده می‌شود.
*/
export const ArticleImage = Image.extend({
addAttributes() {
return {
// ویژگی‌های استاندارد تصویر (src/alt/title/width/height) از افزونه پایه
...this.parent?.(),
caption: {
default: '',
parseHTML: (element) =>
element.closest('figure')?.querySelector('figcaption')?.textContent?.trim() ?? '',
},
}
},
parseHTML() {
return [
{
tag: 'figure',
priority: 60,
getAttrs: (element) => {
if (!(element instanceof HTMLElement)) return false
const img = element.querySelector('img')
if (!img) return false
return {
src: img.getAttribute('src'),
alt: img.getAttribute('alt'),
title: img.getAttribute('title'),
caption: element.querySelector('figcaption')?.textContent?.trim() ?? '',
}
},
},
// تصاویر بدون figure (محتوای قدیمی یا چسبانده‌شده)
{ tag: 'img[src]:not([src^="data:"])' },
]
},
renderHTML({ HTMLAttributes }) {
const { caption, ...imageAttributes } = HTMLAttributes
const image: [string, Record<string, unknown>] = [
'img',
mergeAttributes(this.options.HTMLAttributes, imageAttributes),
]
// کپشن خالی → عنصر figcaption اصلاً رندر نمی‌شود (فقط تصویر می‌ماند)
return caption
? [
'figure',
{ class: 'article-figure' },
image,
['figcaption', { class: 'article-figure__caption' }, caption],
]
: ['figure', { class: 'article-figure' }, image]
},
addNodeView() {
return ReactNodeViewRenderer(ImageNodeView)
},
})
+124 -68
View File
@@ -1,5 +1,6 @@
import { useEffect, 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 { useAdmin } from '../../admin/AdminContext'
@@ -7,120 +8,175 @@ type ImageFieldProps = {
label: string
value: string
onSave: (url: string) => Promise<boolean>
/** متن جایگزین (Alt) — با ارسال این prop ورودی Alt نمایش داده می‌شود */
/** متن جایگزین (Alt) */
alt?: string
/** ذخیره متن جایگزین — بدون آن، Alt فقط برای پیش‌نمایش و پیشنهاد استفاده می‌شود */
onSaveAlt?: (alt: string) => Promise<boolean>
/** پیشنهاد Alt وقتی خالی است (مثل عنوان مقاله یا نام موجودیت) */
/** عنوان تصویر (Title) */
title?: string
onSaveTitle?: (title: string) => Promise<boolean>
/**
* پیشنهاد Alt وقتی کاربر چیزی ننوشته — مثل «{عنوان مقاله} - تصویر شاخص».
* هرگز از URL یا نام فایل ساخته نمی‌شود.
*/
altFallback?: string
/** پیشنهاد عنوان تصویر — پیش‌فرض «Photo 1» */
titleFallback?: string
}
const META_FIELD_CLASS =
'mt-1.5 w-full border border-ivory/15 bg-char px-3 py-2 text-xs font-light text-ivory placeholder:text-ivory/30 focus:border-copper focus:outline-none'
/**
* انتخاب و آپلود تصویر به MinIO — در کشوهای ویرایش و صفحه نوشتن مقاله.
* پس از آپلود موفق، URL نسبی /media/... ذخیره می‌شود.
* اگر Alt خالی باشد، پیش از فراخوانی API از عنوان موجودیت یا نام فایل ساخته می‌شود.
* انتخاب و آپلود تصویر به MinIO با پیش‌نمایش بزرگ.
* ترتیب فیلدها: ۱) عنوان تصویر (پیش‌فرض «Photo 1») ۲) متن جایگزین (پیش‌فرض «{عنوان} - تصویر شاخص»).
* هر دو «دستی یا پیش‌فرض» هستند و Alt هرگز از URL ساخته نمی‌شود.
*/
export function ImageField({ label, value, onSave, alt, onSaveAlt, altFallback = '' }: ImageFieldProps) {
export function ImageField({
label,
value,
onSave,
alt,
onSaveAlt,
title,
onSaveTitle,
altFallback = '',
titleFallback = '',
}: ImageFieldProps) {
const { runSave, isAuthenticated, isEditMode } = useAdmin()
const inputRef = useRef<HTMLInputElement>(null)
const altInputId = useId()
const titleId = useId()
const altId = useId()
const [uploading, setUploading] = useState(false)
const [altText, setAltText] = useState(alt ?? '')
const [titleText, setTitleText] = useState(title ?? '')
const showTitle = title !== undefined || !!onSaveTitle
const showAlt = alt !== undefined || !!onSaveAlt
// همگام‌سازی با مقدار ذخیره‌شده (پس از به‌روزرسانی خوش‌بینانه در درخت داده)
// همگام‌سازی با مقادیر ذخیره‌شده (پس از به‌روزرسانی خوش‌بینانه در درخت داده)
useEffect(() => {
setAltText(alt ?? '')
}, [alt])
useEffect(() => {
setTitleText(title ?? '')
}, [title])
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
/** عنوان نمایشی: ورودی کاربر → پیشنهاد → «Photo 1» (با تغییر عنوان مقاله زنده به‌روز می‌شود) */
const titleDisplayed = titleText.trim() || titleFallback.trim() || DEFAULT_IMAGE_TITLE
/** Alt نمایشی: ورودی کاربر → پیشنهاد عنوان‌محور (هرگز URL) */
const altDisplayed = altText.trim() || altFallback.trim()
const resolveTitle = () => titleDisplayed
const resolveAlt = () => altDisplayed || label
const persistMeta = async () => {
const nextTitle = resolveTitle()
const nextAlt = resolveAlt()
setTitleText(nextTitle)
setAltText(nextAlt)
if (showTitle && nextTitle !== (title ?? '').trim() && onSaveTitle) await onSaveTitle(nextTitle)
if (showAlt && nextAlt !== (alt ?? '').trim() && onSaveAlt) await onSaveAlt(nextAlt)
}
const pick = async (file: File | undefined) => {
if (!file) return
const generatedAlt = resolveAlt(file.name)
setUploading(true)
try {
await runSave(async () => {
const { url } = await adminApi.upload(file)
const ok = await onSave(url)
if (!ok) throw new Error('ذخیره نشد')
// Alt ساخته‌شده هم ذخیره می‌شود تا تصویر بدون متن جایگزین نماند
if (showAlt && generatedAlt !== (alt ?? '').trim()) {
setAltText(generatedAlt)
if (onSaveAlt) await onSaveAlt(generatedAlt)
}
await persistMeta()
})
} finally {
setUploading(false)
}
}
const commitAlt = () => {
const next = altText.trim()
if (!onSaveAlt || next === (alt ?? '').trim()) return
void onSaveAlt(next)
const commitMeta = () => {
if (!onSaveTitle && !onSaveAlt) return
void persistMeta()
}
return (
<div>
<p className="text-xs font-bold text-ivory/80">{label}</p>
<div className="mt-2 flex items-center gap-3">
{/* پیش‌نمایش بزرگ تصویر */}
<div className="mt-2 overflow-hidden border border-ivory/15 bg-char">
{value ? (
<img
src={value}
alt={altText || label}
className="h-16 w-24 border border-ivory/15 object-cover"
title={titleText || undefined}
className="h-[27rem] w-full object-cover"
/>
) : (
<div className="flex h-16 w-24 items-center justify-center border border-dashed border-ivory/20 text-ivory/30">
—
<div className="flex h-[27rem] w-full flex-col items-center justify-center gap-2 border border-dashed border-ivory/20 text-ivory/30">
<ImageIcon className="h-7 w-7" aria-hidden="true" />
<span className="text-[11px] font-light">تصویری انتخاب نشده است</span>
</div>
)}
<div className="min-w-0 flex-1">
<button
type="button"
onClick={() => inputRef.current?.click()}
disabled={uploading}
className="flex items-center gap-2 border border-ivory/20 px-4 py-2 text-xs font-light text-ivory/80 transition-colors duration-200 hover:border-copper hover:text-copper disabled:opacity-50"
>
{uploading ? (
<Loader2Icon className="h-3.5 w-3.5 animate-spin" />
) : (
<ImagePlusIcon className="h-3.5 w-3.5" />
)}
{uploading ? 'در حال آپلود…' : 'انتخاب تصویر'}
</button>
{value && <p className="mt-1.5 truncate text-[10px] font-light text-ivory/35" dir="ltr">{value}</p>}
</div>
</div>
{showAlt && (
<div className="mt-3">
<div className="flex items-center justify-between gap-3">
<label htmlFor={altInputId} className="text-[11px] font-light text-ivory/50">
متن جایگزین (Alt)
</label>
<span className="text-[10px] font-light text-ivory/30">برای سئو و صفحه‌خوان‌ها</span>
</div>
<input
id={altInputId}
type="text"
dir="auto"
value={altText}
onChange={(e) => setAltText(e.target.value)}
onBlur={commitAlt}
placeholder={altFallback ? `پیش‌فرض: ${altFallback}` : 'توضیح کوتاه تصویر…'}
className="mt-1.5 w-full border border-ivory/15 bg-char px-3 py-2 text-xs font-light text-ivory placeholder:text-ivory/30 focus:border-copper focus:outline-none"
/>
<div className="mt-3">
<button
type="button"
onClick={() => inputRef.current?.click()}
disabled={uploading}
className="flex items-center gap-2 border border-ivory/20 px-4 py-2 text-xs font-light text-ivory/80 transition-colors duration-200 hover:border-copper hover:text-copper disabled:opacity-50"
>
{uploading ? (
<Loader2Icon className="h-3.5 w-3.5 animate-spin" />
) : (
<ImagePlusIcon className="h-3.5 w-3.5" />
)}
{uploading ? 'در حال آپلود…' : value ? 'تغییر تصویر' : 'انتخاب تصویر'}
</button>
</div>
{(showTitle || showAlt) && (
<div className="mt-4 grid max-w-md gap-4 border-t border-ivory/10 pt-4 sm:grid-cols-2">
{showTitle && (
<div>
<label htmlFor={titleId} className="text-[11px] font-light text-ivory/50">
عنوان تصویر (Title)
</label>
<input
id={titleId}
type="text"
dir="auto"
value={titleDisplayed}
onChange={(event) => setTitleText(event.target.value)}
onBlur={commitMeta}
placeholder={titleFallback || 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={altDisplayed}
onChange={(event) => setAltText(event.target.value)}
onBlur={commitMeta}
placeholder={
altFallback || 'توضیح کوتاه تصویر برای سئو و صفحه‌خوان‌ها'
}
className={META_FIELD_CLASS}
/>
</div>
)}
</div>
)}
@@ -129,9 +185,9 @@ export function ImageField({ label, value, onSave, alt, onSaveAlt, altFallback =
type="file"
accept="image/jpeg,image/png,image/webp,image/gif,image/avif"
className="hidden"
onChange={(e) => {
void pick(e.target.files?.[0])
e.target.value = ''
onChange={(event) => {
void pick(event.target.files?.[0])
event.target.value = ''
}}
/>
</div>
@@ -0,0 +1,100 @@
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'
/**
* نمایش تصویر مقاله در ویرایشگر — دقیقاً با همان ساختار خروجی سایت:
* <figure><img><figcaption>کپشن</figcaption></figure>
* دو دکمه حذف مستقل دارد: یکی برای خود تصویر و یکی برای حذف کامل کپشن.
* با حذف کپشن، عنصر <figcaption> به‌طور کامل از DOM حذف می‌شود و فقط تصویر می‌ماند.
*/
export function ImageNodeView({ node, updateAttributes, deleteNode, selected }: NodeViewProps) {
const { src, alt, title, caption } = node.attrs as ImageAttrs
const hasCaption = Boolean((caption ?? '').trim())
const [editingCaption, setEditingCaption] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const showCaption = hasCaption || editingCaption
// هنگام افزودن کپشن تازه، ورودی خودکار فوکوس می‌گیرد
useEffect(() => {
if (editingCaption && !hasCaption) inputRef.current?.focus()
}, [editingCaption, hasCaption])
/** حذف کپشن: مقدار صفت پاک و عنصر figcaption کاملاً unmount می‌شود */
const removeCaption = () => {
updateAttributes({ caption: '' })
setEditingCaption(false)
}
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={inputRef}
type="text"
value={caption ?? ''}
onChange={(event) => updateAttributes({ caption: event.target.value })}
placeholder="توضیح تصویر (کپشن)…"
aria-label="کپشن تصویر"
className="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"
/>
{/* حذف فقط کپشن — تصویر دست‌نخورده می‌ماند */}
<button
type="button"
onClick={removeCaption}
aria-label="حذف کپشن"
title="حذف کپشن (تصویر حذف نمی‌شود)"
className={DELETE_BUTTON_CLASS}
>
<XIcon className="h-3.5 w-3.5" />
</button>
</figcaption>
)}
</figure>
{/* وقتی کپشنی وجود ندارد، فقط یک دکمه افزودن نمایش داده می‌شود */}
{!showCaption && (
<button
type="button"
contentEditable={false}
onClick={() => setEditingCaption(true)}
className="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"
>
<PlusIcon className="h-3 w-3" aria-hidden="true" />
افزودن کپشن
</button>
)}
</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 CreatableSelect from 'react-select/creatable'
import type { StylesConfig } from 'react-select'
import type { ClassNamesConfig, GroupBase, MultiValueRemoveProps } from 'react-select'
import { XIcon } from 'lucide-react'
export type LuxeOption = { label: string; value: string }
const COPPER = '#d5874c'
const IVORY = '#F5F3EF'
const SURFACE = '#1e1a16'
const BORDER = 'rgba(245,243,239,0.15)'
type LuxeClassNames<IsMulti extends boolean> = ClassNamesConfig<
LuxeOption,
IsMulti,
GroupBase<LuxeOption>
>
/** استایل تیره و لوکس react-select هماهنگ با پالت سایت (مسی روی زغالی) */
function makeStyles<IsMulti extends boolean>(): StylesConfig<LuxeOption, IsMulti> {
const CONTROL_CLASS_NAMES = (isFocused: boolean): string =>
`!min-h-[42px] !rounded-none !border !bg-char !transition-colors ${
isFocused
? '!border-copper !shadow-[0_0_0_1px_rgba(213,135,76,0.35)]'
: '!border-ivory/15 !shadow-none hover:!border-copper/50'
}`
const OPTION_CLASS_NAMES = (isSelected: boolean, isFocused: boolean): string =>
`!cursor-pointer !rounded-none !text-[13px] ${
isSelected
? '!bg-copper/25 !font-bold !text-copper'
: isFocused
? '!bg-copper/15 !text-ivory'
: '!bg-transparent !text-ivory/85'
}`
/**
* کلاس‌های Tailwind برای همه اسلات‌های react-select — تم تیره پروژه.
* react-select برای هر اسلات تابع می‌خواهد، پس همه با تابع برگردانده می‌شوند.
*/
function makeClassNames<IsMulti extends boolean>(
extras?: Partial<LuxeClassNames<IsMulti>>,
): LuxeClassNames<IsMulti> {
return {
control: (base, state) => ({
...base,
minHeight: 42,
backgroundColor: SURFACE,
borderColor: state.isFocused ? COPPER : BORDER,
borderRadius: 0,
boxShadow: state.isFocused ? '0 0 0 1px rgba(213,135,76,0.35)' : 'none',
transition: 'border-color 150ms ease, box-shadow 150ms ease',
':hover': { borderColor: state.isFocused ? COPPER : 'rgba(213,135,76,0.5)' },
}),
valueContainer: (base) => ({ ...base, padding: '2px 12px' }),
placeholder: (base) => ({ ...base, color: 'rgba(245,243,239,0.35)', fontSize: 13 }),
singleValue: (base) => ({ ...base, color: IVORY, fontSize: 13 }),
input: (base) => ({ ...base, color: IVORY, fontSize: 13 }),
menu: (base) => ({
...base,
backgroundColor: SURFACE,
border: `1px solid ${BORDER}`,
borderRadius: 0,
boxShadow: '0 24px 50px -18px rgba(0,0,0,0.75)',
overflow: 'hidden',
zIndex: 60,
}),
menuList: (base) => ({ ...base, padding: 4 }),
menuPortal: (base) => ({ ...base, zIndex: 200 }),
option: (base, state) => ({
...base,
backgroundColor: state.isSelected
? 'rgba(213,135,76,0.25)'
: state.isFocused
? 'rgba(213,135,76,0.12)'
: 'transparent',
color: state.isSelected ? COPPER : 'rgba(245,243,239,0.85)',
fontSize: 13,
fontWeight: state.isSelected ? 700 : 400,
cursor: 'pointer',
borderRadius: 0,
':active': { backgroundColor: 'rgba(213,135,76,0.3)' },
}),
noOptionsMessage: (base) => ({ ...base, color: 'rgba(245,243,239,0.45)', fontSize: 12 }),
indicatorSeparator: (base) => ({ ...base, backgroundColor: BORDER }),
dropdownIndicator: (base, state) => ({
...base,
color: state.isFocused ? COPPER : 'rgba(245,243,239,0.4)',
':hover': { color: COPPER },
}),
clearIndicator: (base) => ({
...base,
color: 'rgba(245,243,239,0.4)',
':hover': { color: COPPER },
}),
multiValue: (base) => ({
...base,
backgroundColor: 'rgba(213,135,76,0.15)',
border: '1px solid rgba(213,135,76,0.4)',
borderRadius: 999,
paddingInline: 2,
}),
multiValueLabel: (base) => ({ ...base, color: COPPER, fontSize: 12 }),
multiValueRemove: (base) => ({
...base,
color: COPPER,
borderRadius: 999,
':hover': { backgroundColor: COPPER, color: SURFACE },
}),
control: (state) => CONTROL_CLASS_NAMES(state.isFocused),
option: (state) => OPTION_CLASS_NAMES(state.isSelected, state.isFocused),
valueContainer: () => '!gap-1 !px-3 !py-0.5',
placeholder: () => '!text-[13px] !text-ivory/35',
singleValue: () => '!text-[13px] !text-ivory',
input: () => '!text-[13px] !text-ivory',
indicatorsContainer: () => '!text-ivory/40',
dropdownIndicator: () => '!text-ivory/40 hover:!text-copper',
clearIndicator: () => '!text-ivory/40 hover:!text-copper',
indicatorSeparator: () => '!bg-ivory/15',
menu: () => '!mt-1 !rounded-none !border !border-ivory/10 !bg-char !shadow-lift',
menuList: () => '!p-1',
noOptionsMessage: () => '!text-xs !text-ivory/45',
loadingMessage: () => '!text-xs !text-ivory/45',
multiValue: () => '!m-0.5 !rounded-full !border !border-copper/40 !bg-copper/15 !py-0.5 !pe-0 !ps-2',
multiValueLabel: () => '!text-xs !text-copper',
...extras,
}
}
/** دکمه ضربدر حذف برچسب انتخاب‌شده — همیشه واضح و قابل کلیک */
function TagRemoveButton({ innerProps }: MultiValueRemoveProps<LuxeOption, true>) {
return (
<div
{...innerProps}
role="button"
aria-label="حذف برچسب"
title="حذف برچسب"
className="mx-1 flex h-5 w-5 cursor-pointer items-center justify-center rounded-full text-copper transition-colors duration-150 hover:bg-copper hover:text-char"
>
<XIcon className="h-3 w-3" />
</div>
)
}
const portalTarget = (): HTMLElement | undefined =>
typeof document === 'undefined' ? undefined : document.body
const uniqueOptions = (values: string[]): LuxeOption[] =>
[...new Set(values.filter(Boolean))].map((item) => ({ label: item, value: item }))
type LuxeSelectProps = {
value: string
options: string[]
@@ -94,7 +87,7 @@ type LuxeSelectProps = {
inputId?: string
}
/** react-select تکی با ظاهر تیره پروژه — گزینه فعلی حتی اگر در لیست نباشد حفظ می‌شود */
/** react-select تکی با تم تیره — گزینه فعلی حتی اگر در لیست نباشد حفظ می‌شود */
export function LuxeSelect({
value,
options,
@@ -104,9 +97,10 @@ export function LuxeSelect({
isDisabled = false,
inputId,
}: LuxeSelectProps) {
const selectOptions = useMemo(() => [...new Set([...options, ...(value ? [value] : [])])].map(
(item) => ({ label: item, value: item }),
), [options, value])
const selectOptions = useMemo(
() => uniqueOptions([...options, ...(value ? [value] : [])]),
[options, value],
)
return (
<Select<LuxeOption, false>
@@ -119,7 +113,141 @@ export function LuxeSelect({
isClearable={isClearable}
isDisabled={isDisabled}
noOptionsMessage={() => 'موردی یافت نشد'}
styles={makeStyles<false>()}
classNames={makeClassNames<false>()}
styles={{ menuPortal: (base) => ({ ...base, zIndex: 200 }) }}
menuPosition="fixed"
menuPortalTarget={portalTarget()}
/>
)
}
type LuxeCreatableSelectProps = {
value: string
options: string[]
onChange: (value: string) => void
placeholder?: string
inputId?: string
/** متن گزینه «ساخت مقدار جدید» در فهرست بازشو */
createLabel?: (input: string) => string
}
/** react-select تکی و قابل ساخت — کاربر با کلیک روی گزینه ساخت، مقدار تازه می‌سازد */
export function LuxeCreatableSelect({
value,
options,
onChange,
placeholder = 'انتخاب یا نوشتن مورد جدید…',
inputId,
createLabel = (input) => `افزودن «${input}»`,
}: LuxeCreatableSelectProps) {
const selectOptions = useMemo(
() => uniqueOptions([...options, ...(value ? [value] : [])]),
[options, value],
)
return (
<CreatableSelect<LuxeOption, false>
inputId={inputId}
classNamePrefix="luxe"
options={selectOptions}
value={selectOptions.find((option) => option.value === value) ?? null}
onChange={(option) => onChange(option?.value?.trim() ?? '')}
placeholder={placeholder}
formatCreateLabel={(input) => createLabel(input)}
noOptionsMessage={() => 'موردی یافت نشد'}
classNames={makeClassNames<false>()}
styles={{ menuPortal: (base) => ({ ...base, zIndex: 200 }) }}
menuPosition="fixed"
menuPortalTarget={portalTarget()}
isClearable
/>
)
}
export type CategoryOption = { label: string; value: string }
/** کلید نگهداری دسته‌های ساخته‌شده در مرورگر */
const CREATED_CATEGORIES_KEY = 'aramesh_article_categories'
/** دسته‌هایی که ادمین ساخته است — برای مقاله‌های بعدی هم در فهرست می‌مانند */
function readCreatedCategories(): string[] {
try {
const raw = window.localStorage.getItem(CREATED_CATEGORIES_KEY)
const parsed: unknown = raw ? JSON.parse(raw) : []
return Array.isArray(parsed)
? parsed.filter((item): item is string => typeof item === 'string')
: []
} catch {
return []
}
}
function storeCreatedCategory(category: string): void {
try {
const current = readCreatedCategories()
if (current.includes(category)) return
window.localStorage.setItem(CREATED_CATEGORIES_KEY, JSON.stringify([...current, category]))
} catch {
/* حافظه مرورگر در دسترس نیست — بی‌صدا رد شو */
}
}
type CategorySelectProps = {
/** مقدار انتخاب‌شده — یک شیء {value,label} یا null */
value: CategoryOption | null
/** تغییر انتخاب — null یعنی فقط انتخاب پاک شده است (دکمه X) */
onChange: (option: CategoryOption | null) => void
/** دسته‌های پایه */
options: string[]
/** متن گزینه «ساخت دسته جدید» در فهرست بازشو */
createLabel?: (input: string) => string
}
/**
* انتخابگر تک‌مقداری دسته‌بندی (react-select Creatable).
* - isMulti=false و isClearable: دکمه X فقط انتخاب جاری را پاک می‌کند و گزینه‌ها دست‌نخورده می‌مانند.
* - ساخت دسته جدید: به انتهای فهرست گزینه‌ها اضافه و برای مقاله جاری انتخاب می‌شود و
* در همین مرورگر برای مقاله‌های بعدی نگه داشته می‌شود.
*/
export function CategorySelect({
value,
onChange,
options,
createLabel = (input) => `ایجاد دسته جدید: ${input}`,
}: CategorySelectProps) {
const [createdOptions, setCreatedOptions] = useState<string[]>(() => readCreatedCategories())
// گزینه‌های پایه + دسته‌های ساخته‌شده (همیشه در انتهای فهرست)
const selectOptions = useMemo(
() => uniqueOptions([...options, ...createdOptions.filter((item) => !options.includes(item))]),
[options, createdOptions],
)
/** ساخت دسته جدید: افزودن به انتهای فهرست + انتخاب خودکار برای مقاله جاری */
const handleCreate = (input: string) => {
const category = input.trim()
if (!category) return
setCreatedOptions((previous) =>
previous.includes(category) ? previous : [...previous, category],
)
storeCreatedCategory(category)
onChange({ label: category, value: category })
}
return (
<CreatableSelect<CategoryOption, false>
classNamePrefix="luxe"
isMulti={false}
isClearable
options={selectOptions}
value={value}
onChange={(option) => onChange(option ? { label: option.label, value: option.value } : null)}
onCreateOption={handleCreate}
formatCreateLabel={(input) => createLabel(input)}
placeholder="انتخاب یا نوشتن دسته جدید…"
noOptionsMessage={() => 'موردی یافت نشد'}
classNames={makeClassNames<false>()}
styles={{ menuPortal: (base) => ({ ...base, zIndex: 200 }) }}
menuPosition="fixed"
menuPortalTarget={portalTarget()}
/>
@@ -130,18 +258,19 @@ type LuxeTagsSelectProps = {
value: string[]
onChange: (value: string[]) => void
placeholder?: string
}
/** هشتگ‌ها با react-select قابل ساخت — هر تایپ جدید با Enter به فهرست اضافه می‌شود */
}/** هشتگ‌ها با react-select قابل ساخت — هر برچسب دکمه ضربدر حذف مستقل دارد */
export function LuxeTagsSelect({
value,
onChange,
placeholder = 'هشتگ بنویسید و Enter بزنید…',
}: LuxeTagsSelectProps) {
const options = useMemo(
() => [...new Set(value.filter(Boolean))].map((item) => ({ label: item, value: item })),
[value],
)
const options = useMemo(() => uniqueOptions(value), [value])
/** پاک‌سازی و حذف تکراری‌ها هنگام افزودن یا حذف برچسب */
const applyTags = (next: readonly LuxeOption[]) => {
const tags = next.map((option) => option.value.trim()).filter(Boolean)
onChange([...new Set(tags)])
}
return (
<CreatableSelect<LuxeOption, true>
@@ -149,11 +278,14 @@ export function LuxeTagsSelect({
isMulti
options={options}
value={options}
onChange={(next) => onChange(next.map((option) => option.value))}
onChange={applyTags}
placeholder={placeholder}
formatCreateLabel={(input) => `افزودن «${input}»`}
formatCreateLabel={(input) => `افزودن هشتگ «${input}»`}
noOptionsMessage={() => 'هشتگی یافت نشد'}
styles={makeStyles<true>()}
closeMenuOnSelect={false}
components={{ MultiValueRemove: TagRemoveButton }}
classNames={makeClassNames<true>({ multiValueRemove: () => '!bg-transparent' })}
styles={{ menuPortal: (base) => ({ ...base, zIndex: 200 }) }}
menuPosition="fixed"
menuPortalTarget={portalTarget()}
/>
+3 -74
View File
@@ -1,6 +1,7 @@
import { useMemo } from 'react'
import { AlertCircleIcon, CheckCircle2Icon, XCircleIcon } from 'lucide-react'
import type { ArticleBodyDoc } from '../../data/site'
import { docHeadings, docImages, docLinks, docToText, firstParagraph } from '../../utils/tiptapDoc'
export type SeoInput = {
title: string
@@ -13,78 +14,6 @@ export type SeoInput = {
type Status = 'good' | 'ok' | 'bad'
type Check = { label: string; status: Status; hint: string }
// ---------- استخراج متن/ساختار از سند TipTap ----------
function docToText(body: SeoInput['body']): string {
if (Array.isArray(body)) return body.join(' ')
const out: string[] = []
const walk = (node: unknown) => {
if (!node || typeof node !== 'object') return
const n = node as { type?: string; text?: string; content?: unknown[] }
if (n.type === 'text' && n.text) out.push(n.text)
for (const c of n.content ?? []) walk(c)
}
walk(body)
return out.join(' ')
}
function docHeadings(body: SeoInput['body']): string[] {
if (Array.isArray(body)) return []
const out: string[] = []
const walk = (node: unknown) => {
if (!node || typeof node !== 'object') return
const n = node as { type?: string; text?: string; attrs?: { level?: number }; content?: unknown[] }
if (n.type === 'heading' && n.attrs?.level === 2) {
const texts: string[] = []
const collect = (inner: unknown) => {
if (!inner || typeof inner !== 'object') return
const t = inner as { type?: string; text?: string; content?: unknown[] }
if (t.type === 'text' && t.text) texts.push(t.text)
for (const c of t.content ?? []) collect(c)
}
collect(n)
out.push(texts.join(''))
}
for (const c of n.content ?? []) walk(c)
}
walk(body)
return out
}
function docImages(body: SeoInput['body']): { src: string; alt: string }[] {
if (Array.isArray(body)) return []
const out: { src: string; alt: string }[] = []
const walk = (node: unknown) => {
if (!node || typeof node !== 'object') return
const n = node as { type?: string; attrs?: { src?: string; alt?: string }; content?: unknown[] }
if (n.type === 'image') out.push({ src: n.attrs?.src ?? '', alt: n.attrs?.alt ?? '' })
for (const c of n.content ?? []) walk(c)
}
walk(body)
return out
}
function docLinks(body: SeoInput['body']): string[] {
if (Array.isArray(body)) return []
const out: string[] = []
const walk = (node: unknown) => {
if (!node || typeof node !== 'object') return
const n = node as { type?: string; marks?: { type?: string; attrs?: { href?: string } }[]; content?: unknown[] }
for (const m of n.marks ?? []) if (m.type === 'link' && m.attrs?.href) out.push(m.attrs.href)
for (const c of n.content ?? []) walk(c)
}
walk(body)
return out
}
function firstParagraph(body: SeoInput['body']): string {
if (Array.isArray(body)) return body[0] ?? ''
const p = (body as { content?: { type?: string; content?: unknown[] }[] }).content?.find(
(c) => c.type === 'paragraph',
)
return docToText({ type: 'doc', content: p ? [p] : [] } as never)
}
// ---------- محاسبه بررسی‌ها ----------
export function useSeoChecks(input: SeoInput): Check[] {
@@ -117,7 +46,7 @@ export function useSeoChecks(input: SeoInput): Check[] {
? { label: 'کلمه کلیدی در پاراگراف اول', status: 'good', hint: 'کلمه کلیدی در ابتدای متن هست.' }
: { label: 'کلمه کلیدی در پاراگراف اول', status: 'ok', hint: 'بهتر است کلمه کلیدی در پاراگراف اول بیاید.' },
)
const h2s = docHeadings(input.body)
const h2s = docHeadings(input.body, 2)
if (h2s.length === 0) checks.push({ label: 'کلمه کلیدی در تیترها', status: 'ok', hint: 'هنوز تیتر H2 در متن نیست.' })
else
checks.push(
@@ -140,7 +69,7 @@ export function useSeoChecks(input: SeoInput): Check[] {
else checks.push({ label: `طول متن (${words.length} واژه)`, status: 'bad', hint: 'متن خیلی کوتاه است — حداقل ۸۰ و ترجیحاً ۱۵۰ واژه بنویسید.' })
// تیتر H2
const h2s = docHeadings(input.body)
const h2s = docHeadings(input.body, 2)
if (h2s.length > 0) checks.push({ label: `تیتربندی (${h2s.length} تیتر H2)`, status: 'good', hint: 'متن تیتربندی شده است.' })
else checks.push({ label: 'تیتربندی', status: 'bad', hint: 'برای خوانایی، متن را با تیتر H2 بخش‌بندی کنید.' })
+15
View File
@@ -0,0 +1,15 @@
/**
* ثابت‌های مشترک رابط کاربری — تنها منبع مقادیر پیش‌فرض (بدون متن پراکنده در کامپوننت‌ها).
*/
/** عنوان پیش‌فرض تصویر، وقتی کاربر عنوانی وارد نکرده باشد */
export const DEFAULT_IMAGE_TITLE = 'Photo 1'
/** پسوند پیشنهاد Alt برای تصویر شاخص — در کنار عنوان رکورد قرار می‌گیرد */
export const FEATURED_IMAGE_ALT_SUFFIX = 'تصویر شاخص'
/** سرعت مطالعه مبنای محاسبه زمان مطالعه — واژه در دقیقه */
export const WORDS_PER_MINUTE = 200
/** برچسب زمان مطالعه در خروجی */
export const READING_TIME_LABEL = 'دقیقه مطالعه'
+2
View File
@@ -59,6 +59,8 @@ export type Article = {
meta_description?: string;
/** متن جایگزین تصویر شاخص (Alt) */
image_alt?: string;
/** عنوان تصویر شاخص (Title) */
image_title?: string;
/** نویسنده مقاله — در سربرگ مقاله زیر عنوان نمایش داده می‌شود */
author?: string;
/** هشتگ‌ها/برچسب‌های مقاله */
+54 -3
View File
@@ -110,6 +110,10 @@ html[lang='en'] .article-body h3 {
.article-body > :first-child {
margin-top: 0;
}
/* ---------- تایپوگرافی مقاله ----------
راست‌چین (فارسی): پایه ۱۴ / تیتر۱ ۱۸ / تیتر۲ ۱۶ / تیتر۳ ۱۴
چپ‌چین (انگلیسی): پایه ۱۲ / تیتر۱ ۱۶ / تیتر۲ ۱۴ / تیتر۳ ۱۲
جهت با data-dir روی پوسته ویرایشگر یا مقاله تعیین می‌شود. */
.article-body h1,
.article-body h2,
.article-body h3 {
@@ -122,15 +126,33 @@ html[lang='en'] .article-body h3 {
scroll-margin-top: 7rem;
}
.article-body h1 {
font-size: 18px; /* تیتر ۱ سایز ۱۸ */
font-size: 18px;
}
.article-body h2 {
font-size: 16px; /* تیتر ۲ سایز ۱۶ */
font-size: 16px;
padding-inline-start: 0.85rem;
border-inline-start: 3px solid #d5874c;
}
.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;
}
.article-body span[data-small="true"] {
font-size: 12px; /* مارک اختصاصی برای سایز ۱۲ - انگلیسی */
@@ -183,6 +205,35 @@ html[lang='en'] .article-body h3 {
border: 1px solid rgba(245, 243, 239, 0.1);
object-fit: cover;
}
/* ---------- تصویر کپشن‌دار: <figure><img><figcaption> ---------- */
.article-body figure {
margin-top: 2rem;
}
.article-body figure img {
margin-top: 0;
}
.article-body figcaption {
margin-top: 0.7rem;
color: rgba(245, 243, 239, 0.5);
font-size: 0.85em;
line-height: 1.9;
text-align: center;
}
/* ویرایشگر: کپشن ورودی و دکمه حذف زیر تصویر */
[data-wysiwyg] .article-figure-node {
margin: 1.25rem 0;
}
[data-wysiwyg] .article-figure__caption {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.6rem;
text-align: right;
}
[data-wysiwyg] .article-figure-node.is-selected .article-figure {
outline: 1px dashed rgba(213, 135, 76, 0.8);
outline-offset: 3px;
}
.article-body strong {
color: #f5f3ef;
font-weight: 700;
+1
View File
@@ -85,6 +85,7 @@ export function ArticleDetail() {
<img
src={article.image}
alt={article.image_alt || article.title}
title={article.image_title || undefined}
className="absolute inset-0 h-full w-full object-cover"
/>
<div className="absolute inset-0 bg-ink/75" aria-hidden="true" />
+2 -1
View File
@@ -10,6 +10,7 @@ import { useLang } from '../i18n/lang'
import { EditableItem } from '../admin/Editable'
import { useAdmin } from '../admin/AdminContext'
import { formatJalali } from '../utils/date'
import { toPersianDigits } from '../utils/digits'
import { PlusIcon } from 'lucide-react'
function DraftBadge({ className = '' }: { className?: string }) {
@@ -98,7 +99,7 @@ export function Blog() {
<span>{formatJalali(featured.date)}</span>
<span className="flex items-center gap-1.5">
<ClockIcon className="h-3.5 w-3.5" />
{featured.read}
{toPersianDigits(featured.read)}
</span>
</span>
<span className="flex items-center gap-2 text-copper">
+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)])
}
+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}`
}
+75
View File
@@ -0,0 +1,75 @@
/**
* کمک‌تابع‌های مشترک سند TipTap — استخراج متن و ساختار از JSON مقاله.
* هم چک‌لیست سئو و هم محاسبه زمان مطالعه از همین‌جا استفاده می‌کنند (DRY).
*/
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] }) : ''
}