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:
@@ -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)
|
||||
},
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { FormEvent } from 'react'
|
||||
import { Link2Icon, Trash2Icon, XIcon } from 'lucide-react'
|
||||
import { buildLinkRel, normalizeHref } from '../../utils/link'
|
||||
|
||||
export type LinkModalValue = {
|
||||
href: string
|
||||
newTab: boolean
|
||||
nofollow: boolean
|
||||
}
|
||||
|
||||
type LinkModalProps = {
|
||||
/** مقادیر اولیه — از لینک فعال زیر نشانگر یا خالی برای لینک تازه */
|
||||
initial: LinkModalValue
|
||||
/** ویرایش لینک موجود (دکمه «حذف لینک» نمایش داده میشود) */
|
||||
editing: boolean
|
||||
onApply: (value: LinkModalValue) => void
|
||||
onRemove: () => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const FIELD_CLASS =
|
||||
'mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory placeholder:text-ivory/30 focus:border-copper focus:outline-none'
|
||||
|
||||
/** مدت انیمیشن ورود/خروج — با duration-300 کلاسها هماهنگ است */
|
||||
const TRANSITION_MS = 300
|
||||
|
||||
/**
|
||||
* مودال ثبت و ویرایش لینک — انیمیشن نرم fade و scale با کلاسهای Tailwind.
|
||||
* ورود با یک فریم تاخیر (تا کلاسهای بسته اعمال شوند) و خروج پیش از بستن مودال اجرا میشود.
|
||||
* جایگزین پنجره پیشفرض مرورگر برای لینک شده است.
|
||||
*/
|
||||
export function LinkModal({ initial, editing, onApply, onRemove, onClose }: LinkModalProps) {
|
||||
const [href, setHref] = useState(initial.href)
|
||||
const [newTab, setNewTab] = useState(initial.newTab)
|
||||
const [nofollow, setNofollow] = useState(initial.nofollow)
|
||||
const [error, setError] = useState('')
|
||||
const [visible, setVisible] = useState(false)
|
||||
const [closing, setClosing] = useState(false)
|
||||
|
||||
// انیمیشن ورود: ابتدا حالت بسته رندر میشود، سپس به حالت باز میرود
|
||||
useEffect(() => {
|
||||
const frame = window.requestAnimationFrame(() => setVisible(true))
|
||||
return () => window.cancelAnimationFrame(frame)
|
||||
}, [])
|
||||
|
||||
/** بستن نرم: اول انیمیشن خروج کامل میشود، بعد عملیات و unmount */
|
||||
const closeWith = (action?: () => void) => {
|
||||
if (closing) return
|
||||
setClosing(true)
|
||||
setVisible(false)
|
||||
window.setTimeout(() => {
|
||||
action?.()
|
||||
onClose()
|
||||
}, TRANSITION_MS)
|
||||
}
|
||||
|
||||
const submit = (event: FormEvent) => {
|
||||
event.preventDefault()
|
||||
const normalized = normalizeHref(href)
|
||||
if (!normalized) {
|
||||
setError('نشانی معتبر نیست — نمونه: https://example.com یا /contact')
|
||||
return
|
||||
}
|
||||
closeWith(() => onApply({ href: normalized, newTab, nofollow }))
|
||||
}
|
||||
|
||||
const relPreview = buildLinkRel({ newTab, nofollow })
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[97] flex items-center justify-center p-6"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="ثبت لینک"
|
||||
>
|
||||
{/* پسزمینه تار — fade نرم */}
|
||||
<div
|
||||
className={`absolute inset-0 bg-char/70 backdrop-blur-sm transition-opacity duration-300 ease-out ${
|
||||
visible ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
onClick={() => closeWith()}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* کارت مودال — fade + scale نرم */}
|
||||
<form
|
||||
onSubmit={submit}
|
||||
dir="rtl"
|
||||
className={`relative w-full max-w-md border border-ivory/10 bg-ink p-7 shadow-2xl transition-all duration-300 ease-out ${
|
||||
visible ? 'translate-y-0 scale-100 opacity-100' : 'translate-y-2 scale-95 opacity-0'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="flex items-center gap-2 text-sm font-bold text-copper">
|
||||
<Link2Icon className="h-4 w-4" aria-hidden="true" />
|
||||
{editing ? 'ویرایش لینک' : 'افزودن لینک'}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => closeWith()}
|
||||
aria-label="بستن"
|
||||
className="text-ivory/50 transition-colors hover:text-ivory"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* نشانی */}
|
||||
<label htmlFor="link-href" className="mt-6 block text-xs font-light text-ivory/60">
|
||||
نشانی پیوند (URL)
|
||||
</label>
|
||||
<input
|
||||
id="link-href"
|
||||
type="text"
|
||||
dir="ltr"
|
||||
autoFocus
|
||||
value={href}
|
||||
onChange={(event) => {
|
||||
setHref(event.target.value)
|
||||
if (error) setError('')
|
||||
}}
|
||||
placeholder="https://example.com"
|
||||
className={FIELD_CLASS}
|
||||
/>
|
||||
{error && (
|
||||
<p className="mt-2 border-r-2 border-red-400/70 pr-3 text-[11px] font-light leading-6 text-red-300">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* باز شدن در تب جدید */}
|
||||
<label className="mt-5 flex cursor-pointer items-center gap-3 text-sm font-light text-ivory/80">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={newTab}
|
||||
onChange={(event) => setNewTab(event.target.checked)}
|
||||
className="h-4 w-4 accent-copper"
|
||||
/>
|
||||
باز شدن در تب جدید
|
||||
<span className="text-[10px] font-light text-ivory/35" dir="ltr">target="_blank"</span>
|
||||
</label>
|
||||
|
||||
{/* نوع لینک از نظر سئو */}
|
||||
<fieldset className="mt-5">
|
||||
<legend className="text-xs font-light text-ivory/60">انتقال اعتبار سئو (rel)</legend>
|
||||
<div className="mt-3 grid gap-2.5">
|
||||
{[
|
||||
{ key: 'dofollow', label: 'Dofollow', hint: 'اعتبار سئو به مقصد منتقل میشود', value: false },
|
||||
{ key: 'nofollow', label: 'Nofollow', hint: 'گوگل این لینک را دنبال نکند', value: true },
|
||||
].map((option) => (
|
||||
<label
|
||||
key={option.key}
|
||||
className={`flex cursor-pointer items-start gap-3 border px-4 py-3 transition-colors duration-150 ${
|
||||
nofollow === option.value
|
||||
? 'border-copper/60 bg-copper/10'
|
||||
: 'border-ivory/12 hover:border-ivory/25'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="link-rel"
|
||||
checked={nofollow === option.value}
|
||||
onChange={() => setNofollow(option.value)}
|
||||
className="mt-0.5 h-4 w-4 accent-copper"
|
||||
/>
|
||||
<span className="flex flex-col">
|
||||
<span className="text-sm font-light text-ivory">{option.label}</span>
|
||||
<span className="text-[11px] font-light text-ivory/45">{option.hint}</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{relPreview && (
|
||||
<p className="mt-4 text-[10px] font-light text-ivory/35" dir="ltr">
|
||||
rel="{relPreview}"
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* دکمهها */}
|
||||
<div className="mt-7 flex items-center justify-between gap-3">
|
||||
{editing ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => closeWith(onRemove)}
|
||||
className="flex items-center gap-1.5 border border-red-400/40 px-4 py-2.5 text-xs font-light text-red-300 transition-colors hover:bg-red-500/10"
|
||||
>
|
||||
<Trash2Icon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
حذف لینک
|
||||
</button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => closeWith()}
|
||||
className="border border-ivory/20 px-5 py-2.5 text-xs font-light text-ivory/70 transition-colors hover:border-ivory/40 hover:text-ivory"
|
||||
>
|
||||
انصراف
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-copper px-6 py-2.5 text-xs font-bold text-char transition-transform duration-200 hover:scale-105"
|
||||
>
|
||||
{editing ? 'ذخیره لینک' : 'افزودن لینک'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,89 +1,82 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import Select from 'react-select'
|
||||
import CreatableSelect from 'react-select/creatable'
|
||||
import type { StylesConfig } from 'react-select'
|
||||
import type { ClassNamesConfig, GroupBase, MultiValueRemoveProps } from 'react-select'
|
||||
import { XIcon } from 'lucide-react'
|
||||
|
||||
export type LuxeOption = { label: string; value: string }
|
||||
|
||||
const COPPER = '#d5874c'
|
||||
const IVORY = '#F5F3EF'
|
||||
const SURFACE = '#1e1a16'
|
||||
const BORDER = 'rgba(245,243,239,0.15)'
|
||||
type LuxeClassNames<IsMulti extends boolean> = ClassNamesConfig<
|
||||
LuxeOption,
|
||||
IsMulti,
|
||||
GroupBase<LuxeOption>
|
||||
>
|
||||
|
||||
/** استایل تیره و لوکس react-select هماهنگ با پالت سایت (مسی روی زغالی) */
|
||||
function makeStyles<IsMulti extends boolean>(): StylesConfig<LuxeOption, IsMulti> {
|
||||
const CONTROL_CLASS_NAMES = (isFocused: boolean): string =>
|
||||
`!min-h-[42px] !rounded-none !border !bg-char !transition-colors ${
|
||||
isFocused
|
||||
? '!border-copper !shadow-[0_0_0_1px_rgba(213,135,76,0.35)]'
|
||||
: '!border-ivory/15 !shadow-none hover:!border-copper/50'
|
||||
}`
|
||||
|
||||
const OPTION_CLASS_NAMES = (isSelected: boolean, isFocused: boolean): string =>
|
||||
`!cursor-pointer !rounded-none !text-[13px] ${
|
||||
isSelected
|
||||
? '!bg-copper/25 !font-bold !text-copper'
|
||||
: isFocused
|
||||
? '!bg-copper/15 !text-ivory'
|
||||
: '!bg-transparent !text-ivory/85'
|
||||
}`
|
||||
|
||||
/**
|
||||
* کلاسهای Tailwind برای همه اسلاتهای react-select — تم تیره پروژه.
|
||||
* react-select برای هر اسلات تابع میخواهد، پس همه با تابع برگردانده میشوند.
|
||||
*/
|
||||
function makeClassNames<IsMulti extends boolean>(
|
||||
extras?: Partial<LuxeClassNames<IsMulti>>,
|
||||
): LuxeClassNames<IsMulti> {
|
||||
return {
|
||||
control: (base, state) => ({
|
||||
...base,
|
||||
minHeight: 42,
|
||||
backgroundColor: SURFACE,
|
||||
borderColor: state.isFocused ? COPPER : BORDER,
|
||||
borderRadius: 0,
|
||||
boxShadow: state.isFocused ? '0 0 0 1px rgba(213,135,76,0.35)' : 'none',
|
||||
transition: 'border-color 150ms ease, box-shadow 150ms ease',
|
||||
':hover': { borderColor: state.isFocused ? COPPER : 'rgba(213,135,76,0.5)' },
|
||||
}),
|
||||
valueContainer: (base) => ({ ...base, padding: '2px 12px' }),
|
||||
placeholder: (base) => ({ ...base, color: 'rgba(245,243,239,0.35)', fontSize: 13 }),
|
||||
singleValue: (base) => ({ ...base, color: IVORY, fontSize: 13 }),
|
||||
input: (base) => ({ ...base, color: IVORY, fontSize: 13 }),
|
||||
menu: (base) => ({
|
||||
...base,
|
||||
backgroundColor: SURFACE,
|
||||
border: `1px solid ${BORDER}`,
|
||||
borderRadius: 0,
|
||||
boxShadow: '0 24px 50px -18px rgba(0,0,0,0.75)',
|
||||
overflow: 'hidden',
|
||||
zIndex: 60,
|
||||
}),
|
||||
menuList: (base) => ({ ...base, padding: 4 }),
|
||||
menuPortal: (base) => ({ ...base, zIndex: 200 }),
|
||||
option: (base, state) => ({
|
||||
...base,
|
||||
backgroundColor: state.isSelected
|
||||
? 'rgba(213,135,76,0.25)'
|
||||
: state.isFocused
|
||||
? 'rgba(213,135,76,0.12)'
|
||||
: 'transparent',
|
||||
color: state.isSelected ? COPPER : 'rgba(245,243,239,0.85)',
|
||||
fontSize: 13,
|
||||
fontWeight: state.isSelected ? 700 : 400,
|
||||
cursor: 'pointer',
|
||||
borderRadius: 0,
|
||||
':active': { backgroundColor: 'rgba(213,135,76,0.3)' },
|
||||
}),
|
||||
noOptionsMessage: (base) => ({ ...base, color: 'rgba(245,243,239,0.45)', fontSize: 12 }),
|
||||
indicatorSeparator: (base) => ({ ...base, backgroundColor: BORDER }),
|
||||
dropdownIndicator: (base, state) => ({
|
||||
...base,
|
||||
color: state.isFocused ? COPPER : 'rgba(245,243,239,0.4)',
|
||||
':hover': { color: COPPER },
|
||||
}),
|
||||
clearIndicator: (base) => ({
|
||||
...base,
|
||||
color: 'rgba(245,243,239,0.4)',
|
||||
':hover': { color: COPPER },
|
||||
}),
|
||||
multiValue: (base) => ({
|
||||
...base,
|
||||
backgroundColor: 'rgba(213,135,76,0.15)',
|
||||
border: '1px solid rgba(213,135,76,0.4)',
|
||||
borderRadius: 999,
|
||||
paddingInline: 2,
|
||||
}),
|
||||
multiValueLabel: (base) => ({ ...base, color: COPPER, fontSize: 12 }),
|
||||
multiValueRemove: (base) => ({
|
||||
...base,
|
||||
color: COPPER,
|
||||
borderRadius: 999,
|
||||
':hover': { backgroundColor: COPPER, color: SURFACE },
|
||||
}),
|
||||
control: (state) => CONTROL_CLASS_NAMES(state.isFocused),
|
||||
option: (state) => OPTION_CLASS_NAMES(state.isSelected, state.isFocused),
|
||||
valueContainer: () => '!gap-1 !px-3 !py-0.5',
|
||||
placeholder: () => '!text-[13px] !text-ivory/35',
|
||||
singleValue: () => '!text-[13px] !text-ivory',
|
||||
input: () => '!text-[13px] !text-ivory',
|
||||
indicatorsContainer: () => '!text-ivory/40',
|
||||
dropdownIndicator: () => '!text-ivory/40 hover:!text-copper',
|
||||
clearIndicator: () => '!text-ivory/40 hover:!text-copper',
|
||||
indicatorSeparator: () => '!bg-ivory/15',
|
||||
menu: () => '!mt-1 !rounded-none !border !border-ivory/10 !bg-char !shadow-lift',
|
||||
menuList: () => '!p-1',
|
||||
noOptionsMessage: () => '!text-xs !text-ivory/45',
|
||||
loadingMessage: () => '!text-xs !text-ivory/45',
|
||||
multiValue: () => '!m-0.5 !rounded-full !border !border-copper/40 !bg-copper/15 !py-0.5 !pe-0 !ps-2',
|
||||
multiValueLabel: () => '!text-xs !text-copper',
|
||||
...extras,
|
||||
}
|
||||
}
|
||||
|
||||
/** دکمه ضربدر حذف برچسب انتخابشده — همیشه واضح و قابل کلیک */
|
||||
function TagRemoveButton({ innerProps }: MultiValueRemoveProps<LuxeOption, true>) {
|
||||
return (
|
||||
<div
|
||||
{...innerProps}
|
||||
role="button"
|
||||
aria-label="حذف برچسب"
|
||||
title="حذف برچسب"
|
||||
className="mx-1 flex h-5 w-5 cursor-pointer items-center justify-center rounded-full text-copper transition-colors duration-150 hover:bg-copper hover:text-char"
|
||||
>
|
||||
<XIcon className="h-3 w-3" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const portalTarget = (): HTMLElement | undefined =>
|
||||
typeof document === 'undefined' ? undefined : document.body
|
||||
|
||||
const uniqueOptions = (values: string[]): LuxeOption[] =>
|
||||
[...new Set(values.filter(Boolean))].map((item) => ({ label: item, value: item }))
|
||||
|
||||
type LuxeSelectProps = {
|
||||
value: string
|
||||
options: string[]
|
||||
@@ -94,7 +87,7 @@ type LuxeSelectProps = {
|
||||
inputId?: string
|
||||
}
|
||||
|
||||
/** react-select تکی با ظاهر تیره پروژه — گزینه فعلی حتی اگر در لیست نباشد حفظ میشود */
|
||||
/** react-select تکی با تم تیره — گزینه فعلی حتی اگر در لیست نباشد حفظ میشود */
|
||||
export function LuxeSelect({
|
||||
value,
|
||||
options,
|
||||
@@ -104,9 +97,10 @@ export function LuxeSelect({
|
||||
isDisabled = false,
|
||||
inputId,
|
||||
}: LuxeSelectProps) {
|
||||
const selectOptions = useMemo(() => [...new Set([...options, ...(value ? [value] : [])])].map(
|
||||
(item) => ({ label: item, value: item }),
|
||||
), [options, value])
|
||||
const selectOptions = useMemo(
|
||||
() => uniqueOptions([...options, ...(value ? [value] : [])]),
|
||||
[options, value],
|
||||
)
|
||||
|
||||
return (
|
||||
<Select<LuxeOption, false>
|
||||
@@ -119,7 +113,141 @@ export function LuxeSelect({
|
||||
isClearable={isClearable}
|
||||
isDisabled={isDisabled}
|
||||
noOptionsMessage={() => 'موردی یافت نشد'}
|
||||
styles={makeStyles<false>()}
|
||||
classNames={makeClassNames<false>()}
|
||||
styles={{ menuPortal: (base) => ({ ...base, zIndex: 200 }) }}
|
||||
menuPosition="fixed"
|
||||
menuPortalTarget={portalTarget()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type LuxeCreatableSelectProps = {
|
||||
value: string
|
||||
options: string[]
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
inputId?: string
|
||||
/** متن گزینه «ساخت مقدار جدید» در فهرست بازشو */
|
||||
createLabel?: (input: string) => string
|
||||
}
|
||||
|
||||
/** react-select تکی و قابل ساخت — کاربر با کلیک روی گزینه ساخت، مقدار تازه میسازد */
|
||||
export function LuxeCreatableSelect({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
placeholder = 'انتخاب یا نوشتن مورد جدید…',
|
||||
inputId,
|
||||
createLabel = (input) => `افزودن «${input}»`,
|
||||
}: LuxeCreatableSelectProps) {
|
||||
const selectOptions = useMemo(
|
||||
() => uniqueOptions([...options, ...(value ? [value] : [])]),
|
||||
[options, value],
|
||||
)
|
||||
|
||||
return (
|
||||
<CreatableSelect<LuxeOption, false>
|
||||
inputId={inputId}
|
||||
classNamePrefix="luxe"
|
||||
options={selectOptions}
|
||||
value={selectOptions.find((option) => option.value === value) ?? null}
|
||||
onChange={(option) => onChange(option?.value?.trim() ?? '')}
|
||||
placeholder={placeholder}
|
||||
formatCreateLabel={(input) => createLabel(input)}
|
||||
noOptionsMessage={() => 'موردی یافت نشد'}
|
||||
classNames={makeClassNames<false>()}
|
||||
styles={{ menuPortal: (base) => ({ ...base, zIndex: 200 }) }}
|
||||
menuPosition="fixed"
|
||||
menuPortalTarget={portalTarget()}
|
||||
isClearable
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type CategoryOption = { label: string; value: string }
|
||||
|
||||
/** کلید نگهداری دستههای ساختهشده در مرورگر */
|
||||
const CREATED_CATEGORIES_KEY = 'aramesh_article_categories'
|
||||
|
||||
/** دستههایی که ادمین ساخته است — برای مقالههای بعدی هم در فهرست میمانند */
|
||||
function readCreatedCategories(): string[] {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(CREATED_CATEGORIES_KEY)
|
||||
const parsed: unknown = raw ? JSON.parse(raw) : []
|
||||
return Array.isArray(parsed)
|
||||
? parsed.filter((item): item is string => typeof item === 'string')
|
||||
: []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function storeCreatedCategory(category: string): void {
|
||||
try {
|
||||
const current = readCreatedCategories()
|
||||
if (current.includes(category)) return
|
||||
window.localStorage.setItem(CREATED_CATEGORIES_KEY, JSON.stringify([...current, category]))
|
||||
} catch {
|
||||
/* حافظه مرورگر در دسترس نیست — بیصدا رد شو */
|
||||
}
|
||||
}
|
||||
|
||||
type CategorySelectProps = {
|
||||
/** مقدار انتخابشده — یک شیء {value,label} یا null */
|
||||
value: CategoryOption | null
|
||||
/** تغییر انتخاب — null یعنی فقط انتخاب پاک شده است (دکمه X) */
|
||||
onChange: (option: CategoryOption | null) => void
|
||||
/** دستههای پایه */
|
||||
options: string[]
|
||||
/** متن گزینه «ساخت دسته جدید» در فهرست بازشو */
|
||||
createLabel?: (input: string) => string
|
||||
}
|
||||
|
||||
/**
|
||||
* انتخابگر تکمقداری دستهبندی (react-select Creatable).
|
||||
* - isMulti=false و isClearable: دکمه X فقط انتخاب جاری را پاک میکند و گزینهها دستنخورده میمانند.
|
||||
* - ساخت دسته جدید: به انتهای فهرست گزینهها اضافه و برای مقاله جاری انتخاب میشود و
|
||||
* در همین مرورگر برای مقالههای بعدی نگه داشته میشود.
|
||||
*/
|
||||
export function CategorySelect({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
createLabel = (input) => `ایجاد دسته جدید: ${input}`,
|
||||
}: CategorySelectProps) {
|
||||
const [createdOptions, setCreatedOptions] = useState<string[]>(() => readCreatedCategories())
|
||||
|
||||
// گزینههای پایه + دستههای ساختهشده (همیشه در انتهای فهرست)
|
||||
const selectOptions = useMemo(
|
||||
() => uniqueOptions([...options, ...createdOptions.filter((item) => !options.includes(item))]),
|
||||
[options, createdOptions],
|
||||
)
|
||||
|
||||
/** ساخت دسته جدید: افزودن به انتهای فهرست + انتخاب خودکار برای مقاله جاری */
|
||||
const handleCreate = (input: string) => {
|
||||
const category = input.trim()
|
||||
if (!category) return
|
||||
setCreatedOptions((previous) =>
|
||||
previous.includes(category) ? previous : [...previous, category],
|
||||
)
|
||||
storeCreatedCategory(category)
|
||||
onChange({ label: category, value: category })
|
||||
}
|
||||
|
||||
return (
|
||||
<CreatableSelect<CategoryOption, false>
|
||||
classNamePrefix="luxe"
|
||||
isMulti={false}
|
||||
isClearable
|
||||
options={selectOptions}
|
||||
value={value}
|
||||
onChange={(option) => onChange(option ? { label: option.label, value: option.value } : null)}
|
||||
onCreateOption={handleCreate}
|
||||
formatCreateLabel={(input) => createLabel(input)}
|
||||
placeholder="انتخاب یا نوشتن دسته جدید…"
|
||||
noOptionsMessage={() => 'موردی یافت نشد'}
|
||||
classNames={makeClassNames<false>()}
|
||||
styles={{ menuPortal: (base) => ({ ...base, zIndex: 200 }) }}
|
||||
menuPosition="fixed"
|
||||
menuPortalTarget={portalTarget()}
|
||||
/>
|
||||
@@ -130,18 +258,19 @@ type LuxeTagsSelectProps = {
|
||||
value: string[]
|
||||
onChange: (value: string[]) => void
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
/** هشتگها با react-select قابل ساخت — هر تایپ جدید با Enter به فهرست اضافه میشود */
|
||||
}/** هشتگها با react-select قابل ساخت — هر برچسب دکمه ضربدر حذف مستقل دارد */
|
||||
export function LuxeTagsSelect({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'هشتگ بنویسید و Enter بزنید…',
|
||||
}: LuxeTagsSelectProps) {
|
||||
const options = useMemo(
|
||||
() => [...new Set(value.filter(Boolean))].map((item) => ({ label: item, value: item })),
|
||||
[value],
|
||||
)
|
||||
const options = useMemo(() => uniqueOptions(value), [value])
|
||||
|
||||
/** پاکسازی و حذف تکراریها هنگام افزودن یا حذف برچسب */
|
||||
const applyTags = (next: readonly LuxeOption[]) => {
|
||||
const tags = next.map((option) => option.value.trim()).filter(Boolean)
|
||||
onChange([...new Set(tags)])
|
||||
}
|
||||
|
||||
return (
|
||||
<CreatableSelect<LuxeOption, true>
|
||||
@@ -149,11 +278,14 @@ export function LuxeTagsSelect({
|
||||
isMulti
|
||||
options={options}
|
||||
value={options}
|
||||
onChange={(next) => onChange(next.map((option) => option.value))}
|
||||
onChange={applyTags}
|
||||
placeholder={placeholder}
|
||||
formatCreateLabel={(input) => `افزودن «${input}»`}
|
||||
formatCreateLabel={(input) => `افزودن هشتگ «${input}»`}
|
||||
noOptionsMessage={() => 'هشتگی یافت نشد'}
|
||||
styles={makeStyles<true>()}
|
||||
closeMenuOnSelect={false}
|
||||
components={{ MultiValueRemove: TagRemoveButton }}
|
||||
classNames={makeClassNames<true>({ multiValueRemove: () => '!bg-transparent' })}
|
||||
styles={{ menuPortal: (base) => ({ ...base, zIndex: 200 }) }}
|
||||
menuPosition="fixed"
|
||||
menuPortalTarget={portalTarget()}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo } from 'react'
|
||||
import { AlertCircleIcon, CheckCircle2Icon, XCircleIcon } from 'lucide-react'
|
||||
import type { ArticleBodyDoc } from '../../data/site'
|
||||
import { docHeadings, docImages, docLinks, docToText, firstParagraph } from '../../utils/tiptapDoc'
|
||||
|
||||
export type SeoInput = {
|
||||
title: string
|
||||
@@ -13,78 +14,6 @@ export type SeoInput = {
|
||||
type Status = 'good' | 'ok' | 'bad'
|
||||
type Check = { label: string; status: Status; hint: string }
|
||||
|
||||
// ---------- استخراج متن/ساختار از سند TipTap ----------
|
||||
|
||||
function docToText(body: SeoInput['body']): string {
|
||||
if (Array.isArray(body)) return body.join(' ')
|
||||
const out: string[] = []
|
||||
const walk = (node: unknown) => {
|
||||
if (!node || typeof node !== 'object') return
|
||||
const n = node as { type?: string; text?: string; content?: unknown[] }
|
||||
if (n.type === 'text' && n.text) out.push(n.text)
|
||||
for (const c of n.content ?? []) walk(c)
|
||||
}
|
||||
walk(body)
|
||||
return out.join(' ')
|
||||
}
|
||||
|
||||
function docHeadings(body: SeoInput['body']): string[] {
|
||||
if (Array.isArray(body)) return []
|
||||
const out: string[] = []
|
||||
const walk = (node: unknown) => {
|
||||
if (!node || typeof node !== 'object') return
|
||||
const n = node as { type?: string; text?: string; attrs?: { level?: number }; content?: unknown[] }
|
||||
if (n.type === 'heading' && n.attrs?.level === 2) {
|
||||
const texts: string[] = []
|
||||
const collect = (inner: unknown) => {
|
||||
if (!inner || typeof inner !== 'object') return
|
||||
const t = inner as { type?: string; text?: string; content?: unknown[] }
|
||||
if (t.type === 'text' && t.text) texts.push(t.text)
|
||||
for (const c of t.content ?? []) collect(c)
|
||||
}
|
||||
collect(n)
|
||||
out.push(texts.join(''))
|
||||
}
|
||||
for (const c of n.content ?? []) walk(c)
|
||||
}
|
||||
walk(body)
|
||||
return out
|
||||
}
|
||||
|
||||
function docImages(body: SeoInput['body']): { src: string; alt: string }[] {
|
||||
if (Array.isArray(body)) return []
|
||||
const out: { src: string; alt: string }[] = []
|
||||
const walk = (node: unknown) => {
|
||||
if (!node || typeof node !== 'object') return
|
||||
const n = node as { type?: string; attrs?: { src?: string; alt?: string }; content?: unknown[] }
|
||||
if (n.type === 'image') out.push({ src: n.attrs?.src ?? '', alt: n.attrs?.alt ?? '' })
|
||||
for (const c of n.content ?? []) walk(c)
|
||||
}
|
||||
walk(body)
|
||||
return out
|
||||
}
|
||||
|
||||
function docLinks(body: SeoInput['body']): string[] {
|
||||
if (Array.isArray(body)) return []
|
||||
const out: string[] = []
|
||||
const walk = (node: unknown) => {
|
||||
if (!node || typeof node !== 'object') return
|
||||
const n = node as { type?: string; marks?: { type?: string; attrs?: { href?: string } }[]; content?: unknown[] }
|
||||
for (const m of n.marks ?? []) if (m.type === 'link' && m.attrs?.href) out.push(m.attrs.href)
|
||||
for (const c of n.content ?? []) walk(c)
|
||||
}
|
||||
walk(body)
|
||||
return out
|
||||
}
|
||||
|
||||
function firstParagraph(body: SeoInput['body']): string {
|
||||
if (Array.isArray(body)) return body[0] ?? ''
|
||||
const p = (body as { content?: { type?: string; content?: unknown[] }[] }).content?.find(
|
||||
(c) => c.type === 'paragraph',
|
||||
)
|
||||
return docToText({ type: 'doc', content: p ? [p] : [] } as never)
|
||||
}
|
||||
|
||||
// ---------- محاسبه بررسیها ----------
|
||||
|
||||
export function useSeoChecks(input: SeoInput): Check[] {
|
||||
@@ -117,7 +46,7 @@ export function useSeoChecks(input: SeoInput): Check[] {
|
||||
? { label: 'کلمه کلیدی در پاراگراف اول', status: 'good', hint: 'کلمه کلیدی در ابتدای متن هست.' }
|
||||
: { label: 'کلمه کلیدی در پاراگراف اول', status: 'ok', hint: 'بهتر است کلمه کلیدی در پاراگراف اول بیاید.' },
|
||||
)
|
||||
const h2s = docHeadings(input.body)
|
||||
const h2s = docHeadings(input.body, 2)
|
||||
if (h2s.length === 0) checks.push({ label: 'کلمه کلیدی در تیترها', status: 'ok', hint: 'هنوز تیتر H2 در متن نیست.' })
|
||||
else
|
||||
checks.push(
|
||||
@@ -140,7 +69,7 @@ export function useSeoChecks(input: SeoInput): Check[] {
|
||||
else checks.push({ label: `طول متن (${words.length} واژه)`, status: 'bad', hint: 'متن خیلی کوتاه است — حداقل ۸۰ و ترجیحاً ۱۵۰ واژه بنویسید.' })
|
||||
|
||||
// تیتر H2
|
||||
const h2s = docHeadings(input.body)
|
||||
const h2s = docHeadings(input.body, 2)
|
||||
if (h2s.length > 0) checks.push({ label: `تیتربندی (${h2s.length} تیتر H2)`, status: 'good', hint: 'متن تیتربندی شده است.' })
|
||||
else checks.push({ label: 'تیتربندی', status: 'bad', hint: 'برای خوانایی، متن را با تیتر H2 بخشبندی کنید.' })
|
||||
|
||||
|
||||
Reference in New Issue
Block a user