Files
zeropost-web/components/ArticleCoverSVG.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

136 lines
5.0 KiB
JavaScript

/**
* Процедурно-сгенерированная SVG-обложка в стиле сайта.
* Не требует AI — рендерится сразу, выглядит достойно вместо плоского градиента.
*
* Идея: используем seed (id статьи) для воспроизводимой композиции.
* Каждая статья получает свой уникальный, но узнаваемый узор.
*/
// псевдо-рандом по seed (mulberry32)
function rng(seed) {
let t = seed + 0x6D2B79F5;
return () => {
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// Палитры — приглушённые, чтобы не спорить с UI
const PALETTES = [
{ bg: '#ecfdf5', accent: '#10b981', soft: '#a7f3d0', dark: '#065f46' }, // emerald
{ bg: '#f0fdfa', accent: '#14b8a6', soft: '#99f6e4', dark: '#115e59' }, // teal
{ bg: '#fefce8', accent: '#eab308', soft: '#fef08a', dark: '#854d0e' }, // yellow
{ bg: '#eff6ff', accent: '#3b82f6', soft: '#bfdbfe', dark: '#1e40af' }, // blue
{ bg: '#fdf4ff', accent: '#a855f7', soft: '#e9d5ff', dark: '#6b21a8' }, // purple
{ bg: '#fff7ed', accent: '#f97316', soft: '#fed7aa', dark: '#9a3412' }, // orange
];
export default function ArticleCoverSVG({ article, className = '', aspect = '16/9', priority = false }) {
const seed = (article?.id || 1) * 9301 + 49297;
const rand = rng(seed);
const palette = PALETTES[Math.floor(rand() * PALETTES.length)];
// Композиция: 3-5 «слоёв» геометрии
const layers = 3 + Math.floor(rand() * 3);
const shapes = [];
for (let i = 0; i < layers; i++) {
const kind = ['curve', 'circle', 'arc', 'rect'][Math.floor(rand() * 4)];
const opacity = 0.35 + rand() * 0.5;
const colors = [palette.accent, palette.soft, palette.dark];
const fill = colors[Math.floor(rand() * colors.length)];
shapes.push({ kind, opacity, fill, r: rand });
}
// тег (первый) — мелкая метка в углу
const tag = (article?.tags?.[0] || 'zeropost').toString().slice(0, 18);
return (
<div className={`relative overflow-hidden rounded-xl ${className}`} style={{ aspectRatio: aspect, background: palette.bg }}>
<svg
viewBox="0 0 400 225"
preserveAspectRatio="xMidYMid slice"
className="absolute inset-0 w-full h-full"
aria-hidden="true"
>
{shapes.map((s, idx) => {
// координаты, тоже псевдо-случайно
const cx = 60 + s.r() * 320;
const cy = 30 + s.r() * 165;
const size = 60 + s.r() * 180;
const rot = s.r() * 360;
if (s.kind === 'circle') {
return <circle key={idx} cx={cx} cy={cy} r={size / 2} fill={s.fill} opacity={s.opacity} />;
}
if (s.kind === 'rect') {
return (
<rect
key={idx}
x={cx - size / 2}
y={cy - size / 2}
width={size}
height={size * (0.5 + s.r() * 1)}
fill={s.fill}
opacity={s.opacity}
rx={s.r() > 0.5 ? size / 8 : 0}
transform={`rotate(${rot} ${cx} ${cy})`}
/>
);
}
if (s.kind === 'arc') {
// Полукруг
const r = size / 2;
return (
<path
key={idx}
d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy} Z`}
fill={s.fill}
opacity={s.opacity}
transform={`rotate(${rot} ${cx} ${cy})`}
/>
);
}
// curve — плавная волна
const w = size;
const h = size * 0.4;
return (
<path
key={idx}
d={`M ${cx - w / 2} ${cy} Q ${cx} ${cy - h}, ${cx + w / 2} ${cy} T ${cx + w * 1.5} ${cy}`}
stroke={s.fill}
strokeWidth={6 + s.r() * 16}
strokeLinecap="round"
fill="none"
opacity={s.opacity}
/>
);
})}
{/* Мелкие точки-частицы — добавляют детализации */}
{Array.from({ length: 14 }).map((_, i) => (
<circle
key={`d-${i}`}
cx={rand() * 400}
cy={rand() * 225}
r={1 + rand() * 2}
fill={palette.dark}
opacity={0.18 + rand() * 0.18}
/>
))}
</svg>
{/* тэг в углу */}
<div className="absolute inset-0 flex items-end p-3 sm:p-4 pointer-events-none">
<div
className="text-[10px] sm:text-xs font-mono tracking-wider uppercase px-2 py-0.5 rounded-md"
style={{ background: 'rgba(255,255,255,0.65)', color: palette.dark, backdropFilter: 'blur(4px)' }}
>
#{tag}
</div>
</div>
</div>
);
}