- ستون فهرست در دسکتاپ از ۱۷rem به ۲۱rem پهنتر شد تا تیترهای بلند جا شوند - نوار اسکرول بومی مرورگر (با فلش) مخفی شد؛ اسکرول با چرخ ماوس/لمس سر جایش است
192 lines
8.5 KiB
TypeScript
192 lines
8.5 KiB
TypeScript
import React from 'react';
|
|
import { Link, useParams } from 'react-router-dom';
|
|
import { ArrowLeftIcon, ChevronLeftIcon, ChevronRightIcon } 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 { ArticleMeta } from '../components/ArticleMeta';
|
|
import { TableOfContents, extractToc, type TocItem } from '../components/TableOfContents';
|
|
import { Newsletter } from '../components/home/Newsletter';
|
|
import { useSite } from '../api';
|
|
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 { detectDirection } from '../utils/direction';
|
|
import { wrapScriptRuns } from '../utils/bidiText';
|
|
import type { Article } from '../data/site';
|
|
|
|
/** بدنه مقاله را به HTML و فهرست مطالب تبدیل میکند — سند TipTap جدید یا پاراگرافهای قدیمی */
|
|
function renderBody(
|
|
article: Article,
|
|
dir: 'rtl' | 'ltr',
|
|
): { html: string | null; toc: TocItem[]; legacy: string[] } {
|
|
const body = article.body as unknown;
|
|
if (Array.isArray(body)) return { html: null, toc: [], legacy: body };
|
|
if (body && typeof body === 'object' && (body as { type?: string }).type === 'doc') {
|
|
try {
|
|
const html = renderToHTMLString({ extensions: ARTICLE_EXTENSIONS, content: body as never });
|
|
// تیترها شناسه لنگر میگیرند و متن لاتین/فارسی برای قاعده «۲ پیکسل کوچکتر» بستهبندی میشود
|
|
const { items, html: htmlWithIds } = extractToc(html, (root) => wrapScriptRuns(root, dir));
|
|
return { html: htmlWithIds, toc: items, legacy: [] };
|
|
} catch {
|
|
return { html: null, toc: [], legacy: [] };
|
|
}
|
|
}
|
|
return { html: null, toc: [], legacy: [] };
|
|
}
|
|
|
|
export function ArticleDetail() {
|
|
const { slug } = useParams<{slug: string;}>();
|
|
const { articles, brand } = useSite();
|
|
const { t, href, lang } = useLang();
|
|
const article = articles.find((a) => a.slug === slug);
|
|
|
|
// عنوان تب، توضیح متا، OG و JSON-LD از خود مقاله
|
|
useEffect(() => {
|
|
if (!article) return;
|
|
applySeo({
|
|
title: `${article.title} | ${brand.name}`,
|
|
// توضیحات سئوی اختصاصی در اولویت است؛ در نبود آن، چکیده
|
|
description: article.meta_description || article.excerpt,
|
|
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,
|
|
},
|
|
});
|
|
}, [article?.id, lang, href]);
|
|
|
|
if (!article) {
|
|
return (
|
|
<section className="flex min-h-[70vh] flex-col items-center justify-center px-6 text-center">
|
|
<h1 className="text-section font-bold text-ivory">{t('articleNotFound')}</h1>
|
|
<p className="mt-4 text-sm font-light text-ivory/60">{t('articleNotFoundText')}</p>
|
|
<ButtonLink to={href('/blog')} variant="copper" className="mt-8">
|
|
{t('backToBlog')}
|
|
<ArrowLeftIcon className="h-4 w-4 ltr:-scale-x-100" />
|
|
</ButtonLink>
|
|
</section>);
|
|
|
|
}
|
|
|
|
const dir = detectDirection(article.title, article.excerpt, docToText(article.body));
|
|
const { html, toc, legacy } = renderBody(article, dir);
|
|
const related = articles.filter((a) => a.slug !== article.slug && a.status !== 'draft').slice(0, 3);
|
|
|
|
/**
|
|
* جهت غالب متن مقاله — بدنه (با data-dir، طبق قاعده CSS مقاله) و سمت فهرست مطالب
|
|
* از همین پیروی میکنند: فارسی راستبهچپ (فهرست سمت راست)، انگلیسی چپبهراست (فهرست سمت چپ).
|
|
*/
|
|
const hasToc = toc.length > 0;
|
|
// در چیدمان راستبهچپ ستون شماره ۱ سمت راست است؛ پس ستونها صریح شماره میخورند
|
|
const gridColumns = hasToc ? 'lg:grid-cols-[21rem_minmax(0,1fr)]' : '';
|
|
const bodyColumns = 'lg:col-start-2';
|
|
const tocColumns = 'lg:col-start-1';
|
|
|
|
return (
|
|
<>
|
|
<section dir={dir} className="relative flex h-[56vh] min-h-[400px] items-end overflow-hidden">
|
|
<img
|
|
src={article.image}
|
|
alt={article.image_alt || article.title}
|
|
title={article.image_title || undefined}
|
|
className="absolute inset-0 h-full w-full object-cover"
|
|
/>
|
|
<div className="absolute inset-0 bg-ink/75" aria-hidden="true" />
|
|
<div
|
|
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={t('breadcrumbAria')} className="flex items-center gap-2 text-xs font-light text-ivory/50">
|
|
<Link to={href('/')} className="transition-colors hover:text-copper">
|
|
{t('home')}
|
|
</Link>
|
|
{dir === 'ltr' ? <ChevronRightIcon className="h-3.5 w-3.5" /> : <ChevronLeftIcon className="h-3.5 w-3.5" />}
|
|
<Link to={href('/blog')} className="transition-colors hover:text-copper">
|
|
{t('blog')}
|
|
</Link>
|
|
{dir === 'ltr' ? <ChevronRightIcon className="h-3.5 w-3.5" /> : <ChevronLeftIcon className="h-3.5 w-3.5" />}
|
|
<span className="text-copper">{article.category}</span>
|
|
</nav>
|
|
<h1 className="mt-5 max-w-[700px] text-hero font-bold text-ivory">{article.title}</h1>
|
|
|
|
{/* نویسنده، تاریخ جلالی و زمان مطالعه */}
|
|
<ArticleMeta article={article} className="mt-7" />
|
|
</div>
|
|
</section>
|
|
|
|
<article className="bg-ink py-20 lg:py-28">
|
|
<div className="mx-auto max-w-site px-6 lg:px-10">
|
|
<div
|
|
dir={dir}
|
|
data-dir={dir}
|
|
className={`mx-auto grid max-w-5xl gap-12 lg:gap-14 ${gridColumns}`}
|
|
>
|
|
<div className={`min-w-0 lg:row-start-1 ${hasToc ? bodyColumns : ''}`}>
|
|
<Reveal>
|
|
<p className="border-s-2 border-copper ps-6 text-lg font-light leading-10 text-ivory/80">
|
|
{article.excerpt}
|
|
</p>
|
|
</Reveal>
|
|
{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>
|
|
|
|
{/* فهرست مطالب — در دسکتاپ کنار متن میچسبد و همراه مقاله پایین میآید */}
|
|
{hasToc && (
|
|
<aside
|
|
dir={dir}
|
|
className={`order-first lg:order-none lg:row-start-1 ${tocColumns}`}
|
|
>
|
|
<TableOfContents items={toc} className="lg:sticky lg:top-28" />
|
|
</aside>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</article>
|
|
|
|
<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">
|
|
<h2 className="text-section font-bold text-ivory">{t('relatedArticles')}</h2>
|
|
<div className="mt-6 h-px w-24 bg-copper/70" />
|
|
<div className="mt-12 grid gap-6 lg:grid-cols-3">
|
|
{related.map((item, i) =>
|
|
<Reveal key={item.slug} delay={0.06 * i} className="h-full">
|
|
<ArticleCard article={item} />
|
|
</Reveal>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<Newsletter />
|
|
</>);
|
|
|
|
}
|