feat(blog): جدول — ستون‌ها قابل تغییر اندازه/افزودن و حذف؛ لینک کپشن تصویر؛ هدینگ‌های نسخه انگلیسی بزرگ‌تر

This commit is contained in:
aramesh-site deploy
2026-10-06 10:57:01 +00:00
parent 61a2e95278
commit 1b491357ab
45 changed files with 2341 additions and 81 deletions
+49 -14
View File
@@ -13,9 +13,10 @@ import { useLang } from '../i18n/lang';
import { applySeo } from '../seo';
import { useEffect } from 'react';
import { ARTICLE_EXTENSIONS } from '../components/admin/ArticleEditor';
import { docToText } from '../utils/tiptapDoc';
import { docToText, docHasText } from '../utils/tiptapDoc';
import { detectDirection } from '../utils/direction';
import { wrapScriptRuns } from '../utils/bidiText';
import { toPublishedISO } from '../utils/date';
import type { Article } from '../data/site';
/** بدنه مقاله را به HTML و فهرست مطالب تبدیل می‌کند — سند TipTap جدید یا پاراگراف‌های قدیمی */
@@ -44,28 +45,60 @@ export function ArticleDetail() {
const { t, href, lang } = useLang();
const article = articles.find((a) => a.slug === slug);
// عنوان تب، توضیح متا، OG و JSON-LD از خود مقاله
// عنوان تب، توضیح متا، OG و JSON-LD از خود مقاله — هم‌ارز نسخهٔ سمت سرور (seo_pages.py)
useEffect(() => {
if (!article) return;
const pageUrl = new URL(href(`/blog/${article.slug}`), window.location.origin).href;
const absolute = (path: string) => new URL(path, window.location.origin).href;
const authorName = (article.author ?? '').trim();
const description = article.meta_description || article.excerpt;
const articleSchema: Record<string, unknown> = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: article.title,
description,
image: article.image ? absolute(article.image) : undefined,
inLanguage: lang,
// نویسندهٔ انسانی ثبت‌شده → Person (سیگنال E-E-A-T)؛ وگرنه برند
author: authorName
? { '@type': 'Person', name: authorName }
: { '@type': 'Organization', name: brand.name },
publisher: { '@type': 'Organization', name: brand.name },
mainEntityOfPage: pageUrl,
};
const published = toPublishedISO(article.date);
if (published) articleSchema.datePublished = published;
const breadcrumbSchema = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: t('home'), item: absolute(href('/')) },
{ '@type': 'ListItem', position: 2, name: t('blog'), item: absolute(href('/blog')) },
{ '@type': 'ListItem', position: 3, name: article.title, item: pageUrl },
],
};
// پرسش‌وپاسخ‌های پایان مقاله → FAQPage (برای موتورهای هوش مصنوعی)
const faqEntities = (article.faqs ?? [])
.filter((f) => (f.question ?? '').trim() && (f.answer ?? '').trim())
.map((f) => ({
'@type': 'Question',
name: f.question.trim(),
acceptedAnswer: { '@type': 'Answer', text: f.answer.trim() },
}));
const faqSchema = faqEntities.length
? { '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: faqEntities }
: null;
applySeo({
title: `${article.title} | ${brand.name}`,
// توضیحات سئوی اختصاصی در اولویت است؛ در نبود آن، چکیده
description: article.meta_description || article.excerpt,
description,
path: href(`/blog/${article.slug}`),
image: article.image,
type: 'article',
lang,
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,
},
// alternate انگلیسی فقط با وجود ترجمهٔ واقعی
hasEn: !!(article.title_en?.trim() || article.excerpt_en?.trim() || docHasText(article.body_en)),
jsonLd: [articleSchema, breadcrumbSchema, ...(faqSchema ? [faqSchema] : [])],
});
}, [article?.id, lang, href]);
@@ -104,6 +137,8 @@ export function ArticleDetail() {
alt={article.image_alt || article.title}
title={article.image_title || undefined}
className="absolute inset-0 h-full w-full object-cover"
fetchPriority="high"
decoding="async"
/>
<div className="absolute inset-0 bg-ink/75" aria-hidden="true" />
<div
+1 -1
View File
@@ -80,7 +80,7 @@ export function Blog() {
<img
src={featured.image}
alt={`${t('articleImagePrefix')} ${featured.title}`}
className="h-full min-h-[20rem] w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105"
className="h-full min-h-[20rem] w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105" fetchPriority="high" decoding="async"
/>
</div>
<div className="flex flex-col justify-center p-8 lg:p-12">
+28 -2
View File
@@ -21,15 +21,37 @@ export function ProjectDetail() {
const statusLabel = useStatusLabel(project?.status ?? '')
const [lightbox, setLightbox] = useState<number | null>(null)
// عنوان تب، توضیح متا و OG از خود پروژه
// عنوان تب، توضیح متا، OG و JSON-LD از خود پروژه — هم‌ارز نسخهٔ سمت سرور (seo_pages.py)
useEffect(() => {
if (!project) return;
const absolute = (path: string) => new URL(path, window.location.origin).href;
applySeo({
title: `${project.title} | ${brand.name}`,
description: project.summary,
path: href(`/projects/${project.slug}`),
image: project.image,
lang,
// alternate انگلیسی فقط با وجود ترجمهٔ واقعی
hasEn: !!(project.title_en?.trim() || project.summary_en?.trim()),
jsonLd: [
{
'@context': 'https://schema.org',
'@type': 'WebPage',
name: project.title,
description: project.summary,
image: project.image ? absolute(project.image) : undefined,
inLanguage: lang,
},
{
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: t('home'), item: absolute(href('/')) },
{ '@type': 'ListItem', position: 2, name: t('projects'), item: absolute(href('/projects')) },
{ '@type': 'ListItem', position: 3, name: project.title, item: absolute(href(`/projects/${project.slug}`)) },
],
},
],
});
}, [project?.id, lang, href]);
@@ -69,7 +91,7 @@ export function ProjectDetail() {
return (
<>
<section className="relative flex h-[62vh] min-h-[440px] items-end overflow-hidden">
<img src={project.image} alt="" aria-hidden="true" className="absolute inset-0 h-full w-full object-cover" />
<img src={project.image} alt="" aria-hidden="true" className="absolute inset-0 h-full w-full object-cover" fetchPriority="high" decoding="async" />
<div className="absolute inset-0 bg-ink/72" aria-hidden="true" />
<div
className="absolute inset-0"
@@ -147,6 +169,8 @@ export function ProjectDetail() {
src={project.gallery[0] || project.image}
alt=""
className="aspect-[4/3] w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105"
loading="lazy"
decoding="async"
/>
</button>
<div className="mt-4 grid grid-cols-3 gap-4">
@@ -161,6 +185,8 @@ export function ProjectDetail() {
src={img}
alt=""
className="aspect-square w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105"
loading="lazy"
decoding="async"
/>
</button>
))}