diff options
Diffstat (limited to 'src/components/react')
| -rw-r--r-- | src/components/react/ErrorBoundary.tsx | 63 | ||||
| -rw-r--r-- | src/components/react/SearchButton.tsx | 51 | ||||
| -rw-r--r-- | src/components/react/SearchDialog.tsx | 979 |
3 files changed, 1093 insertions, 0 deletions
diff --git a/src/components/react/ErrorBoundary.tsx b/src/components/react/ErrorBoundary.tsx new file mode 100644 index 0000000..4945094 --- /dev/null +++ b/src/components/react/ErrorBoundary.tsx @@ -0,0 +1,63 @@ +import React, { Component } from 'react' +import type { ErrorInfo, ReactNode } from 'react' + +interface Props { + children: ReactNode + fallback?: ReactNode +} + +interface State { + hasError: boolean + error: Error | null +} + +export class ErrorBoundary extends Component<Props, State> { + constructor(props: Props) { + super(props) + this.state = { hasError: false, error: null } + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error } + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + // Log error to console in development + if (import.meta.env.DEV) { + console.error('ErrorBoundary caught an error:', error, errorInfo) + } + // In production, you could send to error tracking service + // Example: Sentry.captureException(error, { contexts: { react: errorInfo } }) + } + + render() { + if (this.state.hasError) { + if (this.props.fallback) { + return this.props.fallback + } + + return ( + <div + className="rounded-lg border border-destructive/50 bg-destructive/10 p-6" + role="alert" + aria-live="assertive" + > + <h2 className="text-lg font-semibold text-destructive mb-2"> + Something went wrong + </h2> + <p className="text-sm text-muted-foreground"> + {this.state.error?.message || 'An unexpected error occurred'} + </p> + <button + onClick={() => this.setState({ hasError: false, error: null })} + className="mt-4 text-sm text-primary hover:underline" + > + Try again + </button> + </div> + ) + } + + return this.props.children + } +} diff --git a/src/components/react/SearchButton.tsx b/src/components/react/SearchButton.tsx new file mode 100644 index 0000000..17aad93 --- /dev/null +++ b/src/components/react/SearchButton.tsx @@ -0,0 +1,51 @@ +import React, { useState, useEffect } from 'react' +import { Search } from 'lucide-react' +import { Button } from '@/components/ui/button' +import SearchDialog from './SearchDialog' +import { ErrorBoundary } from './ErrorBoundary' + +const SearchButton: React.FC = () => { + const [isOpen, setIsOpen] = useState(false) + + // Keyboard shortcut: Cmd/Ctrl + K + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Don't trigger if user is typing in an input, textarea, or contenteditable + const target = e.target as HTMLElement + const isInput = target.tagName === 'INPUT' || + target.tagName === 'TEXTAREA' || + target.isContentEditable + + if ((e.metaKey || e.ctrlKey) && e.key === 'k' && !isInput) { + e.preventDefault() + setIsOpen(true) + } + } + + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, []) + + return ( + <ErrorBoundary> + <Button + variant="outline" + size="icon" + onClick={() => setIsOpen(true)} + className="size-9 border md:border-0 md:bg-transparent md:hover:bg-muted md:-my-2 md:-me-2 md:size-8" + title="Search (⌘K)" + aria-label="Search blog posts" + aria-expanded={isOpen} + aria-haspopup="dialog" + > + <Search className="h-5 w-5 md:h-4 md:w-4" /> + <span className="sr-only">Search</span> + </Button> + <ErrorBoundary> + <SearchDialog open={isOpen} onOpenChange={setIsOpen} /> + </ErrorBoundary> + </ErrorBoundary> + ) +} + +export default SearchButton 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<SearchDialogProps> = ({ open, onOpenChange }) => { + const [query, setQuery] = useState('') + const [results, setResults] = useState<SearchItem[]>([]) + const [isLoading, setIsLoading] = useState(false) + const [isLoadingIndex, setIsLoadingIndex] = useState(false) + const [error, setError] = useState<string | null>(null) + const [selectedIndex, setSelectedIndex] = useState(0) + const [searchIndex, setSearchIndex] = useState<SearchResult>({ + posts: [], + events: [], + }) + const [index, setIndex] = useState<Index | null>(null) + const [displayedResults, setDisplayedResults] = useState(10) + const [recentPosts, setRecentPosts] = useState<SearchPostResult[]>([]) + const [recentEvents, setRecentEvents] = useState<SearchEventResult[]>([]) + const inputRef = useRef<HTMLInputElement>(null) + const resultsRef = useRef<HTMLUListElement>(null) + const scrollContainerRef = useRef<HTMLDivElement>(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<string>() + + 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() ? ( + <mark + key={i} + className="bg-primary/20 text-primary dark:bg-primary/30 dark:text-primary font-medium" + > + {part} + </mark> + ) : ( + 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 ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="data-[state=open]:slide-in-from-top-0 data-[state=closed]:slide-out-to-top-0 top-0 flex h-[100dvh] max-w-full translate-y-0 flex-col gap-0 overflow-hidden rounded-none p-0 sm:top-[10%] sm:h-auto sm:max-h-[85vh] sm:max-w-lg sm:rounded-lg md:max-w-2xl"> + <DialogDescription className="sr-only"> + Search blog posts by title, description, tags, or content + </DialogDescription> + {/* Live region for screen readers */} + <div + role="status" + aria-live="polite" + aria-atomic="true" + className="sr-only" + > + {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}`} + </div> + <div className="border-border pt-safe relative flex shrink-0 items-center border-b px-4 sm:px-4"> + <Search className="text-muted-foreground mr-3 h-5 w-5 shrink-0 sm:h-4 sm:w-4" /> + <input + ref={inputRef} + type="text" + placeholder="Search posts..." + value={query} + onChange={(e) => 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 && ( + <Loader2 className="text-muted-foreground absolute right-14 ml-2 h-5 w-5 shrink-0 animate-spin sm:relative sm:right-0 sm:h-4 sm:w-4" /> + )} + </div> + + {/* Error message */} + {error && ( + <div + role="alert" + className="bg-destructive/10 text-destructive mx-4 mt-2 flex shrink-0 items-center gap-2 rounded-md p-3 text-sm" + > + <AlertCircle className="h-4 w-4 shrink-0" /> + <span>{error}</span> + </div> + )} + + <div + ref={scrollContainerRef} + className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-4 sm:px-2 sm:py-2" + style={{ WebkitOverflowScrolling: 'touch' }} + > + {/* Loading skeleton */} + {isLoadingIndex && ( + <div className="space-y-2 py-4"> + {[1, 2, 3].map((i) => ( + <div key={i} className="bg-muted animate-pulse rounded-md p-3"> + <div className="bg-muted-foreground/20 mb-2 h-4 w-3/4 rounded" /> + <div className="bg-muted-foreground/20 mb-1 h-3 w-full rounded" /> + <div className="bg-muted-foreground/20 h-3 w-2/3 rounded" /> + </div> + ))} + </div> + )} + + {/* No query - show suggestions */} + {!query.trim() && !isLoadingIndex && ( + <div className="py-4"> + {/* Recent posts */} + {recentPosts.length > 0 && ( + <div className="mb-6"> + <h3 className="text-muted-foreground mb-3 px-2 text-xs font-semibold tracking-wider uppercase"> + Recent Posts + </h3> + <ul className="space-y-2 sm:space-y-1"> + {recentPosts.map((post) => ( + <li key={`post-${post.id}`}> + <a + href={post.url} + onClick={() => onOpenChange(false)} + className="active:bg-muted focus:bg-muted focus:ring-ring flex flex-col gap-2 rounded-lg p-4 transition-colors focus:ring-2 focus:outline-none sm:rounded-md sm:p-3" + > + <div className="flex items-start justify-between gap-2"> + <h3 className="text-base leading-tight font-medium sm:text-sm"> + {post.title} + </h3> + <div className="text-muted-foreground flex shrink-0 items-center gap-1 text-xs"> + <Calendar className="h-3 w-3" /> + <span className="hidden sm:inline"> + {formatDate(post.date)} + </span> + </div> + </div> + {post.description && ( + <p className="text-muted-foreground line-clamp-2 text-sm sm:text-xs"> + {post.description} + </p> + )} + </a> + </li> + ))} + </ul> + </div> + )} + + {/* Recent events */} + {recentEvents.length > 0 && ( + <div className="mb-6"> + <h3 className="text-muted-foreground mb-3 px-2 text-xs font-semibold tracking-wider uppercase"> + Recent Events + </h3> + <ul className="space-y-2 sm:space-y-1"> + {recentEvents.map((ev) => ( + <li key={`event-${ev.id}`}> + <a + href={ev.url} + onClick={() => onOpenChange(false)} + className="active:bg-muted focus:bg-muted focus:ring-ring flex flex-col gap-2 rounded-lg p-4 transition-colors focus:ring-2 focus:outline-none sm:rounded-md sm:p-3" + > + <div className="flex items-start justify-between gap-2"> + <h3 className="text-base leading-tight font-medium sm:text-sm"> + {ev.title} + </h3> + <div className="text-muted-foreground flex shrink-0 items-center gap-1 text-xs"> + <Calendar className="h-3 w-3" /> + <span className="hidden sm:inline"> + {formatDate(ev.date)} + </span> + </div> + </div> + {ev.description && ( + <p className="text-muted-foreground line-clamp-2 text-sm sm:text-xs"> + {ev.description} + </p> + )} + </a> + </li> + ))} + </ul> + </div> + )} + + {/* Search history */} + {searchHistory.length > 0 && ( + <div> + <h3 className="text-muted-foreground mb-3 px-2 text-xs font-semibold tracking-wider uppercase"> + Recent Searches + </h3> + <div className="flex flex-wrap gap-2 px-2"> + {searchHistory.slice(0, 5).map((term, idx) => ( + <button + key={idx} + onClick={() => setQuery(term)} + className="bg-muted text-muted-foreground hover:bg-muted/80 focus:ring-ring inline-flex items-center gap-1 rounded-md px-2.5 py-1.5 text-xs transition-colors focus:ring-2 focus:outline-none" + > + <Clock className="h-3 w-3" /> + {term} + </button> + ))} + </div> + </div> + )} + + {/* Empty state if no suggestions */} + {recentPosts.length === 0 && + recentEvents.length === 0 && + searchHistory.length === 0 && ( + <div className="flex flex-col items-center justify-center py-8 text-center"> + <Search className="text-muted-foreground mb-2 h-8 w-8" /> + <p className="text-muted-foreground text-sm"> + Start typing to search posts... + </p> + <p className="text-muted-foreground mt-1 text-xs"> + Search by title, description, tags, or content + </p> + </div> + )} + </div> + )} + + {/* No results */} + {query.trim() && + results.length === 0 && + !isLoading && + !isLoadingIndex && ( + <div className="flex flex-col items-center justify-center py-8 text-center"> + <FileText className="text-muted-foreground mb-2 h-8 w-8" /> + <p className="text-muted-foreground text-sm"> + No results found for "{query}" + </p> + <p className="text-muted-foreground mt-1 text-xs"> + Try different keywords or check your spelling + </p> + </div> + )} + + {/* Results */} + {visibleResults.length > 0 && ( + <ul + ref={resultsRef} + id={listboxId} + role="listbox" + aria-label="Search results" + className="space-y-1" + > + {visibleResults.map((result, idx) => { + const key = + 'tags' in result ? `post-${result.id}` : `event-${result.id}` + return ( + <li + key={key} + id={`result-${idx}`} + role="option" + aria-selected={idx === selectedIndex} + > + <a + href={result.url} + onClick={() => onOpenChange(false)} + className={cn( + 'flex flex-col gap-3 rounded-lg p-4 transition-colors sm:gap-2.5 sm:rounded-md sm:p-3.5', + 'active:bg-muted focus-visible:bg-muted focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none', + idx === selectedIndex && 'bg-muted', + )} + aria-label={`Go to ${result.title}`} + > + <div className="flex items-start justify-between gap-2"> + <h3 className="flex-1 text-base leading-snug font-medium sm:text-sm"> + {highlightText(result.title, query)} + </h3> + <div className="text-muted-foreground flex shrink-0 items-center gap-1 text-xs"> + <Calendar className="h-3 w-3" /> + <span className="hidden sm:inline"> + {formatDate(result.date)} + </span> + </div> + </div> + + {result.description && ( + <p className="text-muted-foreground line-clamp-2 text-sm leading-relaxed sm:text-xs"> + {highlightText(result.description, query)} + </p> + )} + + {/* Content snippet - hidden on mobile for cleaner UI */} + {result.content && ( + <p className="text-muted-foreground/80 line-clamp-2 hidden text-xs leading-relaxed italic sm:block"> + {highlightText( + extractSnippet(result.content, query), + query, + )} + </p> + )} + + {/* Tags for posts */} + {'tags' in result && + result.tags && + result.tags.length > 0 && ( + <div className="flex flex-wrap items-center gap-1.5"> + {result.tags.slice(0, 3).map((tag) => ( + <span + key={tag} + className="bg-muted text-muted-foreground inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs sm:px-1.5 sm:py-0.5" + > + <Hash className="h-3 w-3" /> + {tag} + </span> + ))} + {result.tags.length > 3 && ( + <span className="text-muted-foreground text-xs"> + +{result.tags.length - 3} more + </span> + )} + </div> + )} + + {'type' in result && result.type && ( + <div className="text-muted-foreground mt-1 flex items-center gap-2 text-xs"> + <span className="bg-muted inline-flex items-center rounded px-2 py-0.5 text-xs"> + {result.type} + </span> + </div> + )} + </a> + </li> + ) + })} + </ul> + )} + + {/* Load more button */} + {hasMoreResults && ( + <div className="pb-safe mt-6 mb-4 flex justify-center sm:mt-4 sm:mb-2"> + <button + onClick={() => + setDisplayedResults((prev) => + Math.min(prev + 10, results.length), + ) + } + className="border-border bg-background text-foreground active:bg-muted focus:ring-ring flex items-center gap-2 rounded-lg border px-6 py-3 text-base shadow-sm transition-colors focus:ring-2 focus:outline-none sm:rounded-md sm:px-4 sm:py-2 sm:text-sm" + > + <ChevronDown className="h-5 w-5 sm:h-4 sm:w-4" /> + Load more ({results.length - displayedResults} remaining) + </button> + </div> + )} + + {/* Extra padding at bottom for mobile to ensure last items are scrollable */} + <div className="h-4 sm:h-2" aria-hidden="true" /> + </div> + + {results.length > 0 && ( + <div className="border-border bg-background text-muted-foreground flex shrink-0 items-center justify-center border-t px-4 py-3 text-xs sm:py-2"> + <div className="hidden flex-wrap items-center gap-4 sm:flex"> + <span> + <kbd className="bg-muted pointer-events-none inline-flex h-5 items-center gap-1 rounded border px-1.5 font-mono text-[10px] font-medium opacity-100 select-none"> + ↑↓ + </kbd>{' '} + navigate + </span> + <span> + <kbd className="bg-muted pointer-events-none inline-flex h-5 items-center gap-1 rounded border px-1.5 font-mono text-[10px] font-medium opacity-100 select-none"> + ↵ + </kbd>{' '} + select + </span> + <span> + <kbd className="bg-muted pointer-events-none inline-flex h-5 items-center gap-1 rounded border px-1.5 font-mono text-[10px] font-medium opacity-100 select-none"> + esc + </kbd>{' '} + close + </span> + </div> + </div> + )} + </DialogContent> + </Dialog> + ) +} + +export default SearchDialog |
