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,90 @@
|
||||
import Link from 'next/link';
|
||||
import Header from '@/components/Header';
|
||||
import Footer from '@/components/Footer';
|
||||
import { listArticles } from '@/lib/engine';
|
||||
import { formatDate } from '@/lib/markdown';
|
||||
import { Archive, Clock } from 'lucide-react';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const metadata = { title: 'Архив статей' };
|
||||
|
||||
const MONTHS = [
|
||||
'Январь','Февраль','Март','Апрель','Май','Июнь',
|
||||
'Июль','Август','Сентябрь','Октябрь','Ноябрь','Декабрь',
|
||||
];
|
||||
|
||||
function groupByMonth(articles) {
|
||||
const groups = new Map();
|
||||
for (const a of articles) {
|
||||
const d = new Date(a.published_at);
|
||||
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
|
||||
if (!groups.has(key)) groups.set(key, { year: d.getFullYear(), month: d.getMonth(), items: [] });
|
||||
groups.get(key).items.push(a);
|
||||
}
|
||||
return Array.from(groups.entries())
|
||||
.sort(([a], [b]) => b.localeCompare(a))
|
||||
.map(([, v]) => v);
|
||||
}
|
||||
|
||||
export default async function ArchivePage() {
|
||||
const articles = await listArticles({ limit: 500 });
|
||||
const groups = groupByMonth(articles);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<main className="container-wide pt-10 pb-16">
|
||||
<div
|
||||
className="inline-flex items-center gap-2 text-xs accent px-3 py-1.5 rounded-full mb-4"
|
||||
style={{ background: 'rgb(var(--accent) / 0.1)', border: '1px solid rgb(var(--accent) / 0.2)' }}
|
||||
>
|
||||
<Archive className="w-3.5 h-3.5" /> Архив
|
||||
</div>
|
||||
<h1 className="text-3xl sm:text-5xl font-bold ink mb-3 leading-tight">
|
||||
Все статьи
|
||||
</h1>
|
||||
<p className="mute text-base sm:text-lg mb-10 max-w-2xl">
|
||||
Полный архив. {articles.length} {articles.length === 1 ? 'материал' : 'материалов'} с момента запуска.
|
||||
</p>
|
||||
|
||||
{articles.length === 0 && (
|
||||
<p className="mute">Архив пока пуст.</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-10 sm:space-y-12">
|
||||
{groups.map(g => (
|
||||
<section key={`${g.year}-${g.month}`}>
|
||||
<h2 className="text-xs sm:text-sm font-medium uppercase tracking-widest mute mb-4 sm:mb-5 pb-2 border-b-soft">
|
||||
{MONTHS[g.month]} {g.year}
|
||||
<span className="mute ml-2 normal-case tracking-normal">· {g.items.length}</span>
|
||||
</h2>
|
||||
<ul className="divide-y divide-[rgb(var(--border))]">
|
||||
{g.items.map(a => (
|
||||
<li key={a.id}>
|
||||
<Link
|
||||
href={`/blog/${a.slug}`}
|
||||
className="group flex items-baseline gap-3 sm:gap-4 py-3 sm:py-4 -mx-2 px-2 hover:surface-2 rounded-lg transition-colors"
|
||||
>
|
||||
<span className="text-xs mute tabular-nums w-12 flex-shrink-0 mt-0.5">
|
||||
{new Date(a.published_at).getDate().toString().padStart(2, '0')}.{(new Date(a.published_at).getMonth() + 1).toString().padStart(2, '0')}
|
||||
</span>
|
||||
<span className="flex-1 ink group-hover:accent transition-colors text-sm sm:text-base font-medium leading-snug">
|
||||
{a.title}
|
||||
</span>
|
||||
{a.reading_time && (
|
||||
<span className="hidden sm:inline-flex items-center gap-1 text-xs mute flex-shrink-0">
|
||||
<Clock className="w-3 h-3" /> {a.reading_time} мин
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
+21
-16
@@ -5,10 +5,12 @@ import Footer from '@/components/Footer';
|
||||
import ReadingProgress from '@/components/ReadingProgress';
|
||||
import ScrollToTop from '@/components/ScrollToTop';
|
||||
import ShareButton from '@/components/ShareButton';
|
||||
import ArticleMeta from '@/components/ArticleMeta';
|
||||
import ArticleCard from '@/components/ArticleCard';
|
||||
import ArticleMeta from '@/components/ArticleMeta';
|
||||
import ArticleCoverSVG from '@/components/ArticleCoverSVG';
|
||||
import TableOfContents from '@/components/TableOfContents';
|
||||
import { getArticle, listArticles } from '@/lib/engine';
|
||||
import { renderMarkdown, formatDate } from '@/lib/markdown';
|
||||
import { renderMarkdownWithToc, formatDate } from '@/lib/markdown';
|
||||
import { Clock, ArrowLeft } from 'lucide-react';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
@@ -32,19 +34,14 @@ export async function generateMetadata({ params }) {
|
||||
}
|
||||
|
||||
async function loadRelated(article) {
|
||||
// ищем статьи с пересекающимися тегами, исключая текущую
|
||||
if (!article.tags?.length) return [];
|
||||
const all = await listArticles({ limit: 20 });
|
||||
const scored = all
|
||||
return all
|
||||
.filter(a => a.id !== article.id)
|
||||
.map(a => ({
|
||||
...a,
|
||||
score: (a.tags || []).filter(t => article.tags.includes(t)).length,
|
||||
}))
|
||||
.map(a => ({ ...a, score: (a.tags || []).filter(t => article.tags.includes(t)).length }))
|
||||
.filter(a => a.score > 0)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, 3);
|
||||
return scored;
|
||||
}
|
||||
|
||||
export default async function ArticlePage({ params }) {
|
||||
@@ -54,14 +51,14 @@ export default async function ArticlePage({ params }) {
|
||||
|
||||
const related = await loadRelated(article);
|
||||
const contentWithoutH1 = article.content.replace(/^#\s+.+$/m, '').trim();
|
||||
const html = renderMarkdown(contentWithoutH1);
|
||||
const { html, toc } = renderMarkdownWithToc(contentWithoutH1);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<ReadingProgress />
|
||||
|
||||
<article className="container-narrow pt-6 sm:pt-10 pb-12">
|
||||
<div className="container-wide pt-6 sm:pt-10 pb-12">
|
||||
<Link href="/" className="btn btn-ghost text-sm mb-4 sm:mb-6 -ml-2">
|
||||
<ArrowLeft className="w-4 h-4" /> Все статьи
|
||||
</Link>
|
||||
@@ -72,7 +69,7 @@ export default async function ArticlePage({ params }) {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h1 className="font-serif text-2xl sm:text-4xl lg:text-5xl font-bold leading-[1.15] mb-4 sm:mb-5 tracking-tight ink">
|
||||
<h1 className="font-serif text-2xl sm:text-4xl lg:text-5xl font-bold leading-[1.15] mb-4 sm:mb-5 tracking-tight ink max-w-4xl">
|
||||
{article.title}
|
||||
</h1>
|
||||
|
||||
@@ -93,24 +90,32 @@ export default async function ArticlePage({ params }) {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{article.cover_url && (
|
||||
<div className="mb-8 sm:mb-10 -mx-4 sm:mx-0">
|
||||
{article.cover_url ? (
|
||||
<img
|
||||
src={article.cover_url}
|
||||
alt={article.title}
|
||||
className="w-full sm:rounded-xl"
|
||||
style={{ aspectRatio: '16/9', objectFit: 'cover' }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<ArticleCoverSVG article={article} aspect="16/9" className="!rounded-none sm:!rounded-xl" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Двухколоночный layout на больших экранах: TOC + контент */}
|
||||
<div className="lg:grid lg:grid-cols-[240px_minmax(0,1fr)] lg:gap-12">
|
||||
<TableOfContents items={toc} />
|
||||
|
||||
<article className="max-w-3xl">
|
||||
<div
|
||||
className="prose prose-base sm:prose-lg max-w-none font-serif prose-headings:font-sans prose-p:leading-relaxed prose-img:rounded-xl"
|
||||
className="prose prose-base sm:prose-lg max-w-none font-serif prose-headings:font-sans prose-p:leading-relaxed prose-img:rounded-xl prose-headings:scroll-mt-24"
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
|
||||
<ArticleMeta article={article} />
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{related.length > 0 && (
|
||||
<section className="container-wide pb-12 sm:pb-16">
|
||||
|
||||
@@ -13,6 +13,7 @@ export default async function sitemap() {
|
||||
{ url: `${SITE}/`, lastModified: new Date(), changeFrequency: 'daily', priority: 1 },
|
||||
{ url: `${SITE}/about`, lastModified: new Date(), changeFrequency: 'monthly', priority: 0.5 },
|
||||
{ url: `${SITE}/notes`, lastModified: new Date(), changeFrequency: 'daily', priority: 0.6 },
|
||||
{ url: `${SITE}/archive`, lastModified: new Date(), changeFrequency: 'daily', priority: 0.7 },
|
||||
];
|
||||
|
||||
const articlePages = articles.map(a => ({
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+63
-1
@@ -5,8 +5,70 @@ marked.setOptions({
|
||||
breaks: false,
|
||||
});
|
||||
|
||||
/**
|
||||
* Slug для якорей — поддерживает кириллицу и латиницу.
|
||||
*/
|
||||
function slugifyHeading(text) {
|
||||
const map = {
|
||||
а:'a',б:'b',в:'v',г:'g',д:'d',е:'e',ё:'yo',ж:'zh',з:'z',и:'i',й:'y',
|
||||
к:'k',л:'l',м:'m',н:'n',о:'o',п:'p',р:'r',с:'s',т:'t',у:'u',ф:'f',
|
||||
х:'h',ц:'c',ч:'ch',ш:'sh',щ:'sch',ъ:'',ы:'y',ь:'',э:'e',ю:'yu',я:'ya',
|
||||
};
|
||||
return String(text)
|
||||
.toLowerCase()
|
||||
.split('')
|
||||
.map(c => map[c] !== undefined ? map[c] : c)
|
||||
.join('')
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/[^a-z0-9\s-]/g, '')
|
||||
.trim()
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.substring(0, 80);
|
||||
}
|
||||
|
||||
/**
|
||||
* Рендерит markdown и одновременно возвращает оглавление.
|
||||
* @returns {{ html: string, toc: Array<{level:number,id:string,text:string}> }}
|
||||
*/
|
||||
export function renderMarkdownWithToc(md) {
|
||||
if (!md) return { html: '', toc: [] };
|
||||
|
||||
const toc = [];
|
||||
const usedIds = new Set();
|
||||
const renderer = new marked.Renderer();
|
||||
|
||||
renderer.heading = ({ tokens, depth, text }) => {
|
||||
// marked v13+: heading получает объект, text может быть в tokens
|
||||
let rawText = text;
|
||||
if (Array.isArray(tokens)) {
|
||||
rawText = tokens.map(t => t.raw || t.text || '').join('');
|
||||
}
|
||||
rawText = String(rawText || '').trim();
|
||||
|
||||
let id = slugifyHeading(rawText);
|
||||
if (!id) id = `h-${toc.length}`;
|
||||
// уникализируем
|
||||
let unique = id;
|
||||
let i = 2;
|
||||
while (usedIds.has(unique)) unique = `${id}-${i++}`;
|
||||
usedIds.add(unique);
|
||||
|
||||
// в TOC берём только h2 и h3
|
||||
if (depth === 2 || depth === 3) {
|
||||
toc.push({ level: depth, id: unique, text: rawText });
|
||||
}
|
||||
const innerHtml = marked.parseInline(rawText);
|
||||
return `<h${depth} id="${unique}">${innerHtml}</h${depth}>\n`;
|
||||
};
|
||||
|
||||
const html = marked.parse(md, { renderer });
|
||||
return { html, toc };
|
||||
}
|
||||
|
||||
// Legacy — оставляю, чтобы не сломать другие места
|
||||
export function renderMarkdown(md) {
|
||||
return marked.parse(md || '');
|
||||
return renderMarkdownWithToc(md).html;
|
||||
}
|
||||
|
||||
export function formatDate(iso) {
|
||||
|
||||
Reference in New Issue
Block a user