feat: TOC оглавление + SVG-обложки-фоллбеки + /archive
TOC:
- renderMarkdownWithToc: парсит h2/h3, генерит транслит-якоря для кириллицы, возвращает {html, toc}
- TableOfContents компонент: sticky на десктопе, раскрывающийся блок на мобиле
- IntersectionObserver-free подсветка активной секции через scroll listener
- Двухколоночный layout статьи на lg+: 240px TOC + контент
SVG-обложки:
- ArticleCoverSVG: процедурно сгенерированная композиция (curve/circle/arc/rect) по seed = id статьи
- 6 палитр на выбор (emerald/teal/yellow/blue/purple/orange), seed детерминированный
- Используется в ArticleCard как fallback когда cover_url пусто
- На странице статьи тоже SVG если обложки нет
- Тег статьи отображается лейблом в углу
Архив:
- /archive: все статьи сгруппированы по месяцам, компактный список
- В Header добавлен пункт Архив (desktop+mobile)
- В Footer ссылки на Архив, Заметки, О проекте
- В sitemap.xml включён /archive
This commit is contained in:
+27
-22
@@ -5,10 +5,12 @@ import Footer from '@/components/Footer';
|
||||
import ReadingProgress from '@/components/ReadingProgress';
|
||||
import ScrollToTop from '@/components/ScrollToTop';
|
||||
import ShareButton from '@/components/ShareButton';
|
||||
import ArticleMeta from '@/components/ArticleMeta';
|
||||
import ArticleCard from '@/components/ArticleCard';
|
||||
import ArticleMeta from '@/components/ArticleMeta';
|
||||
import ArticleCoverSVG from '@/components/ArticleCoverSVG';
|
||||
import TableOfContents from '@/components/TableOfContents';
|
||||
import { getArticle, listArticles } from '@/lib/engine';
|
||||
import { renderMarkdown, formatDate } from '@/lib/markdown';
|
||||
import { renderMarkdownWithToc, formatDate } from '@/lib/markdown';
|
||||
import { Clock, ArrowLeft } from 'lucide-react';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
@@ -32,19 +34,14 @@ export async function generateMetadata({ params }) {
|
||||
}
|
||||
|
||||
async function loadRelated(article) {
|
||||
// ищем статьи с пересекающимися тегами, исключая текущую
|
||||
if (!article.tags?.length) return [];
|
||||
const all = await listArticles({ limit: 20 });
|
||||
const scored = all
|
||||
return all
|
||||
.filter(a => a.id !== article.id)
|
||||
.map(a => ({
|
||||
...a,
|
||||
score: (a.tags || []).filter(t => article.tags.includes(t)).length,
|
||||
}))
|
||||
.map(a => ({ ...a, score: (a.tags || []).filter(t => article.tags.includes(t)).length }))
|
||||
.filter(a => a.score > 0)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, 3);
|
||||
return scored;
|
||||
}
|
||||
|
||||
export default async function ArticlePage({ params }) {
|
||||
@@ -54,14 +51,14 @@ export default async function ArticlePage({ params }) {
|
||||
|
||||
const related = await loadRelated(article);
|
||||
const contentWithoutH1 = article.content.replace(/^#\s+.+$/m, '').trim();
|
||||
const html = renderMarkdown(contentWithoutH1);
|
||||
const { html, toc } = renderMarkdownWithToc(contentWithoutH1);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<ReadingProgress />
|
||||
|
||||
<article className="container-narrow pt-6 sm:pt-10 pb-12">
|
||||
<div className="container-wide pt-6 sm:pt-10 pb-12">
|
||||
<Link href="/" className="btn btn-ghost text-sm mb-4 sm:mb-6 -ml-2">
|
||||
<ArrowLeft className="w-4 h-4" /> Все статьи
|
||||
</Link>
|
||||
@@ -72,7 +69,7 @@ export default async function ArticlePage({ params }) {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h1 className="font-serif text-2xl sm:text-4xl lg:text-5xl font-bold leading-[1.15] mb-4 sm:mb-5 tracking-tight ink">
|
||||
<h1 className="font-serif text-2xl sm:text-4xl lg:text-5xl font-bold leading-[1.15] mb-4 sm:mb-5 tracking-tight ink max-w-4xl">
|
||||
{article.title}
|
||||
</h1>
|
||||
|
||||
@@ -93,24 +90,32 @@ export default async function ArticlePage({ params }) {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{article.cover_url && (
|
||||
<div className="mb-8 sm:mb-10 -mx-4 sm:mx-0">
|
||||
<div className="mb-8 sm:mb-10 -mx-4 sm:mx-0">
|
||||
{article.cover_url ? (
|
||||
<img
|
||||
src={article.cover_url}
|
||||
alt={article.title}
|
||||
className="w-full sm:rounded-xl"
|
||||
style={{ aspectRatio: '16/9', objectFit: 'cover' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
) : (
|
||||
<ArticleCoverSVG article={article} aspect="16/9" className="!rounded-none sm:!rounded-xl" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="prose prose-base sm:prose-lg max-w-none font-serif prose-headings:font-sans prose-p:leading-relaxed prose-img:rounded-xl"
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
{/* Двухколоночный layout на больших экранах: TOC + контент */}
|
||||
<div className="lg:grid lg:grid-cols-[240px_minmax(0,1fr)] lg:gap-12">
|
||||
<TableOfContents items={toc} />
|
||||
|
||||
<ArticleMeta article={article} />
|
||||
</article>
|
||||
<article className="max-w-3xl">
|
||||
<div
|
||||
className="prose prose-base sm:prose-lg max-w-none font-serif prose-headings:font-sans prose-p:leading-relaxed prose-img:rounded-xl prose-headings:scroll-mt-24"
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
<ArticleMeta article={article} />
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{related.length > 0 && (
|
||||
<section className="container-wide pb-12 sm:pb-16">
|
||||
|
||||
Reference in New Issue
Block a user