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 />
</>
);
}
+27 -22
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">
<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>
<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"
dangerouslySetInnerHTML={{ __html: html }}
/>
{/* Двухколоночный layout на больших экранах: TOC + контент */}
<div className="lg:grid lg:grid-cols-[240px_minmax(0,1fr)] lg:gap-12">
<TableOfContents items={toc} />
<ArticleMeta article={article} />
</article>
<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 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 => ({