import React, { useState, useEffect, useRef, useCallback } from 'react' const ARTWORKS_API = '/api/search/artworks' const TAGS_API = '/api/tags/search' const USERS_API = '/api/search/users' const DEBOUNCE_MS = 280 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 inputRef = useRef(null) const wrapperRef = useRef(null) const abortRef = useRef(null) const openTimerRef = useRef(null) const closeTimerRef = useRef(null) const debouncedQuery = useDebounce(query, DEBOUNCE_MS) const isExpanded = phase === 'opening' || phase === 'open' // 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() { clearTimeout(openTimerRef.current) setPhase('closing') setOpen(false) setActiveIdx(-1) closeTimerRef.current = setTimeout(() => { setPhase('idle') setQuery('') setArtworks([]) setTags([]) setUsers([]) }, 160) } // ── 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 (wrapperRef.current && !wrapperRef.current.contains(e.target)) collapse() } document.addEventListener('mousedown', onMouse) return () => document.removeEventListener('mousedown', onMouse) }, []) // ── 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 (