import React, { useState, useEffect, useRef, useCallback } from 'react' import SearchOverlay from './SearchOverlay' const ARTWORKS_API = '/api/search/artworks' const TAGS_API = '/api/tags/search' const USERS_API = '/api/search/users' const DEBOUNCE_MS = 300 function useDebounce(value, delay) { const [debounced, setDebounced] = useState(value) useEffect(() => { const id = setTimeout(() => setDebounced(value), delay) return () => clearTimeout(id) }, [value, delay]) return debounced } const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPod|iPad/.test(navigator.platform) export default function SearchBar({ placeholder = 'Search artworks, artists, tags\u2026' }) { const [phase, setPhase] = useState('idle') // idle | opening | open | closing const [query, setQuery] = useState('') const [artworks, setArtworks] = useState([]) const [tags, setTags] = useState([]) const [users, setUsers] = useState([]) const [loading, setLoading] = useState(false) const [open, setOpen] = useState(false) const [activeIdx, setActiveIdx] = useState(-1) const [mobileOverlayPhase, setMobileOverlayPhase] = useState('closed') // closed | opening | open | closing const inputRef = useRef(null) const mobileInputRef = useRef(null) const wrapperRef = useRef(null) const abortRef = useRef(null) const openTimerRef = useRef(null) const closeTimerRef = useRef(null) const mobileOpenTimerRef = useRef(null) const mobileCloseTimerRef = useRef(null) const debouncedQuery = useDebounce(query, DEBOUNCE_MS) const isExpanded = phase === 'opening' || phase === 'open' const isMobileOverlayVisible = mobileOverlayPhase !== 'closed' // flat list of navigable items: artworks → users → tags const allItems = [ ...artworks.map(a => ({ type: 'artwork', ...a })), ...users.map(u => ({ type: 'user', ...u })), ...tags.map(t => ({ type: 'tag', ...t })), ] // ── expand / collapse ──────────────────────────────────────────────────── function expand() { clearTimeout(closeTimerRef.current) setPhase('opening') openTimerRef.current = setTimeout(() => { setPhase('open') inputRef.current?.focus() }, 80) } function collapse() { if (phase === 'idle' || phase === 'closing') return clearTimeout(openTimerRef.current) setPhase('closing') setOpen(false) setActiveIdx(-1) closeTimerRef.current = setTimeout(() => { setPhase('idle') setQuery('') setArtworks([]) setTags([]) setUsers([]) }, 160) } function openMobileOverlay() { clearTimeout(mobileCloseTimerRef.current) setMobileOverlayPhase('opening') mobileOpenTimerRef.current = setTimeout(() => { setMobileOverlayPhase('open') mobileInputRef.current?.focus() }, 20) } function closeMobileOverlay() { if (mobileOverlayPhase === 'closed' || mobileOverlayPhase === 'closing') return clearTimeout(mobileOpenTimerRef.current) setMobileOverlayPhase('closing') clearTimeout(mobileCloseTimerRef.current) mobileCloseTimerRef.current = setTimeout(() => { setMobileOverlayPhase('closed') setQuery('') setActiveIdx(-1) setOpen(false) setArtworks([]) setTags([]) setUsers([]) }, 150) } // ── Ctrl/Cmd+K ─────────────────────────────────────────────────────────── useEffect(() => { function onKey(e) { if ((e.metaKey || e.ctrlKey) && e.key === 'k') { e.preventDefault(); expand() } } document.addEventListener('keydown', onKey) return () => document.removeEventListener('keydown', onKey) }, []) // ── outside click ──────────────────────────────────────────────────────── useEffect(() => { function onMouse(e) { if (!isExpanded) return if (wrapperRef.current && !wrapperRef.current.contains(e.target)) collapse() } document.addEventListener('mousedown', onMouse) return () => document.removeEventListener('mousedown', onMouse) }, [isExpanded, phase]) useEffect(() => { if (!isMobileOverlayVisible) { document.body.style.overflow = '' return } document.body.style.overflow = 'hidden' return () => { document.body.style.overflow = '' } }, [isMobileOverlayVisible]) useEffect(() => { if (!isMobileOverlayVisible) return function onEscape(e) { if (e.key === 'Escape') closeMobileOverlay() } document.addEventListener('keydown', onEscape) return () => document.removeEventListener('keydown', onEscape) }, [isMobileOverlayVisible, mobileOverlayPhase]) useEffect(() => { return () => { clearTimeout(mobileOpenTimerRef.current) clearTimeout(mobileCloseTimerRef.current) } }, []) // ── fetch (parallel artworks + tags) ──────────────────────────────────── const fetchSuggestions = useCallback(async (q) => { const bare = q?.replace(/^@+/, '') ?? '' if (!bare || bare.length < 2) { setArtworks([]); setTags([]); setUsers([]); setOpen(false); return } if (abortRef.current) abortRef.current.abort() abortRef.current = new AbortController() const sig = abortRef.current.signal setLoading(true) try { const isAtMention = q.startsWith('@') // if user typed @foo: emphasise users (4 slots) and skip artworks const fetchArt = isAtMention ? Promise.resolve(null) : fetch(`${ARTWORKS_API}?q=${encodeURIComponent(bare)}&per_page=4`, { signal: sig }) const fetchUsers = fetch(`${USERS_API}?q=${encodeURIComponent(q)}&per_page=4`, { signal: sig }) const fetchTags = isAtMention ? Promise.resolve(null) : fetch(`${TAGS_API}?q=${encodeURIComponent(bare)}&per_page=3`, { signal: sig }) const [artRes, userRes, tagRes] = await Promise.all([fetchArt, fetchUsers, fetchTags]) const artJson = artRes && artRes.ok ? await artRes.json() : {} const userJson = userRes && userRes.ok ? await userRes.json() : {} const tagJson = tagRes && tagRes.ok ? await tagRes.json() : {} const artItems = Array.isArray(artJson.data ?? artJson) ? (artJson.data ?? artJson).slice(0, 4) : [] const userItems = Array.isArray(userJson.data ?? userJson) ? (userJson.data ?? userJson).slice(0, 4) : [] const tagItems = Array.isArray(tagJson.data ?? tagJson) ? (tagJson.data ?? tagJson).slice(0, 3) : [] setArtworks(artItems) setUsers(userItems) setTags(tagItems) setActiveIdx(-1) setOpen(artItems.length > 0 || userItems.length > 0 || tagItems.length > 0) } catch (e) { if (e.name !== 'AbortError') console.error('SearchBar fetch error', e) } finally { setLoading(false) } }, []) useEffect(() => { fetchSuggestions(debouncedQuery) }, [debouncedQuery, fetchSuggestions]) // ── navigation helpers ─────────────────────────────────────────────────── function navigate(item) { if (item.type === 'artwork') window.location.href = item.urls?.web ?? `/${item.slug ?? ''}` else if (item.type === 'user') window.location.href = item.profile_url ?? `/@${item.username}` else window.location.href = `/tags/${item.slug ?? item.name}` } function handleSubmit(e) { e.preventDefault() if (activeIdx >= 0 && allItems[activeIdx]) { navigate(allItems[activeIdx]); return } if (query.trim()) window.location.href = `/search?q=${encodeURIComponent(query.trim())}` } function handleKeyDown(e) { if (e.key === 'Escape') { collapse(); return } if (!open || allItems.length === 0) return if (e.key === 'ArrowDown') { e.preventDefault() setActiveIdx(i => (i + 1) % allItems.length) } else if (e.key === 'ArrowUp') { e.preventDefault() setActiveIdx(i => (i <= 0 ? allItems.length - 1 : i - 1)) } else if (e.key === 'Enter' && activeIdx >= 0) { e.preventDefault() navigate(allItems[activeIdx]) } } // ── widths / opacities ─────────────────────────────────────────────────── const pillOpacity = phase === 'idle' ? 1 : 0 const formOpacity = phase === 'open' ? 1 : 0 return ( <> { setQuery(next); setActiveIdx(-1) }} onClose={closeMobileOverlay} onSubmit={handleSubmit} onKeyDown={handleKeyDown} onNavigate={navigate} />
{/* ── COLLAPSED PILL ── */} {/* ── EXPANDED FORM ── */}
{ setQuery(e.target.value); setActiveIdx(-1) }} onFocus={() => (artworks.length > 0 || tags.length > 0) && setOpen(true)} onKeyDown={handleKeyDown} placeholder={placeholder} aria-label="Search" aria-autocomplete="list" aria-controls="sb-suggestions" aria-activedescendant={activeIdx >= 0 ? `sb-item-${activeIdx}` : undefined} autoComplete="off" className="w-full h-full bg-white/[0.06] border border-white/[0.12] rounded-lg py-0 pl-10 pr-16 text-sm text-white placeholder-soft outline-none focus:border-accent focus:ring-1 focus:ring-accent/30 transition-colors" />
{loading && ( )} Esc
{/* ── SUGGESTIONS DROPDOWN ── */} {open && (artworks.length > 0 || tags.length > 0) && ( )}
) }