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
+90
View File
@@ -0,0 +1,90 @@
import Link from 'next/link';
import Header from '@/components/Header';
import Footer from '@/components/Footer';
import { listArticles } from '@/lib/engine';
import { formatDate } from '@/lib/markdown';
import { Archive, Clock } from 'lucide-react';
export const dynamic = 'force-dynamic';
export const metadata = { title: 'Архив статей' };
const MONTHS = [
'Январь','Февраль','Март','Апрель','Май','Июнь',
'Июль','Август','Сентябрь','Октябрь','Ноябрь','Декабрь',
];
function groupByMonth(articles) {
const groups = new Map();
for (const a of articles) {
const d = new Date(a.published_at);
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
if (!groups.has(key)) groups.set(key, { year: d.getFullYear(), month: d.getMonth(), items: [] });
groups.get(key).items.push(a);
}
return Array.from(groups.entries())
.sort(([a], [b]) => b.localeCompare(a))
.map(([, v]) => v);
}
export default async function ArchivePage() {
const articles = await listArticles({ limit: 500 });
const groups = groupByMonth(articles);
return (
<>
<Header />
<main className="container-wide pt-10 pb-16">
<div
className="inline-flex items-center gap-2 text-xs accent px-3 py-1.5 rounded-full mb-4"
style={{ background: 'rgb(var(--accent) / 0.1)', border: '1px solid rgb(var(--accent) / 0.2)' }}
>
<Archive className="w-3.5 h-3.5" /> Архив
</div>
<h1 className="text-3xl sm:text-5xl font-bold ink mb-3 leading-tight">
Все статьи
</h1>
<p className="mute text-base sm:text-lg mb-10 max-w-2xl">
Полный архив. {articles.length} {articles.length === 1 ? 'материал' : 'материалов'} с момента запуска.
</p>
{articles.length === 0 && (
<p className="mute">Архив пока пуст.</p>
)}
<div className="space-y-10 sm:space-y-12">
{groups.map(g => (
<section key={`${g.year}-${g.month}`}>
<h2 className="text-xs sm:text-sm font-medium uppercase tracking-widest mute mb-4 sm:mb-5 pb-2 border-b-soft">
{MONTHS[g.month]} {g.year}
<span className="mute ml-2 normal-case tracking-normal">· {g.items.length}</span>
</h2>
<ul className="divide-y divide-[rgb(var(--border))]">
{g.items.map(a => (
<li key={a.id}>
<Link
href={`/blog/${a.slug}`}
className="group flex items-baseline gap-3 sm:gap-4 py-3 sm:py-4 -mx-2 px-2 hover:surface-2 rounded-lg transition-colors"
>
<span className="text-xs mute tabular-nums w-12 flex-shrink-0 mt-0.5">
{new Date(a.published_at).getDate().toString().padStart(2, '0')}.{(new Date(a.published_at).getMonth() + 1).toString().padStart(2, '0')}
</span>
<span className="flex-1 ink group-hover:accent transition-colors text-sm sm:text-base font-medium leading-snug">
{a.title}
</span>
{a.reading_time && (
<span className="hidden sm:inline-flex items-center gap-1 text-xs mute flex-shrink-0">
<Clock className="w-3 h-3" /> {a.reading_time} мин
</span>
)}
</Link>
</li>
))}
</ul>
</section>
))}
</div>
</main>
<Footer />
</>
);
}
+21 -16
View File
@@ -5,10 +5,12 @@ import Footer from '@/components/Footer';
import ReadingProgress from '@/components/ReadingProgress';
import ScrollToTop from '@/components/ScrollToTop';
import ShareButton from '@/components/ShareButton';
import ArticleMeta from '@/components/ArticleMeta';
import ArticleCard from '@/components/ArticleCard';
import ArticleMeta from '@/components/ArticleMeta';
import ArticleCoverSVG from '@/components/ArticleCoverSVG';
import TableOfContents from '@/components/TableOfContents';
import { getArticle, listArticles } from '@/lib/engine';
import { renderMarkdown, formatDate } from '@/lib/markdown';
import { renderMarkdownWithToc, formatDate } from '@/lib/markdown';
import { Clock, ArrowLeft } from 'lucide-react';
export const dynamic = 'force-dynamic';
@@ -32,19 +34,14 @@ export async function generateMetadata({ params }) {
}
async function loadRelated(article) {
// ищем статьи с пересекающимися тегами, исключая текущую
if (!article.tags?.length) return [];
const all = await listArticles({ limit: 20 });
const scored = all
return all
.filter(a => a.id !== article.id)
.map(a => ({
...a,
score: (a.tags || []).filter(t => article.tags.includes(t)).length,
}))
.map(a => ({ ...a, score: (a.tags || []).filter(t => article.tags.includes(t)).length }))
.filter(a => a.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, 3);
return scored;
}
export default async function ArticlePage({ params }) {
@@ -54,14 +51,14 @@ export default async function ArticlePage({ params }) {
const related = await loadRelated(article);
const contentWithoutH1 = article.content.replace(/^#\s+.+$/m, '').trim();
const html = renderMarkdown(contentWithoutH1);
const { html, toc } = renderMarkdownWithToc(contentWithoutH1);
return (
<>
<Header />
<ReadingProgress />
<article className="container-narrow pt-6 sm:pt-10 pb-12">
<div className="container-wide pt-6 sm:pt-10 pb-12">
<Link href="/" className="btn btn-ghost text-sm mb-4 sm:mb-6 -ml-2">
<ArrowLeft className="w-4 h-4" /> Все статьи
</Link>
@@ -72,7 +69,7 @@ export default async function ArticlePage({ params }) {
))}
</div>
<h1 className="font-serif text-2xl sm:text-4xl lg:text-5xl font-bold leading-[1.15] mb-4 sm:mb-5 tracking-tight ink">
<h1 className="font-serif text-2xl sm:text-4xl lg:text-5xl font-bold leading-[1.15] mb-4 sm:mb-5 tracking-tight ink max-w-4xl">
{article.title}
</h1>
@@ -93,24 +90,32 @@ export default async function ArticlePage({ params }) {
</span>
</div>
{article.cover_url && (
<div className="mb-8 sm:mb-10 -mx-4 sm:mx-0">
{article.cover_url ? (
<img
src={article.cover_url}
alt={article.title}
className="w-full sm:rounded-xl"
style={{ aspectRatio: '16/9', objectFit: 'cover' }}
/>
</div>
) : (
<ArticleCoverSVG article={article} aspect="16/9" className="!rounded-none sm:!rounded-xl" />
)}
</div>
{/* Двухколоночный layout на больших экранах: TOC + контент */}
<div className="lg:grid lg:grid-cols-[240px_minmax(0,1fr)] lg:gap-12">
<TableOfContents items={toc} />
<article className="max-w-3xl">
<div
className="prose prose-base sm:prose-lg max-w-none font-serif prose-headings:font-sans prose-p:leading-relaxed prose-img:rounded-xl"
className="prose prose-base sm:prose-lg max-w-none font-serif prose-headings:font-sans prose-p:leading-relaxed prose-img:rounded-xl prose-headings:scroll-mt-24"
dangerouslySetInnerHTML={{ __html: html }}
/>
<ArticleMeta article={article} />
</article>
</div>
</div>
{related.length > 0 && (
<section className="container-wide pb-12 sm:pb-16">
+1
View File
@@ -13,6 +13,7 @@ export default async function sitemap() {
{ url: `${SITE}/`, lastModified: new Date(), changeFrequency: 'daily', priority: 1 },
{ url: `${SITE}/about`, lastModified: new Date(), changeFrequency: 'monthly', priority: 0.5 },
{ url: `${SITE}/notes`, lastModified: new Date(), changeFrequency: 'daily', priority: 0.6 },
{ url: `${SITE}/archive`, lastModified: new Date(), changeFrequency: 'daily', priority: 0.7 },
];
const articlePages = articles.map(a => ({
+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>
</>
);
}
+63 -1
View File
@@ -5,8 +5,70 @@ marked.setOptions({
breaks: false,
});
/**
* Slug для якорей — поддерживает кириллицу и латиницу.
*/
function slugifyHeading(text) {
const map = {
а:'a',б:'b',в:'v',г:'g',д:'d',е:'e',ё:'yo',ж:'zh',з:'z',и:'i',й:'y',
к:'k',л:'l',м:'m',н:'n',о:'o',п:'p',р:'r',с:'s',т:'t',у:'u',ф:'f',
х:'h',ц:'c',ч:'ch',ш:'sh',щ:'sch',ъ:'',ы:'y',ь:'',э:'e',ю:'yu',я:'ya',
};
return String(text)
.toLowerCase()
.split('')
.map(c => map[c] !== undefined ? map[c] : c)
.join('')
.replace(/<[^>]+>/g, '')
.replace(/[^a-z0-9\s-]/g, '')
.trim()
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.substring(0, 80);
}
/**
* Рендерит markdown и одновременно возвращает оглавление.
* @returns {{ html: string, toc: Array<{level:number,id:string,text:string}> }}
*/
export function renderMarkdownWithToc(md) {
if (!md) return { html: '', toc: [] };
const toc = [];
const usedIds = new Set();
const renderer = new marked.Renderer();
renderer.heading = ({ tokens, depth, text }) => {
// marked v13+: heading получает объект, text может быть в tokens
let rawText = text;
if (Array.isArray(tokens)) {
rawText = tokens.map(t => t.raw || t.text || '').join('');
}
rawText = String(rawText || '').trim();
let id = slugifyHeading(rawText);
if (!id) id = `h-${toc.length}`;
// уникализируем
let unique = id;
let i = 2;
while (usedIds.has(unique)) unique = `${id}-${i++}`;
usedIds.add(unique);
// в TOC берём только h2 и h3
if (depth === 2 || depth === 3) {
toc.push({ level: depth, id: unique, text: rawText });
}
const innerHtml = marked.parseInline(rawText);
return `<h${depth} id="${unique}">${innerHtml}</h${depth}>\n`;
};
const html = marked.parse(md, { renderer });
return { html, toc };
}
// Legacy — оставляю, чтобы не сломать другие места
export function renderMarkdown(md) {
return marked.parse(md || '');
return renderMarkdownWithToc(md).html;
}
export function formatDate(iso) {