|
1 | 1 | 'use client'; |
2 | 2 |
|
3 | | -import { Link } from '@components/Link/Link'; |
4 | | -import { |
5 | | - Breadcrumb, |
6 | | - type BreadcrumbLink, |
7 | | - Input, |
8 | | - Loader, |
9 | | -} from '@intlayer/design-system'; |
10 | | -import { useSearchDoc } from '@intlayer/design-system/hooks'; |
11 | | -import type { BlogMetadata, DocMetadata } from '@intlayer/docs'; |
12 | | -import Fuse, { type IFuseOptions } from 'fuse.js'; |
13 | | -import { getIntlayer } from 'intlayer'; |
14 | | -import { ArrowRight, Search } from 'lucide-react'; |
15 | | -import { useSearchParams } from 'next/navigation'; |
16 | | -import { useIntlayer, useLocale } from 'next-intlayer'; |
17 | | -import { type FC, useEffect, useRef, useState } from 'react'; |
18 | | - |
19 | | -/* -------------------------------------------------------------------------- */ |
20 | | -/* Fuse.js Configuration */ |
21 | | -/* -------------------------------------------------------------------------- */ |
22 | | - |
23 | | -const fuseOptions: IFuseOptions<DocMetadata> = { |
24 | | - includeScore: true, |
25 | | - shouldSort: true, |
26 | | - threshold: 0.25, // More flexible fuzzy matching |
27 | | - ignoreLocation: true, // Word order insensitive |
28 | | - distance: 100, |
29 | | - minMatchCharLength: 2, |
30 | | - findAllMatches: true, |
31 | | - keys: [ |
32 | | - { name: 'title', weight: 0.7 }, |
33 | | - { name: 'description', weight: 0.15 }, |
34 | | - { name: 'keywords', weight: 0.1 }, |
35 | | - { name: 'excerpt', weight: 0.05 }, // Optional short snippet per doc |
36 | | - ], |
37 | | -}; |
38 | | - |
39 | | -/* -------------------------------------------------------------------------- */ |
40 | | -/* Utilities */ |
41 | | -/* -------------------------------------------------------------------------- */ |
42 | | - |
43 | | -// Debounce utility |
44 | | -const debounce = <T extends (...args: any[]) => void>( |
45 | | - func: T, |
46 | | - delay: number, |
47 | | - onAbort: () => void |
48 | | -): ((...args: Parameters<T>) => void) => { |
49 | | - let timeoutId: ReturnType<typeof setTimeout> | null = null; |
50 | | - return (...args: Parameters<T>) => { |
51 | | - if (timeoutId) { |
52 | | - onAbort(); |
53 | | - clearTimeout(timeoutId); |
54 | | - } |
55 | | - timeoutId = setTimeout(() => func(...args), delay); |
56 | | - }; |
57 | | -}; |
58 | | - |
59 | | -/** |
60 | | - * Hybrid rank merge between Fuse.js (local) and embedding backend results. |
61 | | - */ |
62 | | -function mergeHybridResults( |
63 | | - fuseResults: Fuse.FuseResult<DocMetadata>[], |
64 | | - backendResults: { docKey: string; score: number }[], |
65 | | - allDocs: DocMetadata[] |
66 | | -): DocMetadata[] { |
67 | | - const normalizeFuse = (score?: number) => 1 - Math.min((score ?? 1) / 0.5, 1); // invert Fuse score |
68 | | - const normalizeBackend = (score: number) => Math.min(score / 1.0, 1); // already cosine-like |
69 | | - |
70 | | - const backendMap = new Map( |
71 | | - backendResults.map((r) => [r.docKey, normalizeBackend(r.score)]) |
72 | | - ); |
73 | | - const combinedMap = new Map<string, { doc: DocMetadata; score: number }>(); |
74 | | - |
75 | | - for (const fuseItem of fuseResults) { |
76 | | - const doc = fuseItem.item; |
77 | | - const fuseScore = normalizeFuse(fuseItem.score); |
78 | | - const backendScore = backendMap.get(doc.docKey); |
79 | | - const combinedScore = backendScore |
80 | | - ? 0.7 * backendScore + 0.3 * fuseScore |
81 | | - : fuseScore; |
82 | | - combinedMap.set(doc.docKey, { doc, score: combinedScore }); |
83 | | - } |
84 | | - |
85 | | - for (const [docKey, backendScore] of backendMap) { |
86 | | - if (!combinedMap.has(docKey)) { |
87 | | - const doc = allDocs.find((d) => d.docKey === docKey); |
88 | | - if (doc) combinedMap.set(docKey, { doc, score: 0.7 * backendScore }); |
89 | | - } |
90 | | - } |
91 | | - |
92 | | - return Array.from(combinedMap.values()) |
93 | | - .sort((a, b) => b.score - a.score) |
94 | | - .map((entry) => entry.doc); |
95 | | -} |
96 | | - |
97 | | -/* -------------------------------------------------------------------------- */ |
98 | | -/* UI Components */ |
99 | | -/* -------------------------------------------------------------------------- */ |
100 | | - |
101 | | -const SearchResultItem: FC<{ doc: DocMetadata; onClickLink: () => void }> = ({ |
102 | | - doc, |
103 | | - onClickLink, |
104 | | -}) => { |
105 | | - const { searchResultItemButton } = useIntlayer('doc-search-view'); |
106 | | - const breadcrumbLinks: BreadcrumbLink[] = doc.url |
107 | | - .split('/') |
108 | | - .slice(2) |
109 | | - .map((path) => ({ text: path })); |
110 | | - |
111 | | - return ( |
112 | | - <Link |
113 | | - label={searchResultItemButton.label.value} |
114 | | - variant="hoverable" |
115 | | - color="text" |
116 | | - id={doc.url} |
117 | | - href={doc.url} |
118 | | - className="w-full max-w-full" |
119 | | - onClick={onClickLink} |
120 | | - > |
121 | | - <div className="flex items-center justify-between gap-2 text-wrap p-3"> |
122 | | - <div className="flex flex-1 flex-col gap-2 text-left"> |
123 | | - <strong className="text-base">{doc.title}</strong> |
124 | | - <p className="text-neutral text-sm">{doc.description}</p> |
125 | | - <Breadcrumb links={breadcrumbLinks} className="text-xs opacity-30" /> |
126 | | - </div> |
127 | | - <ArrowRight size={24} /> |
128 | | - </div> |
129 | | - </Link> |
130 | | - ); |
| 3 | +import { Button, Modal } from '@intlayer/design-system'; |
| 4 | +import { Search } from 'lucide-react'; |
| 5 | +import { useIntlayer } from 'next-intlayer'; |
| 6 | +import { type FC, useState } from 'react'; |
| 7 | +import { SearchView } from './SearchView'; |
| 8 | + |
| 9 | +type SearchTriggerProps = { |
| 10 | + isMini?: boolean; |
131 | 11 | }; |
132 | 12 |
|
133 | | -/* -------------------------------------------------------------------------- */ |
134 | | -/* Main Search View */ |
135 | | -/* -------------------------------------------------------------------------- */ |
136 | | - |
137 | | -export const SearchView: FC<{ |
138 | | - onClickLink?: () => void; |
139 | | - isOpen?: boolean; |
140 | | -}> = ({ onClickLink = () => {}, isOpen = false }) => { |
141 | | - const inputRef = useRef<HTMLInputElement>(null); |
142 | | - const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); |
143 | | - const searchQuery = useSearchParams().get('search'); |
144 | | - |
145 | | - const [results, setResults] = useState<DocMetadata[]>([]); |
146 | | - const [currentSearchQuery, setCurrentSearchQuery] = useState<string | null>( |
147 | | - searchQuery |
148 | | - ); |
149 | | - const { data: searchDocData, isFetching } = useSearchDoc({ |
150 | | - input: currentSearchQuery ?? '', |
151 | | - }); |
152 | | - |
153 | | - const { noContentText, searchInput } = useIntlayer('doc-search-view'); |
154 | | - const { locale } = useLocale(); |
155 | | - |
156 | | - const docMetadata = getIntlayer('doc-metadata', locale) as DocMetadata[]; |
157 | | - const blogMetadata = getIntlayer('blog-metadata', locale) as BlogMetadata[]; |
158 | | - const filesData = [...docMetadata, ...blogMetadata]; |
159 | | - const fuse = new Fuse(filesData, fuseOptions); |
160 | | - |
161 | | - const handleSearch = async (query: string) => { |
162 | | - if (!query) { |
163 | | - setCurrentSearchQuery(null); |
164 | | - setResults([]); |
165 | | - return; |
166 | | - } |
167 | | - |
168 | | - if (query.length > 2) { |
169 | | - setCurrentSearchQuery(query); |
170 | | - } else { |
171 | | - setCurrentSearchQuery(null); |
172 | | - const fuseSearchResults = fuse.search(query).map((r) => r.item); |
173 | | - setResults(fuseSearchResults); |
174 | | - } |
175 | | - }; |
176 | | - |
177 | | - const debouncedSearch = debounce(handleSearch, 200, () => null); |
178 | | - |
179 | | - /* ---------------------- Handle backend + Fuse merge ---------------------- */ |
180 | | - useEffect(() => { |
181 | | - if (searchDocData?.data && currentSearchQuery) { |
182 | | - const backendDocsWithScore = |
183 | | - (searchDocData?.data ?? []).map((d: any) => ({ |
184 | | - docKey: d.fileKey, |
185 | | - score: d.similarityScore ?? 0.5, |
186 | | - })) ?? []; |
187 | | - |
188 | | - const fuseSearchResults = fuse.search(currentSearchQuery); |
189 | | - const mergedResults = mergeHybridResults( |
190 | | - fuseSearchResults, |
191 | | - backendDocsWithScore, |
192 | | - filesData |
193 | | - ); |
194 | | - |
195 | | - setResults(mergedResults); |
196 | | - } |
197 | | - }, [searchDocData, currentSearchQuery, filesData, fuse]); |
198 | | - |
199 | | - /* ---------------------- Handle initial URL-based search ---------------------- */ |
200 | | - useEffect(() => { |
201 | | - if (searchQuery) handleSearch(searchQuery); |
202 | | - }, [searchQuery]); |
203 | | - |
204 | | - /* --------------------------- Autofocus on open --------------------------- */ |
205 | | - useEffect(() => { |
206 | | - if (isOpen) { |
207 | | - timeoutRef.current = setTimeout(() => inputRef.current?.focus(), 10); |
208 | | - } |
209 | | - return () => { |
210 | | - if (timeoutRef.current) clearTimeout(timeoutRef.current); |
211 | | - }; |
212 | | - }, [isOpen]); |
213 | | - |
214 | | - const isNoResult = |
215 | | - !isFetching && results.length === 0 && inputRef.current?.value !== ''; |
| 13 | +export const SearchTrigger: FC<SearchTriggerProps> = ({ isMini = false }) => { |
| 14 | + const [isModalOpen, setIsModalOpen] = useState(false); |
| 15 | + const { searchButton } = useIntlayer('doc-search-trigger'); |
216 | 16 |
|
217 | 17 | return ( |
218 | | - <div className="relative w-full p-4"> |
219 | | - <div className="flex items-center gap-1"> |
220 | | - <Search /> |
221 | | - <Input |
222 | | - type="search" |
223 | | - placeholder={searchInput.placeholder.value} |
224 | | - aria-label={searchInput.label.value} |
225 | | - onChange={(e) => debouncedSearch(e.target.value)} |
226 | | - defaultValue={searchQuery ?? ''} |
227 | | - className="m-3" |
228 | | - ref={inputRef} |
| 18 | + <> |
| 19 | + {isMini ? ( |
| 20 | + <Button |
| 21 | + label={searchButton.label.value} |
| 22 | + Icon={Search} |
| 23 | + variant="hoverable" |
| 24 | + size="icon-md" |
| 25 | + color="text" |
| 26 | + onClick={() => setIsModalOpen(true)} |
229 | 27 | /> |
230 | | - </div> |
231 | | - |
232 | | - <div className="mt-4"> |
233 | | - {isNoResult && ( |
234 | | - <p className="text-center text-neutral text-sm">{noContentText}</p> |
235 | | - )} |
236 | | - |
237 | | - {results.length > 0 && ( |
238 | | - <ul className="flex flex-col gap-10"> |
239 | | - {results.map((result, i) => ( |
240 | | - <li key={result.url}> |
241 | | - <SearchResultItem doc={result} onClickLink={onClickLink} /> |
242 | | - <p className="text-gray-400 text-xs">Rank #{i + 1}</p> |
243 | | - </li> |
244 | | - ))} |
245 | | - </ul> |
246 | | - )} |
247 | | - <Loader isLoading={isFetching} /> |
248 | | - </div> |
249 | | - </div> |
| 28 | + ) : ( |
| 29 | + <Button |
| 30 | + label={searchButton.label.value} |
| 31 | + Icon={Search} |
| 32 | + variant="input" |
| 33 | + color="custom" |
| 34 | + onClick={() => setIsModalOpen(true)} |
| 35 | + isFullWidth={false} |
| 36 | + > |
| 37 | + {searchButton.text} |
| 38 | + </Button> |
| 39 | + )} |
| 40 | + <Modal |
| 41 | + isOpen={isModalOpen} |
| 42 | + onClose={() => setIsModalOpen(false)} |
| 43 | + title={searchButton.text.value} |
| 44 | + size="lg" |
| 45 | + > |
| 46 | + <SearchView |
| 47 | + onClickLink={() => setIsModalOpen(false)} |
| 48 | + isOpen={isModalOpen} |
| 49 | + /> |
| 50 | + </Modal> |
| 51 | + </> |
250 | 52 | ); |
251 | 53 | }; |
0 commit comments