Something went wrong. Try again.
Tour the Atmosphere. Switch clients, share universal links, browse any PDS. forked from atpota.to/aturi
Something went wrong. Try again.
10 kB · 321 lines
TSX
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322'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<ActorTypeaheadResult[]>([]); const [highlightIndex, setHighlightIndex] = useState(-1); const [history, setHistory] = useState<SearchHistoryEntry[]>([]); const inputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) { 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 ( <form onSubmit={onSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}> <div style={{ position: 'relative' }}> <Search size={16} style={{ position: 'absolute', top: '50%', transform: 'translateY(-50%)', left: '0.75rem', color: 'var(--text-tertiary)', pointerEvents: 'none', }} /> <input ref={inputRef} type="text" autoComplete="off" spellCheck={false} placeholder="handle, DID, or URL" value={value} onChange={(e) => 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', }} /> </div>
{showList && ( <ul id="compact-search-typeahead-list" role="listbox" style={{ listStyle: 'none', margin: 0, padding: 0, background: 'var(--bg-tertiary)', border: '1px solid var(--border-subtle)', maxHeight: '18rem', overflowY: 'auto', }} > {suggestions.map((actor, i) => { const isActive = i === highlightIndex; return ( <li key={actor.did} id={`compact-search-typeahead-${i}`} role="option" aria-selected={isActive} onMouseDown={(e) => { // 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 <img src={actor.avatar} alt="" width={24} height={24} style={{ width: 24, height: 24, objectFit: 'cover', background: 'var(--bg-secondary)', flexShrink: 0, }} /> ) : ( <span style={{ width: 24, height: 24, background: 'var(--bg-secondary)', flexShrink: 0, }} /> )} <div style={{ minWidth: 0, lineHeight: 1.2 }}> {actor.displayName?.trim() && ( <div style={{ fontSize: '0.8125rem', color: 'var(--text-primary)', fontFamily: 'var(--font-serif)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', }} > {actor.displayName} </div> )} <div style={{ fontSize: '0.7rem', color: 'var(--text-tertiary)', fontFamily: 'var(--font-mono)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', }} > @{actor.handle} </div> </div> </li> ); })} </ul> )}
{showRecommendations && ( <div style={{ background: 'var(--bg-tertiary)', border: '1px solid var(--border-subtle)', maxHeight: '20rem', overflowY: 'auto', }} > <SearchRecommendations recents={recents} frequent={frequent} onPick={goToEntry} onEnriched={reloadHistory} /> </div> )} </form> );}