4702614896
Мобилка: - 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
135 lines
5.2 KiB
JavaScript
135 lines
5.2 KiB
JavaScript
'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>
|
||
)}
|
||
</>
|
||
);
|
||
}
|