aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorNolawz <Nolawz333@protonmail.com>2026-03-03 11:52:14 +0530
committerNolawz <Nolawz333@protonmail.com>2026-03-03 11:52:14 +0530
commit13bca5d95de10c76e95b05beddea20de8cabda61 (patch)
tree14cefd995ecd35dc258973ecc883ba72715067b7 /src
parent58b0433ceeab26ac6b29b85119671eb5fc9027aa (diff)
Added search functionality
Diffstat (limited to 'src')
-rw-r--r--src/components/Head.astro6
-rw-r--r--src/components/Header.astro2
-rw-r--r--src/components/react/ErrorBoundary.tsx63
-rw-r--r--src/components/react/SearchButton.tsx51
-rw-r--r--src/components/react/SearchDialog.tsx979
-rw-r--r--src/components/ui/dialog.tsx120
-rw-r--r--src/pages/api/search-index.json.ts74
7 files changed, 1295 insertions, 0 deletions
diff --git a/src/components/Head.astro b/src/components/Head.astro
index 8ce42cb..8474374 100644
--- a/src/components/Head.astro
+++ b/src/components/Head.astro
@@ -43,6 +43,12 @@ import Favicons from './Favicons.astro'
/>
<Favicons />
+ <link
+ rel="prefetch"
+ href="/api/search-index.json"
+ as="fetch"
+ crossorigin="anonymous"
+ />
<ClientRouter />
<slot />
diff --git a/src/components/Header.astro b/src/components/Header.astro
index c8b06d1..2402c2a 100644
--- a/src/components/Header.astro
+++ b/src/components/Header.astro
@@ -1,6 +1,7 @@
---
import Link from '@/components/Link.astro'
import ThemeToggle from '@/components/ThemeToggle.astro'
+import SearchButton from '@/components/react/SearchButton'
import { NAV_LINKS, SITE } from '@/consts'
import { Image } from 'astro:assets'
import logo from '../../public/static/logo.svg'
@@ -30,6 +31,7 @@ import logo from '../../public/static/logo.svg'
))
}
</nav>
+ <SearchButton client:idle />
<ThemeToggle transition:persist />
</div>
</div>
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 &quot;{query}&quot;
+ </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
diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx
new file mode 100644
index 0000000..9dbeaa0
--- /dev/null
+++ b/src/components/ui/dialog.tsx
@@ -0,0 +1,120 @@
+import * as React from "react"
+import * as DialogPrimitive from "@radix-ui/react-dialog"
+import { X } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const Dialog = DialogPrimitive.Root
+
+const DialogTrigger = DialogPrimitive.Trigger
+
+const DialogPortal = DialogPrimitive.Portal
+
+const DialogClose = DialogPrimitive.Close
+
+const DialogOverlay = React.forwardRef<
+ React.ElementRef<typeof DialogPrimitive.Overlay>,
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
+>(({ className, ...props }, ref) => (
+ <DialogPrimitive.Overlay
+ ref={ref}
+ className={cn(
+ "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
+ className
+ )}
+ {...props}
+ />
+))
+DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
+
+const DialogContent = React.forwardRef<
+ React.ElementRef<typeof DialogPrimitive.Content>,
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
+>(({ className, children, ...props }, ref) => (
+ <DialogPortal>
+ <DialogOverlay />
+ <DialogPrimitive.Content
+ ref={ref}
+ className={cn(
+ "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
+ className
+ )}
+ {...props}
+ >
+ {children}
+ <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
+ <X className="h-4 w-4" />
+ <span className="sr-only">Close</span>
+ </DialogPrimitive.Close>
+ </DialogPrimitive.Content>
+ </DialogPortal>
+))
+DialogContent.displayName = DialogPrimitive.Content.displayName
+
+const DialogHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes<HTMLDivElement>) => (
+ <div
+ className={cn(
+ "flex flex-col space-y-1.5 text-center sm:text-left",
+ className
+ )}
+ {...props}
+ />
+)
+DialogHeader.displayName = "DialogHeader"
+
+const DialogFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes<HTMLDivElement>) => (
+ <div
+ className={cn(
+ "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
+ className
+ )}
+ {...props}
+ />
+)
+DialogFooter.displayName = "DialogFooter"
+
+const DialogTitle = React.forwardRef<
+ React.ElementRef<typeof DialogPrimitive.Title>,
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
+>(({ className, ...props }, ref) => (
+ <DialogPrimitive.Title
+ ref={ref}
+ className={cn(
+ "text-lg font-semibold leading-none tracking-tight",
+ className
+ )}
+ {...props}
+ />
+))
+DialogTitle.displayName = DialogPrimitive.Title.displayName
+
+const DialogDescription = React.forwardRef<
+ React.ElementRef<typeof DialogPrimitive.Description>,
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
+>(({ className, ...props }, ref) => (
+ <DialogPrimitive.Description
+ ref={ref}
+ className={cn("text-sm text-muted-foreground", className)}
+ {...props}
+ />
+))
+DialogDescription.displayName = DialogPrimitive.Description.displayName
+
+export {
+ Dialog,
+ DialogPortal,
+ DialogOverlay,
+ DialogTrigger,
+ DialogClose,
+ DialogContent,
+ DialogHeader,
+ DialogFooter,
+ DialogTitle,
+ DialogDescription,
+}
diff --git a/src/pages/api/search-index.json.ts b/src/pages/api/search-index.json.ts
new file mode 100644
index 0000000..340c26a
--- /dev/null
+++ b/src/pages/api/search-index.json.ts
@@ -0,0 +1,74 @@
+import type { APIRoute } from 'astro'
+import { getAllEvents, getAllPosts } from '@/lib/data-utils'
+
+export const prerender = true
+
+export const GET: APIRoute = async () => {
+ try {
+ const posts = await getAllPosts()
+ const events = await getAllEvents()
+
+ const searchIndex = {
+ "posts": posts.map((post) => {
+ // Extract text content from HTML body (remove tags)
+ // The body property contains the raw HTML content
+ const htmlContent = (post as { body?: string }).body || ''
+ const textContent = htmlContent
+ .replace(/<[^>]+>/g, ' ')
+ .replace(/\s+/g, ' ')
+ .trim()
+
+ return {
+ id: post.id || '',
+ title: post.data.title || '',
+ description: post.data.description || '',
+ date: post.data.date?.toISOString() || new Date().toISOString(),
+ tags: post.data.tags || [],
+ authors: post.data.authors || [],
+ url: `/blog/${post.id}`,
+ // Include full content for better search results
+ content: textContent, // Full content for indexing
+ }
+ }),
+ "events": events.map((event) => {
+ // Extract text content from HTML body (remove tags)
+ // The body property contains the raw HTML content
+ const htmlContent = (event as { body?: string }).body || ''
+ const textContent = htmlContent
+ .replace(/<[^>]+>/g, ' ')
+ .replace(/\s+/g, ' ')
+ .trim()
+
+ return {
+ id: event.id || '',
+ title: event.data.title || '',
+ description: event.data.description || '',
+ date: event.data.date?.toISOString() || new Date().toISOString(),
+ type: event.data.type,
+ url: `/events/${event.id}`,
+ // Include full content for better search results
+ content: textContent, // Full content for indexing
+ }
+ }),
+ }
+
+
+ return new Response(JSON.stringify(searchIndex), {
+ status: 200,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Cache-Control': 'public, max-age=3600',
+ },
+ })
+ } catch (error) {
+ console.error('Error generating search index:', error)
+ // Return empty array on error instead of failing
+ return new Response(JSON.stringify([]), {
+ status: 200,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Cache-Control': 'public, max-age=3600',
+ },
+ })
+ }
+}