Files
zeropost-web/app/blog/[slug]/page.js
T
Alexey Pavlov c27985614e feat: блок «Сейчас» + «Заметки редактора» + ArticleMeta
- NowBlock: live indicator (последняя статья / идёт генерация) + bar-чарт за 7 дней
- NotesBlock: карточки заметок редактора с pin
- /notes: отдельная страница со всеми заметками
- ArticleMeta: раскрывающийся блок «Как сделана эта статья» на странице статьи
- В шапку добавлена ссылка «Заметки» (desktop и mobile)
2026-05-31 10:05:28 +03:00

131 lines
4.4 KiB
JavaScript

import { notFound } from 'next/navigation';
import Link from 'next/link';
import Header from '@/components/Header';
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 { getArticle, listArticles } from '@/lib/engine';
import { renderMarkdown, formatDate } from '@/lib/markdown';
import { Clock, ArrowLeft } from 'lucide-react';
export const dynamic = 'force-dynamic';
export async function generateMetadata({ params }) {
const { slug } = await params;
const article = await getArticle(slug);
if (!article) return { title: 'Статья не найдена' };
return {
title: article.seo_title || article.title,
description: article.seo_descr || article.excerpt,
openGraph: {
title: article.title,
description: article.excerpt,
type: 'article',
publishedTime: article.published_at,
tags: article.tags || [],
images: article.cover_url ? [{ url: article.cover_url }] : undefined,
},
};
}
async function loadRelated(article) {
// ищем статьи с пересекающимися тегами, исключая текущую
if (!article.tags?.length) return [];
const all = await listArticles({ limit: 20 });
const scored = all
.filter(a => a.id !== article.id)
.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 }) {
const { slug } = await params;
const article = await getArticle(slug);
if (!article) notFound();
const related = await loadRelated(article);
const contentWithoutH1 = article.content.replace(/^#\s+.+$/m, '').trim();
const html = renderMarkdown(contentWithoutH1);
return (
<>
<Header />
<ReadingProgress />
<article className="container-narrow 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>
<div className="flex flex-wrap items-center gap-2 mb-3">
{(article.tags || []).map(t => (
<Link key={t} href={`/tag/${encodeURIComponent(t)}`} className="tag">#{t}</Link>
))}
</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">
{article.title}
</h1>
<div className="flex items-center flex-wrap gap-x-3 gap-y-2 text-xs sm:text-sm mute pb-6 sm:pb-8 mb-6 sm:mb-8 border-b-soft">
<span>{article.author}</span>
<span aria-hidden>·</span>
<span>{formatDate(article.published_at)}</span>
{article.reading_time && (
<>
<span aria-hidden>·</span>
<span className="inline-flex items-center gap-1">
<Clock className="w-3.5 h-3.5" /> {article.reading_time} мин чтения
</span>
</>
)}
<span className="ml-auto">
<ShareButton title={article.title} />
</span>
</div>
{article.cover_url && (
<div className="mb-8 sm:mb-10 -mx-4 sm:mx-0">
<img
src={article.cover_url}
alt={article.title}
className="w-full sm:rounded-xl"
style={{ aspectRatio: '16/9', objectFit: 'cover' }}
/>
</div>
)}
<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"
dangerouslySetInnerHTML={{ __html: html }}
/>
<ArticleMeta article={article} />
</article>
{related.length > 0 && (
<section className="container-wide pb-12 sm:pb-16">
<h2 className="text-xs sm:text-sm font-medium uppercase tracking-widest mute mb-4 sm:mb-5">
Похожее
</h2>
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-5">
{related.map(a => <ArticleCard key={a.id} article={a} />)}
</div>
</section>
)}
<ScrollToTop />
<Footer />
</>
);
}