20b67f11e0
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
91 lines
3.3 KiB
JavaScript
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>
|
|
);
|
|
}
|