Skip to main content
Glama
SearchView.tsx7.24 kB
'use client'; import { Link } from '@components/Link/Link'; import { Breadcrumb, type BreadcrumbLink, Input, Loader, } from '@intlayer/design-system'; import { useSearch, useSearchDoc } from '@intlayer/design-system/hooks'; import type { BlogMetadata, DocMetadata } from '@intlayer/docs'; import Fuse, { type IFuseOptions } from 'fuse.js'; import { getIntlayer } from 'intlayer'; import { ArrowRight, Search } from 'lucide-react'; import { useRouter, useSearchParams } from 'next/navigation'; import { useIntlayer, useLocale } from 'next-intlayer'; import { type FC, useEffect, useRef, useState } from 'react'; // Convert the documentation into an array of objects for Fuse.js // Fuse.js options const fuseOptions: IFuseOptions<DocMetadata> = { keys: [ { name: 'title', weight: 0.8 }, { name: 'description', weight: 0.1 }, { name: 'keywords', weight: 0.1 }, ], threshold: 0.02, // Defines how fuzzy the matching should be (lower is more strict) }; const SearchResultItem: FC<{ doc: DocMetadata; onClickLink: () => void; isSelected: boolean; }> = ({ doc, onClickLink, isSelected }) => { const { searchResultItemButton } = useIntlayer('doc-search-view'); const itemRef = useRef<HTMLAnchorElement>(null); const breadcrumbLinks: BreadcrumbLink[] = doc.url .split('/') .slice(2) .map((path) => { return { text: path }; }); // Scroll into view when selected useEffect(() => { if (isSelected && itemRef.current) { itemRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest', }); } }, [isSelected]); return ( <Link ref={itemRef} label={searchResultItemButton.label.value} variant="hoverable" color="text" id={doc.url} href={doc.url.replace(process.env.NEXT_PUBLIC_URL ?? '', '')} className="w-full max-w-full" isActive={isSelected} onClick={onClickLink} > <div className="flex items-center justify-between gap-2 text-wrap p-3"> <div className="flex flex-1 flex-col gap-2 text-left"> <strong className="text-base">{doc.title}</strong> <p className="text-neutral text-sm">{doc.description}</p> <Breadcrumb links={breadcrumbLinks} className="text-xs opacity-30" /> </div> <ArrowRight size={24} /> </div> </Link> ); }; export const SearchView: FC<{ onClickLink?: () => void; isOpen?: boolean; }> = ({ onClickLink = () => {}, isOpen = false }) => { const inputRef = useRef<HTMLInputElement>(null); const router = useRouter(); const searchQueryParam = useSearchParams().get('search'); const [results, setResults] = useState<DocMetadata[]>([]); const [selectedIndex, setSelectedIndex] = useState<number>(-1); const { search, setSearch } = useSearch({ defaultValue: searchQueryParam, onClear: () => { setResults([]); setSelectedIndex(-1); }, onSearch: (searchQuery: string) => { const fuseSearchResults = fuse .search(searchQuery) .map((result) => result.item); setResults(fuseSearchResults); setSelectedIndex(-1); }, }); const { data: searchDocData, isFetching } = useSearchDoc({ input: search, }); const { noContentText, searchInput } = useIntlayer('doc-search-view'); const { locale } = useLocale(); const docMetadata = getIntlayer('doc-metadata', locale) as DocMetadata[]; const blogMetadata = getIntlayer('blog-metadata', locale) as BlogMetadata[]; const frequentQuestionMetadata = getIntlayer( 'frequent-question-metadata', locale ) as DocMetadata[]; const filesData = [ ...docMetadata, ...blogMetadata, ...frequentQuestionMetadata, ]; // Create a new Fuse instance with the options and documentation data const fuse = new Fuse(filesData, fuseOptions); // Handle backend search results: append to Fuse results, avoiding duplicates useEffect(() => { if (searchDocData?.data && search) { const backendResults: DocMetadata[] = searchDocData.data .map((docKey: string) => filesData.find((doc) => doc.docKey === docKey)) .filter((doc: DocMetadata | undefined): doc is DocMetadata => Boolean(doc) ); const fuseSearchResults = fuse .search(search) .map((result) => result.item); const combinedResults = [...fuseSearchResults]; const seenDocKeys = new Set(combinedResults.map((doc) => doc.docKey)); backendResults.forEach((backendDoc) => { if (!seenDocKeys.has(backendDoc.docKey)) { combinedResults.push(backendDoc); } }); setResults(combinedResults); } }, [searchDocData, search]); // Focus input when modal opens using setTimeout // This waits for the browser's paint cycle and the modal animation useEffect(() => { let timeout: ReturnType<typeof setTimeout> | undefined; if (isOpen) { timeout = setTimeout(() => { inputRef.current?.focus(); }, 50); } return () => { if (timeout) clearTimeout(timeout); }; }, [isOpen]); // Handle keyboard navigation useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (results.length === 0) return; if (e.key === 'ArrowDown') { e.preventDefault(); setSelectedIndex((prev) => prev < results.length - 1 ? prev + 1 : prev ); } else if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedIndex((prev) => (prev > 0 ? prev - 1 : -1)); } else if (e.key === 'Enter' && selectedIndex >= 0) { e.preventDefault(); const selectedDoc = results[selectedIndex]; if (selectedDoc) { const href = selectedDoc.url.replace( process.env.NEXT_PUBLIC_URL ?? '', '' ); router.push(href); onClickLink(); } } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [results, selectedIndex, router, onClickLink]); const isNoResult = !isFetching && results.length === 0 && search.length > 0; return ( <> <div className="relative flex w-full items-center gap-1"> <Search /> <Input type="search" placeholder={searchInput.placeholder.value} aria-label={searchInput.label.value} onChange={(e) => setSearch(e.target.value)} defaultValue={searchQueryParam ?? ''} className="m-3" ref={inputRef} /> </div> <div className="mt-8 flex flex-1 flex-col overflow-y-auto"> {isNoResult && ( <p className="text-center text-neutral text-sm">{noContentText}</p> )} {results.length > 0 && ( <ul className="flex flex-col gap-10"> {results.map((result, index) => ( <li key={result.url}> <SearchResultItem doc={result} onClickLink={onClickLink} isSelected={index === selectedIndex} /> </li> ))} </ul> )} <Loader isLoading={isFetching} /> </div> </> ); };

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/aymericzip/intlayer'

If you have feedback or need assistance with the MCP directory API, please join our Discord server