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:
Alexey Pavlov
2026-05-31 10:54:34 +03:00
parent af4223bd0c
commit 20b67f11e0
9 changed files with 426 additions and 57 deletions
+3 -34
View File
@@ -1,43 +1,13 @@
import Link from 'next/link';
import { formatDate } from '@/lib/markdown';
import { Clock } from 'lucide-react';
import ArticleCoverSVG from './ArticleCoverSVG';
function imageUrl(article) {
if (!article.cover_url) return null;
return article.cover_url;
}
function gradientFor(article) {
const seed = (article.id || 0) * 31 + (article.title?.length || 0);
const variants = [
'linear-gradient(135deg, #10b981 0%, #0ea5e9 100%)',
'linear-gradient(135deg, #34d399 0%, #14b8a6 100%)',
'linear-gradient(135deg, #059669 0%, #6366f1 100%)',
'linear-gradient(135deg, #10b981 0%, #fbbf24 100%)',
'linear-gradient(135deg, #14b8a6 0%, #a78bfa 100%)',
'linear-gradient(135deg, #047857 0%, #0891b2 100%)',
];
return variants[seed % variants.length];
}
function CoverPlaceholder({ article, featured = false, className = '' }) {
const gradient = gradientFor(article);
return (
<div
className={`relative overflow-hidden ${featured ? 'aspect-[16/9] sm:aspect-[16/10]' : 'aspect-[16/9]'} rounded-xl ${className}`}
style={{ background: gradient }}
>
<div className="absolute -top-10 -right-10 w-40 h-40 rounded-full opacity-30" style={{ background: 'rgba(255,255,255,0.3)' }} />
<div className="absolute -bottom-12 -left-8 w-32 h-32 rounded-full opacity-20" style={{ background: 'rgba(0,0,0,0.2)' }} />
<div className="absolute inset-0 flex items-end p-4">
<div className="text-white/90 text-xs font-mono tracking-wider uppercase">
#{(article.tags?.[0] || 'zeropost')}
</div>
</div>
</div>
);
}
export default function ArticleCard({ article, featured = false }) {
const img = imageUrl(article);
@@ -45,7 +15,6 @@ export default function ArticleCard({ article, featured = false }) {
return (
<Link href={`/blog/${article.slug}`} className="article-card block group overflow-hidden p-0">
<div className="flex flex-col sm:grid sm:grid-cols-5 sm:gap-0">
{/* Image: full-width на мобиле, 2/5 на десктопе */}
<div className="p-3 sm:p-5 sm:col-span-2">
{img ? (
<img
@@ -55,7 +24,7 @@ export default function ArticleCard({ article, featured = false }) {
loading="eager"
/>
) : (
<CoverPlaceholder article={article} className="sm:aspect-square" />
<ArticleCoverSVG article={article} aspect="16/9" className="sm:!aspect-square" />
)}
</div>
<div className="p-5 sm:p-8 sm:col-span-3 flex flex-col justify-center">
@@ -92,7 +61,7 @@ export default function ArticleCard({ article, featured = false }) {
{img ? (
<img src={img} alt={article.title} className="w-full aspect-[16/9] object-cover rounded-lg" loading="lazy" />
) : (
<CoverPlaceholder article={article} />
<ArticleCoverSVG article={article} />
)}
</div>
<div className="p-4 sm:p-5 pt-2">
+135
View File
@@ -0,0 +1,135 @@
/**
* Процедурно-сгенерированная SVG-обложка в стиле сайта.
* Не требует AI — рендерится сразу, выглядит достойно вместо плоского градиента.
*
* Идея: используем seed (id статьи) для воспроизводимой композиции.
* Каждая статья получает свой уникальный, но узнаваемый узор.
*/
// псевдо-рандом по seed (mulberry32)
function rng(seed) {
let t = seed + 0x6D2B79F5;
return () => {
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// Палитры — приглушённые, чтобы не спорить с UI
const PALETTES = [
{ bg: '#ecfdf5', accent: '#10b981', soft: '#a7f3d0', dark: '#065f46' }, // emerald
{ bg: '#f0fdfa', accent: '#14b8a6', soft: '#99f6e4', dark: '#115e59' }, // teal
{ bg: '#fefce8', accent: '#eab308', soft: '#fef08a', dark: '#854d0e' }, // yellow
{ bg: '#eff6ff', accent: '#3b82f6', soft: '#bfdbfe', dark: '#1e40af' }, // blue
{ bg: '#fdf4ff', accent: '#a855f7', soft: '#e9d5ff', dark: '#6b21a8' }, // purple
{ bg: '#fff7ed', accent: '#f97316', soft: '#fed7aa', dark: '#9a3412' }, // orange
];
export default function ArticleCoverSVG({ article, className = '', aspect = '16/9', priority = false }) {
const seed = (article?.id || 1) * 9301 + 49297;
const rand = rng(seed);
const palette = PALETTES[Math.floor(rand() * PALETTES.length)];
// Композиция: 3-5 «слоёв» геометрии
const layers = 3 + Math.floor(rand() * 3);
const shapes = [];
for (let i = 0; i < layers; i++) {
const kind = ['curve', 'circle', 'arc', 'rect'][Math.floor(rand() * 4)];
const opacity = 0.35 + rand() * 0.5;
const colors = [palette.accent, palette.soft, palette.dark];
const fill = colors[Math.floor(rand() * colors.length)];
shapes.push({ kind, opacity, fill, r: rand });
}
// тег (первый) — мелкая метка в углу
const tag = (article?.tags?.[0] || 'zeropost').toString().slice(0, 18);
return (
<div className={`relative overflow-hidden rounded-xl ${className}`} style={{ aspectRatio: aspect, background: palette.bg }}>
<svg
viewBox="0 0 400 225"
preserveAspectRatio="xMidYMid slice"
className="absolute inset-0 w-full h-full"
aria-hidden="true"
>
{shapes.map((s, idx) => {
// координаты, тоже псевдо-случайно
const cx = 60 + s.r() * 320;
const cy = 30 + s.r() * 165;
const size = 60 + s.r() * 180;
const rot = s.r() * 360;
if (s.kind === 'circle') {
return <circle key={idx} cx={cx} cy={cy} r={size / 2} fill={s.fill} opacity={s.opacity} />;
}
if (s.kind === 'rect') {
return (
<rect
key={idx}
x={cx - size / 2}
y={cy - size / 2}
width={size}
height={size * (0.5 + s.r() * 1)}
fill={s.fill}
opacity={s.opacity}
rx={s.r() > 0.5 ? size / 8 : 0}
transform={`rotate(${rot} ${cx} ${cy})`}
/>
);
}
if (s.kind === 'arc') {
// Полукруг
const r = size / 2;
return (
<path
key={idx}
d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy} Z`}
fill={s.fill}
opacity={s.opacity}
transform={`rotate(${rot} ${cx} ${cy})`}
/>
);
}
// curve — плавная волна
const w = size;
const h = size * 0.4;
return (
<path
key={idx}
d={`M ${cx - w / 2} ${cy} Q ${cx} ${cy - h}, ${cx + w / 2} ${cy} T ${cx + w * 1.5} ${cy}`}
stroke={s.fill}
strokeWidth={6 + s.r() * 16}
strokeLinecap="round"
fill="none"
opacity={s.opacity}
/>
);
})}
{/* Мелкие точки-частицы — добавляют детализации */}
{Array.from({ length: 14 }).map((_, i) => (
<circle
key={`d-${i}`}
cx={rand() * 400}
cy={rand() * 225}
r={1 + rand() * 2}
fill={palette.dark}
opacity={0.18 + rand() * 0.18}
/>
))}
</svg>
{/* тэг в углу */}
<div className="absolute inset-0 flex items-end p-3 sm:p-4 pointer-events-none">
<div
className="text-[10px] sm:text-xs font-mono tracking-wider uppercase px-2 py-0.5 rounded-md"
style={{ background: 'rgba(255,255,255,0.65)', color: palette.dark, backdropFilter: 'blur(4px)' }}
>
#{tag}
</div>
</div>
</div>
);
}
+2
View File
@@ -8,6 +8,8 @@ export default function Footer() {
© {new Date().getFullYear()} ZeroPost генерируется ИИ, читается людьми
</div>
<div className="flex items-center gap-4">
<Link href="/archive" className="hover:ink transition-colors">Архив</Link>
<Link href="/notes" className="hover:ink transition-colors">Заметки</Link>
<Link href="/about" className="hover:ink transition-colors">О проекте</Link>
</div>
</div>
+6
View File
@@ -56,6 +56,7 @@ export default function Header() {
{/* 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">
@@ -91,6 +92,11 @@ export default function Header() {
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)}
+99
View File
@@ -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>
</>
);
}