Files
zeropost-web/components/ArticleCard.js
T
Alexey Pavlov 20b67f11e0 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
2026-05-31 10:54:34 +03:00

91 lines
3.3 KiB
JavaScript

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;
}
export default function ArticleCard({ article, featured = false }) {
const img = imageUrl(article);
if (featured) {
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">
<div className="p-3 sm:p-5 sm:col-span-2">
{img ? (
<img
src={img}
alt={article.title}
className="w-full aspect-[16/9] sm:aspect-square object-cover rounded-xl"
loading="eager"
/>
) : (
<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">
<div className="flex flex-wrap items-center gap-2 mb-3">
{(article.tags || []).slice(0, 3).map(t => (
<span key={t} className="tag">#{t}</span>
))}
</div>
<h2 className="text-xl sm:text-3xl font-bold mb-3 leading-tight ink group-hover:accent transition-colors">
{article.title}
</h2>
{article.excerpt && (
<p className="mute text-sm sm:text-base leading-relaxed line-clamp-3 mb-4">
{article.excerpt}
</p>
)}
<div className="flex items-center gap-3 text-xs mute">
<span>{formatDate(article.published_at)}</span>
{article.reading_time && (
<span className="inline-flex items-center gap-1">
<Clock className="w-3 h-3" /> {article.reading_time} мин
</span>
)}
</div>
</div>
</div>
</Link>
);
}
return (
<Link href={`/blog/${article.slug}`} className="article-card block group overflow-hidden p-0">
<div className="p-3">
{img ? (
<img src={img} alt={article.title} className="w-full aspect-[16/9] object-cover rounded-lg" loading="lazy" />
) : (
<ArticleCoverSVG article={article} />
)}
</div>
<div className="p-4 sm:p-5 pt-2">
<div className="flex flex-wrap items-center gap-2 mb-2">
{(article.tags || []).slice(0, 2).map(t => (
<span key={t} className="tag">#{t}</span>
))}
</div>
<h3 className="text-base sm:text-lg font-semibold mb-2 ink group-hover:accent transition-colors leading-snug line-clamp-2">
{article.title}
</h3>
{article.excerpt && (
<p className="mute text-sm line-clamp-2 mb-4">{article.excerpt}</p>
)}
<div className="flex items-center gap-3 text-xs mute">
<span>{formatDate(article.published_at)}</span>
{article.reading_time && (
<span className="inline-flex items-center gap-1">
<Clock className="w-3 h-3" /> {article.reading_time} мин
</span>
)}
</div>
</div>
</Link>
);
}