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">