وبلاگ: نام‌گذاری و متادیتای تصاویر، پرسش‌وپاسخ مقاله، میزبانی تصاویر چسبانده‌شده

- Alt/Title تصویر شاخص اکنون ذخیره می‌شود (ستون‌های جدید image_alt/image_title — قبلاً بی‌صدا دور ریخته می‌شد)
- Alt و Title تصاویر داخل متن پس از درج هم قابل ویرایش‌اند (کنار کپشن)
- نام فایل ذخیره‌سازی خوانا: نام دلخواه هنگام درج یا نام خود فایل (به‌جای UUID خالی)
- پنل پرسش‌وپاسخ پایان مقاله (ستون‌های faqs/faqs_en) با ظاهر آکاردئونی سایت و اسکیمای FAQPage
- چسباندن از Outline و هر منبع خارجی: تصاویر خودکار دانلود و در Object Storage سایت میزبانی می‌شوند (endpoint جدید import-image با محافظ SSRF) + دکمه میزبانی مجدد روی تصاویر خارجی موجود
This commit is contained in:
MMD
2026-09-26 22:46:56 +03:30
parent 851e3fc2dc
commit 1d29e50852
15 changed files with 580 additions and 45 deletions
+65
View File
@@ -0,0 +1,65 @@
import { useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { PlusIcon } from 'lucide-react'
/** یک پرسش‌وپاسخ مقاله — همان شکل داده ستون faqs مقاله در دیتابیس */
export type ArticleFaq = { question: string; answer: string }
/**
* پرسش‌وپاسخ‌های پایان مقاله — همان رفتار آکاردئونی بخش FAQ سایت، بدون ویرایش مدیریتی.
* در سرچ کنسول/نتایج غنی گوگل، اسکیمای FAQPage آن جداگانه تزریق می‌شود.
*/
export function ArticleFaq({ faqs }: { faqs: ArticleFaq[] }) {
const [open, setOpen] = useState<number | null>(0)
if (!faqs.length) return null
return (
<section className="mt-16" aria-label="پرسش‌های متداول مقاله">
<div className="mt-6 h-px w-24 bg-copper/70" />
<ul className="mt-2">
{faqs.map((faq, i) => {
const isOpen = open === i
return (
<li key={i} className="border-b border-ivory/10">
<button
type="button"
onClick={() => setOpen(isOpen ? null : i)}
aria-expanded={isOpen}
className="flex w-full items-center justify-between gap-6 py-6 text-start transition-colors duration-200 ease-lux hover:text-copper"
>
<span
className={`text-base font-bold md:text-lg ${
isOpen ? 'text-copper' : 'text-ivory'
}`}
>
{faq.question}
</span>
<PlusIcon
className={`h-5 w-5 shrink-0 text-copper transition-transform duration-300 ease-lux ${
isOpen ? 'rotate-45' : ''
}`}
/>
</button>
<AnimatePresence initial={false}>
{isOpen && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }}
className="overflow-hidden"
>
<p className="pb-7 ps-2 text-sm font-light leading-9 text-ivory/60">
{faq.answer}
</p>
</motion.div>
)}
</AnimatePresence>
</li>
)
})}
</ul>
</section>
)
}
+1 -1
View File
@@ -203,7 +203,7 @@ export function ContactSection() {
className="mt-8 font-light"
onClick={() => {
setSent(false)
setForm({ name: '', phone: '', note: '' })
setForm({ name: '', phone: '', note: '', website: '' })
}}
>
<InlineEdit value={content.success_button_label} onSave={(v) => saveContent('success_button_label', v)} />
+132 -3
View File
@@ -102,6 +102,42 @@ const CLOSED_LINK_MODAL: LinkModalState = {
/** چند نویسه پیش از نشانگر برای تشخیص نشانیِ تایپ‌شده بررسی می‌شود */
const URL_LOOKBACK = 300
/** آیا src تصویر خارج از میزبانی سایت است (مثل چسبانده‌شده از Outline)؟ */
function isExternalImageSrc(src: string): boolean {
if (!src || src.startsWith('data:')) return false
if (src.startsWith('/')) return false // مسیر نسبی/داخلی
try {
return new URL(src).origin !== window.location.origin
} catch {
return true
}
}
/**
* میزبانی مجدد تصاویر خارجی یک HTML — هر <img> با نشانی خارجی از سرور دانلود و در
* Object Storage خود سایت ذخیره و src آن بازنویسی می‌شود (چسباندن از Outline و مانند آن).
* تصویری که درون‌ریزی‌اش شکست بخورد با src اصلی می‌ماند.
*/
async function rehostExternalImages(html: string): Promise<string> {
const parsed = new DOMParser().parseFromString(html, 'text/html')
const imgs = Array.from(parsed.querySelectorAll('img[src]'))
const externals = imgs.filter((img) => isExternalImageSrc(img.getAttribute('src') ?? ''))
if (!externals.length) return html
await Promise.all(
externals.map(async (img) => {
const original = img.getAttribute('src') ?? ''
try {
const { url } = await adminApi.importImage(original)
img.setAttribute('src', url)
} catch {
// نشانی خارجی می‌ماند — شاید خودش کار کند
}
}),
)
return parsed.body.innerHTML
}
function ToolButton({
onClick,
active = false,
@@ -144,6 +180,8 @@ export function ArticleEditor({ doc, onChange, contentKey }: ArticleEditorProps)
const [uploading, setUploading] = useState(false)
const [direction, setDirection] = useState<TextDirection>('rtl')
const [linkModal, setLinkModal] = useState<LinkModalState>(CLOSED_LINK_MODAL)
// مرحله نام‌گذاری درج تصویر از نوار ابزار — پیش‌فرض از نام خود فایل
const [pendingImage, setPendingImage] = useState<{ file: File; name: string } | null>(null)
const editor = useEditor({
extensions: ARTICLE_EXTENSIONS,
@@ -163,6 +201,22 @@ export function ArticleEditor({ doc, onChange, contentKey }: ArticleEditorProps)
void uploadAndInsert(file)
return true
}
// محتوای HTML با تصویر خارجی (Outline و مانند آن) — تصاویر به میزبانی سایت منتقل می‌شوند
const html = event.clipboardData?.getData('text/html') ?? ''
if (html.includes('<img')) {
const parsed = new DOMParser().parseFromString(html, 'text/html')
const hasExternal = Array.from(parsed.querySelectorAll('img[src]')).some((img) =>
isExternalImageSrc(img.getAttribute('src') ?? ''),
)
if (hasExternal) {
event.preventDefault()
void (async () => {
const transformed = await rehostExternalImages(html)
editor?.chain().focus().insertContent(transformed).run()
})()
return true
}
}
// چسباندن یک نشانی → مودال لینک بلافاصله باز می‌شود
const text = event.clipboardData?.getData('text/plain')?.trim() ?? ''
if (isUrlLike(text)) {
@@ -186,6 +240,21 @@ export function ArticleEditor({ doc, onChange, contentKey }: ArticleEditorProps)
void uploadAndInsert(file)
return true
}
const html = event.dataTransfer?.getData('text/html') ?? ''
if (html.includes('<img')) {
const parsed = new DOMParser().parseFromString(html, 'text/html')
const hasExternal = Array.from(parsed.querySelectorAll('img[src]')).some((img) =>
isExternalImageSrc(img.getAttribute('src') ?? ''),
)
if (hasExternal) {
event.preventDefault()
void (async () => {
const transformed = await rehostExternalImages(html)
editor?.chain().focus().insertContent(transformed).run()
})()
return true
}
}
return false
},
// پایان تایپ یک نشانی (فاصله بعد از آن) → مودال لینک باز می‌شود
@@ -237,11 +306,11 @@ export function ArticleEditor({ doc, onChange, contentKey }: ArticleEditorProps)
editor.commands.setContent(incoming, { emitUpdate: false })
}, [editor, contentKey, doc])
async function uploadAndInsert(file: File) {
async function uploadAndInsert(file: File, name?: string) {
if (!editor) return
setUploading(true)
await runSave(async () => {
const { url } = await adminApi.upload(file)
const { url } = await adminApi.upload(file, name)
// نام پیش‌فرض از نام فایل ساخته می‌شود و در نبود آن، مقدار پیش‌فرض ثابت می‌ماند
const defaultAlt = file.name.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ').trim()
editor
@@ -411,12 +480,72 @@ export function ArticleEditor({ doc, onChange, contentKey }: ArticleEditorProps)
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) void uploadAndInsert(file)
if (file) {
setPendingImage({ file, name: file.name.replace(/\.[^.]+$/, '') })
}
e.target.value = ''
}}
/>
</div>
{/* مرحله نام‌گذاری تصویر — نام خوانای ذخیره‌سازی با یک کلیک تایید می‌شود */}
{pendingImage && (
<div
className="fixed inset-0 z-[90] flex items-center justify-center bg-black/60 p-6 backdrop-blur-sm"
role="dialog"
aria-modal="true"
aria-label="نام تصویر"
onClick={() => setPendingImage(null)}
>
<div
dir="rtl"
className="w-full max-w-sm border border-ivory/15 bg-ink p-6 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<h2 className="text-sm font-bold text-copper">درج تصویر</h2>
<label htmlFor="image-name-input" className="mt-4 block text-xs font-light text-ivory/60">
نام فایل تصویر (برای نشانی ذخیره‌سازی — اختیاری)
</label>
<input
id="image-name-input"
type="text"
dir="auto"
autoFocus
value={pendingImage.name}
onChange={(e) => setPendingImage({ ...pendingImage, name: e.target.value })}
onKeyDown={(e) => {
if (e.key === 'Enter') {
const { file, name } = pendingImage
setPendingImage(null)
void uploadAndInsert(file, name)
}
}}
className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
/>
<div className="mt-5 flex items-center justify-end gap-3">
<button
type="button"
onClick={() => setPendingImage(null)}
className="border border-ivory/20 px-4 py-2 text-xs font-light text-ivory/70 transition-colors hover:border-copper hover:text-copper"
>
انصراف
</button>
<button
type="button"
onClick={() => {
const { file, name } = pendingImage
setPendingImage(null)
void uploadAndInsert(file, name)
}}
className="bg-copper px-6 py-2 text-xs font-bold text-char transition-transform hover:scale-105"
>
درج تصویر
</button>
</div>
</div>
</div>
)}
{/* مودال لینک — انیمیشن ورود/خروج را خودش با کلاس‌های Tailwind مدیریت می‌کند */}
{linkModal.open && (
<LinkModal
+24 -2
View File
@@ -48,7 +48,10 @@ export function ImageField({
const inputRef = useRef<HTMLInputElement>(null)
const titleId = useId()
const altId = useId()
const nameId = useId()
const [uploading, setUploading] = useState(false)
/** نام دلخواه فایل برای ذخیره‌سازی خوانا — پیش‌فرض از نام فایل انتخابی ساخته می‌شود */
const [fileBaseName, setFileBaseName] = useState('')
const showTitle = title !== undefined || !!onSaveTitle
const showAlt = alt !== undefined || !!onSaveAlt
@@ -73,13 +76,16 @@ export function ImageField({
setUploading(true)
try {
await runSave(async () => {
const { url } = await adminApi.upload(file)
// نام ذخیره‌سازی: نام تایپ‌شده کاربر، وگرنه نام خود فایل بدون پسوند
const name = fileBaseName.trim() || file.name.replace(/\.[^.]+$/, '')
const { url } = await adminApi.upload(file, name)
const ok = await onSave(url)
if (!ok) throw new Error('ذخیره نشد')
// پیش‌فرض‌های Alt/Title همان لحظه ثبت می‌شوند تا تصویر بدون متادیتا نماند
if (showTitle) await titleField.commit()
if (showAlt) await altField.commit()
})
setFileBaseName('')
} finally {
setUploading(false)
}
@@ -106,7 +112,7 @@ export function ImageField({
)}
</div>
<div className="mt-3">
<div className="mt-3 flex flex-wrap items-end gap-3">
<button
type="button"
onClick={() => inputRef.current?.click()}
@@ -120,6 +126,22 @@ export function ImageField({
)}
{uploading ? 'در حال آپلود…' : value ? 'تغییر تصویر' : 'انتخاب تصویر'}
</button>
{/* نام ذخیره‌سازی فایل — خالی یعنی از نام خود فایل استفاده شود */}
<div className="w-56">
<label htmlFor={nameId} className="text-[11px] font-light text-ivory/50">
نام فایل تصویر (اختیاری)
</label>
<input
id={nameId}
type="text"
dir="auto"
value={fileBaseName}
onChange={(event) => setFileBaseName(event.target.value)}
placeholder="مثلاً: elahieh-tower"
className={META_FIELD_CLASS.replace('mt-1.5 ', '')}
/>
</div>
</div>
{(showTitle || showAlt) && (
@@ -1,7 +1,9 @@
import { useEffect, useRef, useState } from 'react'
import { NodeViewWrapper } from '@tiptap/react'
import type { NodeViewProps } from '@tiptap/react'
import { PlusIcon, XIcon } from 'lucide-react'
import { CloudUploadIcon, PlusIcon, XIcon } from 'lucide-react'
import { adminApi } from '../../admin/adminClient'
import { useAdmin } from '../../admin/AdminContext'
type ImageAttrs = {
src: string
@@ -19,25 +21,39 @@ const INLINE_FIELD_CLASS =
const ADD_CAPTION_CLASS =
'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'
/** تصاویر میزبان خارجی (مثل چسبانده‌شده از Outline) قابل میزبانی مجدد هستند */
function isExternalImage(src: string): boolean {
if (!src) return false
if (src.startsWith('/media/') || src.startsWith('/')) return false
try {
return new URL(src, window.location.origin).origin !== window.location.origin
} catch {
return true
}
}
/**
* نمایش تصویر مقاله در ویرایشگر — دقیقاً با همان ساختار خروجی سایت:
* <figure><img><figcaption>کپشن</figcaption></figure>
*
* ویرایش کاملاً درون‌خطی است (بدون پاپ‌اور/مودال):
* - هر تصویر یک «جعبه توضیح/کپشن» همراه خود دارد و متن آن در figcaption ذخیره می‌شود.
* - دکمه X کپشن را کامل حذف می‌کند: صفت کپشن به مقدار پیش‌فرض برمی‌گردد و عنصر
* figcaption با ورودی‌اش از DOM ویرایشگر (و از خروجی ذخیره‌شده) حذف می‌شود؛
* فقط تصویر می‌ماند و «افزودن کپشن» دوباره جعبه را باز می‌کند.
* - ویرایش Alt در ویرایشگر وجود ندارد؛ Alt هنگام درج از نام فایل ثبت می‌شود.
* - دکمه X کپشن را کامل حذف می‌کند؛ «افزودن کپشن» دوباره جعبه را باز می‌کند.
* - Alt و Title تصویر (نام تصویر برای سئو/دسترس‌پذیری) پس از درج هم قابل ویرایش‌اند.
* - تصویر میزبان خارجی دکمه «میزبانی مجدد» دارد: تصویر دانلود و در Object Storage
* خود سایت ذخیره می‌شود (ترمیم تصاویر چسبانده‌شده از Outline).
* - دکمه X روی تصویر کل تصویر را حذف می‌کند.
*/
export function ImageNodeView({ node, updateAttributes, deleteNode, selected }: NodeViewProps) {
const { src, alt, title, caption } = node.attrs as ImageAttrs
const { runSave } = useAdmin()
const hasCaption = Boolean((caption ?? '').trim())
const [captionOpen, setCaptionOpen] = useState(true)
const [rehosting, setRehosting] = useState(false)
const captionInputRef = useRef<HTMLInputElement>(null)
const focusOnOpen = useRef(false)
const showCaption = captionOpen || hasCaption
const external = isExternalImage(src)
// فقط وقتی کاربر «افزودن کپشن» را می‌زند ورودی فوکوس می‌گیرد (نه هنگام بارگذاری مقاله)
useEffect(() => {
@@ -57,6 +73,16 @@ export function ImageNodeView({ node, updateAttributes, deleteNode, selected }:
setCaptionOpen(true)
}
/** میزبانی مجدد تصویر خارجی — دانلود از منبع و ذخیره در Object Storage سایت */
const rehost = () => {
if (rehosting) return
setRehosting(true)
void runSave(async () => {
const { url } = await adminApi.importImage(src)
updateAttributes({ src: url })
}).finally(() => setRehosting(false))
}
return (
<NodeViewWrapper
className={`article-figure-node ${selected ? 'is-selected' : ''}`}
@@ -77,6 +103,42 @@ export function ImageNodeView({ node, updateAttributes, deleteNode, selected }:
<XIcon className="h-3.5 w-3.5" />
</button>
{/* تصویر خارجی (مثل Outline) — منتقل به میزبانی سایت */}
{external && (
<button
type="button"
contentEditable={false}
onClick={rehost}
disabled={rehosting}
aria-label="میزبانی مجدد تصویر"
title="تصویر از منبع خارجی دانلود و در سایت ذخیره می‌شود"
className="absolute right-2 top-2 z-10 flex items-center gap-1.5 border border-copper/60 bg-ink/85 px-2.5 py-1 text-[11px] font-light text-copper backdrop-blur-sm transition-colors duration-150 hover:bg-copper hover:text-char disabled:opacity-60"
>
<CloudUploadIcon className={`h-3.5 w-3.5 ${rehosting ? 'animate-pulse' : ''}`} />
{rehosting ? 'در حال انتقال…' : 'میزبانی مجدد'}
</button>
)}
{/* نام تصویر — Alt و Title پس از درج هم قابل ویرایش‌اند */}
<div contentEditable={false} className="mt-1.5 grid gap-1.5 sm:grid-cols-2">
<input
type="text"
value={alt ?? ''}
onChange={(event) => updateAttributes({ alt: event.target.value })}
placeholder="نام تصویر / متن جایگزین (Alt)…"
aria-label="متن جایگزین تصویر"
className={INLINE_FIELD_CLASS}
/>
<input
type="text"
value={title ?? ''}
onChange={(event) => updateAttributes({ title: event.target.value })}
placeholder="عنوان تصویر (Title)…"
aria-label="عنوان تصویر"
className={INLINE_FIELD_CLASS}
/>
</div>
{showCaption ? (
<figcaption className="article-figure__caption" contentEditable={false}>
<input