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.
This commit is contained in:
fatemeh sane
2026-09-15 11:33:04 +03:30
parent 35c03ef35e
commit 049291c109
40 changed files with 55736 additions and 440 deletions
+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] }) : ''
}