Files
zeropost-web/components/TableOfContents.js
T
Alexey Pavlov 20b67f11e0 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
2026-05-31 10:54:34 +03:00

100 lines
3.4 KiB
JavaScript

'use client';
import { useEffect, useState } from 'react';
import { List, ChevronDown } from 'lucide-react';
export default function TableOfContents({ items }) {
const [activeId, setActiveId] = useState(null);
const [mobileOpen, setMobileOpen] = useState(false);
// следим за тем, какой заголовок сейчас в viewport
useEffect(() => {
if (!items?.length) return;
const headings = items
.map(i => document.getElementById(i.id))
.filter(Boolean);
if (!headings.length) return;
const onScroll = () => {
// ближайший к верху, но не выше offset
const offset = 120;
let current = headings[0]?.id || null;
for (const h of headings) {
const top = h.getBoundingClientRect().top;
if (top <= offset) current = h.id;
else break;
}
setActiveId(current);
};
onScroll();
window.addEventListener('scroll', onScroll, { passive: true });
return () => window.removeEventListener('scroll', onScroll);
}, [items]);
function scrollTo(e, id) {
e.preventDefault();
const el = document.getElementById(id);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
setMobileOpen(false);
}
}
if (!items || items.length < 2) return null;
return (
<>
{/* Desktop: sticky слева, видна на lg+ */}
<aside className="hidden lg:block sticky top-24 self-start max-h-[calc(100vh-8rem)] overflow-y-auto pr-4" style={{ width: '240px' }}>
<div className="text-[11px] font-medium uppercase tracking-widest mute mb-3 flex items-center gap-2">
<List className="w-3 h-3" /> Оглавление
</div>
<nav className="space-y-1">
{items.map(i => (
<a
key={i.id}
href={`#${i.id}`}
onClick={(e) => scrollTo(e, i.id)}
className={`block text-sm leading-snug transition-colors py-1 ${i.level === 3 ? 'pl-3' : ''} ${
activeId === i.id ? 'accent font-medium' : 'mute hover:ink'
}`}
>
{i.text}
</a>
))}
</nav>
</aside>
{/* Mobile: раскрывающийся блок над текстом */}
<div className="lg:hidden mb-6 article-card overflow-hidden p-0">
<button
onClick={() => setMobileOpen(o => !o)}
className="w-full px-4 py-3 flex items-center justify-between text-left"
aria-expanded={mobileOpen}
>
<span className="flex items-center gap-2 text-sm font-medium ink">
<List className="w-4 h-4 mute" /> Оглавление
<span className="mute text-xs">({items.length})</span>
</span>
<ChevronDown className={`w-4 h-4 mute transition-transform ${mobileOpen ? 'rotate-180' : ''}`} />
</button>
{mobileOpen && (
<nav className="border-t-soft px-4 py-3 space-y-1.5">
{items.map(i => (
<a
key={i.id}
href={`#${i.id}`}
onClick={(e) => scrollTo(e, i.id)}
className={`block text-sm leading-snug py-1.5 ${i.level === 3 ? 'pl-4 text-[13px]' : ''} ${
activeId === i.id ? 'accent font-medium' : 'mute'
}`}
>
{i.text}
</a>
))}
</nav>
)}
</div>
</>
);
}