"use client"; import { createContext, useCallback, useContext, useEffect, useMemo, useState, } from "react"; import type { SearchContextValue, SearchProviderProps, SearchResult, } from "./searchbox-types"; const SearchContext = createContext(null); const DEFAULT_DEBOUNCE_MS = 150; export function useSearchContext() { const context = useContext(SearchContext); if (!context) { throw new Error("useSearchContext must be used within SearchProvider"); } return context; } export function SearchProvider({ children, documents = [], debounceMs = DEFAULT_DEBOUNCE_MS, }: SearchProviderProps) { const [isOpen, setIsOpen] = useState(false); const [query, setQuery] = useState(""); const [results, setResults] = useState([]); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); // Keyboard shortcut (⌘K / Ctrl+K) useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === "k") { e.preventDefault(); setIsOpen((prev) => !prev); } }; document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); }, []); // Reset query when dialog closes useEffect(() => { if (!isOpen) { setQuery(""); setError(null); } }, [isOpen]); // Search when query changes useEffect(() => { if (!query) { setResults([]); setError(null); return; } setIsLoading(true); const timer = setTimeout(() => { const filtered = filterDocuments(documents, query); setResults(filtered); setIsLoading(false); }, debounceMs); return () => clearTimeout(timer); }, [query, documents, debounceMs]); const selectResult = useCallback((result: SearchResult) => { // Navigate to the result URL if (result.url) { window.location.href = result.url; } setQuery(""); setIsOpen(false); }, []); const contextValue = useMemo( () => ({ isOpen, setIsOpen, query, setQuery, results, isLoading, error, selectResult, }), [isOpen, query, results, isLoading, error, selectResult], ); return ( {children} ); } function filterDocuments( documents: SearchResult[], query: string, ): SearchResult[] { const lowerQuery = query.toLowerCase(); return documents .map((doc) => { // Calculate relevance score based on matches let score = 0; // Title match (highest weight) if (doc.title.toLowerCase().includes(lowerQuery)) { score += 10; } // Tag match if (doc.tag?.toLowerCase().includes(lowerQuery)) { score += 5; } // Breadcrumb match if (doc.breadcrumbs?.some((b) => b.toLowerCase().includes(lowerQuery))) { score += 3; } // Match content and sections const matchingMatches = doc.matches.filter((m) => { if (m.type === "section") { return ( m.heading.toLowerCase().includes(lowerQuery) || m.snippet.toLowerCase().includes(lowerQuery) ); } return m.snippet.toLowerCase().includes(lowerQuery); }); score += matchingMatches.length * 2; return { doc, score }; }) .filter(({ score }) => score > 0) .sort((a, b) => b.score - a.score) .map(({ doc }) => doc); }