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:
Alexey Pavlov
2026-05-31 09:43:11 +03:00
parent 9e77f920c1
commit 4702614896
15 changed files with 595 additions and 77 deletions
+28
View File
@@ -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 });
}
}
+52 -12
View File
@@ -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 />
</>
);
+47 -21
View File
@@ -4,15 +4,15 @@
/* === Theme tokens === */
:root {
--bg: 250 250 249; /* почти белый, тёплый */
--surface: 255 255 255; /* карточки */
--surface-2: 245 245 244; /* приглушённый фон */
--bg: 250 250 249;
--surface: 255 255 255;
--surface-2: 245 245 244;
--border: 231 229 228;
--ink: 28 25 23; /* основной текст */
--mute: 120 113 108; /* приглушённый текст */
--accent: 16 185 129; /* emerald-500 */
--accent-2: 5 150 105; /* emerald-600 */
--accent-soft: 209 250 229; /* emerald-100 */
--ink: 28 25 23;
--mute: 120 113 108;
--accent: 16 185 129;
--accent-2: 5 150 105;
--accent-soft: 209 250 229;
}
.dark {
@@ -31,28 +31,39 @@
html {
background: rgb(var(--bg));
color: rgb(var(--ink));
scroll-behavior: smooth;
/* лучше для мобильных WebKit */
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: transparent;
}
body {
@apply font-sans antialiased;
/* safe areas на iOS */
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
}
body { @apply font-sans antialiased; }
::selection { background: rgb(var(--accent) / 0.25); }
/* tap target минимум 44px для кнопок и ссылок в навигации/действиях */
button, a.btn {
min-height: 40px;
}
/* плавный скролл к якорям с учётом sticky header */
:where(h1, h2, h3, h4, [id]) {
scroll-margin-top: 80px;
}
}
@layer components {
.btn {
@apply inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg font-medium transition-all disabled:opacity-50 disabled:cursor-not-allowed;
}
.btn-primary {
background: rgb(var(--accent));
color: white;
}
.btn-primary { background: rgb(var(--accent)); color: white; }
.btn-primary:hover { background: rgb(var(--accent-2)); }
.btn-ghost {
color: rgb(var(--mute));
}
.btn-ghost:hover {
background: rgb(var(--surface-2));
color: rgb(var(--ink));
}
.btn-ghost { color: rgb(var(--mute)); }
.btn-ghost:hover { background: rgb(var(--surface-2)); color: rgb(var(--ink)); }
.container-narrow { @apply max-w-3xl mx-auto px-4; }
.container-wide { @apply max-w-6xl mx-auto px-4; }
@@ -70,10 +81,15 @@
.dark .article-card:hover {
box-shadow: 0 10px 25px -10px rgb(0 0 0 / 0.5);
}
/* на мобилке hover-эффект transform не нужен (тач) */
@media (hover: none) {
.article-card:hover { transform: none; box-shadow: none; }
.article-card:active { transform: scale(0.99); }
}
.tag {
display: inline-block;
@apply text-xs px-2.5 py-1 rounded-full transition-colors;
@apply text-xs px-2.5 py-1 rounded-full transition-colors whitespace-nowrap;
background: rgb(var(--surface-2));
color: rgb(var(--mute));
}
@@ -91,3 +107,13 @@
.border-b-soft { border-bottom: 1px solid rgb(var(--border)); }
.border-t-soft { border-top: 1px solid rgb(var(--border)); }
}
/* Уважаем системную настройку "меньше анимаций" */
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
+15 -1
View File
@@ -12,9 +12,22 @@ export const metadata = {
locale: 'ru_RU',
siteName: 'ZeroPost',
},
alternates: {
types: { 'application/rss+xml': '/rss.xml' },
},
};
export const viewport = {
width: 'device-width',
initialScale: 1,
maximumScale: 5,
themeColor: [
{ media: '(prefers-color-scheme: light)', color: '#fafaf9' },
{ media: '(prefers-color-scheme: dark)', color: '#0a0a0a' },
],
viewportFit: 'cover',
};
// Защита от FOUC: ставим тему до первого рендера
const themeInitScript = `
(function() {
try {
@@ -36,6 +49,7 @@ export default function RootLayout({ children }) {
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Source+Serif+Pro:ital,wght@0,400;0,600;0,700;1,400&display=swap"
rel="stylesheet"
/>
<link rel="alternate" type="application/rss+xml" title="ZeroPost RSS" href="/rss.xml" />
</head>
<body>{children}</body>
</html>
+6 -6
View File
@@ -33,7 +33,7 @@ export default async function HomePage() {
{/* Hero */}
<section className="relative">
<HeroBackground />
<div className="container-wide pt-16 pb-12 sm:pt-24 sm:pb-20 relative">
<div className="container-wide pt-12 pb-12 sm:pt-24 sm:pb-20 relative">
<Reveal>
<div className="max-w-3xl reveal">
<div
@@ -43,18 +43,18 @@ export default async function HomePage() {
<Sparkles className="w-3.5 h-3.5" />
Блог, который ведёт ИИ
</div>
<h1 className="text-4xl sm:text-6xl lg:text-7xl font-bold tracking-tight leading-[1.05] mb-5 ink">
<h1 className="text-[2.5rem] sm:text-6xl lg:text-7xl font-bold tracking-tight leading-[1.05] mb-5 ink">
Практический ИИ.<br />
<span className="mute">Без воды и хайпа.</span>
</h1>
<p className="text-lg sm:text-xl mute mb-8 max-w-2xl leading-relaxed">
<p className="text-base sm:text-xl mute mb-8 max-w-2xl leading-relaxed">
Промпты, кейсы, инструменты и разборы. Эксперимент: блог, который ведёт ИИ а человек только следит за курсом.
</p>
<div className="flex flex-wrap gap-3">
<Link href="#articles" className="btn btn-primary">
<div className="flex flex-col sm:flex-row gap-3">
<Link href="#articles" className="btn btn-primary w-full sm:w-auto">
Читать статьи <ArrowRight className="w-4 h-4" />
</Link>
<Link href="/about" className="btn btn-ghost">
<Link href="/about" className="btn btn-ghost w-full sm:w-auto">
Как это работает
</Link>
</div>
+9
View File
@@ -0,0 +1,9 @@
export default function robots() {
return {
rules: [
{ userAgent: '*', allow: '/', disallow: ['/api/'] },
],
sitemap: 'https://zeropost.ru/sitemap.xml',
host: 'https://zeropost.ru',
};
}
+44
View File
@@ -0,0 +1,44 @@
import { listArticles } from '@/lib/engine';
const SITE = 'https://zeropost.ru';
function escapeXml(s = '') {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
export async function GET() {
const articles = await listArticles({ limit: 50 });
const items = articles.map(a => `
<item>
<title>${escapeXml(a.title)}</title>
<link>${SITE}/blog/${a.slug}</link>
<guid isPermaLink="true">${SITE}/blog/${a.slug}</guid>
<pubDate>${new Date(a.published_at).toUTCString()}</pubDate>
<description>${escapeXml(a.excerpt || '')}</description>
${(a.tags || []).map(t => `<category>${escapeXml(t)}</category>`).join('')}
</item>`).join('');
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>ZeroPost</title>
<link>${SITE}</link>
<description>Блог про практическое применение ИИ</description>
<language>ru</language>
<atom:link href="${SITE}/rss.xml" rel="self" type="application/rss+xml"/>
${items}
</channel>
</rss>`;
return new Response(xml, {
headers: {
'Content-Type': 'application/rss+xml; charset=utf-8',
'Cache-Control': 'public, max-age=600, s-maxage=600',
},
});
}
+31
View File
@@ -0,0 +1,31 @@
import { listArticles, listTags } from '@/lib/engine';
const SITE = 'https://zeropost.ru';
export default async function sitemap() {
const [articles, tags] = await Promise.all([
listArticles({ limit: 200 }).catch(() => []),
listTags().catch(() => []),
]);
const staticPages = [
{ url: `${SITE}/`, lastModified: new Date(), changeFrequency: 'daily', priority: 1 },
{ url: `${SITE}/about`, lastModified: new Date(), changeFrequency: 'monthly', priority: 0.5 },
];
const articlePages = articles.map(a => ({
url: `${SITE}/blog/${a.slug}`,
lastModified: a.published_at ? new Date(a.published_at) : new Date(),
changeFrequency: 'monthly',
priority: 0.8,
}));
const tagPages = tags.map(t => ({
url: `${SITE}/tag/${encodeURIComponent(t.tag)}`,
lastModified: new Date(),
changeFrequency: 'weekly',
priority: 0.6,
}));
return [...staticPages, ...articlePages, ...tagPages];
}