334b2f51df
- Журнальная главная: 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
175 lines
6.7 KiB
JavaScript
175 lines
6.7 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 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 />
|
||
</>
|
||
);
|
||
}
|