فرانت‌اند: ویرایشگر WYSIWYG وبلاگ (TipTap) با آپلود تصویر در متن

- ArticleEditor: ویرایشگر TipTap v3 راست‌به‌چسب با نوار ابزار فارسی —
  بولد/ایتالیک/زیرخط/خط‌خورده، تیتر H2/H3، لیست نقطه‌ای/شماره‌ای،
  نقل‌قول، لینک (مسدودسازی javascript: با isAllowedUri)، واگرد/ازنو
- درج تصویر داخل متن: دکمه آپلود، چسباندن یا کشیدن فایل — ذخیره در
  MinIO از طریق /api/admin/upload و درج URL نسبی /media/...
- ذخیره خودکار: debounce ۸۰۰ms + flush هنگام بستن کشو
- کشوی مقاله عریض‌تر (max-w-3xl)؛ فیلد body مقالات به‌جای لیست متنی،
  ویرایشگر WYSIWYG رندر می‌کند
- رندر صفحه مقاله: تبدیل سند به HTML با @tiptap/static-renderer
  (فقط تگ‌های شناخته‌شده — بدون ریسک XSS) + استایل‌های .article-body
  (تیتر با خط مسی، لیست، نقل‌قول، تصویر) + پشتیبانی از ردیف‌های قدیمی
- وابستگی‌های جدید: @tiptap/react، @tiptap/pm، @tiptap/starter-kit،
  @tiptap/extension-image، @tiptap/extension-link، @tiptap/static-renderer
