وبلاگ: نامگذاری و متادیتای تصاویر، پرسشوپاسخ مقاله، میزبانی تصاویر چسباندهشده
- 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:
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Controller, useController, useForm } from 'react-hook-form'
|
||||
import { Link, Navigate, useNavigate, useParams } from 'react-router-dom'
|
||||
import { ArrowRightIcon, CheckIcon, EyeIcon, Trash2Icon } from 'lucide-react'
|
||||
import { ArrowRightIcon, CheckIcon, EyeIcon, PlusIcon, Trash2Icon, XIcon } from 'lucide-react'
|
||||
import Swal from 'sweetalert2'
|
||||
import { useSiteData } from '../api'
|
||||
import { useAdmin } from './AdminContext'
|
||||
@@ -54,6 +54,75 @@ function normalizeTags(value: unknown): string[] {
|
||||
return []
|
||||
}
|
||||
|
||||
/** یک ردیف پرسشوپاسخ مقاله */
|
||||
type ArticleFaq = { question: string; answer: string }
|
||||
|
||||
/**
|
||||
* پنل پرسشوپاسخهای پایان مقاله — مثل بخش متا دیسکریپشن زیر ویرایشگر.
|
||||
* در زبان فارسی ستون faqs و در زبان انگلیسی faqs_en را ویرایش میکند؛
|
||||
* نسخه انگلیسیِ خالی در سایت به فارسی برمیگردد. ذخیرهسازی خودکار است.
|
||||
*/
|
||||
function FaqPanel({
|
||||
faqs,
|
||||
onChange,
|
||||
}: {
|
||||
faqs: ArticleFaq[]
|
||||
onChange: (next: ArticleFaq[]) => void
|
||||
}) {
|
||||
const update = (index: number, patch: Partial<ArticleFaq>) => {
|
||||
const next = faqs.map((f, i) => (i === index ? { ...f, ...patch } : f))
|
||||
onChange(next)
|
||||
}
|
||||
const remove = (index: number) => onChange(faqs.filter((_, i) => i !== index))
|
||||
const add = () => onChange([...faqs, { question: '', answer: '' }])
|
||||
|
||||
return (
|
||||
<div className="mt-4 grid gap-4">
|
||||
{faqs.map((faq, i) => (
|
||||
<div key={i} className="grid gap-3 border border-ivory/10 bg-ink/60 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-[11px] font-light text-copper">پرسش {i + 1}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(i)}
|
||||
aria-label={`حذف پرسش ${i + 1}`}
|
||||
className="flex h-7 w-7 items-center justify-center border border-red-400/40 text-red-300 transition-colors hover:bg-red-500/10"
|
||||
>
|
||||
<XIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
dir="auto"
|
||||
value={faq.question}
|
||||
onChange={(e) => update(i, { question: e.target.value })}
|
||||
placeholder="پرسش مخاطب…"
|
||||
aria-label={`متن پرسش ${i + 1}`}
|
||||
className="w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
|
||||
/>
|
||||
<textarea
|
||||
rows={2}
|
||||
dir="auto"
|
||||
value={faq.answer}
|
||||
onChange={(e) => update(i, { answer: e.target.value })}
|
||||
placeholder="پاسخ کوتاه و روشن…"
|
||||
aria-label={`پاسخ پرسش ${i + 1}`}
|
||||
className="w-full resize-y border border-ivory/15 bg-char px-3 py-2 text-sm font-light leading-8 text-ivory focus:border-copper focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={add}
|
||||
className="flex w-fit items-center gap-2 border border-dashed border-ivory/25 px-4 py-2 text-xs font-light text-ivory/60 transition-colors hover:border-copper hover:text-copper"
|
||||
>
|
||||
<PlusIcon className="h-3.5 w-3.5" />
|
||||
افزودن پرسشوپاسخ
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const STATUS_TEXT: Record<string, string> = {
|
||||
idle: 'ذخیره شد',
|
||||
saving: 'در حال ذخیره…',
|
||||
@@ -458,6 +527,20 @@ export function ArticleWritePage() {
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* پرسشوپاسخهای پایان مقاله — سئوی FAQPage */}
|
||||
<section className="mt-8 border border-ivory/15 bg-char p-6">
|
||||
<h2 className="text-sm font-bold text-copper">پرسشهای متداول مقاله</h2>
|
||||
<p className="mt-1 text-xs font-light text-ivory/50">
|
||||
در پایان مقاله با ظاهر آکاردئونی سایت نمایش داده میشود و با اسکیمای FAQPage
|
||||
شانس نمایش در نتایج غنی گوگل را بالا میبرد.
|
||||
{editLang === 'en' && ' — ویرایش نسخه انگلیسی (خالی = نمایش متن فارسی)'}
|
||||
</p>
|
||||
<FaqPanel
|
||||
faqs={(editLang === 'en' ? draft.faqs_en : draft.faqs) ?? []}
|
||||
onChange={(next) => queueSave({ [suf('faqs')]: next })}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* چکیده و متادیتا */}
|
||||
<SeoChecklist
|
||||
input={{
|
||||
|
||||
@@ -83,12 +83,17 @@ export const adminApi = {
|
||||
reorder: <T = unknown>(resource: string, ids: number[]) =>
|
||||
authFetch<T[]>(`/api/admin/${resource}/reorder`, { method: 'POST', body: JSON.stringify({ ids }) }),
|
||||
|
||||
upload: async (file: File): Promise<{ url: string; key: string }> => {
|
||||
upload: async (file: File, name?: string): Promise<{ url: string; key: string }> => {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
if (name && name.trim()) fd.append('name', name.trim())
|
||||
return authFetch('/api/admin/upload', { method: 'POST', body: fd })
|
||||
},
|
||||
|
||||
/** درونریزی تصویر از نشانی خارجی — دانلود و ذخیره در Object Storage خود سایت */
|
||||
importImage: async (url: string): Promise<{ url: string; key: string }> =>
|
||||
authFetch('/api/admin/import-image', { method: 'POST', body: JSON.stringify({ url }) }),
|
||||
|
||||
changePassword: (currentPassword: string, newPassword: string) =>
|
||||
authFetch<{ ok: boolean }>('/api/auth/change-password', {
|
||||
method: 'POST',
|
||||
|
||||
Reference in New Issue
Block a user