Files
zeropost-web/app/blog/[slug]/page.js
T
Nik (Claude) 334b2f51df feat: журнальная главная, страница Зеро, TG-баннер, stats, auto-publish UI
- Журнальная главная: hero, CategoryRow, PopularBlock, RecentBlock (Сегодня/Вчера/Неделя)
- ArticleCard: 3 размера (hero/regular/compact), цветной badge без дублей тегов
- ArticleCoverSVG: 6 брендовых палитр, аватар Зеро в углу вместо #ZEROPOST
- /about/zero: страница персонажа с галереей 8 поз
- Footer: TG-баннер с аватаром Зеро на каждой странице
- Конец статьи: блок «Понравилась? → Подписаться на канал»
- ChannelEditor: 4 вкладки (Настройки/Расписание/Авто-публикация/Ручная)
- AutoPublishTab: toggle, категории, delay, template, live preview
- ArticlePicker: typeahead с was_sent_to_channel / next_scheduled_at флагами
- /admin/channels/[id]/stats: график роста подписчиков (recharts)
- Dashboard: блок TG-статистики (подписчики, delta 24h/7d, постов)
- Header: упрощён до 2 пунктов desktop + расширенное мобильное меню
- AutogenPanel: корректные time-picker'ы, calcNextRun с учётом last_run_at
2026-06-07 14:04:09 +03:00

175 lines
6.7 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 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 { renderMarkdownWithToc, 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: 'Статья не найдена' };
const ogImages = article.cover_url
? [{ url: article.cover_url.startsWith('http') ? article.cover_url : `https://zeropost.ru${article.cover_url}`, width: 1600, height: 900, alt: article.title }]
: [{ url: 'https://zeropost.ru/og-default.png', width: 1200, height: 630 }];
return {
title: article.seo_title || article.title,
description: article.seo_descr || article.excerpt,
alternates: { canonical: `https://zeropost.ru/blog/${slug}` },
openGraph: {
title: article.title,
description: article.excerpt,
type: 'article',
url: `https://zeropost.ru/blog/${slug}`,
publishedTime: article.published_at,
tags: article.tags || [],
images: ogImages,
},
twitter: {
card: 'summary_large_image',
title: article.seo_title || article.title,
description: article.seo_descr || article.excerpt,
images: ogImages.map(i => i.url),
},
};
}
async function loadRelated(article) {
if (!article.tags?.length) return [];
const all = await listArticles({ limit: 20 });
return 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);
}
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, toc } = renderMarkdownWithToc(contentWithoutH1);
return (
<>
<Header />
<ReadingProgress />
<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>
<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 max-w-4xl">
{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>
<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' }}
/>
) : (
<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 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">
<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>
)}
{/* TG-банер после контента */}
<section className="container-narrow pb-10">
<a
href="https://t.me/zeropostru"
target="_blank"
rel="noopener noreferrer"
className="flex flex-col sm:flex-row items-center gap-4 p-6 rounded-2xl no-underline group transition-all"
style={{ background: 'rgb(var(--accent) / 0.06)', border: '1px solid rgb(var(--accent) / 0.15)' }}
>
<img src="/uploads/zero-avatar.webp" alt="Зеро"
className="w-16 h-16 rounded-xl object-cover shrink-0" />
<div className="flex-1 text-center sm:text-left">
<div className="font-semibold ink mb-1">Понравилась заметка?</div>
<div className="text-sm mute">
Зеро публикует новые материалы каждый день в Telegram.
Подпишитесь следующая уже завтра.
</div>
</div>
<div
className="shrink-0 inline-flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium text-white"
style={{ background: 'rgb(var(--accent))' }}
>
В канал
</div>
</a>
</section>
<ScrollToTop />
<Footer />
</>
);
}