From 20b67f11e003a1ebea7676ec6c8522e9eeca341e Mon Sep 17 00:00:00 2001 From: Alexey Pavlov Date: Sun, 31 May 2026 10:54:34 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20TOC=20=D0=BE=D0=B3=D0=BB=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20+=20SVG-=D0=BE=D0=B1=D0=BB?= =?UTF-8?q?=D0=BE=D0=B6=D0=BA=D0=B8-=D1=84=D0=BE=D0=BB=D0=BB=D0=B1=D0=B5?= =?UTF-8?q?=D0=BA=D0=B8=20+=20/archive?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/archive/page.js | 90 +++++++++++++++++++++++ app/blog/[slug]/page.js | 49 ++++++------ app/sitemap.js | 1 + components/ArticleCard.js | 37 +--------- components/ArticleCoverSVG.js | 135 ++++++++++++++++++++++++++++++++++ components/Footer.js | 2 + components/Header.js | 6 ++ components/TableOfContents.js | 99 +++++++++++++++++++++++++ lib/markdown.js | 64 +++++++++++++++- 9 files changed, 426 insertions(+), 57 deletions(-) create mode 100644 app/archive/page.js create mode 100644 components/ArticleCoverSVG.js create mode 100644 components/TableOfContents.js diff --git a/app/archive/page.js b/app/archive/page.js new file mode 100644 index 0000000..bdb608e --- /dev/null +++ b/app/archive/page.js @@ -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 ( + <> +
+
+
+ Архив +
+

+ Все статьи +

+

+ Полный архив. {articles.length} {articles.length === 1 ? 'материал' : 'материалов'} с момента запуска. +

+ + {articles.length === 0 && ( +

Архив пока пуст.

+ )} + +
+ {groups.map(g => ( +
+

+ {MONTHS[g.month]} {g.year} + · {g.items.length} +

+
    + {g.items.map(a => ( +
  • + + + {new Date(a.published_at).getDate().toString().padStart(2, '0')}.{(new Date(a.published_at).getMonth() + 1).toString().padStart(2, '0')} + + + {a.title} + + {a.reading_time && ( + + {a.reading_time} мин + + )} + +
  • + ))} +
+
+ ))} +
+
+