'use client'; import { useCallback, useEffect, useRef, useState, type KeyboardEvent } from 'react'; import { useRouter } from 'next/navigation'; import { Search } from 'lucide-react'; import { encodeRepo } from '@/utils/atproto/urls'; import { resolveSearchPath } from '@/utils/atproto/searchRouting'; import { searchActorsTypeahead, type ActorTypeaheadResult, } from '@/utils/atproto/appview'; import { getFrequent, getRecents, readSearchHistory, recordActorVisit, recordQueryVisit, type SearchHistoryEntry, } from '@/utils/searchHistory'; import SearchRecommendations from './explore/SearchRecommendations'; const TYPEAHEAD_DEBOUNCE_MS = 180; const RECENTS_LIMIT = 6; const FREQUENT_LIMIT = 4; type Props = { /** When the header opens this panel, set to true to focus the input. */ active: boolean; /** Called after a navigation/submit so the parent can close the panel. */ onDone: () => void; }; /** * Lightweight search box for the compact header's expanding panel. Mirrors * the explorer's SearchBox typeahead + at:// shortcuts but is styled to * fit inside the slim header card and intentionally omits the standalone * "Look up" button (the input alone is the UI). When the input is empty it * surfaces locally-stored "recent" and "frequent" recommendations. */ export default function CompactSearchPanel({ active, onDone }: Props) { const router = useRouter(); const [value, setValue] = useState(''); const [suggestions, setSuggestions] = useState([]); const [highlightIndex, setHighlightIndex] = useState(-1); const [history, setHistory] = useState([]); const inputRef = useRef(null); // Focus + select existing text whenever the panel becomes active. Using // a microtask so the input is mounted and visible before we focus it // (the parent panel animates in via transform/opacity). Reload history // here too so freshly-recorded visits show up next time it opens. useEffect(() => { if (!active) return; const id = window.setTimeout(() => { setHistory(readSearchHistory()); inputRef.current?.focus(); inputRef.current?.select(); }, 0); return () => window.clearTimeout(id); }, [active]); // Debounced typeahead. Skip DIDs and at:// URIs since the appview // typeahead is handle/display-name oriented. Clearing happens in the // timer callback (not synchronously in the effect body) so this stays // compatible with react-hooks/set-state-in-effect. useEffect(() => { const trimmed = value.trim(); const controller = new AbortController(); const t = window.setTimeout(async () => { if ( !trimmed || trimmed.startsWith('did:') || trimmed.startsWith('at://') || trimmed.length < 2 ) { setSuggestions([]); setHighlightIndex(-1); return; } const results = await searchActorsTypeahead(trimmed, { limit: 6, signal: controller.signal, }); setSuggestions(results); setHighlightIndex(-1); }, TYPEAHEAD_DEBOUNCE_MS); return () => { controller.abort(); window.clearTimeout(t); }; }, [value]); const finish = useCallback( (path: string) => { router.push(path); setValue(''); setSuggestions([]); onDone(); }, [router, onDone], ); const goToActor = useCallback( (actor: ActorTypeaheadResult) => { recordActorVisit(actor); finish(`/explore/${encodeRepo(actor.handle)}`); }, [finish], ); const goToEntry = useCallback( (entry: SearchHistoryEntry) => { // Re-picking bumps the count; merge logic keeps the stored avatar/handle. recordQueryVisit(entry.label, entry.path); finish(entry.path); }, [finish], ); const reloadHistory = useCallback(() => setHistory(readSearchHistory()), []); function onSubmit(e: React.FormEvent) { e.preventDefault(); if (highlightIndex >= 0 && suggestions[highlightIndex]) { goToActor(suggestions[highlightIndex]); return; } const path = resolveSearchPath(value); if (!path) return; recordQueryVisit(value, path); finish(path); } function onKeyDown(e: KeyboardEvent) { if (e.key === 'Escape') { onDone(); return; } if (suggestions.length === 0) return; if (e.key === 'ArrowDown') { e.preventDefault(); setHighlightIndex((i) => (i + 1) % suggestions.length); } else if (e.key === 'ArrowUp') { e.preventDefault(); setHighlightIndex((i) => (i <= 0 ? suggestions.length - 1 : i - 1)); } } const showList = suggestions.length > 0; const recents = getRecents(history, RECENTS_LIMIT); const frequent = getFrequent(history, FREQUENT_LIMIT); // Recommendations only show on an empty input (typeahead takes over once // the user starts typing). const showRecommendations = !showList && value.trim() === '' && (recents.length > 0 || frequent.length > 0); return (
setValue(e.target.value)} onKeyDown={onKeyDown} role="combobox" aria-expanded={showList} aria-autocomplete="list" aria-controls="compact-search-typeahead-list" aria-activedescendant={ highlightIndex >= 0 ? `compact-search-typeahead-${highlightIndex}` : undefined } style={{ width: '100%', padding: '0.55rem 0.75rem 0.55rem 2.25rem', background: 'var(--bg-tertiary)', border: '1px solid var(--border-medium)', color: 'var(--text-primary)', fontFamily: 'var(--font-mono)', fontSize: '0.78rem', outline: 'none', }} />
{showList && (
    {suggestions.map((actor, i) => { const isActive = i === highlightIndex; return (
  • { // mousedown beats the input's blur so the click registers // before any focus-related state change unmounts us. e.preventDefault(); goToActor(actor); }} onMouseEnter={() => setHighlightIndex(i)} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.4rem 0.6rem', background: isActive ? 'var(--bg-elevated)' : 'transparent', borderBottom: i < suggestions.length - 1 ? '1px solid var(--border-subtle)' : 'none', cursor: 'pointer', transition: 'background 0.12s ease', }} > {actor.avatar ? ( // eslint-disable-next-line @next/next/no-img-element ) : ( )}
    {actor.displayName?.trim() && (
    {actor.displayName}
    )}
    @{actor.handle}
  • ); })}
)} {showRecommendations && (
)}
); }