Files
zeropost-web/components/SearchBox.js
T
Alexey Pavlov 4702614896 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
2026-05-31 09:43:11 +03:00

135 lines
5.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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>
)}
</>
);
}