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:
@@ -0,0 +1,99 @@
|
||||
'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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user