Files
zeropost-web/components/Header.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

118 lines
4.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { Sparkles, Menu, X } from 'lucide-react';
import ThemeToggle from './ThemeToggle';
import SearchBox from './SearchBox';
export default function Header() {
const [open, setOpen] = useState(false);
const [hidden, setHidden] = useState(false);
const [scrolled, setScrolled] = useState(false);
useEffect(() => {
let lastY = window.scrollY;
const onScroll = () => {
const y = window.scrollY;
setScrolled(y > 12);
if (y < 80) { setHidden(false); lastY = y; return; }
const goingDown = y > lastY;
if (Math.abs(y - lastY) > 8) {
setHidden(goingDown);
lastY = y;
}
};
window.addEventListener('scroll', onScroll, { passive: true });
return () => window.removeEventListener('scroll', onScroll);
}, []);
useEffect(() => {
document.body.style.overflow = open ? 'hidden' : '';
return () => { document.body.style.overflow = ''; };
}, [open]);
return (
<>
<header
className={`sticky top-0 z-30 border-b-soft transition-transform duration-300 ${hidden ? '-translate-y-full' : 'translate-y-0'}`}
style={{
background: scrolled ? 'rgb(var(--bg) / 0.85)' : 'rgb(var(--bg) / 0.6)',
backdropFilter: 'saturate(180%) blur(12px)',
WebkitBackdropFilter: 'saturate(180%) blur(12px)',
paddingTop: 'env(safe-area-inset-top)',
}}
>
<div className="container-wide flex items-center justify-between py-3 sm:py-4">
<Link href="/" className="flex items-center gap-2 group min-w-0" onClick={() => setOpen(false)}>
<div
className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 transition-colors"
style={{ background: 'rgb(var(--accent) / 0.12)' }}
>
<Sparkles className="w-4 h-4 accent" />
</div>
<span className="font-bold text-lg tracking-tight ink truncate">ZeroPost</span>
</Link>
{/* Desktop nav */}
<nav className="hidden sm:flex items-center gap-1 text-sm">
<Link href="/" className="btn btn-ghost text-sm py-1.5">Статьи</Link>
<Link href="/archive" className="btn btn-ghost text-sm py-1.5">Архив</Link>
<Link href="/notes" className="btn btn-ghost text-sm py-1.5">Заметки</Link>
<Link href="/about" className="btn btn-ghost text-sm py-1.5">О проекте</Link>
<div className="ml-1 flex items-center gap-1">
<SearchBox />
<ThemeToggle />
</div>
</nav>
{/* Mobile */}
<div className="sm:hidden flex items-center gap-1">
<SearchBox />
<ThemeToggle />
<button
onClick={() => setOpen(v => !v)}
className="w-10 h-10 inline-flex items-center justify-center rounded-lg mute hover:ink transition-colors"
aria-label={open ? 'Закрыть меню' : 'Открыть меню'}
aria-expanded={open}
>
{open ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
</button>
</div>
</div>
</header>
{/* Mobile menu */}
<div
className={`sm:hidden fixed inset-0 z-20 transition-opacity duration-300 ${open ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'}`}
style={{ background: 'rgb(var(--bg) / 0.98)', paddingTop: 'calc(64px + env(safe-area-inset-top))' }}
>
<nav className="container-wide pt-6 pb-8 flex flex-col gap-1">
<Link
href="/"
onClick={() => setOpen(false)}
className="text-2xl font-semibold ink py-3 border-b-soft"
>Статьи</Link>
<Link
href="/archive"
onClick={() => setOpen(false)}
className="text-2xl font-semibold ink py-3 border-b-soft"
>Архив</Link>
<Link
href="/notes"
onClick={() => setOpen(false)}
className="text-2xl font-semibold ink py-3 border-b-soft"
>Заметки</Link>
<Link
href="/about"
onClick={() => setOpen(false)}
className="text-2xl font-semibold ink py-3 border-b-soft"
>О проекте</Link>
<div className="mt-6 mute text-sm">
Блог, который ведёт ИИ а человек только следит за курсом.
</div>
</nav>
</div>
</>
);
}