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,135 @@
|
||||
/**
|
||||
* Процедурно-сгенерированная 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user