وبلاگ: نامگذاری و متادیتای تصاویر، پرسشوپاسخ مقاله، میزبانی تصاویر چسباندهشده
- 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:
+121
-17
@@ -1,13 +1,19 @@
|
|||||||
"""انتهای پنل مدیریت — همه مسیرها نیازمند توکن JWT معتبر هستند."""
|
"""انتهای پنل مدیریت — همه مسیرها نیازمند توکن JWT معتبر هستند."""
|
||||||
|
|
||||||
import csv
|
import csv
|
||||||
|
import ipaddress
|
||||||
import io
|
import io
|
||||||
|
import re
|
||||||
|
import socket
|
||||||
|
import urllib.request
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import boto3
|
import boto3
|
||||||
from fastapi import APIRouter, Depends, File, HTTPException, Request, Response, UploadFile, status
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, Response, UploadFile, status
|
||||||
from PIL import Image, ImageOps
|
from PIL import Image, ImageOps
|
||||||
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -294,9 +300,41 @@ def _optimize_image(data: bytes, content_type: str) -> tuple[bytes, str, int, in
|
|||||||
return data, content_type, orig_w, orig_h
|
return data, content_type, orig_w, orig_h
|
||||||
|
|
||||||
|
|
||||||
|
def _readable_key(name: str, ext: str) -> str:
|
||||||
|
"""کلید ذخیرهسازی خوانا از نام دلخواه/نام فایل — «۱۴۰۳/elahieh-tower-a1b2c3.jpg».
|
||||||
|
|
||||||
|
نامگذاریها ایمن میشوند: نویسههای ناامن و جداکننده مسیر حذف، فاصلهها خط تیره،
|
||||||
|
طول محدود؛ خالی بودن نام → شناسه تصادفی مثل قبل. پسوند ۶ نویسهای تضمین یکتایی است.
|
||||||
|
"""
|
||||||
|
slug = re.sub(r"[^\w-ۿ\- ]+", "", name, flags=re.UNICODE).strip()
|
||||||
|
slug = re.sub(r"[\s_]+", "-", slug).strip("-")[:80]
|
||||||
|
unique = uuid.uuid4().hex[:6]
|
||||||
|
base = f"{slug}-{unique}" if slug else uuid.uuid4().hex
|
||||||
|
return f"{datetime.now(timezone.utc):%Y/%m}/{base}.{ext}"
|
||||||
|
|
||||||
|
|
||||||
|
def _store_image(data: bytes, content_type: str, name: str) -> str:
|
||||||
|
"""ذخیره تصویر در Object Storage با کلید خوانا — URL نسبی برمیگردد."""
|
||||||
|
ext = ALLOWED_IMAGE_TYPES[content_type]
|
||||||
|
key = _readable_key(name, ext)
|
||||||
|
s3 = boto3.client(
|
||||||
|
"s3",
|
||||||
|
endpoint_url=settings.s3_endpoint,
|
||||||
|
aws_access_key_id=settings.s3_access_key,
|
||||||
|
aws_secret_access_key=settings.s3_secret_key,
|
||||||
|
region_name="us-east-1",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
s3.put_object(Bucket=settings.s3_bucket, Key=key, Body=data, ContentType=content_type)
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=502, detail="ذخیره فایل در Object Storage ناموفق بود")
|
||||||
|
return f"{settings.media_public_base}/{key}"
|
||||||
|
|
||||||
|
|
||||||
@router.post("/upload")
|
@router.post("/upload")
|
||||||
async def upload_media(
|
async def upload_media(
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
|
name: str = Form(""),
|
||||||
_admin: models.AdminUser = Depends(get_current_admin),
|
_admin: models.AdminUser = Depends(get_current_admin),
|
||||||
):
|
):
|
||||||
content_type = (file.content_type or "").lower()
|
content_type = (file.content_type or "").lower()
|
||||||
@@ -325,24 +363,90 @@ async def upload_media(
|
|||||||
# تصویر خراب/ناشناخته — همان داده اصلی ذخیره میشود
|
# تصویر خراب/ناشناخته — همان داده اصلی ذخیره میشود
|
||||||
pass
|
pass
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
# نام خوانا: نام ارسالی فرم، وگرنه نام اصلی فایل بدون پسوند
|
||||||
key = f"{now:%Y/%m}/{uuid.uuid4().hex}.{ext}"
|
display_name = (name or "").strip() or (file.filename or "").rsplit(".", 1)[0]
|
||||||
|
url = _store_image(data, content_type, display_name)
|
||||||
s3 = boto3.client(
|
|
||||||
"s3",
|
|
||||||
endpoint_url=settings.s3_endpoint,
|
|
||||||
aws_access_key_id=settings.s3_access_key,
|
|
||||||
aws_secret_access_key=settings.s3_secret_key,
|
|
||||||
region_name="us-east-1",
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
s3.put_object(Bucket=settings.s3_bucket, Key=key, Body=data, ContentType=content_type)
|
|
||||||
except Exception:
|
|
||||||
raise HTTPException(status_code=502, detail="ذخیره فایل در Object Storage ناموفق بود")
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"url": f"{settings.media_public_base}/{key}",
|
"url": url,
|
||||||
"key": key,
|
"key": url.removeprefix(f"{settings.media_public_base}/"),
|
||||||
|
"size": len(data),
|
||||||
|
"content_type": content_type,
|
||||||
|
"width": width,
|
||||||
|
"height": height,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- درونریزی تصویر از نشانی خارجی (چسباندن از Outline و مانند آن) ----------
|
||||||
|
|
||||||
|
|
||||||
|
class ImportImageIn(BaseModel):
|
||||||
|
url: str
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_public_http(url: str) -> None:
|
||||||
|
"""فقط http/https به مقاصد عمومی — جلوگیری از SSRF به شبکه داخلی."""
|
||||||
|
parsed = urlparse(url)
|
||||||
|
if parsed.scheme not in ("http", "https") or not parsed.hostname:
|
||||||
|
raise HTTPException(status_code=400, detail="نشانی تصویر معتبر نیست")
|
||||||
|
try:
|
||||||
|
infos = socket.getaddrinfo(parsed.hostname, None)
|
||||||
|
except socket.gaierror:
|
||||||
|
raise HTTPException(status_code=400, detail="میزبان تصویر پیدا نشد")
|
||||||
|
for info in infos:
|
||||||
|
ip = ipaddress.ip_address(info[4][0])
|
||||||
|
if not ip.is_global:
|
||||||
|
raise HTTPException(status_code=400, detail="نشانی تصویر به میزبان داخلی اشاره میکند")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/import-image")
|
||||||
|
async def import_image(
|
||||||
|
payload: ImportImageIn,
|
||||||
|
_admin: models.AdminUser = Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
"""دانلود تصویر از نشانی خارجی، بهینهسازی و ذخیره در Object Storage خودِ سایت —
|
||||||
|
تصاویر چسباندهشده از Outline/مستندات خارجی به میزبانی سایت منتقل میشوند."""
|
||||||
|
_assert_public_http(payload.url)
|
||||||
|
|
||||||
|
max_bytes = settings.media_max_upload_mb * 1024 * 1024
|
||||||
|
request = urllib.request.Request(
|
||||||
|
payload.url, headers={"User-Agent": "ArameshSite/1.0 (+image import)"}
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=15) as response:
|
||||||
|
content_type = (response.headers.get("Content-Type") or "").split(";")[0].strip().lower()
|
||||||
|
if content_type not in ALLOWED_IMAGE_TYPES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=415,
|
||||||
|
detail="فرمت تصویر پشتیبانی نمیشود (مجاز: jpg، png، webp، gif، avif)",
|
||||||
|
)
|
||||||
|
data = response.read(max_bytes + 1)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=502, detail="دانلود تصویر از نشانی ناموفق بود")
|
||||||
|
if len(data) == 0:
|
||||||
|
raise HTTPException(status_code=400, detail="تصویر خالی است")
|
||||||
|
if len(data) > max_bytes:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=413,
|
||||||
|
detail=f"حجم تصویر بیش از {settings.media_max_upload_mb} مگابایت است",
|
||||||
|
)
|
||||||
|
|
||||||
|
width = height = None
|
||||||
|
if content_type in REENCODE_TYPES:
|
||||||
|
try:
|
||||||
|
data, content_type, width, height = _optimize_image(data, content_type)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# نام خوانا از مسیر نشانی — بدون پارامترهای کوئری
|
||||||
|
url_name = urlparse(payload.url).path.rsplit("/", 1)[-1].rsplit(".", 1)[0]
|
||||||
|
media_url = _store_image(data, content_type, url_name)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"url": media_url,
|
||||||
|
"key": media_url.removeprefix(f"{settings.media_public_base}/"),
|
||||||
"size": len(data),
|
"size": len(data),
|
||||||
"content_type": content_type,
|
"content_type": content_type,
|
||||||
"width": width,
|
"width": width,
|
||||||
|
|||||||
@@ -145,6 +145,17 @@ def run_ddl_migrations() -> None:
|
|||||||
conn.execute(
|
conn.execute(
|
||||||
text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS seo_keyphrase VARCHAR(128) NOT NULL DEFAULT ''")
|
text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS seo_keyphrase VARCHAR(128) NOT NULL DEFAULT ''")
|
||||||
)
|
)
|
||||||
|
# متادیتای تصویر شاخص مقاله + پرسشوپاسخهای پایان مقاله (سئو)
|
||||||
|
conn.execute(
|
||||||
|
text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS image_alt VARCHAR(256) NOT NULL DEFAULT ''")
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS image_title VARCHAR(256) NOT NULL DEFAULT ''")
|
||||||
|
)
|
||||||
|
conn.execute(text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS faqs JSON"))
|
||||||
|
conn.execute(text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS faqs_en JSON"))
|
||||||
|
# سطرهای موجود ستون تازه را NULL میگیرند — آرایه خالی یعنی «بدون پرسشوپاسخ»
|
||||||
|
conn.execute(text("UPDATE articles SET faqs = '[]'::json WHERE faqs IS NULL"))
|
||||||
# ستونهای انگلیسی — رشتهها با پیشفرض خالی، JSON بدون پیشفرض (NULL = بدون ترجمه)
|
# ستونهای انگلیسی — رشتهها با پیشفرض خالی، JSON بدون پیشفرض (NULL = بدون ترجمه)
|
||||||
for table, column, col_type in EN_COLUMNS:
|
for table, column, col_type in EN_COLUMNS:
|
||||||
if col_type == "JSON":
|
if col_type == "JSON":
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from sqlalchemy import JSON, Boolean, DateTime, Integer, String, Text
|
from sqlalchemy import JSON, Boolean, DateTime, Integer, String, Text
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
@@ -95,12 +96,18 @@ class Article(Base):
|
|||||||
category: Mapped[str] = mapped_column(String(64), default="")
|
category: Mapped[str] = mapped_column(String(64), default="")
|
||||||
category_en: Mapped[str] = mapped_column(String(64), default="")
|
category_en: Mapped[str] = mapped_column(String(64), default="")
|
||||||
image: Mapped[str] = mapped_column(Text, default="")
|
image: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
# متن جایگزین و عنوان تصویر شاخص — سئو و دسترسپذیری
|
||||||
|
image_alt: Mapped[str] = mapped_column(String(256), default="")
|
||||||
|
image_title: Mapped[str] = mapped_column(String(256), default="")
|
||||||
read: Mapped[str] = mapped_column(String(32), default="")
|
read: Mapped[str] = mapped_column(String(32), default="")
|
||||||
read_en: Mapped[str] = mapped_column(String(32), default="")
|
read_en: Mapped[str] = mapped_column(String(32), default="")
|
||||||
excerpt: Mapped[str] = mapped_column(Text, default="")
|
excerpt: Mapped[str] = mapped_column(Text, default="")
|
||||||
excerpt_en: Mapped[str] = mapped_column(Text, default="")
|
excerpt_en: Mapped[str] = mapped_column(Text, default="")
|
||||||
body: Mapped[list] = mapped_column(JSON, default=list) # سند TipTap (تاریخی: لیست پاراگراف)
|
body: Mapped[list] = mapped_column(JSON, default=list) # سند TipTap (تاریخی: لیست پاراگراف)
|
||||||
body_en: Mapped[dict] = mapped_column(JSON, default=dict) # سند TipTap انگلیسی
|
body_en: Mapped[dict] = mapped_column(JSON, default=dict) # سند TipTap انگلیسی
|
||||||
|
# پرسش و پاسخهای پایان مقاله — [{question, answer}]؛ برای نتایج غنی سئو (FAQPage)
|
||||||
|
faqs: Mapped[list] = mapped_column(JSON, default=list)
|
||||||
|
faqs_en: Mapped[Optional[list]] = mapped_column(JSON) # NULL یعنی بدون ترجمه — برگشت به فارسی
|
||||||
# draft | published — پیشنویس فقط برای ادمین دیده میشود
|
# draft | published — پیشنویس فقط برای ادمین دیده میشود
|
||||||
status: Mapped[str] = mapped_column(String(16), default="published")
|
status: Mapped[str] = mapped_column(String(16), default="published")
|
||||||
# کلمه کلیدی هدف چکلیست سئو — راهنمای نوشتن، در سایت نمایش داده نمیشود
|
# کلمه کلیدی هدف چکلیست سئو — راهنمای نوشتن، در سایت نمایش داده نمیشود
|
||||||
|
|||||||
@@ -77,12 +77,17 @@ class ArticleOut(ORMModel):
|
|||||||
category: str
|
category: str
|
||||||
category_en: str = ""
|
category_en: str = ""
|
||||||
image: str
|
image: str
|
||||||
|
image_alt: str = ""
|
||||||
|
image_title: str = ""
|
||||||
read: str
|
read: str
|
||||||
read_en: str = ""
|
read_en: str = ""
|
||||||
excerpt: str
|
excerpt: str
|
||||||
excerpt_en: str = ""
|
excerpt_en: str = ""
|
||||||
body: dict
|
body: dict
|
||||||
body_en: Optional[dict] = None
|
body_en: Optional[dict] = None
|
||||||
|
# پرسشوپاسخهای پایان مقاله — [{question, answer}]؛ سطرهای قدیمی ممکن است NULL باشند
|
||||||
|
faqs: Optional[list[dict]] = None
|
||||||
|
faqs_en: Optional[list[dict]] = None
|
||||||
status: str
|
status: str
|
||||||
seo_keyphrase: str
|
seo_keyphrase: str
|
||||||
|
|
||||||
@@ -484,12 +489,16 @@ class ArticleIn(BaseModel):
|
|||||||
category: str = ""
|
category: str = ""
|
||||||
category_en: str = ""
|
category_en: str = ""
|
||||||
image: str = ""
|
image: str = ""
|
||||||
|
image_alt: str = ""
|
||||||
|
image_title: str = ""
|
||||||
read: str = ""
|
read: str = ""
|
||||||
read_en: str = ""
|
read_en: str = ""
|
||||||
excerpt: str = ""
|
excerpt: str = ""
|
||||||
excerpt_en: str = ""
|
excerpt_en: str = ""
|
||||||
body: dict = Field(default_factory=lambda: dict(EMPTY_DOC))
|
body: dict = Field(default_factory=lambda: dict(EMPTY_DOC))
|
||||||
body_en: dict = Field(default_factory=lambda: dict(EMPTY_DOC))
|
body_en: dict = Field(default_factory=lambda: dict(EMPTY_DOC))
|
||||||
|
faqs: list[dict] = []
|
||||||
|
faqs_en: list[dict] = []
|
||||||
status: Literal["draft", "published"] = "draft"
|
status: Literal["draft", "published"] = "draft"
|
||||||
seo_keyphrase: str = Field(default="", max_length=128)
|
seo_keyphrase: str = Field(default="", max_length=128)
|
||||||
_validate_body = field_validator("body")(validate_doc_body)
|
_validate_body = field_validator("body")(validate_doc_body)
|
||||||
@@ -786,12 +795,16 @@ class ArticlePatch(BaseModel):
|
|||||||
category: Optional[str] = None
|
category: Optional[str] = None
|
||||||
category_en: Optional[str] = None
|
category_en: Optional[str] = None
|
||||||
image: Optional[str] = None
|
image: Optional[str] = None
|
||||||
|
image_alt: Optional[str] = None
|
||||||
|
image_title: Optional[str] = None
|
||||||
read: Optional[str] = None
|
read: Optional[str] = None
|
||||||
read_en: Optional[str] = None
|
read_en: Optional[str] = None
|
||||||
excerpt: Optional[str] = None
|
excerpt: Optional[str] = None
|
||||||
excerpt_en: Optional[str] = None
|
excerpt_en: Optional[str] = None
|
||||||
body: Optional[dict] = None
|
body: Optional[dict] = None
|
||||||
body_en: Optional[dict] = None
|
body_en: Optional[dict] = None
|
||||||
|
faqs: Optional[list[dict]] = None
|
||||||
|
faqs_en: Optional[list[dict]] = None
|
||||||
status: Optional[Literal["draft", "published"]] = None
|
status: Optional[Literal["draft", "published"]] = None
|
||||||
seo_keyphrase: Optional[str] = None
|
seo_keyphrase: Optional[str] = None
|
||||||
_validate_body = field_validator("body")(validate_doc_body)
|
_validate_body = field_validator("body")(validate_doc_body)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { Controller, useController, useForm } from 'react-hook-form'
|
import { Controller, useController, useForm } from 'react-hook-form'
|
||||||
import { Link, Navigate, useNavigate, useParams } from 'react-router-dom'
|
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 Swal from 'sweetalert2'
|
||||||
import { useSiteData } from '../api'
|
import { useSiteData } from '../api'
|
||||||
import { useAdmin } from './AdminContext'
|
import { useAdmin } from './AdminContext'
|
||||||
@@ -54,6 +54,75 @@ function normalizeTags(value: unknown): string[] {
|
|||||||
return []
|
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> = {
|
const STATUS_TEXT: Record<string, string> = {
|
||||||
idle: 'ذخیره شد',
|
idle: 'ذخیره شد',
|
||||||
saving: 'در حال ذخیره…',
|
saving: 'در حال ذخیره…',
|
||||||
@@ -458,6 +527,20 @@ export function ArticleWritePage() {
|
|||||||
/>
|
/>
|
||||||
</section>
|
</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
|
<SeoChecklist
|
||||||
input={{
|
input={{
|
||||||
|
|||||||
@@ -83,12 +83,17 @@ export const adminApi = {
|
|||||||
reorder: <T = unknown>(resource: string, ids: number[]) =>
|
reorder: <T = unknown>(resource: string, ids: number[]) =>
|
||||||
authFetch<T[]>(`/api/admin/${resource}/reorder`, { method: 'POST', body: JSON.stringify({ ids }) }),
|
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()
|
const fd = new FormData()
|
||||||
fd.append('file', file)
|
fd.append('file', file)
|
||||||
|
if (name && name.trim()) fd.append('name', name.trim())
|
||||||
return authFetch('/api/admin/upload', { method: 'POST', body: fd })
|
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) =>
|
changePassword: (currentPassword: string, newPassword: string) =>
|
||||||
authFetch<{ ok: boolean }>('/api/auth/change-password', {
|
authFetch<{ ok: boolean }>('/api/auth/change-password', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -203,7 +203,7 @@ export function ContactSection() {
|
|||||||
className="mt-8 font-light"
|
className="mt-8 font-light"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSent(false)
|
setSent(false)
|
||||||
setForm({ name: '', phone: '', note: '' })
|
setForm({ name: '', phone: '', note: '', website: '' })
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<InlineEdit value={content.success_button_label} onSave={(v) => saveContent('success_button_label', v)} />
|
<InlineEdit value={content.success_button_label} onSave={(v) => saveContent('success_button_label', v)} />
|
||||||
|
|||||||
@@ -102,6 +102,42 @@ const CLOSED_LINK_MODAL: LinkModalState = {
|
|||||||
/** چند نویسه پیش از نشانگر برای تشخیص نشانیِ تایپشده بررسی میشود */
|
/** چند نویسه پیش از نشانگر برای تشخیص نشانیِ تایپشده بررسی میشود */
|
||||||
const URL_LOOKBACK = 300
|
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({
|
function ToolButton({
|
||||||
onClick,
|
onClick,
|
||||||
active = false,
|
active = false,
|
||||||
@@ -144,6 +180,8 @@ export function ArticleEditor({ doc, onChange, contentKey }: ArticleEditorProps)
|
|||||||
const [uploading, setUploading] = useState(false)
|
const [uploading, setUploading] = useState(false)
|
||||||
const [direction, setDirection] = useState<TextDirection>('rtl')
|
const [direction, setDirection] = useState<TextDirection>('rtl')
|
||||||
const [linkModal, setLinkModal] = useState<LinkModalState>(CLOSED_LINK_MODAL)
|
const [linkModal, setLinkModal] = useState<LinkModalState>(CLOSED_LINK_MODAL)
|
||||||
|
// مرحله نامگذاری درج تصویر از نوار ابزار — پیشفرض از نام خود فایل
|
||||||
|
const [pendingImage, setPendingImage] = useState<{ file: File; name: string } | null>(null)
|
||||||
|
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
extensions: ARTICLE_EXTENSIONS,
|
extensions: ARTICLE_EXTENSIONS,
|
||||||
@@ -163,6 +201,22 @@ export function ArticleEditor({ doc, onChange, contentKey }: ArticleEditorProps)
|
|||||||
void uploadAndInsert(file)
|
void uploadAndInsert(file)
|
||||||
return true
|
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() ?? ''
|
const text = event.clipboardData?.getData('text/plain')?.trim() ?? ''
|
||||||
if (isUrlLike(text)) {
|
if (isUrlLike(text)) {
|
||||||
@@ -186,6 +240,21 @@ export function ArticleEditor({ doc, onChange, contentKey }: ArticleEditorProps)
|
|||||||
void uploadAndInsert(file)
|
void uploadAndInsert(file)
|
||||||
return true
|
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
|
return false
|
||||||
},
|
},
|
||||||
// پایان تایپ یک نشانی (فاصله بعد از آن) → مودال لینک باز میشود
|
// پایان تایپ یک نشانی (فاصله بعد از آن) → مودال لینک باز میشود
|
||||||
@@ -237,11 +306,11 @@ export function ArticleEditor({ doc, onChange, contentKey }: ArticleEditorProps)
|
|||||||
editor.commands.setContent(incoming, { emitUpdate: false })
|
editor.commands.setContent(incoming, { emitUpdate: false })
|
||||||
}, [editor, contentKey, doc])
|
}, [editor, contentKey, doc])
|
||||||
|
|
||||||
async function uploadAndInsert(file: File) {
|
async function uploadAndInsert(file: File, name?: string) {
|
||||||
if (!editor) return
|
if (!editor) return
|
||||||
setUploading(true)
|
setUploading(true)
|
||||||
await runSave(async () => {
|
await runSave(async () => {
|
||||||
const { url } = await adminApi.upload(file)
|
const { url } = await adminApi.upload(file, name)
|
||||||
// نام پیشفرض از نام فایل ساخته میشود و در نبود آن، مقدار پیشفرض ثابت میماند
|
// نام پیشفرض از نام فایل ساخته میشود و در نبود آن، مقدار پیشفرض ثابت میماند
|
||||||
const defaultAlt = file.name.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ').trim()
|
const defaultAlt = file.name.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ').trim()
|
||||||
editor
|
editor
|
||||||
@@ -411,12 +480,72 @@ export function ArticleEditor({ doc, onChange, contentKey }: ArticleEditorProps)
|
|||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const file = e.target.files?.[0]
|
const file = e.target.files?.[0]
|
||||||
if (file) void uploadAndInsert(file)
|
if (file) {
|
||||||
|
setPendingImage({ file, name: file.name.replace(/\.[^.]+$/, '') })
|
||||||
|
}
|
||||||
e.target.value = ''
|
e.target.value = ''
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 مدیریت میکند */}
|
{/* مودال لینک — انیمیشن ورود/خروج را خودش با کلاسهای Tailwind مدیریت میکند */}
|
||||||
{linkModal.open && (
|
{linkModal.open && (
|
||||||
<LinkModal
|
<LinkModal
|
||||||
|
|||||||
@@ -48,7 +48,10 @@ export function ImageField({
|
|||||||
const inputRef = useRef<HTMLInputElement>(null)
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
const titleId = useId()
|
const titleId = useId()
|
||||||
const altId = useId()
|
const altId = useId()
|
||||||
|
const nameId = useId()
|
||||||
const [uploading, setUploading] = useState(false)
|
const [uploading, setUploading] = useState(false)
|
||||||
|
/** نام دلخواه فایل برای ذخیرهسازی خوانا — پیشفرض از نام فایل انتخابی ساخته میشود */
|
||||||
|
const [fileBaseName, setFileBaseName] = useState('')
|
||||||
|
|
||||||
const showTitle = title !== undefined || !!onSaveTitle
|
const showTitle = title !== undefined || !!onSaveTitle
|
||||||
const showAlt = alt !== undefined || !!onSaveAlt
|
const showAlt = alt !== undefined || !!onSaveAlt
|
||||||
@@ -73,13 +76,16 @@ export function ImageField({
|
|||||||
setUploading(true)
|
setUploading(true)
|
||||||
try {
|
try {
|
||||||
await runSave(async () => {
|
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)
|
const ok = await onSave(url)
|
||||||
if (!ok) throw new Error('ذخیره نشد')
|
if (!ok) throw new Error('ذخیره نشد')
|
||||||
// پیشفرضهای Alt/Title همان لحظه ثبت میشوند تا تصویر بدون متادیتا نماند
|
// پیشفرضهای Alt/Title همان لحظه ثبت میشوند تا تصویر بدون متادیتا نماند
|
||||||
if (showTitle) await titleField.commit()
|
if (showTitle) await titleField.commit()
|
||||||
if (showAlt) await altField.commit()
|
if (showAlt) await altField.commit()
|
||||||
})
|
})
|
||||||
|
setFileBaseName('')
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false)
|
setUploading(false)
|
||||||
}
|
}
|
||||||
@@ -106,7 +112,7 @@ export function ImageField({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3">
|
<div className="mt-3 flex flex-wrap items-end gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => inputRef.current?.click()}
|
onClick={() => inputRef.current?.click()}
|
||||||
@@ -120,6 +126,22 @@ export function ImageField({
|
|||||||
)}
|
)}
|
||||||
{uploading ? 'در حال آپلود…' : value ? 'تغییر تصویر' : 'انتخاب تصویر'}
|
{uploading ? 'در حال آپلود…' : value ? 'تغییر تصویر' : 'انتخاب تصویر'}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{(showTitle || showAlt) && (
|
{(showTitle || showAlt) && (
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { NodeViewWrapper } from '@tiptap/react'
|
import { NodeViewWrapper } from '@tiptap/react'
|
||||||
import type { NodeViewProps } 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 = {
|
type ImageAttrs = {
|
||||||
src: string
|
src: string
|
||||||
@@ -19,25 +21,39 @@ const INLINE_FIELD_CLASS =
|
|||||||
const ADD_CAPTION_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'
|
'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>
|
* <figure><img><figcaption>کپشن</figcaption></figure>
|
||||||
*
|
*
|
||||||
* ویرایش کاملاً درونخطی است (بدون پاپاور/مودال):
|
* ویرایش کاملاً درونخطی است (بدون پاپاور/مودال):
|
||||||
* - هر تصویر یک «جعبه توضیح/کپشن» همراه خود دارد و متن آن در figcaption ذخیره میشود.
|
* - هر تصویر یک «جعبه توضیح/کپشن» همراه خود دارد و متن آن در figcaption ذخیره میشود.
|
||||||
* - دکمه X کپشن را کامل حذف میکند: صفت کپشن به مقدار پیشفرض برمیگردد و عنصر
|
* - دکمه X کپشن را کامل حذف میکند؛ «افزودن کپشن» دوباره جعبه را باز میکند.
|
||||||
* figcaption با ورودیاش از DOM ویرایشگر (و از خروجی ذخیرهشده) حذف میشود؛
|
* - Alt و Title تصویر (نام تصویر برای سئو/دسترسپذیری) پس از درج هم قابل ویرایشاند.
|
||||||
* فقط تصویر میماند و «افزودن کپشن» دوباره جعبه را باز میکند.
|
* - تصویر میزبان خارجی دکمه «میزبانی مجدد» دارد: تصویر دانلود و در Object Storage
|
||||||
* - ویرایش Alt در ویرایشگر وجود ندارد؛ Alt هنگام درج از نام فایل ثبت میشود.
|
* خود سایت ذخیره میشود (ترمیم تصاویر چسباندهشده از Outline).
|
||||||
* - دکمه X روی تصویر کل تصویر را حذف میکند.
|
* - دکمه X روی تصویر کل تصویر را حذف میکند.
|
||||||
*/
|
*/
|
||||||
export function ImageNodeView({ node, updateAttributes, deleteNode, selected }: NodeViewProps) {
|
export function ImageNodeView({ node, updateAttributes, deleteNode, selected }: NodeViewProps) {
|
||||||
const { src, alt, title, caption } = node.attrs as ImageAttrs
|
const { src, alt, title, caption } = node.attrs as ImageAttrs
|
||||||
|
const { runSave } = useAdmin()
|
||||||
const hasCaption = Boolean((caption ?? '').trim())
|
const hasCaption = Boolean((caption ?? '').trim())
|
||||||
const [captionOpen, setCaptionOpen] = useState(true)
|
const [captionOpen, setCaptionOpen] = useState(true)
|
||||||
|
const [rehosting, setRehosting] = useState(false)
|
||||||
const captionInputRef = useRef<HTMLInputElement>(null)
|
const captionInputRef = useRef<HTMLInputElement>(null)
|
||||||
const focusOnOpen = useRef(false)
|
const focusOnOpen = useRef(false)
|
||||||
const showCaption = captionOpen || hasCaption
|
const showCaption = captionOpen || hasCaption
|
||||||
|
const external = isExternalImage(src)
|
||||||
|
|
||||||
// فقط وقتی کاربر «افزودن کپشن» را میزند ورودی فوکوس میگیرد (نه هنگام بارگذاری مقاله)
|
// فقط وقتی کاربر «افزودن کپشن» را میزند ورودی فوکوس میگیرد (نه هنگام بارگذاری مقاله)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -57,6 +73,16 @@ export function ImageNodeView({ node, updateAttributes, deleteNode, selected }:
|
|||||||
setCaptionOpen(true)
|
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 (
|
return (
|
||||||
<NodeViewWrapper
|
<NodeViewWrapper
|
||||||
className={`article-figure-node ${selected ? 'is-selected' : ''}`}
|
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" />
|
<XIcon className="h-3.5 w-3.5" />
|
||||||
</button>
|
</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 ? (
|
{showCaption ? (
|
||||||
<figcaption className="article-figure__caption" contentEditable={false}>
|
<figcaption className="article-figure__caption" contentEditable={false}>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -46,7 +46,11 @@ export type ArticleStatus = 'draft' | 'published';
|
|||||||
export type Article = {
|
export type Article = {
|
||||||
id: number;
|
id: number;
|
||||||
slug: string; title: string; title_en?: string; date: string; date_en?: string; category: string; category_en?: string;
|
slug: string; title: string; title_en?: string; date: string; date_en?: string; category: string; category_en?: string;
|
||||||
image: string; read: string; read_en?: string; excerpt: string; excerpt_en?: string;
|
image: string;
|
||||||
|
read: string; read_en?: string; excerpt: string; excerpt_en?: string;
|
||||||
|
/** پرسشوپاسخهای پایان مقاله — سئوی FAQPage (faqs_en خالی → برگشت به فارسی) */
|
||||||
|
faqs?: { question: string; answer: string }[];
|
||||||
|
faqs_en?: { question: string; answer: string }[] | null;
|
||||||
/** سند TipTap — ردیفهای قدیمی ممکن است هنوز لیست پاراگراف باشند */
|
/** سند TipTap — ردیفهای قدیمی ممکن است هنوز لیست پاراگراف باشند */
|
||||||
body: ArticleBodyDoc | string[];
|
body: ArticleBodyDoc | string[];
|
||||||
/** بدنه انگلیسی — سند TipTap؛ null یعنی بدون ترجمه */
|
/** بدنه انگلیسی — سند TipTap؛ null یعنی بدون ترجمه */
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ export function localizeSite(data: SiteData, lang: Lang): SiteData {
|
|||||||
read: s(a.read, a.read_en),
|
read: s(a.read, a.read_en),
|
||||||
excerpt: s(a.excerpt, a.excerpt_en),
|
excerpt: s(a.excerpt, a.excerpt_en),
|
||||||
body: docHasText(a.body_en) ? a.body_en! : a.body,
|
body: docHasText(a.body_en) ? a.body_en! : a.body,
|
||||||
|
faqs: list(a.faqs ?? [], a.faqs_en),
|
||||||
})),
|
})),
|
||||||
testimonials: data.testimonials.map((tm) => ({
|
testimonials: data.testimonials.map((tm) => ({
|
||||||
...tm,
|
...tm,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Reveal } from '../components/Reveal';
|
|||||||
import { ButtonLink } from '../components/Button';
|
import { ButtonLink } from '../components/Button';
|
||||||
import { ArticleCard } from '../components/ArticleCard';
|
import { ArticleCard } from '../components/ArticleCard';
|
||||||
import { ArticleMeta } from '../components/ArticleMeta';
|
import { ArticleMeta } from '../components/ArticleMeta';
|
||||||
|
import { ArticleFaq } from '../components/ArticleFaq';
|
||||||
import { TableOfContents, extractToc, type TocItem } from '../components/TableOfContents';
|
import { TableOfContents, extractToc, type TocItem } from '../components/TableOfContents';
|
||||||
import { Newsletter } from '../components/home/Newsletter';
|
import { Newsletter } from '../components/home/Newsletter';
|
||||||
import { useSite } from '../api';
|
import { useSite } from '../api';
|
||||||
@@ -47,6 +48,35 @@ export function ArticleDetail() {
|
|||||||
// عنوان تب، توضیح متا، OG و JSON-LD از خود مقاله
|
// عنوان تب، توضیح متا، OG و JSON-LD از خود مقاله
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!article) return;
|
if (!article) return;
|
||||||
|
let jsonLd: Record<string, unknown> | Record<string, unknown>[] = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'Article',
|
||||||
|
headline: article.title,
|
||||||
|
description: article.meta_description || article.excerpt,
|
||||||
|
image: article.image ? new URL(article.image, window.location.origin).href : undefined,
|
||||||
|
inLanguage: lang,
|
||||||
|
author: { '@type': 'Organization', name: article.author?.trim() || brand.name },
|
||||||
|
publisher: { '@type': 'Organization', name: brand.name },
|
||||||
|
mainEntityOfPage: new URL(href(`/blog/${article.slug}`), window.location.origin).href,
|
||||||
|
};
|
||||||
|
// پرسشوپاسخ مقاله → اسکیمای FAQPage در همان بلوک (آرایهای از دو اسکیما)
|
||||||
|
const articleFaqs = article.faqs ?? [];
|
||||||
|
if (articleFaqs.length) {
|
||||||
|
jsonLd = [
|
||||||
|
jsonLd,
|
||||||
|
{
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'FAQPage',
|
||||||
|
mainEntity: articleFaqs
|
||||||
|
.filter((f) => f.question.trim())
|
||||||
|
.map((f) => ({
|
||||||
|
'@type': 'Question',
|
||||||
|
name: f.question,
|
||||||
|
acceptedAnswer: { '@type': 'Answer', text: f.answer },
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
applySeo({
|
applySeo({
|
||||||
title: `${article.title} | ${brand.name}`,
|
title: `${article.title} | ${brand.name}`,
|
||||||
// توضیحات سئوی اختصاصی در اولویت است؛ در نبود آن، چکیده
|
// توضیحات سئوی اختصاصی در اولویت است؛ در نبود آن، چکیده
|
||||||
@@ -55,17 +85,7 @@ export function ArticleDetail() {
|
|||||||
image: article.image,
|
image: article.image,
|
||||||
type: 'article',
|
type: 'article',
|
||||||
lang,
|
lang,
|
||||||
jsonLd: {
|
jsonLd,
|
||||||
'@context': 'https://schema.org',
|
|
||||||
'@type': 'Article',
|
|
||||||
headline: article.title,
|
|
||||||
description: article.meta_description || article.excerpt,
|
|
||||||
image: article.image ? new URL(article.image, window.location.origin).href : undefined,
|
|
||||||
inLanguage: lang,
|
|
||||||
author: { '@type': 'Organization', name: article.author?.trim() || brand.name },
|
|
||||||
publisher: { '@type': 'Organization', name: brand },
|
|
||||||
mainEntityOfPage: new URL(href(`/blog/${article.slug}`), window.location.origin).href,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}, [article?.id, lang, href]);
|
}, [article?.id, lang, href]);
|
||||||
|
|
||||||
@@ -171,6 +191,14 @@ export function ArticleDetail() {
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
{/* پرسشوپاسخهای پایان مقاله — همان ظاهر آکاردئونی FAQ سایت */}
|
||||||
|
<div className="bg-ink pb-4">
|
||||||
|
<div className="mx-auto max-w-5xl px-6 lg:px-10">
|
||||||
|
<h2 className="text-section font-bold text-ivory">{t('fallbackFaqTitle')}</h2>
|
||||||
|
<ArticleFaq faqs={article.faqs ?? []} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<section className="bg-char py-20 lg:py-28" aria-label={t('relatedArticlesAria')}>
|
<section className="bg-char py-20 lg:py-28" aria-label={t('relatedArticlesAria')}>
|
||||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||||
<h2 className="text-section font-bold text-ivory">{t('relatedArticles')}</h2>
|
<h2 className="text-section font-bold text-ivory">{t('relatedArticles')}</h2>
|
||||||
|
|||||||
+4
-3
@@ -72,10 +72,10 @@ function upsertAlternates(path: string): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setJsonLd(data: Record<string, unknown> | null): void {
|
function setJsonLd(data: Record<string, unknown> | Record<string, unknown>[] | null): void {
|
||||||
const id = 'seo-jsonld'
|
const id = 'seo-jsonld'
|
||||||
let el = document.getElementById(id)
|
let el = document.getElementById(id)
|
||||||
if (!data) {
|
if (!data || (Array.isArray(data) && data.length === 0)) {
|
||||||
el?.remove()
|
el?.remove()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -98,7 +98,8 @@ export type SeoInput = {
|
|||||||
type?: 'website' | 'article'
|
type?: 'website' | 'article'
|
||||||
/** زبان جاری — برای og:locale و لینکهای hreflang */
|
/** زبان جاری — برای og:locale و لینکهای hreflang */
|
||||||
lang?: Lang
|
lang?: Lang
|
||||||
jsonLd?: Record<string, unknown> | null
|
/** اسکیمای JSON-LD — یک شیء یا آرایهای از چند اسکیما (مثل Article + FAQPage) */
|
||||||
|
jsonLd?: Record<string, unknown> | Record<string, unknown>[] | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** تنظیم همه تگهای سئوی صفحه در یک فراخوانی */
|
/** تنظیم همه تگهای سئوی صفحه در یک فراخوانی */
|
||||||
|
|||||||
Reference in New Issue
Block a user