feat: мобильная версия + поиск + SEO-инфраструктура
Мобилка: - Header: hide-on-scroll, мобильный burger-menu, тонкая адаптация - Hero: текст и кнопки оптимизированы под узкие экраны (full-width buttons) - ArticleCard featured: на мобилке в столбик, картинка сверху - Stats: компактная сетка 2x2 с уменьшенным шрифтом - Глобально: scroll-behavior smooth, safe-area-inset, tap targets 40px+ - prefers-reduced-motion respected Страница статьи: - ReadingProgress: прогресс-бар сверху при скролле - ScrollToTop: круглая кнопка наверху после 800px скролла - ShareButton: Web Share API на мобилках, копирование URL на десктопе - Related articles: подбираем по пересечению тегов (max 3) - Мобильная типографика: prose-base sm:prose-lg, leading-relaxed SEO/инфра: - /api/search: простой поиск по title/excerpt/tags с подсветкой и скорингом - SearchBox: оверлей с / хоткеем, дебаунс 250ms, мобиле-friendly - /rss.xml: полноценный RSS-фид - sitemap.xml: динамический через next sitemap() - robots.txt: динамический - viewport metadata + theme-color для светлой/тёмной темы - alternates rel=alternate type=application/rss+xml
This commit is contained in:
+52
-12
@@ -2,7 +2,11 @@ import { notFound } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import Header from '@/components/Header';
|
||||
import Footer from '@/components/Footer';
|
||||
import { getArticle } from '@/lib/engine';
|
||||
import ReadingProgress from '@/components/ReadingProgress';
|
||||
import ScrollToTop from '@/components/ScrollToTop';
|
||||
import ShareButton from '@/components/ShareButton';
|
||||
import ArticleCard from '@/components/ArticleCard';
|
||||
import { getArticle, listArticles } from '@/lib/engine';
|
||||
import { renderMarkdown, formatDate } from '@/lib/markdown';
|
||||
import { Clock, ArrowLeft } from 'lucide-react';
|
||||
|
||||
@@ -21,70 +25,106 @@ export async function generateMetadata({ params }) {
|
||||
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 />
|
||||
<article className="container-narrow pt-10 pb-16">
|
||||
<Link href="/" className="btn btn-ghost text-sm mb-6 -ml-2">
|
||||
<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-4">
|
||||
<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-3xl sm:text-5xl font-bold leading-tight 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">
|
||||
{article.title}
|
||||
</h1>
|
||||
|
||||
<div className="flex items-center gap-4 text-sm mute pb-8 mb-8 border-b-soft">
|
||||
<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>·</span>
|
||||
<span aria-hidden>·</span>
|
||||
<span>{formatDate(article.published_at)}</span>
|
||||
{article.reading_time && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<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-10 -mx-4 sm:mx-0">
|
||||
<div className="mb-8 sm:mb-10 -mx-4 sm:mx-0">
|
||||
<img
|
||||
src={article.cover_url}
|
||||
alt={article.title}
|
||||
className="w-full rounded-xl"
|
||||
className="w-full sm:rounded-xl"
|
||||
style={{ aspectRatio: '16/9', objectFit: 'cover' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="prose prose-lg max-w-none font-serif prose-headings:font-sans"
|
||||
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 }}
|
||||
/>
|
||||
|
||||
<div className="mt-16 pt-8 border-t-soft text-center">
|
||||
<div className="mt-12 pt-6 border-t-soft text-center">
|
||||
<p className="mute text-sm">Статья сгенерирована ИИ под редакторским присмотром.</p>
|
||||
</div>
|
||||
</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 />
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user