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 />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
+27
-22
@@ -5,10 +5,12 @@ import Footer from '@/components/Footer';
|
|||||||
import ReadingProgress from '@/components/ReadingProgress';
|
import ReadingProgress from '@/components/ReadingProgress';
|
||||||
import ScrollToTop from '@/components/ScrollToTop';
|
import ScrollToTop from '@/components/ScrollToTop';
|
||||||
import ShareButton from '@/components/ShareButton';
|
import ShareButton from '@/components/ShareButton';
|
||||||
import ArticleMeta from '@/components/ArticleMeta';
|
|
||||||
import ArticleCard from '@/components/ArticleCard';
|
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 { getArticle, listArticles } from '@/lib/engine';
|
||||||
import { renderMarkdown, formatDate } from '@/lib/markdown';
|
import { renderMarkdownWithToc, formatDate } from '@/lib/markdown';
|
||||||
import { Clock, ArrowLeft } from 'lucide-react';
|
import { Clock, ArrowLeft } from 'lucide-react';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
@@ -32,19 +34,14 @@ export async function generateMetadata({ params }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadRelated(article) {
|
async function loadRelated(article) {
|
||||||
// ищем статьи с пересекающимися тегами, исключая текущую
|
|
||||||
if (!article.tags?.length) return [];
|
if (!article.tags?.length) return [];
|
||||||
const all = await listArticles({ limit: 20 });
|
const all = await listArticles({ limit: 20 });
|
||||||
const scored = all
|
return all
|
||||||
.filter(a => a.id !== article.id)
|
.filter(a => a.id !== article.id)
|
||||||
.map(a => ({
|
.map(a => ({ ...a, score: (a.tags || []).filter(t => article.tags.includes(t)).length }))
|
||||||
...a,
|
|
||||||
score: (a.tags || []).filter(t => article.tags.includes(t)).length,
|
|
||||||
}))
|
|
||||||
.filter(a => a.score > 0)
|
.filter(a => a.score > 0)
|
||||||
.sort((a, b) => b.score - a.score)
|
.sort((a, b) => b.score - a.score)
|
||||||
.slice(0, 3);
|
.slice(0, 3);
|
||||||
return scored;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function ArticlePage({ params }) {
|
export default async function ArticlePage({ params }) {
|
||||||
@@ -54,14 +51,14 @@ export default async function ArticlePage({ params }) {
|
|||||||
|
|
||||||
const related = await loadRelated(article);
|
const related = await loadRelated(article);
|
||||||
const contentWithoutH1 = article.content.replace(/^#\s+.+$/m, '').trim();
|
const contentWithoutH1 = article.content.replace(/^#\s+.+$/m, '').trim();
|
||||||
const html = renderMarkdown(contentWithoutH1);
|
const { html, toc } = renderMarkdownWithToc(contentWithoutH1);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header />
|
<Header />
|
||||||
<ReadingProgress />
|
<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">
|
<Link href="/" className="btn btn-ghost text-sm mb-4 sm:mb-6 -ml-2">
|
||||||
<ArrowLeft className="w-4 h-4" /> Все статьи
|
<ArrowLeft className="w-4 h-4" /> Все статьи
|
||||||
</Link>
|
</Link>
|
||||||
@@ -72,7 +69,7 @@ export default async function ArticlePage({ params }) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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}
|
{article.title}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
@@ -93,24 +90,32 @@ export default async function ArticlePage({ params }) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{article.cover_url && (
|
<div className="mb-8 sm:mb-10 -mx-4 sm:mx-0">
|
||||||
<div className="mb-8 sm:mb-10 -mx-4 sm:mx-0">
|
{article.cover_url ? (
|
||||||
<img
|
<img
|
||||||
src={article.cover_url}
|
src={article.cover_url}
|
||||||
alt={article.title}
|
alt={article.title}
|
||||||
className="w-full sm:rounded-xl"
|
className="w-full sm:rounded-xl"
|
||||||
style={{ aspectRatio: '16/9', objectFit: 'cover' }}
|
style={{ aspectRatio: '16/9', objectFit: 'cover' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
) : (
|
||||||
)}
|
<ArticleCoverSVG article={article} aspect="16/9" className="!rounded-none sm:!rounded-xl" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
{/* Двухколоночный layout на больших экранах: TOC + контент */}
|
||||||
className="prose prose-base sm:prose-lg max-w-none font-serif prose-headings:font-sans prose-p:leading-relaxed prose-img:rounded-xl"
|
<div className="lg:grid lg:grid-cols-[240px_minmax(0,1fr)] lg:gap-12">
|
||||||
dangerouslySetInnerHTML={{ __html: html }}
|
<TableOfContents items={toc} />
|
||||||
/>
|
|
||||||
|
|
||||||
<ArticleMeta article={article} />
|
<article className="max-w-3xl">
|
||||||
</article>
|
<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 prose-headings:scroll-mt-24"
|
||||||
|
dangerouslySetInnerHTML={{ __html: html }}
|
||||||
|
/>
|
||||||
|
<ArticleMeta article={article} />
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{related.length > 0 && (
|
{related.length > 0 && (
|
||||||
<section className="container-wide pb-12 sm:pb-16">
|
<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}/`, lastModified: new Date(), changeFrequency: 'daily', priority: 1 },
|
||||||
{ url: `${SITE}/about`, lastModified: new Date(), changeFrequency: 'monthly', priority: 0.5 },
|
{ url: `${SITE}/about`, lastModified: new Date(), changeFrequency: 'monthly', priority: 0.5 },
|
||||||
{ url: `${SITE}/notes`, lastModified: new Date(), changeFrequency: 'daily', priority: 0.6 },
|
{ 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 => ({
|
const articlePages = articles.map(a => ({
|
||||||
|
|||||||
@@ -1,43 +1,13 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { formatDate } from '@/lib/markdown';
|
import { formatDate } from '@/lib/markdown';
|
||||||
import { Clock } from 'lucide-react';
|
import { Clock } from 'lucide-react';
|
||||||
|
import ArticleCoverSVG from './ArticleCoverSVG';
|
||||||
|
|
||||||
function imageUrl(article) {
|
function imageUrl(article) {
|
||||||
if (!article.cover_url) return null;
|
if (!article.cover_url) return null;
|
||||||
return article.cover_url;
|
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 }) {
|
export default function ArticleCard({ article, featured = false }) {
|
||||||
const img = imageUrl(article);
|
const img = imageUrl(article);
|
||||||
|
|
||||||
@@ -45,7 +15,6 @@ export default function ArticleCard({ article, featured = false }) {
|
|||||||
return (
|
return (
|
||||||
<Link href={`/blog/${article.slug}`} className="article-card block group overflow-hidden p-0">
|
<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="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">
|
<div className="p-3 sm:p-5 sm:col-span-2">
|
||||||
{img ? (
|
{img ? (
|
||||||
<img
|
<img
|
||||||
@@ -55,7 +24,7 @@ export default function ArticleCard({ article, featured = false }) {
|
|||||||
loading="eager"
|
loading="eager"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<CoverPlaceholder article={article} className="sm:aspect-square" />
|
<ArticleCoverSVG article={article} aspect="16/9" className="sm:!aspect-square" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="p-5 sm:p-8 sm:col-span-3 flex flex-col justify-center">
|
<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 ? (
|
||||||
<img src={img} alt={article.title} className="w-full aspect-[16/9] object-cover rounded-lg" loading="lazy" />
|
<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>
|
||||||
<div className="p-4 sm:p-5 pt-2">
|
<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 — генерируется ИИ, читается людьми
|
© {new Date().getFullYear()} ZeroPost — генерируется ИИ, читается людьми
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<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>
|
<Link href="/about" className="hover:ink transition-colors">О проекте</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export default function Header() {
|
|||||||
{/* Desktop nav */}
|
{/* Desktop nav */}
|
||||||
<nav className="hidden sm:flex items-center gap-1 text-sm">
|
<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="/" 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="/notes" className="btn btn-ghost text-sm py-1.5">Заметки</Link>
|
||||||
<Link href="/about" 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">
|
<div className="ml-1 flex items-center gap-1">
|
||||||
@@ -91,6 +92,11 @@ export default function Header() {
|
|||||||
onClick={() => setOpen(false)}
|
onClick={() => setOpen(false)}
|
||||||
className="text-2xl font-semibold ink py-3 border-b-soft"
|
className="text-2xl font-semibold ink py-3 border-b-soft"
|
||||||
>Статьи</Link>
|
>Статьи</Link>
|
||||||
|
<Link
|
||||||
|
href="/archive"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className="text-2xl font-semibold ink py-3 border-b-soft"
|
||||||
|
>Архив</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/notes"
|
href="/notes"
|
||||||
onClick={() => setOpen(false)}
|
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,
|
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) {
|
export function renderMarkdown(md) {
|
||||||
return marked.parse(md || '');
|
return renderMarkdownWithToc(md).html;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatDate(iso) {
|
export function formatDate(iso) {
|
||||||
|
|||||||
Reference in New Issue
Block a user