From 13bca5d95de10c76e95b05beddea20de8cabda61 Mon Sep 17 00:00:00 2001 From: Nolawz Date: Tue, 3 Mar 2026 11:52:14 +0530 Subject: Added search functionality --- src/components/react/SearchDialog.tsx | 979 ++++++++++++++++++++++++++++++++++ 1 file changed, 979 insertions(+) create mode 100644 src/components/react/SearchDialog.tsx (limited to 'src/components/react/SearchDialog.tsx') diff --git a/src/components/react/SearchDialog.tsx b/src/components/react/SearchDialog.tsx new file mode 100644 index 0000000..4c0d938 --- /dev/null +++ b/src/components/react/SearchDialog.tsx @@ -0,0 +1,979 @@ +import React, { useState, useEffect, useRef, useCallback } from 'react' +import { + Search, + Loader2, + FileText, + Calendar, + Hash, + Clock, + AlertCircle, + ChevronDown, + X, + Icon, +} 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, + query: string, + 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, searchQuery, 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 -- cgit v1.2.3