11"use client" ;
22
3- import { useState , useEffect , useRef , useMemo } from "react" ;
3+ import { useState , useEffect , useRef , useMemo , useCallback } from "react" ;
44import { useRouter } from "next/navigation" ;
55import searchIndex from "@/data/search-index.json" ;
6+ import type { Registry } from "@/lib/registry" ;
67
78interface SearchResult {
89 type : "integration" | "feature" | "demo" | "page" | "reference" | "ag-ui" ;
@@ -12,16 +13,58 @@ interface SearchResult {
1213 href : string ;
1314}
1415
16+ function isExternalHref ( href : string ) : boolean {
17+ return / ^ ( h t t p s ? : ) ? \/ \/ / i. test ( href ) ;
18+ }
19+
20+ function dedupeResults ( items : SearchResult [ ] ) : SearchResult [ ] {
21+ const seen = new Set < string > ( ) ;
22+ const out : SearchResult [ ] = [ ] ;
23+ for ( const item of items ) {
24+ const key = `${ item . type } ::${ item . href } ` ;
25+ if ( seen . has ( key ) ) continue ;
26+ seen . add ( key ) ;
27+ out . push ( item ) ;
28+ }
29+ return out ;
30+ }
31+
1532export function SearchModal ( { onClose } : { onClose : ( ) => void } ) {
1633 const [ query , setQuery ] = useState ( "" ) ;
1734 const [ selectedIndex , setSelectedIndex ] = useState ( 0 ) ;
18- const [ registryData , setRegistryData ] = useState < any > ( null ) ;
35+ const [ registryData , setRegistryData ] = useState < Registry | null > ( null ) ;
36+ const [ registryError , setRegistryError ] = useState ( false ) ;
1937 const inputRef = useRef < HTMLInputElement > ( null ) ;
38+ const selectedIndexRef = useRef ( 0 ) ;
2039 const router = useRouter ( ) ;
2140
41+ // Keep a ref in sync with selectedIndex so the Enter handler never reads
42+ // a stale closure value (reset-on-input + key-handler race).
2243 useEffect ( ( ) => {
23- setTimeout ( ( ) => inputRef . current ?. focus ( ) , 50 ) ;
24- import ( "@/data/registry.json" ) . then ( ( mod ) => setRegistryData ( mod . default ) ) ;
44+ selectedIndexRef . current = selectedIndex ;
45+ } , [ selectedIndex ] ) ;
46+
47+ useEffect ( ( ) => {
48+ const focusId = window . setTimeout (
49+ ( ) => inputRef . current ?. focus ( ) ,
50+ 50 ,
51+ ) ;
52+ let cancelled = false ;
53+ import ( "@/data/registry.json" )
54+ . then ( ( mod ) => {
55+ if ( ! cancelled ) setRegistryData ( mod . default as Registry ) ;
56+ } )
57+ . catch ( ( err ) => {
58+ if ( ! cancelled ) {
59+ // eslint-disable-next-line no-console
60+ console . error ( "[search-modal] failed to load registry" , err ) ;
61+ setRegistryError ( true ) ;
62+ }
63+ } ) ;
64+ return ( ) => {
65+ cancelled = true ;
66+ window . clearTimeout ( focusId ) ;
67+ } ;
2568 } , [ ] ) ;
2669
2770 useEffect ( ( ) => {
@@ -33,11 +76,13 @@ export function SearchModal({ onClose }: { onClose: () => void }) {
3376 } , [ onClose ] ) ;
3477
3578 const results = useMemo ( ( ) => {
36- if ( ! query . trim ( ) || ! registryData ) return [ ] ;
79+ if ( ! query . trim ( ) ) return [ ] ;
3780
3881 const q = query . toLowerCase ( ) ;
3982 const items : SearchResult [ ] = [ ] ;
4083
84+ // Static search index is available immediately — search it even before
85+ // the dynamic registry.json has resolved.
4186 // Auto-generated by: npx tsx showcase/scripts/generate-search-index.ts
4287 const pages = searchIndex as SearchResult [ ] ;
4388
@@ -51,64 +96,95 @@ export function SearchModal({ onClose }: { onClose: () => void }) {
5196 }
5297 }
5398
54- for ( const i of registryData . integrations || [ ] ) {
55- if (
56- i . name . toLowerCase ( ) . includes ( q ) ||
57- i . description ?. toLowerCase ( ) . includes ( q )
58- ) {
59- items . push ( {
60- type : "integration" ,
61- title : i . name ,
62- subtitle : ( i . description || "" ) . slice ( 0 , 80 ) ,
63- href : `/integrations/${ i . slug } ` ,
64- } ) ;
65- }
66- for ( const d of i . demos || [ ] ) {
99+ if ( registryData ) {
100+ for ( const i of registryData . integrations || [ ] ) {
67101 if (
68- d . name . toLowerCase ( ) . includes ( q ) ||
69- d . description ?. toLowerCase ( ) . includes ( q ) ||
70- d . tags ?. some ( ( t : string ) => t . toLowerCase ( ) . includes ( q ) )
102+ process . env . NODE_ENV !== "production" &&
103+ ( ! i . description || i . description . trim ( ) === "" )
104+ ) {
105+ // eslint-disable-next-line no-console
106+ console . warn (
107+ `[search-modal] integration "${ i . slug } " has no description — fix upstream in registry` ,
108+ ) ;
109+ }
110+ if (
111+ i . name . toLowerCase ( ) . includes ( q ) ||
112+ i . description ?. toLowerCase ( ) . includes ( q )
71113 ) {
72114 items . push ( {
73- type : "demo " ,
74- title : d . name ,
75- subtitle : ` ${ i . name } · ${ d . description } ` ,
76- href : `/integrations/${ i . slug } / ${ d . id } ` ,
115+ type : "integration " ,
116+ title : i . name ,
117+ subtitle : ( i . description || "" ) . slice ( 0 , 80 ) ,
118+ href : `/integrations/${ i . slug } ` ,
77119 } ) ;
78120 }
121+ for ( const d of i . demos || [ ] ) {
122+ if (
123+ d . name . toLowerCase ( ) . includes ( q ) ||
124+ d . description ?. toLowerCase ( ) . includes ( q ) ||
125+ d . tags ?. some ( ( t : string ) => t . toLowerCase ( ) . includes ( q ) )
126+ ) {
127+ items . push ( {
128+ type : "demo" ,
129+ title : d . name ,
130+ subtitle : `${ i . name } · ${ d . description } ` ,
131+ href : `/integrations/${ i . slug } /${ d . id } ` ,
132+ } ) ;
133+ }
134+ }
79135 }
80- }
81136
82- for ( const f of registryData . feature_registry ?. features || [ ] ) {
83- if (
84- f . name . toLowerCase ( ) . includes ( q ) ||
85- f . description ?. toLowerCase ( ) . includes ( q )
86- ) {
87- items . push ( {
88- type : "feature" ,
89- title : f . name ,
90- subtitle : f . description ,
91- href : "/matrix" ,
92- } ) ;
137+ for ( const f of registryData . feature_registry ?. features || [ ] ) {
138+ if (
139+ f . name . toLowerCase ( ) . includes ( q ) ||
140+ f . description ?. toLowerCase ( ) . includes ( q )
141+ ) {
142+ items . push ( {
143+ type : "feature" ,
144+ title : f . name ,
145+ subtitle : f . description ,
146+ href : "/matrix" ,
147+ } ) ;
148+ }
93149 }
94150 }
95151
96- return items . slice ( 0 , 12 ) ;
152+ return dedupeResults ( items ) . slice ( 0 , 12 ) ;
97153 } , [ query , registryData ] ) ;
98154
99- function onInputKeyDown ( e : React . KeyboardEvent ) {
100- if ( e . key === "ArrowDown" ) {
101- e . preventDefault ( ) ;
102- setSelectedIndex ( ( i ) => Math . min ( i + 1 , results . length - 1 ) ) ;
103- } else if ( e . key === "ArrowUp" ) {
104- e . preventDefault ( ) ;
105- setSelectedIndex ( ( i ) => Math . max ( i - 1 , 0 ) ) ;
106- } else if ( e . key === "Enter" && results [ selectedIndex ] ) {
107- e . preventDefault ( ) ;
108- router . push ( results [ selectedIndex ] . href ) ;
155+ const navigateTo = useCallback (
156+ ( href : string ) => {
157+ if ( isExternalHref ( href ) ) {
158+ window . location . assign ( href ) ;
159+ } else {
160+ router . push ( href ) ;
161+ }
109162 onClose ( ) ;
110- }
111- }
163+ } ,
164+ [ router , onClose ] ,
165+ ) ;
166+
167+ const onInputKeyDown = useCallback (
168+ ( e : React . KeyboardEvent ) => {
169+ if ( e . key === "ArrowDown" ) {
170+ e . preventDefault ( ) ;
171+ setSelectedIndex ( ( i ) => Math . min ( i + 1 , results . length - 1 ) ) ;
172+ } else if ( e . key === "ArrowUp" ) {
173+ e . preventDefault ( ) ;
174+ setSelectedIndex ( ( i ) => Math . max ( i - 1 , 0 ) ) ;
175+ } else if ( e . key === "Enter" ) {
176+ const idx = selectedIndexRef . current ;
177+ const chosen = results [ idx ] ;
178+ if ( chosen ) {
179+ e . preventDefault ( ) ;
180+ navigateTo ( chosen . href ) ;
181+ }
182+ }
183+ } ,
184+ [ results , navigateTo ] ,
185+ ) ;
186+
187+ const registryLoading = ! registryData && ! registryError ;
112188
113189 return (
114190 < >
@@ -137,20 +213,29 @@ export function SearchModal({ onClose }: { onClose: () => void }) {
137213 </ kbd >
138214 </ div >
139215
216+ { registryError && (
217+ < div className = "px-5 py-2.5 text-[12px] text-[var(--text-muted)] border-b border-[var(--border)] bg-[var(--bg-elevated)]" >
218+ Search index failed to load. Try refresh.
219+ </ div >
220+ ) }
221+
222+ { query . trim ( ) && registryLoading && (
223+ < div className = "px-5 py-2 text-[11px] text-[var(--text-faint)] border-b border-[var(--border)]" >
224+ [loading integrations & features… ]
225+ </ div >
226+ ) }
227+
140228 { results . length > 0 && (
141229 < div className = "max-h-[320px] overflow-y-auto py-2" >
142230 { results . map ( ( r , idx ) => (
143231 < button
144- key = { `${ r . href } -${ idx } ` }
232+ key = { `${ r . type } -${ r . href } ` }
145233 className = { `w-full text-left px-5 py-3 flex items-center gap-3 transition-colors ${
146234 idx === selectedIndex
147235 ? "bg-[var(--bg-elevated)]"
148236 : "hover:bg-[var(--bg-hover)]"
149237 } `}
150- onClick = { ( ) => {
151- router . push ( r . href ) ;
152- onClose ( ) ;
153- } }
238+ onClick = { ( ) => navigateTo ( r . href ) }
154239 onMouseEnter = { ( ) => setSelectedIndex ( idx ) }
155240 >
156241 < span className = "text-[10px] font-mono text-[var(--text-faint)] uppercase w-14 shrink-0" >
@@ -174,7 +259,7 @@ export function SearchModal({ onClose }: { onClose: () => void }) {
174259 </ div >
175260 ) }
176261
177- { query . trim ( ) && results . length === 0 && (
262+ { query . trim ( ) && results . length === 0 && ! registryLoading && (
178263 < div className = "px-5 py-8 text-center text-[13px] text-[var(--text-muted)]" >
179264 No results for “{ query } ”
180265 </ div >
0 commit comments