import { unwrap } from '@pipu/core'; import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { useNavigate } from '@tanstack/react-router'; import { useEffect, useId, useRef, useState, type KeyboardEvent } from 'react'; import { useTranslation } from 'react-i18next'; import { useSession } from '../app/session'; import { Cover } from '../entities/catalog'; import { formatDuration } from '../shared/lib/format'; import { searchIcon } from '../shared/ui/icons'; import { cx, Icon } from '../shared/ui/ui'; import s from './SearchBox.module.css'; const DEBOUNCE_MS = 150; type Item = { key: string; group: 'artists' | 'albums' | 'tracks'; title: string; subtitle: string; cover: { id: string; cover_url?: string | null; cover_colors?: string[] }; round?: boolean; go: () => void; }; /** Поиск в топбаре: подсказки по мере ввода, Enter — страница поиска или выбранный элемент. */ export function SearchBox() { const { t } = useTranslation(); const session = useSession(); const navigate = useNavigate(); const listId = useId(); const inputRef = useRef(null); const [q, setQ] = useState(''); const [debounced, setDebounced] = useState(''); const [open, setOpen] = useState(false); const [active, setActive] = useState(-1); useEffect(() => { const id = setTimeout(() => setDebounced(q.trim()), DEBOUNCE_MS); return () => clearTimeout(id); }, [q]); // «/» или Ctrl+K — фокус в поиск (если пользователь не печатает в другом поле). useEffect(() => { const onKey = (e: globalThis.KeyboardEvent) => { const target = e.target as HTMLElement; const typing = target.closest('input, textarea, select, [contenteditable="true"]'); if ((e.key === '/' && !typing) || (e.key.toLowerCase() === 'k' && (e.ctrlKey || e.metaKey))) { e.preventDefault(); inputRef.current?.focus(); } }; document.addEventListener('keydown', onKey); return () => document.removeEventListener('keydown', onKey); }, []); const suggest = useQuery({ queryKey: ['suggest', debounced], enabled: debounced !== '', placeholderData: keepPreviousData, // signal отменяет устаревший запрос, когда пользователь печатает дальше. queryFn: async ({ signal }) => unwrap(await session.client.GET('/api/v1/search/suggest', { params: { query: { q: debounced } }, signal })), staleTime: 30_000, }); const data = debounced ? suggest.data : undefined; const close = () => { setOpen(false); setActive(-1); }; const goSearch = () => { const query = q.trim(); if (!query) return; close(); inputRef.current?.blur(); void navigate({ to: '/search', search: { q: query } }); }; const items: Item[] = []; if (data) { for (const a of data.artists) items.push({ key: `ar-${a.id}`, group: 'artists', title: a.name, subtitle: t('search.artist_kind'), cover: { id: a.id }, round: true, go: () => void navigate({ to: '/artist/$id', params: { id: a.id } }), }); for (const al of data.albums) items.push({ key: `al-${al.id}`, group: 'albums', title: al.title, subtitle: [al.artist.name, al.year].filter(Boolean).join(' · '), cover: al, go: () => void navigate({ to: '/album/$id', params: { id: al.id } }), }); for (const tr of data.tracks) items.push({ key: `tr-${tr.id}`, group: 'tracks', title: tr.title, subtitle: `${tr.artists.map((a) => a.name).join(', ')} · ${formatDuration(tr.duration_ms)}`, cover: tr.album, // Воспроизведение — M3; пока трек открывает свой альбом. go: () => void navigate({ to: '/album/$id', params: { id: tr.album.id } }), }); } const choose = (i: number) => { const it = items[i]; if (!it) return goSearch(); close(); setQ(''); inputRef.current?.blur(); it.go(); }; const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'ArrowDown') { e.preventDefault(); setOpen(true); setActive((i) => Math.min(items.length - 1, i + 1)); } else if (e.key === 'ArrowUp') { e.preventDefault(); setActive((i) => Math.max(-1, i - 1)); } else if (e.key === 'Enter') { e.preventDefault(); choose(active); } else if (e.key === 'Escape') { close(); inputRef.current?.blur(); } }; const showList = open && debounced !== '' && data !== undefined; const groups: Item['group'][] = ['artists', 'albums', 'tracks']; const optionId = (i: number) => `${listId}-${i}`; return (
= 0 ? optionId(active) : undefined} onChange={(e) => { setQ(e.target.value); setOpen(true); setActive(-1); }} onFocus={() => setOpen(true)} onBlur={() => setTimeout(close, 120)} onKeyDown={onKeyDown} /> / {showList && (
{data.corrected_query && (
{t('search.corrected', { query: data.corrected_query })}
)}
{items.length === 0 &&
{t('search.nothing')}
} {groups.map((g) => { const groupItems = items.map((it, i) => ({ it, i })).filter(({ it }) => it.group === g); if (groupItems.length === 0) return null; return (
{t(`search.${g}`)}
{groupItems.map(({ it, i }) => (
e.preventDefault()} onMouseEnter={() => setActive(i)} onClick={() => choose(i)} > {it.title} {it.subtitle}
))}
); })}
)}
); }