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:
@@ -0,0 +1,6 @@
|
||||
const PERSIAN_DIGITS = '۰۱۲۳۴۵۶۷۸۹'
|
||||
|
||||
/** تبدیل ارقام لاتین به ارقام فارسی — برای نمایش یکدست فارسی */
|
||||
export function toPersianDigits(value: string | number): string {
|
||||
return String(value).replace(/[0-9]/g, (digit) => PERSIAN_DIGITS[Number(digit)])
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/** پروتکلها و الگوهای مجاز برای نشانی پیوند — ویرایشگر و مودال لینک هر دو از اینها استفاده میکنند */
|
||||
const ALLOWED_URI = /^(https?:\/\/|\/|#|mailto:|tel:)/i
|
||||
const BARE_DOMAIN = /^[\w-]+(\.[\w-]+)+([/?#].*)?$/i
|
||||
/** نشانی کامل بدون فاصله — برای تشخیص متن چسباندهشده یا تایپشده */
|
||||
const URL_LIKE = /^(https?:\/\/\S+|www\.\S+|[a-z0-9-]+(\.[a-z0-9-]+)+(\/\S*)?)$/i
|
||||
|
||||
/** آیا متن یک نشانی کامل است؟ (برای بازکردن خودکار مودال لینک) */
|
||||
export function isUrlLike(value: string): boolean {
|
||||
return URL_LIKE.test(value.trim())
|
||||
}
|
||||
|
||||
/** آیا نشانی برای درج در لینک مجاز است؟ */
|
||||
export function isAllowedHref(uri: string): boolean {
|
||||
return ALLOWED_URI.test(uri.trim())
|
||||
}
|
||||
|
||||
/**
|
||||
* نرمالسازی نشانی ورودی کاربر:
|
||||
* «example.com» → «https://example.com» و ورودی خالی/نامعتبر → null
|
||||
*/
|
||||
export function normalizeHref(raw: string): string | null {
|
||||
const value = raw.trim()
|
||||
if (!value) return null
|
||||
if (isAllowedHref(value)) return value
|
||||
if (BARE_DOMAIN.test(value)) return `https://${value}`
|
||||
return null
|
||||
}
|
||||
|
||||
export type LinkOptions = {
|
||||
/** باز شدن در تب جدید */
|
||||
newTab: boolean
|
||||
/** عدم انتقال اعتبار سئو (nofollow) */
|
||||
nofollow: boolean
|
||||
}
|
||||
|
||||
/** ساخت مقدار rel بر اساس گزینههای انتخابی کاربر */
|
||||
export function buildLinkRel({ newTab, nofollow }: LinkOptions): string | null {
|
||||
const tokens: string[] = []
|
||||
if (newTab) tokens.push('noopener', 'noreferrer')
|
||||
if (nofollow) tokens.push('nofollow')
|
||||
return tokens.length ? tokens.join(' ') : null
|
||||
}
|
||||
|
||||
/** خواندن گزینه nofollow از مقدار rel لینک موجود */
|
||||
export function parseLinkRel(rel?: string | null): LinkOptions {
|
||||
const tokens = (rel ?? '').split(/\s+/).filter(Boolean)
|
||||
return { newTab: tokens.includes('noopener') || tokens.includes('noreferrer'), nofollow: tokens.includes('nofollow') }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { READING_TIME_LABEL, WORDS_PER_MINUTE } from '../data/constants'
|
||||
import { toPersianDigits } from './digits'
|
||||
import { docToText } from './tiptapDoc'
|
||||
|
||||
export { WORDS_PER_MINUTE, READING_TIME_LABEL }
|
||||
|
||||
/** شمارش واژههای متن سند (بدون شمارش نویسههای قالببندی) */
|
||||
export function countWords(body: unknown): number {
|
||||
return docToText(body)
|
||||
.split(/\s+/)
|
||||
.filter(Boolean).length
|
||||
}
|
||||
|
||||
/** زمان مطالعه بر حسب دقیقه — سقف واژهها تقسیم بر سرعت مطالعه، حداقل یک دقیقه */
|
||||
export function readingTimeMinutes(body: unknown): number {
|
||||
return Math.max(1, Math.ceil(countWords(body) / WORDS_PER_MINUTE))
|
||||
}
|
||||
|
||||
/** زمان مطالعه آماده نمایش با ارقام فارسی — مثل «۵ دقیقه مطالعه» */
|
||||
export function formatReadingTime(body: unknown): string {
|
||||
return `${toPersianDigits(readingTimeMinutes(body))} ${READING_TIME_LABEL}`
|
||||
}
|
||||
@@ -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] }) : ''
|
||||
}
|
||||
Reference in New Issue
Block a user