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
+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>
)}
</>
);
}