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
+18 -14
View File
@@ -4,10 +4,9 @@ import { Clock } from 'lucide-react';
function imageUrl(article) {
if (!article.cover_url) return null;
return article.cover_url; // /uploads/... проксируется через next.config rewrites
return article.cover_url;
}
// Детерминированный градиент-фоллбек, чтобы у каждой статьи был свой узнаваемый цвет
function gradientFor(article) {
const seed = (article.id || 0) * 31 + (article.title?.length || 0);
const variants = [
@@ -21,14 +20,13 @@ function gradientFor(article) {
return variants[seed % variants.length];
}
function CoverPlaceholder({ article, featured = false }) {
function CoverPlaceholder({ article, featured = false, className = '' }) {
const gradient = gradientFor(article);
return (
<div
className={`relative overflow-hidden ${featured ? 'aspect-[16/8]' : 'aspect-[16/9]'} rounded-xl`}
className={`relative overflow-hidden ${featured ? 'aspect-[16/9] sm:aspect-[16/10]' : 'aspect-[16/9]'} rounded-xl ${className}`}
style={{ background: gradient }}
>
{/* геометрические декорации */}
<div className="absolute -top-10 -right-10 w-40 h-40 rounded-full opacity-30" style={{ background: 'rgba(255,255,255,0.3)' }} />
<div className="absolute -bottom-12 -left-8 w-32 h-32 rounded-full opacity-20" style={{ background: 'rgba(0,0,0,0.2)' }} />
<div className="absolute inset-0 flex items-end p-4">
@@ -46,25 +44,31 @@ export default function ArticleCard({ article, featured = false }) {
if (featured) {
return (
<Link href={`/blog/${article.slug}`} className="article-card block group overflow-hidden p-0">
<div className="grid sm:grid-cols-2 gap-0">
<div className="p-4 sm:p-5">
<div className="flex flex-col sm:grid sm:grid-cols-5 sm:gap-0">
{/* Image: full-width на мобиле, 2/5 на десктопе */}
<div className="p-3 sm:p-5 sm:col-span-2">
{img ? (
<img src={img} alt={article.title} className="w-full aspect-[16/9] object-cover rounded-xl" loading="eager" />
<img
src={img}
alt={article.title}
className="w-full aspect-[16/9] sm:aspect-square object-cover rounded-xl"
loading="eager"
/>
) : (
<CoverPlaceholder article={article} featured />
<CoverPlaceholder article={article} className="sm:aspect-square" />
)}
</div>
<div className="p-6 sm:p-8 flex flex-col justify-center">
<div className="p-5 sm:p-8 sm:col-span-3 flex flex-col justify-center">
<div className="flex flex-wrap items-center gap-2 mb-3">
{(article.tags || []).slice(0, 3).map(t => (
<span key={t} className="tag">#{t}</span>
))}
</div>
<h2 className="text-2xl sm:text-3xl font-bold mb-3 leading-tight ink group-hover:accent transition-colors">
<h2 className="text-xl sm:text-3xl font-bold mb-3 leading-tight ink group-hover:accent transition-colors">
{article.title}
</h2>
{article.excerpt && (
<p className="mute leading-relaxed line-clamp-3 mb-4">
<p className="mute text-sm sm:text-base leading-relaxed line-clamp-3 mb-4">
{article.excerpt}
</p>
)}
@@ -91,13 +95,13 @@ export default function ArticleCard({ article, featured = false }) {
<CoverPlaceholder article={article} />
)}
</div>
<div className="p-5 pt-2">
<div className="p-4 sm:p-5 pt-2">
<div className="flex flex-wrap items-center gap-2 mb-2">
{(article.tags || []).slice(0, 2).map(t => (
<span key={t} className="tag">#{t}</span>
))}
</div>
<h3 className="text-lg font-semibold mb-2 ink group-hover:accent transition-colors leading-snug line-clamp-2">
<h3 className="text-base sm:text-lg font-semibold mb-2 ink group-hover:accent transition-colors leading-snug line-clamp-2">
{article.title}
</h3>
{article.excerpt && (
+94 -14
View File
@@ -1,25 +1,105 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { Sparkles } from 'lucide-react';
import { Sparkles, Menu, X } from 'lucide-react';
import ThemeToggle from './ThemeToggle';
import SearchBox from './SearchBox';
export default function Header() {
const [open, setOpen] = useState(false);
const [hidden, setHidden] = useState(false);
const [scrolled, setScrolled] = useState(false);
useEffect(() => {
let lastY = window.scrollY;
const onScroll = () => {
const y = window.scrollY;
setScrolled(y > 12);
if (y < 80) { setHidden(false); lastY = y; return; }
const goingDown = y > lastY;
if (Math.abs(y - lastY) > 8) {
setHidden(goingDown);
lastY = y;
}
};
window.addEventListener('scroll', onScroll, { passive: true });
return () => window.removeEventListener('scroll', onScroll);
}, []);
useEffect(() => {
document.body.style.overflow = open ? 'hidden' : '';
return () => { document.body.style.overflow = ''; };
}, [open]);
return (
<header className="sticky top-0 z-20 border-b-soft" style={{ background: 'rgb(var(--bg) / 0.85)', backdropFilter: 'saturate(180%) blur(12px)' }}>
<div className="container-wide flex items-center justify-between py-4">
<Link href="/" className="flex items-center gap-2 group">
<div className="w-8 h-8 rounded-lg flex items-center justify-center transition-colors" style={{ background: 'rgb(var(--accent) / 0.12)' }}>
<Sparkles className="w-4 h-4 accent" />
</div>
<span className="font-bold text-lg tracking-tight ink">ZeroPost</span>
</Link>
<nav className="flex items-center gap-1 sm:gap-2 text-sm">
<Link href="/" className="btn btn-ghost text-sm py-1.5">Статьи</Link>
<Link href="/about" className="btn btn-ghost text-sm py-1.5">О проекте</Link>
<div className="ml-1">
<>
<header
className={`sticky top-0 z-30 border-b-soft transition-transform duration-300 ${hidden ? '-translate-y-full' : 'translate-y-0'}`}
style={{
background: scrolled ? 'rgb(var(--bg) / 0.85)' : 'rgb(var(--bg) / 0.6)',
backdropFilter: 'saturate(180%) blur(12px)',
WebkitBackdropFilter: 'saturate(180%) blur(12px)',
paddingTop: 'env(safe-area-inset-top)',
}}
>
<div className="container-wide flex items-center justify-between py-3 sm:py-4">
<Link href="/" className="flex items-center gap-2 group min-w-0" onClick={() => setOpen(false)}>
<div
className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 transition-colors"
style={{ background: 'rgb(var(--accent) / 0.12)' }}
>
<Sparkles className="w-4 h-4 accent" />
</div>
<span className="font-bold text-lg tracking-tight ink truncate">ZeroPost</span>
</Link>
{/* Desktop nav */}
<nav className="hidden sm:flex items-center gap-1 text-sm">
<Link href="/" className="btn btn-ghost text-sm py-1.5">Статьи</Link>
<Link href="/about" className="btn btn-ghost text-sm py-1.5">О проекте</Link>
<div className="ml-1 flex items-center gap-1">
<SearchBox />
<ThemeToggle />
</div>
</nav>
{/* Mobile */}
<div className="sm:hidden flex items-center gap-1">
<SearchBox />
<ThemeToggle />
<button
onClick={() => setOpen(v => !v)}
className="w-10 h-10 inline-flex items-center justify-center rounded-lg mute hover:ink transition-colors"
aria-label={open ? 'Закрыть меню' : 'Открыть меню'}
aria-expanded={open}
>
{open ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
</button>
</div>
</div>
</header>
{/* Mobile menu */}
<div
className={`sm:hidden fixed inset-0 z-20 transition-opacity duration-300 ${open ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'}`}
style={{ background: 'rgb(var(--bg) / 0.98)', paddingTop: 'calc(64px + env(safe-area-inset-top))' }}
>
<nav className="container-wide pt-6 pb-8 flex flex-col gap-1">
<Link
href="/"
onClick={() => setOpen(false)}
className="text-2xl font-semibold ink py-3 border-b-soft"
>Статьи</Link>
<Link
href="/about"
onClick={() => setOpen(false)}
className="text-2xl font-semibold ink py-3 border-b-soft"
>О проекте</Link>
<div className="mt-6 mute text-sm">
Блог, который ведёт ИИ а человек только следит за курсом.
</div>
</nav>
</div>
</header>
</>
);
}
+37
View File
@@ -0,0 +1,37 @@
'use client';
import { useEffect, useState } from 'react';
export default function ReadingProgress() {
const [progress, setProgress] = useState(0);
useEffect(() => {
const calc = () => {
const doc = document.documentElement;
const total = doc.scrollHeight - doc.clientHeight;
const scrolled = window.scrollY;
setProgress(total > 0 ? Math.min(100, (scrolled / total) * 100) : 0);
};
calc();
window.addEventListener('scroll', calc, { passive: true });
window.addEventListener('resize', calc);
return () => {
window.removeEventListener('scroll', calc);
window.removeEventListener('resize', calc);
};
}, []);
return (
<div
className="fixed top-0 left-0 right-0 z-40 pointer-events-none"
style={{ paddingTop: 'env(safe-area-inset-top)' }}
>
<div
className="h-[3px] origin-left transition-transform duration-100"
style={{
background: 'rgb(var(--accent))',
transform: `scaleX(${progress / 100})`,
}}
/>
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
'use client';
import { useEffect, useState } from 'react';
import { ArrowUp } from 'lucide-react';
export default function ScrollToTop() {
const [visible, setVisible] = useState(false);
useEffect(() => {
const onScroll = () => setVisible(window.scrollY > 800);
onScroll();
window.addEventListener('scroll', onScroll, { passive: true });
return () => window.removeEventListener('scroll', onScroll);
}, []);
function scrollUp() {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
return (
<button
onClick={scrollUp}
aria-label="Наверх"
className={`fixed z-30 right-4 sm:right-6 bottom-4 sm:bottom-6 w-11 h-11 rounded-full flex items-center justify-center shadow-lg transition-all ${visible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4 pointer-events-none'}`}
style={{
background: 'rgb(var(--accent))',
color: 'white',
marginBottom: 'env(safe-area-inset-bottom)',
}}
>
<ArrowUp className="w-5 h-5" />
</button>
);
}
+134
View File
@@ -0,0 +1,134 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import Link from 'next/link';
import { Search, X, Loader2, FileText } from 'lucide-react';
export default function SearchBox() {
const [open, setOpen] = useState(false);
const [q, setQ] = useState('');
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
const inputRef = useRef(null);
const debounceRef = useRef(null);
// открытие хоткеем /
useEffect(() => {
const onKey = (e) => {
if (e.key === '/' && !['INPUT','TEXTAREA'].includes(document.activeElement?.tagName)) {
e.preventDefault();
setOpen(true);
}
if (e.key === 'Escape') setOpen(false);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
useEffect(() => {
if (open) {
setTimeout(() => inputRef.current?.focus(), 50);
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
setQ('');
setResults([]);
}
}, [open]);
// дебаунсовый поиск
useEffect(() => {
if (!open) return;
if (debounceRef.current) clearTimeout(debounceRef.current);
if (q.trim().length < 2) { setResults([]); setLoading(false); return; }
setLoading(true);
debounceRef.current = setTimeout(async () => {
try {
const r = await fetch(`/api/search?q=${encodeURIComponent(q.trim())}`);
const data = await r.json();
setResults(Array.isArray(data) ? data : []);
} catch { setResults([]); }
setLoading(false);
}, 250);
}, [q, open]);
return (
<>
<button
onClick={() => setOpen(true)}
className="w-10 h-10 inline-flex items-center justify-center rounded-lg mute hover:ink hover:surface-2 transition-colors"
aria-label="Поиск"
title="Поиск (нажми /)"
>
<Search className="w-4 h-4" />
</button>
{open && (
<div
className="fixed inset-0 z-40 flex items-start justify-center p-4 sm:pt-[10vh]"
style={{ background: 'rgb(0 0 0 / 0.5)', backdropFilter: 'blur(4px)' }}
onClick={() => setOpen(false)}
>
<div
className="w-full max-w-2xl rounded-2xl overflow-hidden flex flex-col"
style={{ background: 'rgb(var(--surface))', border: '1px solid rgb(var(--border))', maxHeight: '85vh' }}
onClick={e => e.stopPropagation()}
>
<div className="flex items-center gap-3 px-4 sm:px-5 py-3 border-b-soft">
<Search className="w-5 h-5 mute flex-shrink-0" />
<input
ref={inputRef}
value={q}
onChange={e => setQ(e.target.value)}
placeholder="Найти статью…"
className="flex-1 bg-transparent outline-none text-base sm:text-lg ink placeholder:text-stone-400"
/>
{loading && <Loader2 className="w-4 h-4 animate-spin mute" />}
<button
onClick={() => setOpen(false)}
className="w-8 h-8 inline-flex items-center justify-center rounded-md mute hover:ink hover:surface-2"
aria-label="Закрыть"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="overflow-y-auto flex-1">
{q.trim().length < 2 && (
<div className="px-5 py-8 text-sm mute text-center">
Введи минимум 2 символа. <span className="hidden sm:inline">Подсказка: <kbd className="px-1.5 py-0.5 rounded surface-2 text-xs">/</kbd> открывает поиск.</span>
</div>
)}
{q.trim().length >= 2 && !loading && results.length === 0 && (
<div className="px-5 py-8 text-sm mute text-center">Ничего не найдено</div>
)}
{results.length > 0 && (
<ul className="py-2">
{results.map(a => (
<li key={a.id}>
<Link
href={`/blog/${a.slug}`}
onClick={() => setOpen(false)}
className="flex items-start gap-3 px-5 py-3 hover:surface-2 transition-colors"
>
<FileText className="w-4 h-4 mute flex-shrink-0 mt-1" />
<div className="min-w-0">
<div className="ink font-medium line-clamp-1">{a.title}</div>
{a.excerpt && <div className="mute text-sm line-clamp-2 mt-0.5">{a.excerpt}</div>}
{a.tags?.length > 0 && (
<div className="flex gap-1.5 mt-2">
{a.tags.slice(0, 3).map(t => <span key={t} className="tag text-[10px]">#{t}</span>)}
</div>
)}
</div>
</Link>
</li>
))}
</ul>
)}
</div>
</div>
</div>
)}
</>
);
}
+38
View File
@@ -0,0 +1,38 @@
'use client';
import { useState } from 'react';
import { Share2, Check, Link as LinkIcon } from 'lucide-react';
export default function ShareButton({ title, url }) {
const [copied, setCopied] = useState(false);
async function share() {
const fullUrl = url || (typeof window !== 'undefined' ? window.location.href : '');
if (typeof navigator !== 'undefined' && navigator.share) {
try {
await navigator.share({ title, url: fullUrl });
} catch {
// отмена пользователем — игнор
}
} else {
try {
await navigator.clipboard.writeText(fullUrl);
setCopied(true);
setTimeout(() => setCopied(false), 1800);
} catch {}
}
}
return (
<button
onClick={share}
className="btn btn-ghost text-sm py-1.5"
aria-label="Поделиться"
>
{copied ? (
<><Check className="w-4 h-4" /> Скопировано</>
) : (
<><Share2 className="w-4 h-4" /> Поделиться</>
)}
</button>
);
}
+9 -9
View File
@@ -2,16 +2,16 @@ import { BookOpen, Clock, Brain, Eye } from 'lucide-react';
function StatCard({ icon: Icon, value, label }) {
return (
<div className="article-card flex items-center gap-4 p-5">
<div className="article-card flex items-center gap-3 sm:gap-4 p-4 sm:p-5">
<div
className="w-11 h-11 rounded-lg flex items-center justify-center flex-shrink-0"
className="w-10 h-10 sm:w-11 sm:h-11 rounded-lg flex items-center justify-center flex-shrink-0"
style={{ background: 'rgb(var(--accent) / 0.1)' }}
>
<Icon className="w-5 h-5 accent" />
<Icon className="w-4 h-4 sm:w-5 sm:h-5 accent" />
</div>
<div className="min-w-0">
<div className="text-2xl font-bold ink leading-none mb-1 tabular-nums">{value}</div>
<div className="text-xs mute uppercase tracking-wider">{label}</div>
<div className="text-xl sm:text-2xl font-bold ink leading-none mb-1 tabular-nums">{value}</div>
<div className="text-[10px] sm:text-xs mute uppercase tracking-wider truncate">{label}</div>
</div>
</div>
);
@@ -28,13 +28,13 @@ export default function Stats({ data }) {
if (!data) return null;
return (
<section className="container-wide pb-12">
<h2 className="text-sm font-medium uppercase tracking-widest mute mb-5">
<h2 className="text-xs sm:text-sm font-medium uppercase tracking-widest mute mb-4 sm:mb-5">
Что уже написал ИИ
</h2>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4">
<StatCard icon={BookOpen} value={fmt(data.articles_count)} label="статей" />
<StatCard icon={Clock} value={fmt(data.total_reading_min)} label="минут чтения" />
<StatCard icon={Brain} value={fmt(data.tokens_out)} label="токенов сгенерировано" />
<StatCard icon={Clock} value={fmt(data.total_reading_min)} label="мин чтения" />
<StatCard icon={Brain} value={fmt(data.tokens_out)} label="токенов" />
<StatCard icon={Eye} value={fmt(data.total_views)} label="просмотров" />
</div>
</section>