import React, { useState, useEffect, useRef, useCallback } from 'react' import { Search, Loader2, FileText, Calendar, Hash, Clock, AlertCircle, ChevronDown, } from 'lucide-react' import { Index } from 'flexsearch' import { Dialog, DialogContent, DialogDescription, } from '@/components/ui/dialog' import { cn } from '@/lib/utils' interface SearchPostResult { id: string title: string description: string date: string tags: string[] authors: string[] url: string content: string } interface SearchEventResult { id: string title: string description: string date: string type: string url: string content: string } interface SearchResult { posts: SearchPostResult[] events: SearchEventResult[] } type SearchItem = SearchPostResult | SearchEventResult interface SearchDialogProps { open: boolean onOpenChange: (open: boolean) => void } // LocalStorage keys const STORAGE_KEYS = { SEARCH_INDEX: 'search_index', SEARCH_INDEX_VERSION: 'search_index_version', SEARCH_HISTORY: 'search_history', } as const // Cache version - increment when index structure changes const INDEX_VERSION = '1.0' const SEARCH_DEBOUNCE = 150 // Calculate relevance score function calculateRelevanceScore( result: SearchPostResult | SearchEventResult, queryLower: string, ): number { let score = 0 const titleLower = result.title.toLowerCase() const descLower = result.description.toLowerCase() const contentLower = result.content.toLowerCase() let extraFields = '' if ('tags' in result) { extraFields = (result.tags || []).join(' ') } else if ('type' in result) { extraFields = result.type || '' } const extraLower = extraFields.toLowerCase() // Exact title match (highest priority) if (titleLower === queryLower) { score += 1000 } else if (titleLower.startsWith(queryLower)) { score += 500 } else if (titleLower.includes(queryLower)) { score += 300 } // Title word matches const titleWords = titleLower.split(/\s+/) const queryWords = queryLower.split(/\s+/) queryWords.forEach((qWord) => { titleWords.forEach((tWord) => { if (tWord === qWord) score += 50 else if (tWord.startsWith(qWord)) score += 30 else if (tWord.includes(qWord)) score += 10 }) }) // Description match if (descLower.includes(queryLower)) { score += 100 } queryWords.forEach((qWord) => { if (descLower.includes(qWord)) score += 20 }) // Tag/type matches queryWords.forEach((qWord) => { if (extraLower.includes(qWord)) score += 40 }) // Content match (lower priority) if (contentLower.includes(queryLower)) { score += 10 } queryWords.forEach((qWord) => { const matches = (contentLower.match(new RegExp(qWord, 'g')) || []).length score += matches * 2 }) // Recency boost (newer posts get slight boost) try { const postDate = new Date(result.date) const now = new Date() const daysSince = (now.getTime() - postDate.getTime()) / (1000 * 60 * 60 * 24) if (!isNaN(daysSince)) { if (daysSince < 30) score += 5 else if (daysSince < 90) score += 2 } } catch { // Ignore date errors } return score } // Get search history from localStorage function getSearchHistory(): string[] { try { const history = localStorage.getItem(STORAGE_KEYS.SEARCH_HISTORY) return history ? JSON.parse(history) : [] } catch { return [] } } // Save search to history function saveSearchHistory(query: string): void { if (!query.trim()) return try { const history = getSearchHistory() const normalizedQuery = query.trim().toLowerCase() // Remove if exists and add to front const filtered = history.filter((q) => q.toLowerCase() !== normalizedQuery) const updated = [normalizedQuery, ...filtered].slice(0, 10) // Keep last 10 localStorage.setItem(STORAGE_KEYS.SEARCH_HISTORY, JSON.stringify(updated)) } catch { // Ignore localStorage errors } } // Get cached search index function getCachedIndex(): { data: SearchResult; version: string } | null { try { const cached = localStorage.getItem(STORAGE_KEYS.SEARCH_INDEX) const version = localStorage.getItem(STORAGE_KEYS.SEARCH_INDEX_VERSION) if (cached && version === INDEX_VERSION) { return { data: JSON.parse(cached), version } } } catch { // Ignore errors } return null } // Cache search index function cacheIndex(data: SearchResult): void { try { localStorage.setItem(STORAGE_KEYS.SEARCH_INDEX, JSON.stringify(data)) localStorage.setItem(STORAGE_KEYS.SEARCH_INDEX_VERSION, INDEX_VERSION) } catch { // Ignore localStorage errors (quota exceeded, etc.) } } // Extract content snippet around search terms function extractSnippet( content: string, query: string, maxLength: number = 150, ): string { if (!content) return content ? content.substring(0, maxLength) : '' if (!query) return ( content.substring(0, maxLength) + (content.length > maxLength ? '...' : '') ) const queryLower = query.toLowerCase() const contentLower = content.toLowerCase() const index = contentLower.indexOf(queryLower) if (index === -1) { return ( content.substring(0, maxLength) + (content.length > maxLength ? '...' : '') ) } const start = Math.max(0, index - 50) const end = Math.min(content.length, index + query.length + maxLength - 50) let snippet = content.substring(start, end) if (start > 0) snippet = '...' + snippet if (end < content.length) snippet = snippet + '...' return snippet } const SearchDialog: React.FC = ({ open, onOpenChange }) => { const [query, setQuery] = useState('') const [results, setResults] = useState([]) const [isLoading, setIsLoading] = useState(false) const [isLoadingIndex, setIsLoadingIndex] = useState(false) const [error, setError] = useState(null) const [selectedIndex, setSelectedIndex] = useState(0) const [searchIndex, setSearchIndex] = useState({ posts: [], events: [], }) const [index, setIndex] = useState(null) const [displayedResults, setDisplayedResults] = useState(10) const [recentPosts, setRecentPosts] = useState([]) const [recentEvents, setRecentEvents] = useState([]) const inputRef = useRef(null) const resultsRef = useRef(null) const scrollContainerRef = useRef(null) const listboxId = 'search-results-listbox' // Load search index (with caching) useEffect(() => { if (!open) return let cancelled = false const loadSearchIndex = async () => { // Check cache first const cached = getCachedIndex() if ( cached && (cached.data.posts.length > 0 || cached.data.events.length > 0) ) { setSearchIndex(cached.data) setIsLoadingIndex(false) // Create FlexSearch index from cached data const searchIndex = new Index({ tokenize: 'forward', }) cached.data.posts.forEach((item) => { const searchableText = [ item.title || '', item.description || '', (item.tags || []).join(' '), item.content || '', ] .join(' ') .toLowerCase() searchIndex.add(`post-${item.id}`, searchableText) }) cached.data.events.forEach((item) => { const searchableText = [ item.title || '', item.description || '', item.type || '', item.content || '', ] .join(' ') .toLowerCase() searchIndex.add(`event-${item.id}`, searchableText) }) if (!cancelled) { setIndex(searchIndex) // Set recent posts from cached data setRecentPosts(cached.data.posts.slice(0, 5)) setRecentEvents(cached.data.events.slice(0, 5)) } return } setIsLoadingIndex(true) setError(null) try { const response = await fetch('/api/search-index.json') if (!response.ok) throw new Error('Failed to load search index') const data: SearchResult = await response.json() const posts: SearchPostResult[] = data['posts'] || [] const events: SearchEventResult[] = data['events'] || [] if (cancelled) return // Cache the index cacheIndex({ posts, events }) setSearchIndex({ posts, events }) setRecentPosts(posts.slice(0, 5)) setRecentEvents(events.slice(0, 5)) // Create FlexSearch index const searchIndex = new Index({ tokenize: 'forward', }) // Index all fields posts.forEach((item) => { const searchableText = [ item.title || '', item.description || '', (item.tags || []).join(' '), item.content || '', ] .join(' ') .toLowerCase() searchIndex.add(`post-${item.id}`, searchableText) }) events.forEach((item) => { const searchableText = [ item.title || '', item.description || '', item.type || '', item.content || '', ] .join(' ') .toLowerCase() searchIndex.add(`event-${item.id}`, searchableText) }) if (!cancelled) { setIndex(searchIndex) } } catch (error) { if (!cancelled) { console.error('Error loading search index:', error) setError('Failed to load search index. Please try again later.') } } finally { if (!cancelled) { setIsLoadingIndex(false) } } } // Only load if index hasn't been loaded yet if (!index) { loadSearchIndex() } else { if (recentPosts.length === 0 && searchIndex.posts.length > 0) { setRecentPosts(searchIndex.posts.slice(0, 5)) } if (recentEvents.length === 0 && searchIndex.events.length > 0) { setRecentEvents(searchIndex.events.slice(0, 5)) } } return () => { cancelled = true } }, [ open, index, searchIndex.posts.length, searchIndex.events.length, recentPosts.length, recentEvents.length, ]) // Perform search with fuzzy matching and improved relevance const performSearch = useCallback( (searchQuery: string) => { if (!index || !searchQuery.trim()) { setResults([]) setSelectedIndex(0) setDisplayedResults(10) return } setIsLoading(true) setError(null) const normalizedQuery = searchQuery.toLowerCase().trim() try { // Save to search history saveSearchHistory(searchQuery) // First try exact search let searchResults: string[] = (index.search(normalizedQuery, { limit: 50, // Get more results for better sorting }) as any) || [] // If no results, try fuzzy matching if ( (!searchResults || searchResults.length === 0) && normalizedQuery.length > 2 ) { // Try searching with each word separately const words = normalizedQuery.split(/\s+/) const wordResults = new Set() words.forEach((word) => { if (word.length > 2) { const wordSearch = (index.search(word, { limit: 20 }) as any) || [] wordSearch.forEach((id: any) => wordResults.add(String(id))) } }) searchResults = Array.from(wordResults) } // Map to actual items const matchedResults = (searchResults || []) .map((rawId) => { const id = String(rawId) if (id.startsWith('post-')) { const realId = id.replace('post-', '') return searchIndex.posts.find((p) => p.id === realId) } if (id.startsWith('event-')) { const realId = id.replace('event-', '') return searchIndex.events.find((e) => e.id === realId) } return undefined }) .filter((item): item is SearchItem => Boolean(item)) // Calculate relevance scores and sort const scoredResults = matchedResults.map((result) => ({ result, score: calculateRelevanceScore(result, normalizedQuery), })) // Sort by score (descending) scoredResults.sort((a, b) => b.score - a.score) // Extract results const sortedResults = scoredResults.map((item) => item.result) setResults(sortedResults) setSelectedIndex(0) setDisplayedResults(10) // Reset to first 10 } catch (error) { console.error('Search error:', error) setError('An error occurred while searching. Please try again.') setResults([]) } finally { setIsLoading(false) } }, [index, searchIndex], ) // Debounced search useEffect(() => { if (!query.trim()) { setResults([]) setSelectedIndex(0) setDisplayedResults(10) return } const timeoutId = setTimeout(() => { performSearch(query) }, SEARCH_DEBOUNCE) return () => clearTimeout(timeoutId) }, [query, performSearch]) // Focus input when dialog opens and reset scroll position useEffect(() => { if (open) { setTimeout(() => { inputRef.current?.focus() // Reset scroll position to top when dialog opens if (scrollContainerRef.current) { scrollContainerRef.current.scrollTop = 0 } }, 100) setQuery('') setResults([]) setSelectedIndex(0) setDisplayedResults(10) setError(null) } }, [open]) // Reset scroll position when query changes (new search) useEffect(() => { if (scrollContainerRef.current && query.trim()) { // Small delay to ensure DOM is updated const timeoutId = setTimeout(() => { if (scrollContainerRef.current) { scrollContainerRef.current.scrollTop = 0 } }, 100) return () => clearTimeout(timeoutId) } }, [query]) // Keyboard navigation useEffect(() => { if (!open) return const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'ArrowDown') { e.preventDefault() const maxIndex = Math.min(displayedResults - 1, results.length - 1) setSelectedIndex((prev) => (prev < maxIndex ? prev + 1 : prev)) } else if (e.key === 'ArrowUp') { e.preventDefault() setSelectedIndex((prev) => (prev > 0 ? prev - 1 : 0)) } else if (e.key === 'Enter' && results[selectedIndex]?.url) { e.preventDefault() const url = results[selectedIndex].url if (url) { // Use proper navigation that preserves browser history window.location.href = url onOpenChange(false) } } else if (e.key === 'Escape') { onOpenChange(false) } } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) }, [open, results, selectedIndex, onOpenChange, displayedResults]) // Scroll selected result into view useEffect(() => { if (resultsRef.current && selectedIndex >= 0) { const selectedElement = resultsRef.current.children[selectedIndex] as | HTMLElement | undefined if (selectedElement) { selectedElement.scrollIntoView({ behavior: 'smooth', block: 'nearest', }) } } }, [selectedIndex]) // Highlight search terms in text const highlightText = (text: string, query: string) => { if (!query.trim() || !text) return text try { // Escape special regex characters in query const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') const parts = text.split(new RegExp(`(${escapedQuery})`, 'gi')) return parts.map((part, i) => part.toLowerCase() === query.toLowerCase() ? ( {part} ) : ( part ), ) } catch (error) { // Fallback to plain text if regex fails return text } } const formatDate = (dateString: string) => { try { const date = new Date(dateString) if (isNaN(date.getTime())) { return dateString // Return original if invalid date } return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', }) } catch (error) { return dateString // Return original on error } } const searchHistory = getSearchHistory() const hasMoreResults = results.length > displayedResults const visibleResults = results.slice(0, displayedResults) return ( Search blog posts by title, description, tags, or content {/* Live region for screen readers */}
{isLoadingIndex && 'Loading search index...'} {isLoading && 'Searching...'} {!isLoading && !isLoadingIndex && query.trim() && results.length > 0 && `${results.length} result${results.length !== 1 ? 's' : ''} found`} {!isLoading && !isLoadingIndex && query.trim() && results.length === 0 && 'No results found'} {selectedIndex >= 0 && results[selectedIndex] && `Selected: ${results[selectedIndex].title}`}
setQuery(e.target.value)} className="placeholder:text-muted-foreground flex h-16 w-full appearance-none border-0 bg-transparent py-4 pr-12 text-base shadow-none outline-none focus-visible:ring-0 disabled:cursor-not-allowed disabled:opacity-50 sm:h-14 sm:py-3 sm:pr-0 sm:text-sm" style={{ WebkitAppearance: 'none', MozAppearance: 'textfield', border: 'none', outline: 'none', boxShadow: 'none', }} aria-label="Search blog posts" aria-expanded={results.length > 0} aria-controls={listboxId} aria-activedescendant={ results.length > 0 && selectedIndex >= 0 ? `result-${selectedIndex}` : undefined } role="combobox" aria-autocomplete="list" /> {isLoading && ( )}
{/* Error message */} {error && (
{error}
)}
{/* Loading skeleton */} {isLoadingIndex && (
{[1, 2, 3].map((i) => (
))}
)} {/* No query - show suggestions */} {!query.trim() && !isLoadingIndex && (
{/* Recent posts */} {recentPosts.length > 0 && ( )} {/* Recent events */} {recentEvents.length > 0 && ( )} {/* Search history */} {searchHistory.length > 0 && (

Recent Searches

{searchHistory.slice(0, 5).map((term, idx) => ( ))}
)} {/* Empty state if no suggestions */} {recentPosts.length === 0 && recentEvents.length === 0 && searchHistory.length === 0 && (

Start typing to search posts...

Search by title, description, tags, or content

)}
)} {/* No results */} {query.trim() && results.length === 0 && !isLoading && !isLoadingIndex && (

No results found for "{query}"

Try different keywords or check your spelling

)} {/* Results */} {visibleResults.length > 0 && ( )} {/* Load more button */} {hasMoreResults && (
)} {/* Extra padding at bottom for mobile to ensure last items are scrollable */} {results.length > 0 && (
↑↓ {' '} navigate ↵ {' '} select esc {' '} close
)}
) } export default SearchDialog