20b67f11e0
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
100 lines
3.4 KiB
JavaScript
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>
|
|
</>
|
|
);
|
|
}
|