From 4702614896cfba84569f70dd73d357b8d4182bea Mon Sep 17 00:00:00 2001 From: Alexey Pavlov Date: Sun, 31 May 2026 09:43:11 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BC=D0=BE=D0=B1=D0=B8=D0=BB=D1=8C?= =?UTF-8?q?=D0=BD=D0=B0=D1=8F=20=D0=B2=D0=B5=D1=80=D1=81=D0=B8=D1=8F=20+?= =?UTF-8?q?=20=D0=BF=D0=BE=D0=B8=D1=81=D0=BA=20+=20SEO-=D0=B8=D0=BD=D1=84?= =?UTF-8?q?=D1=80=D0=B0=D1=81=D1=82=D1=80=D1=83=D0=BA=D1=82=D1=83=D1=80?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Мобилка: - 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 --- app/api/search/route.js | 28 +++++++ app/blog/[slug]/page.js | 64 +++++++++++++--- app/globals.css | 68 +++++++++++------ app/layout.js | 16 +++- app/page.js | 12 +-- app/robots.js | 9 +++ app/rss.xml/route.js | 44 +++++++++++ app/sitemap.js | 31 ++++++++ components/ArticleCard.js | 32 ++++---- components/Header.js | 108 +++++++++++++++++++++++---- components/ReadingProgress.js | 37 ++++++++++ components/ScrollToTop.js | 33 +++++++++ components/SearchBox.js | 134 ++++++++++++++++++++++++++++++++++ components/ShareButton.js | 38 ++++++++++ components/Stats.js | 18 ++--- 15 files changed, 595 insertions(+), 77 deletions(-) create mode 100644 app/api/search/route.js create mode 100644 app/robots.js create mode 100644 app/rss.xml/route.js create mode 100644 app/sitemap.js create mode 100644 components/ReadingProgress.js create mode 100644 components/ScrollToTop.js create mode 100644 components/SearchBox.js create mode 100644 components/ShareButton.js diff --git a/app/api/search/route.js b/app/api/search/route.js new file mode 100644 index 0000000..b13136b --- /dev/null +++ b/app/api/search/route.js @@ -0,0 +1,28 @@ +import { NextResponse } from 'next/server'; +import { listArticles } from '@/lib/engine'; + +// GET /api/search?q=foo — простой поиск по title/excerpt/tags +export async function GET(req) { + const q = (req.nextUrl.searchParams.get('q') || '').trim().toLowerCase(); + if (!q || q.length < 2) return NextResponse.json([]); + + try { + const all = await listArticles({ limit: 100 }); + const tokens = q.split(/\s+/).filter(Boolean); + + const matched = all + .map(a => { + const haystack = [a.title, a.excerpt, (a.tags || []).join(' ')] + .filter(Boolean).join(' ').toLowerCase(); + const score = tokens.reduce((acc, t) => acc + (haystack.includes(t) ? 1 : 0), 0); + return { ...a, score }; + }) + .filter(a => a.score > 0) + .sort((a, b) => b.score - a.score) + .slice(0, 12); + + return NextResponse.json(matched); + } catch (err) { + return NextResponse.json({ error: err.message }, { status: 500 }); + } +} diff --git a/app/blog/[slug]/page.js b/app/blog/[slug]/page.js index 749fac2..b4fa6eb 100644 --- a/app/blog/[slug]/page.js +++ b/app/blog/[slug]/page.js @@ -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 ( <>
-
- + + +
+ Все статьи -
+
{(article.tags || []).map(t => ( #{t} ))}
-

+

{article.title}

-
+
{article.author} - · + · {formatDate(article.published_at)} {article.reading_time && ( <> - · + · {article.reading_time} мин чтения )} + + +
{article.cover_url && ( -
+
{article.title}
)}
-
+

Статья сгенерирована ИИ под редакторским присмотром.

+ + {related.length > 0 && ( +
+

+ Похожее +

+
+ {related.map(a => )} +
+
+ )} + +