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:
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,8 @@ export default function Footer() {
|
||||
© {new Date().getFullYear()} ZeroPost — генерируется ИИ, читается людьми
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/archive" className="hover:ink transition-colors">Архив</Link>
|
||||
<Link href="/notes" className="hover:ink transition-colors">Заметки</Link>
|
||||
<Link href="/about" className="hover:ink transition-colors">О проекте</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -56,6 +56,7 @@ export default function Header() {
|
||||
{/* Desktop nav */}
|
||||
<nav className="hidden sm:flex items-center gap-1 text-sm">
|
||||
<Link href="/" className="btn btn-ghost text-sm py-1.5">Статьи</Link>
|
||||
<Link href="/archive" className="btn btn-ghost text-sm py-1.5">Архив</Link>
|
||||
<Link href="/notes" className="btn btn-ghost text-sm py-1.5">Заметки</Link>
|
||||
<Link href="/about" className="btn btn-ghost text-sm py-1.5">О проекте</Link>
|
||||
<div className="ml-1 flex items-center gap-1">
|
||||
@@ -91,6 +92,11 @@ export default function Header() {
|
||||
onClick={() => setOpen(false)}
|
||||
className="text-2xl font-semibold ink py-3 border-b-soft"
|
||||
>Статьи</Link>
|
||||
<Link
|
||||
href="/archive"
|
||||
onClick={() => setOpen(false)}
|
||||
className="text-2xl font-semibold ink py-3 border-b-soft"
|
||||
>Архив</Link>
|
||||
<Link
|
||||
href="/notes"
|
||||
onClick={() => setOpen(false)}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
'use client';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { List, ChevronDown } from 'lucide-react';
|
||||
|
||||
export default function TableOfContents({ items }) {
|
||||
const [activeId, setActiveId] = useState(null);
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
|
||||
// следим за тем, какой заголовок сейчас в viewport
|
||||
useEffect(() => {
|
||||
if (!items?.length) return;
|
||||
const headings = items
|
||||
.map(i => document.getElementById(i.id))
|
||||
.filter(Boolean);
|
||||
if (!headings.length) return;
|
||||
|
||||
const onScroll = () => {
|
||||
// ближайший к верху, но не выше offset
|
||||
const offset = 120;
|
||||
let current = headings[0]?.id || null;
|
||||
for (const h of headings) {
|
||||
const top = h.getBoundingClientRect().top;
|
||||
if (top <= offset) current = h.id;
|
||||
else break;
|
||||
}
|
||||
setActiveId(current);
|
||||
};
|
||||
onScroll();
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => window.removeEventListener('scroll', onScroll);
|
||||
}, [items]);
|
||||
|
||||
function scrollTo(e, id) {
|
||||
e.preventDefault();
|
||||
const el = document.getElementById(id);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
setMobileOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!items || items.length < 2) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop: sticky слева, видна на lg+ */}
|
||||
<aside className="hidden lg:block sticky top-24 self-start max-h-[calc(100vh-8rem)] overflow-y-auto pr-4" style={{ width: '240px' }}>
|
||||
<div className="text-[11px] font-medium uppercase tracking-widest mute mb-3 flex items-center gap-2">
|
||||
<List className="w-3 h-3" /> Оглавление
|
||||
</div>
|
||||
<nav className="space-y-1">
|
||||
{items.map(i => (
|
||||
<a
|
||||
key={i.id}
|
||||
href={`#${i.id}`}
|
||||
onClick={(e) => scrollTo(e, i.id)}
|
||||
className={`block text-sm leading-snug transition-colors py-1 ${i.level === 3 ? 'pl-3' : ''} ${
|
||||
activeId === i.id ? 'accent font-medium' : 'mute hover:ink'
|
||||
}`}
|
||||
>
|
||||
{i.text}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{/* Mobile: раскрывающийся блок над текстом */}
|
||||
<div className="lg:hidden mb-6 article-card overflow-hidden p-0">
|
||||
<button
|
||||
onClick={() => setMobileOpen(o => !o)}
|
||||
className="w-full px-4 py-3 flex items-center justify-between text-left"
|
||||
aria-expanded={mobileOpen}
|
||||
>
|
||||
<span className="flex items-center gap-2 text-sm font-medium ink">
|
||||
<List className="w-4 h-4 mute" /> Оглавление
|
||||
<span className="mute text-xs">({items.length})</span>
|
||||
</span>
|
||||
<ChevronDown className={`w-4 h-4 mute transition-transform ${mobileOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{mobileOpen && (
|
||||
<nav className="border-t-soft px-4 py-3 space-y-1.5">
|
||||
{items.map(i => (
|
||||
<a
|
||||
key={i.id}
|
||||
href={`#${i.id}`}
|
||||
onClick={(e) => scrollTo(e, i.id)}
|
||||
className={`block text-sm leading-snug py-1.5 ${i.level === 3 ? 'pl-4 text-[13px]' : ''} ${
|
||||
activeId === i.id ? 'accent font-medium' : 'mute'
|
||||
}`}
|
||||
>
|
||||
{i.text}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user