This commit is contained in:
MMD
2026-09-06 23:33:08 +03:30
parent 8f11df99ea
commit cfe5512ec7
8 changed files with 1188 additions and 16 deletions
+93 -4
View File
@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { PlusIcon, Trash2Icon, XIcon } from 'lucide-react'
import Swal from 'sweetalert2'
@@ -7,6 +7,7 @@ import { useAdmin, useFieldSaver } from './AdminContext'
import { adminApi } from './adminClient'
import type { EntityConfig, FieldConfig } from './entities'
import { ImageField } from '../components/admin/ImageField'
import { ArticleEditor } from '../components/admin/ArticleEditor'
type EntityDrawerProps = {
config: EntityConfig
@@ -22,6 +23,12 @@ export function EntityDrawer({ config, item, onClose, deletable = false }: Entit
const { runSave } = useAdmin()
const { reload } = useSiteData()
const [deleting, setDeleting] = useState(false)
const flushRef = useRef<() => void>(() => {})
const closeWithFlush = () => {
flushRef.current()
onClose()
}
const saveField = (name: string, value: unknown) =>
save(config.resource, item.id, { [name]: value }, (d) => {
@@ -69,7 +76,7 @@ export function EntityDrawer({ config, item, onClose, deletable = false }: Entit
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
onClick={closeWithFlush}
aria-hidden="true"
/>
<motion.aside
@@ -77,7 +84,9 @@ export function EntityDrawer({ config, item, onClose, deletable = false }: Entit
dir="rtl"
role="dialog"
aria-label={`ویرایش ${config.title}`}
className="fixed inset-y-0 left-0 z-[96] flex w-full max-w-md flex-col border-e border-ivory/10 bg-ink shadow-2xl"
className={`fixed inset-y-0 left-0 z-[96] flex w-full flex-col border-e border-ivory/10 bg-ink shadow-2xl ${
config.resource === 'articles' ? 'sm:max-w-3xl' : 'sm:max-w-md'
}`}
initial={{ x: '-100%' }}
animate={{ x: 0 }}
exit={{ x: '-100%' }}
@@ -92,7 +101,7 @@ export function EntityDrawer({ config, item, onClose, deletable = false }: Entit
</div>
<button
type="button"
onClick={onClose}
onClick={closeWithFlush}
aria-label="بستن"
className="flex h-9 w-9 items-center justify-center border border-ivory/20 text-ivory/70 transition-colors hover:border-copper hover:text-copper"
>
@@ -108,6 +117,13 @@ export function EntityDrawer({ config, item, onClose, deletable = false }: Entit
field={field}
value={item[field.name]}
onSave={(value) => saveField(field.name, value)}
registerFlush={
field.type === 'wysiwyg'
? (fn) => {
flushRef.current = fn
}
: undefined
}
/>
))}
</div>
@@ -135,10 +151,12 @@ function FieldControl({
field,
value,
onSave,
registerFlush,
}: {
field: FieldConfig
value: unknown
onSave: (value: unknown) => Promise<boolean>
registerFlush?: (fn: () => void) => void
}) {
const [text, setText] = useState<string>(String(value ?? ''))
@@ -152,6 +170,10 @@ function FieldControl({
return <ImageField label={field.label} value={String(value ?? '')} onSave={(url) => onSave(url)} />
}
if (field.type === 'wysiwyg') {
return <WysiwygField field={field} value={value} onSave={onSave} registerFlush={registerFlush} />
}
if (field.type === 'list') {
return <ListField field={field} value={value} onSave={onSave} />
}
@@ -248,3 +270,70 @@ function ListField({
</div>
)
}
/** فیلد WYSIWYG مقاله — ذخیره خودکار با تاخیر کوتاه و flush هنگام بستن کشو */
function WysiwygField({
field,
value,
onSave,
registerFlush,
}: {
field: FieldConfig
value: unknown
onSave: (value: unknown) => Promise<boolean>
registerFlush?: (fn: () => void) => void
}) {
const [doc, setDoc] = useState<Record<string, unknown>>(() =>
value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: { type: 'doc', content: [{ type: 'paragraph' }] },
)
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
const pending = useRef<Record<string, unknown> | null>(null)
const flush = () => {
if (timer.current) {
clearTimeout(timer.current)
timer.current = null
}
if (pending.current) {
const next = pending.current
pending.current = null
void onSave(next)
}
}
// ثبت آخرین flush در کشو + ذخیره تغییرات در انتظار هنگام unmount
useEffect(() => {
registerFlush?.(() => flush())
return () => {
if (timer.current) clearTimeout(timer.current)
if (pending.current) {
const next = pending.current
pending.current = null
void onSave(next)
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
})
const handleChange = (json: Record<string, unknown>) => {
setDoc(json)
pending.current = json
if (timer.current) clearTimeout(timer.current)
timer.current = setTimeout(flush, 800)
}
return (
<div>
<p className="text-xs font-bold text-ivory/80">{field.label}</p>
<p className="mt-1 text-[10px] font-light text-ivory/40">
ذخیره خودکار — تصویر را داخل متن بچسبانید یا بکشید
</p>
<div className="mt-2">
<ArticleEditor doc={doc} onChange={handleChange} />
</div>
</div>
)
}
+3 -3
View File
@@ -1,6 +1,6 @@
import type { SiteData } from '../api'
export type FieldType = 'text' | 'textarea' | 'number' | 'image' | 'list'
export type FieldType = 'text' | 'textarea' | 'number' | 'image' | 'list' | 'wysiwyg'
export type FieldConfig = {
name: string
@@ -57,9 +57,9 @@ export const ENTITIES: Record<string, EntityConfig> = {
fields: [
F('title', 'عنوان'), F('slug', 'نشانی (slug)'), F('date', 'تاریخ'), F('category', 'دسته'),
F('image', 'تصویر شاخص', 'image'), F('read', 'زمان مطالعه'), F('excerpt', 'چکیده', 'textarea'),
F('body', 'متن مقاله (هر پاراگراف یک خط)', 'list'),
F('body', 'متن مقاله', 'wysiwyg'),
],
defaults: { title: 'مقاله جدید', slug: `article-${Date.now()}`, date: '', category: '', image: '', read: '', excerpt: '', body: [] },
defaults: { title: 'مقاله جدید', slug: `article-${Date.now()}`, date: '', category: '', image: '', read: '', excerpt: '', body: { type: 'doc', content: [{ type: 'paragraph' }] } },
},
testimonials: {
resource: 'testimonials', dataKey: 'testimonials', title: 'نظرات مشتریان', kind: 'collection',
@@ -0,0 +1,267 @@
import { 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 {
BoldIcon,
ItalicIcon,
Heading2Icon,
Heading3Icon,
ImagePlusIcon,
Link2Icon,
ListIcon,
ListOrderedIcon,
QuoteIcon,
Redo2Icon,
StrikethroughIcon,
UnderlineIcon,
Undo2Icon,
Loader2Icon,
} from 'lucide-react'
import Swal from 'sweetalert2'
import { useAdmin } from '../../admin/AdminContext'
import { adminApi } from '../../admin/adminClient'
/** اکستنشن‌های مشترک ویرایشگر و رندر مقاله — هر دو باید یکسان باشند */
export const ARTICLE_EXTENSIONS = [
StarterKit.configure({
link: false,
heading: { levels: [2, 3] },
}),
Image.configure({ allowBase64: false }),
Link.configure({
openOnClick: false,
HTMLAttributes: { rel: 'noopener noreferrer', target: '_blank' },
isAllowedUri: (uri: string) => /^(https?:\/\/|\/|#|mailto:|tel:)/i.test(uri),
}),
]
type ArticleEditorProps = {
doc: Record<string, unknown>
onChange: (json: Record<string, unknown>) => void
}
function ToolButton({
onClick,
active = false,
label,
children,
}: {
onClick: () => void
active?: boolean
label: string
children: React.ReactNode
}) {
return (
<button
type="button"
title={label}
aria-label={label}
onMouseDown={(e) => e.preventDefault()}
onClick={onClick}
className={`flex h-8 w-8 items-center justify-center border transition-colors duration-150 ${
active
? 'border-copper/50 bg-copper/15 text-copper'
: 'border-transparent text-ivory/60 hover:border-ivory/15 hover:text-copper'
}`}
>
{children}
</button>
)
}
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 editor = useEditor({
extensions: ARTICLE_EXTENSIONS,
content: doc && Object.keys(doc).length ? doc : undefined,
immediatelyRender: false,
editorProps: {
attributes: {
dir: 'rtl',
class: 'article-body min-h-[300px] px-4 py-4 focus:outline-none',
'data-wysiwyg': 'true',
},
handlePaste: (_event_ignored, event) => {
const file = event.clipboardData?.files?.[0]
if (file && file.type.startsWith('image/')) {
event.preventDefault()
void uploadAndInsert(file)
return true
}
return false
},
handleDrop: (_view, event, _slice, moved) => {
if (moved) return false
const file = event.dataTransfer?.files?.[0]
if (file && file.type.startsWith('image/')) {
event.preventDefault()
void uploadAndInsert(file)
return true
}
return false
},
},
onUpdate: ({ editor: instance }) => onChange(instance.getJSON()),
})
async function uploadAndInsert(file: File) {
if (!editor) return
setUploading(true)
await runSave(async () => {
const { url } = await adminApi.upload(file)
editor.chain().focus().setImage({ src: url, alt: file.name.replace(/\.[^.]+$/, '') }).run()
})
setUploading(false)
}
const setLink = () => {
if (!editor) return
if (editor.isActive('link')) {
editor.chain().focus().unsetLink().run()
return
}
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',
},
}).then((result) => {
if (!result.isConfirmed || !result.value) return
editor.chain().focus().extendMarkRange('link').setLink({ href: result.value }).run()
})
}
if (!editor) return null
return (
<div className="border border-ivory/15 bg-char">
<div className="flex flex-wrap items-center gap-0.5 border-b border-ivory/10 px-2 py-1.5">
<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('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 />
<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>
)
}
+6 -1
View File
@@ -26,7 +26,12 @@ export type Project = {
body: string[];
};
/** سند TipTap (ProseMirror) — بدنه ساختاریافته مقاله */
export type ArticleBodyDoc = { type: 'doc'; content: unknown[] };
export type Article = {
id: number;
slug: string; title: string; date: string; category: string; image: string; read: string; excerpt: string; body: string[];
slug: string; title: string; date: string; category: string; image: string; read: string; excerpt: string;
/** سند TipTap — ردیف‌های قدیمی ممکن است هنوز لیست پاراگراف باشند */
body: ArticleBodyDoc | string[];
};
+99
View File
@@ -40,3 +40,102 @@ body {
scroll-behavior: auto;
}
}
/* ---------- بدنه مقاله — رندر سند TipTap و ویرایشگر WYSIWYG ---------- */
.article-body {
line-height: 2.4;
word-wrap: break-word;
}
.article-body p {
margin-top: 1.75rem;
color: rgba(245, 243, 239, 0.7);
}
.article-body > :first-child {
margin-top: 0;
}
.article-body h2,
.article-body h3 {
color: #f5f3ef;
font-weight: 700;
margin-top: 3rem;
margin-bottom: 0.25rem;
line-height: 1.9;
}
.article-body h2 {
font-size: 1.6rem;
padding-right: 0.85rem;
border-right: 3px solid #d5874c;
}
.article-body h3 {
font-size: 1.25rem;
color: #e8c9a8;
}
.article-body ul,
.article-body ol {
margin-top: 1.5rem;
padding-right: 1.5rem;
}
.article-body ul {
list-style: disc;
}
.article-body ol {
list-style: decimal;
}
.article-body li {
margin-top: 0.6rem;
color: rgba(245, 243, 239, 0.7);
}
.article-body li p {
margin-top: 0;
display: inline;
}
.article-body blockquote {
margin-top: 2rem;
border-right: 3px solid #d5874c;
background: rgba(30, 26, 22, 0.5);
padding: 1rem 1.25rem;
font-style: normal;
color: rgba(245, 243, 239, 0.85);
}
.article-body blockquote p {
margin-top: 0;
color: inherit;
}
.article-body a {
color: #d5874c;
text-decoration: underline;
text-underline-offset: 4px;
}
.article-body img {
width: 100%;
margin-top: 2rem;
border: 1px solid rgba(245, 243, 239, 0.1);
object-fit: cover;
}
.article-body strong {
color: #f5f3ef;
font-weight: 700;
}
.article-body hr {
margin-top: 2.5rem;
border: 0;
height: 1px;
background: rgba(245, 243, 239, 0.12);
}
.article-body code {
background: rgba(30, 26, 22, 0.7);
padding: 0.15rem 0.45rem;
font-size: 0.85em;
}
.article-body pre {
background: rgba(30, 26, 22, 0.7);
padding: 1rem 1.25rem;
margin-top: 1.5rem;
overflow-x: auto;
direction: ltr;
text-align: left;
}
.article-body pre code {
background: transparent;
padding: 0;
}
+34 -5
View File
@@ -1,11 +1,31 @@
import React from 'react';
import { Link, useParams } from 'react-router-dom';
import { ArrowLeftIcon, ChevronLeftIcon, ClockIcon } from 'lucide-react';
import { renderToHTMLString } from '@tiptap/static-renderer';
import { Reveal } from '../components/Reveal';
import { ButtonLink } from '../components/Button';
import { ArticleCard } from '../components/ArticleCard';
import { Newsletter } from '../components/home/Newsletter';
import { useSite } from '../api';
import { ARTICLE_EXTENSIONS } from '../components/admin/ArticleEditor';
import type { Article } from '../data/site';
/** بدنه مقاله را به HTML تبدیل می‌کند — سند TipTap جدید یا پاراگراف‌های قدیمی */
function renderBody(article: Article): { html: string | null; legacy: string[] } {
const body = article.body as unknown;
if (Array.isArray(body)) return { html: null, legacy: body };
if (body && typeof body === 'object' && (body as { type?: string }).type === 'doc') {
try {
return {
html: renderToHTMLString({ extensions: ARTICLE_EXTENSIONS, content: body as never }),
legacy: [],
};
} catch {
return { html: null, legacy: [] };
}
}
return { html: null, legacy: [] };
}
export function ArticleDetail() {
const { slug } = useParams<{slug: string;}>();
@@ -25,6 +45,7 @@ export function ArticleDetail() {
}
const { html, legacy } = renderBody(article);
const related = articles.filter((a) => a.slug !== article.slug).slice(0, 3);
return (
@@ -36,7 +57,7 @@ export function ArticleDetail() {
className="absolute inset-0"
style={{ background: 'linear-gradient(to top, #27221c 6%, rgba(39,34,28,0.15) 70%)' }}
aria-hidden="true" />
<div className="relative mx-auto w-full max-w-site px-6 pb-16 lg:px-10">
<nav aria-label="مسیر صفحه" className="flex items-center gap-2 text-xs font-light text-ivory/50">
<Link to="/" className="transition-colors hover:text-copper">
@@ -67,10 +88,18 @@ export function ArticleDetail() {
{article.excerpt}
</p>
</Reveal>
{article.body.map((paragraph, i) =>
<Reveal key={paragraph} delay={0.03 * i}>
<p className="mt-8 text-base font-light leading-10 text-ivory/70">{paragraph}</p>
{html ? (
<Reveal delay={0.05}>
<div
className="article-body mt-4 text-base font-light text-ivory/70"
dangerouslySetInnerHTML={{ __html: html }} />
</Reveal>
) : (
legacy.map((paragraph, i) =>
<Reveal key={i} delay={0.03 * i}>
<p className="mt-8 text-base font-light leading-10 text-ivory/70">{paragraph}</p>
</Reveal>
)
)}
</div>
</article>
@@ -92,4 +121,4 @@ export function ArticleDetail() {
<Newsletter />
</>);
}
}