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:
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user