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:
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user