Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/llms-txt-hub / apps/web/components/search/search-autocomplete.tsx |
| Date | Not specified in source |
| Repository | Janady13/llms-txt-hub |
| Revision | e0f4bf978d4df168369c7601d9c5f5ea89e84377 |
| Branch | main |
| Path or archive member | apps/web/components/search/search-autocomplete.tsx |
| Original SHA-256 | 074693f59228b3284a10cf054acc21cc29595eb42b234327f9a51872925d2848 |
| Git blob SHA | 6b2a3c3a0bcd5539cb278f7e6405fcdd10608b8f |
| Reading-copy SHA-256 | 074693f59228b3284a10cf054acc21cc29595eb42b234327f9a51872925d2848 |
Recovered source transcript
Download complete source text ↓297 lines · 10.7 KB
'use client'
import { cn } from '@thedaviddias/design-system/lib/utils'
import { logger } from '@thedaviddias/logging'
import { ArrowRight, Clock, Search, TrendingUp } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useAnalyticsEvents } from '@/components/analytics-tracker'
import { categories } from '@/lib/categories'
import { getRoute } from '@/lib/routes'
import { Favicon } from './favicon'
interface SearchSuggestion {
type: 'website' | 'category' | 'recent' | 'trending'
title: string
description?: string
url?: string
category?: string
website?: string
icon?: React.ComponentType<{ className?: string }>
}
interface SearchAutocompleteProps {
searchQuery: string
onSelect?: (suggestion: SearchSuggestion) => void
isOpen: boolean
onClose: () => void
anchorRef?: React.RefObject<HTMLInputElement | null>
}
/**
* Renders a search autocomplete dropdown with suggestions, recent searches, and categories
*/
export function SearchAutocomplete({
searchQuery,
onSelect,
isOpen,
onClose,
anchorRef
}: SearchAutocompleteProps) {
const [suggestions, setSuggestions] = useState<SearchSuggestion[]>([])
const [selectedIndex, setSelectedIndex] = useState(-1)
const [loading, setLoading] = useState(false)
const router = useRouter()
const dropdownRef = useRef<HTMLDivElement>(null)
const { trackSearch, trackSearchAutocomplete } = useAnalyticsEvents()
const getRecentSearches = useCallback((): string[] => {
if (typeof window === 'undefined') return []
const recent = localStorage.getItem('recentSearches')
return recent ? JSON.parse(recent).slice(0, 3) : []
}, [])
const saveRecentSearch = useCallback(
(query: string) => {
if (typeof window === 'undefined') return
const recent = getRecentSearches()
const updated = [query, ...recent.filter(s => s !== query)].slice(0, 5)
localStorage.setItem('recentSearches', JSON.stringify(updated))
},
[getRecentSearches]
)
useEffect(() => {
/** Fetches and filters search suggestions based on the current query */
const fetchSuggestions = async () => {
if (!searchQuery.trim()) {
const recentSearches = getRecentSearches().map(search => ({
type: 'recent' as const,
title: search,
icon: Clock
}))
const categoryMatches = categories.slice(0, 3).map(cat => ({
type: 'category' as const,
title: `Browse ${cat.name}`,
description: `View all ${cat.name.toLowerCase()} websites`,
icon: cat.icon,
url: getRoute('category.page', { category: cat.slug })
}))
setSuggestions([...recentSearches, ...categoryMatches])
setSelectedIndex(-1)
return
}
setLoading(true)
try {
const response = await fetch('/search/search-index.json')
if (!response.ok) throw new Error('Failed to fetch search index')
const searchIndex = await response.json()
const query = searchQuery.toLowerCase()
const websiteMatches = searchIndex
.filter((item: any) => {
const searchableText = `${item.name} ${item.description} ${item.category}`.toLowerCase()
return searchableText.includes(query)
})
.slice(0, 5)
.map((item: any) => ({
type: 'website' as const,
title: item.name,
description: item.description,
url: getRoute('website.detail', { slug: item.slug }),
category: item.category,
website: item.website
}))
const categoryMatches = categories
.filter(cat => cat.name.toLowerCase().includes(query) || cat.slug.includes(query))
.slice(0, 2)
.map(cat => ({
type: 'category' as const,
title: `Browse ${cat.name}`,
description: `View all ${cat.name.toLowerCase()} websites`,
icon: cat.icon,
url: getRoute('category.page', { category: cat.slug })
}))
setSuggestions([...websiteMatches, ...categoryMatches])
setSelectedIndex(-1)
} catch (error) {
logger.error('Error fetching suggestions:', { data: error, tags: { type: 'component' } })
setSuggestions([])
} finally {
setLoading(false)
}
}
const debounceTimer = setTimeout(fetchSuggestions, 150)
return () => clearTimeout(debounceTimer)
}, [searchQuery, getRecentSearches])
useEffect(() => {
if (!isOpen) return
/** Handles keyboard navigation within the autocomplete dropdown */
const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
setSelectedIndex(prev => (prev < suggestions.length - 1 ? prev + 1 : 0))
break
case 'ArrowUp':
e.preventDefault()
setSelectedIndex(prev => (prev > 0 ? prev - 1 : suggestions.length - 1))
break
case 'Enter':
e.preventDefault()
if (selectedIndex >= 0 && suggestions[selectedIndex]) {
handleSuggestionClick(suggestions[selectedIndex])
} else if (searchQuery.trim()) {
trackSearch(searchQuery, 0, 'autocomplete-keyboard-enter')
saveRecentSearch(searchQuery)
router.push(`${getRoute('search')}?q=${encodeURIComponent(searchQuery)}`)
onClose()
}
break
case 'Escape':
e.preventDefault()
onClose()
break
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [isOpen, selectedIndex, suggestions, searchQuery, router, onClose, saveRecentSearch])
useEffect(() => {
if (!isOpen) return
/** Closes the dropdown when clicking outside */
const handleClickOutside = (e: MouseEvent) => {
const target = e.target
if (!(target instanceof Node)) return
if (
dropdownRef.current &&
!dropdownRef.current.contains(target) &&
anchorRef?.current &&
!anchorRef.current.contains(target)
) {
onClose()
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [isOpen, onClose, anchorRef])
/** Navigates to the selected suggestion and tracks the interaction */
const handleSuggestionClick = (suggestion: SearchSuggestion) => {
trackSearchAutocomplete(searchQuery, suggestion.title, `autocomplete-${suggestion.type}`)
if (suggestion.type === 'recent') {
saveRecentSearch(suggestion.title)
trackSearch(suggestion.title, 0, 'autocomplete-recent')
router.push(`${getRoute('search')}?q=${encodeURIComponent(suggestion.title)}`)
} else if (suggestion.url) {
router.push(suggestion.url)
}
onSelect?.(suggestion)
onClose()
}
if (!isOpen || (!suggestions.length && !loading && searchQuery)) return null
return (
<div
ref={dropdownRef}
className="absolute top-full mt-2 w-full bg-background border rounded-lg shadow-lg z-[100] max-h-[400px] overflow-y-auto"
>
{loading ? (
<div className="p-4 text-center text-muted-foreground">
<div className="animate-pulse">Searching...</div>
</div>
) : suggestions.length === 0 ? (
<div className="p-4 text-center text-muted-foreground">
{searchQuery ? 'No suggestions found' : 'Start typing to search'}
</div>
) : (
<div className="py-2">
{suggestions.map((suggestion, index) => {
const Icon = suggestion.icon || Search
const isSelected = index === selectedIndex
return (
<button
key={`${suggestion.type}-${suggestion.title}-${index}`}
type="button"
onClick={() => handleSuggestionClick(suggestion)}
onMouseEnter={() => setSelectedIndex(index)}
className={cn(
'w-full px-4 py-3 flex items-start gap-3 hover:bg-muted/50 transition-colors text-left cursor-pointer group',
isSelected && 'bg-muted/50'
)}
>
<div className="mt-0.5">
{suggestion.type === 'website' && suggestion.website ? (
<Favicon
website={suggestion.website}
fallbackIcon={Icon}
title={suggestion.title}
className="h-4 w-4"
/>
) : (
<Icon className="h-4 w-4 text-muted-foreground" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-sm truncate">{suggestion.title}</div>
{suggestion.description && (
<div className="text-xs text-muted-foreground truncate mt-1">
{suggestion.description}
</div>
)}
{suggestion.category && (
<div className="text-xs text-muted-foreground mt-1">
Category: {suggestion.category}
</div>
)}
</div>
{suggestion.type === 'recent' && (
<div className="text-xs text-muted-foreground bg-muted/30 px-1.5 py-0.5 rounded">
Recent
</div>
)}
{suggestion.type === 'trending' && (
<TrendingUp className="h-3 w-3 text-muted-foreground" />
)}
{suggestion.type === 'category' && (
<div className="text-xs text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-950/50 px-1.5 py-0.5 rounded">
Category
</div>
)}
<ArrowRight className="h-4 w-4 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity" />
</button>
)
})}
</div>
)}
{searchQuery && (
<div className="border-t px-4 py-2">
<button
type="button"
onClick={() => {
trackSearch(searchQuery, 0, 'autocomplete-search-button')
saveRecentSearch(searchQuery)
router.push(`${getRoute('search')}?q=${encodeURIComponent(searchQuery)}`)
onClose()
}}
className="text-xs text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1 cursor-pointer"
>
<Search className="h-3 w-3" />
Search for "{searchQuery}"
</button>
</div>
)}
</div>
)
}