@eventcatalog/core 2.53.1 → 2.54.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,815 @@
1
+ import React, { useState, useEffect, useCallback, useMemo } from 'react';
2
+ import {
3
+ MagnifyingGlassIcon,
4
+ QueueListIcon,
5
+ RectangleGroupIcon,
6
+ BoltIcon,
7
+ ChatBubbleLeftIcon,
8
+ ServerIcon,
9
+ UserGroupIcon,
10
+ UserIcon,
11
+ BookOpenIcon,
12
+ DocumentTextIcon,
13
+ CubeIcon,
14
+ } from '@heroicons/react/24/outline';
15
+
16
+ interface SearchResult {
17
+ id: string;
18
+ name: string;
19
+ type: string;
20
+ description: string;
21
+ url: string;
22
+ tags: string[];
23
+ }
24
+
25
+ // Memoized SearchResult component for better performance
26
+ const SearchResultItem = React.memo<{
27
+ item: SearchResult;
28
+ typeConfig: any;
29
+ currentSearch: string;
30
+ }>(({ item, typeConfig, currentSearch }) => {
31
+ const config = typeConfig[item.type as keyof typeof typeConfig] || typeConfig.other;
32
+ const getDisplayType = (type: string) => {
33
+ if (type === 'other') return 'Page';
34
+ if (type === 'asyncapi') return 'AsyncAPI';
35
+ if (type === 'openapi') return 'OpenAPI';
36
+ if (type === 'language') return 'Language';
37
+ // For plurals, remove 's' at the end, otherwise just capitalize
38
+ if (type.endsWith('s')) {
39
+ return type.charAt(0).toUpperCase() + type.slice(1, -1);
40
+ }
41
+ return type.charAt(0).toUpperCase() + type.slice(1);
42
+ };
43
+ const displayType = getDisplayType(item.type);
44
+ const IconComponent = config.icon;
45
+
46
+ return (
47
+ <a href={item.url} className="block group">
48
+ <div
49
+ className={`bg-gradient-to-br ${config.bg} to-white border rounded-lg p-3 hover:border-purple-300 hover:shadow-md transition-all duration-200 group-hover:shadow-lg ${config.border}`}
50
+ >
51
+ <div className="flex items-start justify-between mb-2">
52
+ <h3 className="text-base font-semibold text-gray-900 group-hover:text-purple-700 transition-colors">{item.name}</h3>
53
+ <span
54
+ className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${config.badgeBg} ${config.text} ${config.border} border`}
55
+ >
56
+ <IconComponent className="h-3 w-3" />
57
+ {displayType}
58
+ </span>
59
+ </div>
60
+ {item.description && (
61
+ <div className="text-xs text-gray-500 mb-2 line-clamp-2 opacity-80">
62
+ {currentSearch.trim() ? (
63
+ <span dangerouslySetInnerHTML={{ __html: item.description }} />
64
+ ) : (
65
+ <span>{item.description.replace(/<[^>]*>/g, '')}</span>
66
+ )}
67
+ </div>
68
+ )}
69
+ {item.tags.length > 0 && (
70
+ <div className="flex flex-wrap gap-1">
71
+ {item.tags.map((tag, index) => (
72
+ <span
73
+ key={index}
74
+ className="inline-flex items-center px-1.5 py-0.5 rounded-md text-xs font-medium bg-gray-100 text-gray-700"
75
+ >
76
+ {tag}
77
+ </span>
78
+ ))}
79
+ </div>
80
+ )}
81
+ </div>
82
+ </a>
83
+ );
84
+ });
85
+
86
+ // Memoized SearchResults component
87
+ const SearchResults = React.memo<{
88
+ results: SearchResult[];
89
+ typeConfig: any;
90
+ currentSearch: string;
91
+ }>(({ results, typeConfig, currentSearch }) => {
92
+ return (
93
+ <>
94
+ {results.map((item) => (
95
+ <SearchResultItem key={item.id} item={item} typeConfig={typeConfig} currentSearch={currentSearch} />
96
+ ))}
97
+ </>
98
+ );
99
+ });
100
+
101
+ const SearchModal: React.FC = () => {
102
+ const [isOpen, setIsOpen] = useState(false);
103
+ const [pagefind, setPagefind] = useState<any>(null);
104
+ const [pagefindLoadError, setPagefindLoadError] = useState(false);
105
+ const [currentSearch, setCurrentSearch] = useState('');
106
+ const [currentFilter, setCurrentFilter] = useState('all');
107
+ const [allResults, setAllResults] = useState<SearchResult[]>([]);
108
+ const [isLoading, setIsLoading] = useState(false);
109
+ const [exactMatch, setExactMatch] = useState(false);
110
+
111
+ // Listen for modal state changes from Astro component
112
+ useEffect(() => {
113
+ const handleModalToggle = (event: CustomEvent) => {
114
+ setIsOpen(event.detail.isOpen);
115
+
116
+ // Load all results when modal opens - will be handled by another effect
117
+ };
118
+
119
+ window.addEventListener('searchModalToggle', handleModalToggle as EventListener);
120
+ return () => window.removeEventListener('searchModalToggle', handleModalToggle as EventListener);
121
+ }, []);
122
+
123
+ const onClose = () => {
124
+ if ((window as any).searchModalState) {
125
+ (window as any).searchModalState.close();
126
+ }
127
+ };
128
+
129
+ // Type colors and icons - memoized to prevent recreating on every render
130
+ const typeConfig = useMemo(
131
+ () => ({
132
+ domains: {
133
+ bg: 'bg-orange-50/10',
134
+ badgeBg: 'bg-orange-500/20',
135
+ text: 'text-orange-800',
136
+ border: 'border-orange-200',
137
+ icon: RectangleGroupIcon,
138
+ },
139
+ services: {
140
+ bg: 'bg-pink-50/10',
141
+ badgeBg: 'bg-pink-500/20',
142
+ text: 'text-pink-800',
143
+ border: 'border-pink-200',
144
+ icon: ServerIcon,
145
+ },
146
+ events: {
147
+ bg: 'bg-orange-50/10',
148
+ badgeBg: 'bg-orange-500/20',
149
+ text: 'text-orange-800',
150
+ border: 'border-orange-200',
151
+ icon: BoltIcon,
152
+ },
153
+ commands: {
154
+ bg: 'bg-blue-50/10',
155
+ badgeBg: 'bg-blue-500/20',
156
+ text: 'text-blue-800',
157
+ border: 'border-blue-200',
158
+ icon: ChatBubbleLeftIcon,
159
+ },
160
+ queries: {
161
+ bg: 'bg-green-50/10',
162
+ badgeBg: 'bg-green-500/20',
163
+ text: 'text-green-800',
164
+ border: 'border-green-200',
165
+ icon: MagnifyingGlassIcon,
166
+ },
167
+ entities: {
168
+ bg: 'bg-purple-50/10',
169
+ badgeBg: 'bg-purple-500/20',
170
+ text: 'text-purple-800',
171
+ border: 'border-purple-200',
172
+ icon: CubeIcon,
173
+ },
174
+ channels: {
175
+ bg: 'bg-indigo-50/10',
176
+ badgeBg: 'bg-indigo-500/20',
177
+ text: 'text-indigo-800',
178
+ border: 'border-indigo-200',
179
+ icon: QueueListIcon,
180
+ },
181
+ teams: {
182
+ bg: 'bg-teal-50/10',
183
+ badgeBg: 'bg-teal-500/20',
184
+ text: 'text-teal-800',
185
+ border: 'border-teal-200',
186
+ icon: UserGroupIcon,
187
+ },
188
+ users: {
189
+ bg: 'bg-cyan-50/10',
190
+ badgeBg: 'bg-cyan-500/20',
191
+ text: 'text-cyan-800',
192
+ border: 'border-cyan-200',
193
+ icon: UserIcon,
194
+ },
195
+ language: {
196
+ bg: 'bg-amber-50/10',
197
+ badgeBg: 'bg-amber-500/20',
198
+ text: 'text-amber-800',
199
+ border: 'border-amber-200',
200
+ icon: BookOpenIcon,
201
+ },
202
+ openapi: {
203
+ bg: 'bg-emerald-50/10',
204
+ badgeBg: 'bg-emerald-500/20',
205
+ text: 'text-emerald-800',
206
+ border: 'border-emerald-200',
207
+ icon: DocumentTextIcon,
208
+ },
209
+ asyncapi: {
210
+ bg: 'bg-violet-50/10',
211
+ badgeBg: 'bg-violet-500/20',
212
+ text: 'text-violet-800',
213
+ border: 'border-violet-200',
214
+ icon: DocumentTextIcon,
215
+ },
216
+ other: {
217
+ bg: 'bg-gray-50/10',
218
+ badgeBg: 'bg-gray-500/20',
219
+ text: 'text-gray-800',
220
+ border: 'border-gray-200',
221
+ icon: DocumentTextIcon,
222
+ },
223
+ }),
224
+ []
225
+ );
226
+
227
+ // Initialize Pagefind
228
+ useEffect(() => {
229
+ if (typeof window !== 'undefined') {
230
+ const initPagefind = async () => {
231
+ try {
232
+ // Wait for Pagefind to be loaded by the Astro script
233
+ const waitForPagefind = () =>
234
+ new Promise<any>((resolve, reject) => {
235
+ if ((window as any).pagefind) {
236
+ resolve((window as any).pagefind);
237
+ } else {
238
+ const handler = () => {
239
+ window.removeEventListener('pagefindLoaded', handler);
240
+ resolve((window as any).pagefind);
241
+ };
242
+
243
+ // Set a timeout to detect if pagefind fails to load
244
+ const timeout = setTimeout(() => {
245
+ window.removeEventListener('pagefindLoaded', handler);
246
+ reject(new Error('Pagefind failed to load - catalog may not be indexed'));
247
+ }, 2000); // 5 second timeout
248
+
249
+ window.addEventListener('pagefindLoaded', () => {
250
+ clearTimeout(timeout);
251
+ handler();
252
+ });
253
+ }
254
+ });
255
+
256
+ const pagefindModule = await waitForPagefind();
257
+ await pagefindModule.init();
258
+ setPagefind(pagefindModule);
259
+ } catch (error) {
260
+ console.error('Failed to initialize Pagefind:', error);
261
+ setPagefindLoadError(true);
262
+ }
263
+ };
264
+
265
+ initPagefind();
266
+ }
267
+ }, []);
268
+
269
+ // Type mapping based on URL patterns - memoized callback
270
+ const getTypeFromUrl = useCallback((url: string): string => {
271
+ // Check for language first since it can be nested under other paths
272
+ if (url.includes('/language/')) return 'language';
273
+ // Check for spec types after language but before other types since they can be nested
274
+ if (url.includes('/spec/')) return 'openapi';
275
+ if (url.includes('/asyncapi')) return 'asyncapi';
276
+ if (url.includes('/domains/')) return 'domains';
277
+ if (url.includes('/services/')) return 'services';
278
+ if (url.includes('/events/')) return 'events';
279
+ if (url.includes('/commands/')) return 'commands';
280
+ if (url.includes('/queries/')) return 'queries';
281
+ if (url.includes('/entities/')) return 'entities';
282
+ if (url.includes('/channels/')) return 'channels';
283
+ if (url.includes('/teams/')) return 'teams';
284
+ if (url.includes('/users/')) return 'users';
285
+ return 'other';
286
+ }, []);
287
+
288
+ // Load all results using a wildcard search
289
+ const loadAllResults = useCallback(async () => {
290
+ if (!pagefind) return;
291
+
292
+ setIsLoading(true);
293
+ try {
294
+ // Use a common word or wildcard to get all indexed content
295
+ const search = await pagefind.search('a');
296
+ if (!search || !search.results) {
297
+ return;
298
+ }
299
+ const processedResults: SearchResult[] = [];
300
+
301
+ for (const result of search.results) {
302
+ const data = await result.data();
303
+ const type = getTypeFromUrl(data.url);
304
+
305
+ // Clean the title by removing any "Type | " prefix if it exists
306
+ let cleanTitle = data.meta?.title || 'Untitled';
307
+
308
+ // Use regex for more efficient prefix removal
309
+ cleanTitle = cleanTitle.replace(
310
+ /^(Domains?|Services?|Events?|Commands?|Queries?|Entities?|Channels?|Teams?|Users?|Language) \| /,
311
+ ''
312
+ );
313
+
314
+ processedResults.push({
315
+ id: result.id,
316
+ name: cleanTitle,
317
+ type: type,
318
+ description: data.excerpt || '',
319
+ url: data.url,
320
+ tags: data.meta?.tags ? data.meta.tags.split(',').map((tag: string) => tag.trim()) : [],
321
+ });
322
+ }
323
+
324
+ setAllResults(processedResults);
325
+ } catch (error) {
326
+ console.error('Error loading all results:', error);
327
+ } finally {
328
+ setIsLoading(false);
329
+ }
330
+ }, [pagefind, getTypeFromUrl]);
331
+
332
+ // Load results when modal opens and pagefind is available
333
+ useEffect(() => {
334
+ if (isOpen && pagefind && !pagefindLoadError) {
335
+ loadAllResults();
336
+ }
337
+ }, [isOpen, pagefind, pagefindLoadError, loadAllResults]);
338
+
339
+ // Perform search
340
+ const performSearch = useCallback(
341
+ async (searchTerm: string): Promise<SearchResult[]> => {
342
+ if (!pagefind || !searchTerm.trim()) {
343
+ return [];
344
+ }
345
+
346
+ setIsLoading(true);
347
+
348
+ try {
349
+ const search = await pagefind.debouncedSearch(searchTerm);
350
+ if (!search || !search.results) {
351
+ return [];
352
+ }
353
+ const processedResults: SearchResult[] = [];
354
+
355
+ for (const result of search.results) {
356
+ const data = await result.data();
357
+ const type = getTypeFromUrl(data.url);
358
+
359
+ // Clean the title by removing any "Type | " prefix if it exists
360
+ let cleanTitle = data.meta?.title || 'Untitled';
361
+
362
+ // Use regex for more efficient prefix removal
363
+ cleanTitle = cleanTitle.replace(
364
+ /^(Domains?|Services?|Events?|Commands?|Queries?|Entities?|Channels?|Teams?|Users?|Language) \| /,
365
+ ''
366
+ );
367
+
368
+ processedResults.push({
369
+ id: result.id,
370
+ name: cleanTitle,
371
+ type: type,
372
+ description: data.excerpt || '',
373
+ url: data.url,
374
+ tags: data.meta?.tags ? data.meta.tags.split(',').map((tag: string) => tag.trim()) : [],
375
+ });
376
+ }
377
+
378
+ return processedResults;
379
+ } catch (error) {
380
+ console.error('Search error:', error);
381
+ return [];
382
+ } finally {
383
+ setIsLoading(false);
384
+ }
385
+ },
386
+ [pagefind]
387
+ );
388
+
389
+ // Filter results - memoized callback
390
+ const filterResults = useCallback(
391
+ (results: SearchResult[], filterType: string): SearchResult[] => {
392
+ let filteredResults = results;
393
+
394
+ // Apply type filter
395
+ if (filterType !== 'all') {
396
+ filteredResults = filteredResults.filter((item) => item.type === filterType);
397
+ }
398
+
399
+ // Apply exact match filter if enabled
400
+ if (exactMatch && currentSearch.trim()) {
401
+ filteredResults = filteredResults.filter((item) => item.name.toLowerCase().includes(currentSearch.toLowerCase()));
402
+ }
403
+
404
+ return filteredResults;
405
+ },
406
+ [exactMatch, currentSearch]
407
+ );
408
+
409
+ // Update results
410
+ const updateResults = useCallback(async () => {
411
+ if (currentSearch.trim()) {
412
+ const results = await performSearch(currentSearch);
413
+ setAllResults(results);
414
+ } else {
415
+ setAllResults([]);
416
+ }
417
+ }, [currentSearch, performSearch]);
418
+
419
+ // Search on input change
420
+ useEffect(() => {
421
+ updateResults();
422
+ }, [updateResults]);
423
+
424
+ // Handle input change
425
+ const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
426
+ setCurrentSearch(e.target.value);
427
+ };
428
+
429
+ // Handle filter change
430
+ const handleFilterChange = (filter: string) => {
431
+ setCurrentFilter(filter);
432
+ };
433
+
434
+ // Get filtered results - memoized to prevent recalculation
435
+ const filteredResults = useMemo(() => {
436
+ return filterResults(allResults, currentFilter);
437
+ }, [allResults, currentFilter, exactMatch, currentSearch]);
438
+
439
+ // Get filter counts - memoized to prevent recalculation on every render
440
+ const getFilterCounts = useMemo(() => {
441
+ return {
442
+ all: allResults.length,
443
+ domains: allResults.filter((r) => r.type === 'domains').length,
444
+ services: allResults.filter((r) => r.type === 'services').length,
445
+ events: allResults.filter((r) => r.type === 'events').length,
446
+ commands: allResults.filter((r) => r.type === 'commands').length,
447
+ queries: allResults.filter((r) => r.type === 'queries').length,
448
+ entities: allResults.filter((r) => r.type === 'entities').length,
449
+ channels: allResults.filter((r) => r.type === 'channels').length,
450
+ teams: allResults.filter((r) => r.type === 'teams').length,
451
+ users: allResults.filter((r) => r.type === 'users').length,
452
+ language: allResults.filter((r) => r.type === 'language').length,
453
+ openapi: allResults.filter((r) => r.type === 'openapi').length,
454
+ asyncapi: allResults.filter((r) => r.type === 'asyncapi').length,
455
+ };
456
+ }, [allResults]);
457
+
458
+ const counts = getFilterCounts;
459
+
460
+ // Handle escape key
461
+ useEffect(() => {
462
+ const handleEscape = (e: KeyboardEvent) => {
463
+ if (e.key === 'Escape') {
464
+ onClose();
465
+ }
466
+ };
467
+
468
+ if (isOpen) {
469
+ document.addEventListener('keydown', handleEscape);
470
+ return () => document.removeEventListener('keydown', handleEscape);
471
+ }
472
+ }, [isOpen, onClose]);
473
+
474
+ if (!isOpen) return null;
475
+
476
+ return (
477
+ <div>
478
+ <style>{`
479
+ .search-results mark {
480
+ background-color: #fef3c7;
481
+ color: #92400e;
482
+ padding: 0.125rem 0.25rem;
483
+ border-radius: 0.25rem;
484
+ font-weight: 500;
485
+ }
486
+ `}</style>
487
+ <div className="fixed inset-0 z-[9999] overflow-y-auto" role="dialog" aria-modal="true">
488
+ <div
489
+ className="fixed inset-0 bg-gray-500 bg-opacity-25 transition-opacity backdrop-blur-sm bg-black/10 z-[9998]"
490
+ onClick={onClose}
491
+ ></div>
492
+ <div className="fixed inset-0 z-[10000] w-screen overflow-y-auto p-4 sm:p-6 md:p-10" onClick={onClose}>
493
+ <div
494
+ className="mx-auto max-w-6xl divide-y divide-gray-100 overflow-hidden rounded-xl bg-white shadow-2xl ring-1 ring-black ring-opacity-5 transition-all"
495
+ onClick={(e) => e.stopPropagation()}
496
+ >
497
+ {pagefindLoadError ? (
498
+ // Show indexing required message when Pagefind fails to load - full modal content
499
+ <div className="flex items-center justify-center py-10 px-8">
500
+ <div className="text-left max-w-lg">
501
+ <div className="mb-8">
502
+ <h2 className="text-2xl font-bold text-gray-900 mb-3">Search Index Not Found</h2>
503
+ <p className="text-gray-600 mb-8 leading-relaxed text-sm">
504
+ Your EventCatalog needs to be built to generate the search index. This enables fast searching across all
505
+ your domains, services, events, and documentation.
506
+ </p>
507
+ </div>
508
+
509
+ <div className="bg-gradient-to-r from-gray-50 to-gray-100 rounded-xl p-6 mb-6 border border-gray-200">
510
+ <h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center justify-center">
511
+ <svg className="h-5 w-5 text-green-600 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
512
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
513
+ </svg>
514
+ Build Your Catalog
515
+ </h3>
516
+ <div className="bg-gray-900 rounded-lg p-4 mb-4">
517
+ <code className="text-green-400 font-mono text-sm">npm run build</code>
518
+ </div>
519
+ <p className="text-sm text-gray-600">This will generate your catalog and create the search index</p>
520
+ </div>
521
+
522
+ <div className="flex items-start text-left bg-blue-50 rounded-lg p-4 border border-blue-200">
523
+ <svg
524
+ className="h-5 w-5 text-blue-600 mr-3 mt-0.5 flex-shrink-0"
525
+ fill="none"
526
+ stroke="currentColor"
527
+ viewBox="0 0 24 24"
528
+ >
529
+ <path
530
+ strokeLinecap="round"
531
+ strokeLinejoin="round"
532
+ strokeWidth="2"
533
+ d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
534
+ />
535
+ </svg>
536
+ <div>
537
+ <h4 className="font-medium text-blue-900 mb-1">Need to update search results?</h4>
538
+ <p className="text-sm text-blue-700">
539
+ Run <code className="bg-blue-100 px-1 py-0.5 rounded text-xs font-mono">npm run build</code> again after
540
+ making changes to your catalog content.
541
+ </p>
542
+ </div>
543
+ </div>
544
+ </div>
545
+ </div>
546
+ ) : (
547
+ <>
548
+ {/* Search Input */}
549
+ <div className="relative px-6 pt-4 pb-2">
550
+ <MagnifyingGlassIcon className="pointer-events-none absolute left-10 top-[25px] h-5 w-5 text-gray-400" />
551
+ <input
552
+ type="text"
553
+ placeholder="Search for domains, services, events..."
554
+ className="w-full border border-gray-200 rounded-lg bg-white pl-12 pr-4 py-2 text-gray-900 placeholder:text-gray-400 focus:ring-2 focus:ring-purple-500 focus:border-purple-500 text-sm"
555
+ value={currentSearch}
556
+ onChange={handleSearchChange}
557
+ autoFocus
558
+ />
559
+ </div>
560
+
561
+ {/* Main Content Area */}
562
+ <div className="flex h-[500px]">
563
+ {/* Left Filters */}
564
+ <div className="w-56 p-3 border-r border-gray-200 bg-gray-50 overflow-y-auto">
565
+ {/* All Resources */}
566
+ <div className="mb-4">
567
+ <div className="space-y-1">
568
+ <button
569
+ className={`w-full px-2 py-1 text-xs rounded-md transition-colors ${
570
+ currentFilter === 'all'
571
+ ? 'bg-purple-200 text-purple-900 font-semibold'
572
+ : 'hover:bg-purple-100 text-gray-700 hover:text-purple-800'
573
+ }`}
574
+ onClick={() => handleFilterChange('all')}
575
+ >
576
+ <div className="flex items-center justify-between">
577
+ <div className="flex items-center gap-1.5">
578
+ <span>All Resources</span>
579
+ </div>
580
+ <span className="text-xs text-gray-400">{counts.all}</span>
581
+ </div>
582
+ </button>
583
+ </div>
584
+ </div>
585
+
586
+ {/* Resources Section */}
587
+ <div className="mb-4">
588
+ <h3 className="text-xs font-medium text-gray-600 mb-2">Resources</h3>
589
+ <div className="space-y-1">
590
+ {Object.entries({
591
+ domains: 'Domains',
592
+ services: 'Services',
593
+ entities: 'Entities',
594
+ language: 'Ubiquitous Language',
595
+ }).map(([key, label]) => {
596
+ const config = typeConfig[key as keyof typeof typeConfig];
597
+ const IconComponent = config?.icon;
598
+
599
+ return (
600
+ <button
601
+ key={key}
602
+ className={`w-full px-2 py-1 text-xs rounded-md transition-colors ${
603
+ currentFilter === key
604
+ ? 'bg-purple-200 text-purple-900 font-semibold'
605
+ : 'hover:bg-purple-100 text-gray-700 hover:text-purple-800'
606
+ }`}
607
+ onClick={() => handleFilterChange(key)}
608
+ >
609
+ <div className="flex items-center justify-between">
610
+ <div className="flex items-center gap-1.5">
611
+ {IconComponent && <IconComponent className="h-3 w-3" />}
612
+ <span>{label}</span>
613
+ </div>
614
+ <span className="text-xs text-gray-400">{counts[key as keyof typeof counts]}</span>
615
+ </div>
616
+ </button>
617
+ );
618
+ })}
619
+ </div>
620
+ </div>
621
+
622
+ {/* Messages Section */}
623
+ <div className="mb-4">
624
+ <h3 className="text-xs font-medium text-gray-600 mb-2">Messages</h3>
625
+ <div className="space-y-1">
626
+ {Object.entries({
627
+ events: 'Events',
628
+ commands: 'Commands',
629
+ queries: 'Queries',
630
+ channels: 'Channels',
631
+ }).map(([key, label]) => {
632
+ const config = typeConfig[key as keyof typeof typeConfig];
633
+ const IconComponent = config?.icon;
634
+
635
+ return (
636
+ <button
637
+ key={key}
638
+ className={`w-full px-2 py-1 text-xs rounded-md transition-colors ${
639
+ currentFilter === key
640
+ ? 'bg-purple-200 text-purple-900 font-semibold'
641
+ : 'hover:bg-purple-100 text-gray-700 hover:text-purple-800'
642
+ }`}
643
+ onClick={() => handleFilterChange(key)}
644
+ >
645
+ <div className="flex items-center justify-between">
646
+ <div className="flex items-center gap-1.5">
647
+ {IconComponent && <IconComponent className="h-3 w-3" />}
648
+ <span>{label}</span>
649
+ </div>
650
+ <span className="text-xs text-gray-400">{counts[key as keyof typeof counts]}</span>
651
+ </div>
652
+ </button>
653
+ );
654
+ })}
655
+ </div>
656
+ </div>
657
+
658
+ {/* Organization Section */}
659
+ <div className="mb-4">
660
+ <h3 className="text-xs font-medium text-gray-600 mb-2">Organization</h3>
661
+ <div className="space-y-1">
662
+ {Object.entries({
663
+ teams: 'Teams',
664
+ users: 'Users',
665
+ }).map(([key, label]) => {
666
+ const config = typeConfig[key as keyof typeof typeConfig];
667
+ const IconComponent = config?.icon;
668
+
669
+ return (
670
+ <button
671
+ key={key}
672
+ className={`w-full px-2 py-1 text-xs rounded-md transition-colors ${
673
+ currentFilter === key
674
+ ? 'bg-purple-200 text-purple-900 font-semibold'
675
+ : 'hover:bg-purple-100 text-gray-700 hover:text-purple-800'
676
+ }`}
677
+ onClick={() => handleFilterChange(key)}
678
+ >
679
+ <div className="flex items-center justify-between">
680
+ <div className="flex items-center gap-1.5">
681
+ {IconComponent && <IconComponent className="h-3 w-3" />}
682
+ <span>{label}</span>
683
+ </div>
684
+ <span className="text-xs text-gray-400">{counts[key as keyof typeof counts]}</span>
685
+ </div>
686
+ </button>
687
+ );
688
+ })}
689
+ </div>
690
+ </div>
691
+
692
+ {/* Specifications Section */}
693
+ <div className="mb-4">
694
+ <h3 className="text-xs font-medium text-gray-600 mb-2">Specifications</h3>
695
+ <div className="space-y-1">
696
+ {Object.entries({
697
+ openapi: 'OpenAPI Specification',
698
+ asyncapi: 'AsyncAPI Specification',
699
+ }).map(([key, label]) => {
700
+ const config = typeConfig[key as keyof typeof typeConfig];
701
+ const IconComponent = config.icon;
702
+
703
+ return (
704
+ <button
705
+ key={key}
706
+ className={`w-full px-2 py-1 text-xs rounded-md transition-colors ${
707
+ currentFilter === key
708
+ ? 'bg-purple-200 text-purple-900 font-semibold'
709
+ : 'hover:bg-purple-100 text-gray-700 hover:text-purple-800'
710
+ }`}
711
+ onClick={() => handleFilterChange(key)}
712
+ >
713
+ <div className="flex items-center justify-between">
714
+ <div className="flex items-center gap-1.5">
715
+ <IconComponent className="h-3 w-3" />
716
+ <span>{label}</span>
717
+ </div>
718
+ <span className="text-xs text-gray-400">{counts[key as keyof typeof counts]}</span>
719
+ </div>
720
+ </button>
721
+ );
722
+ })}
723
+ </div>
724
+ </div>
725
+ </div>
726
+
727
+ {/* Right Results */}
728
+ <div className="flex-1 p-4 overflow-y-auto">
729
+ {/* Show stats and exact match toggle */}
730
+ <div className="mb-4 flex items-center justify-between">
731
+ <div className="text-sm text-gray-500">
732
+ {currentSearch.trim() ? (
733
+ <>
734
+ <span>{filteredResults.length} results</span> for "{currentSearch}"
735
+ {isLoading && <span className="ml-2">Loading...</span>}
736
+ </>
737
+ ) : (
738
+ <>
739
+ <span>{filteredResults.length} resources</span> in EventCatalog
740
+ {isLoading && <span className="ml-2">Loading...</span>}
741
+ </>
742
+ )}
743
+ </div>
744
+
745
+ {/* Exact Match Checkbox - moved here */}
746
+ {currentSearch.trim() && (
747
+ <div className="flex items-center">
748
+ <input
749
+ id="exact-match-results"
750
+ type="checkbox"
751
+ checked={exactMatch}
752
+ onChange={(e) => setExactMatch(e.target.checked)}
753
+ className="h-4 w-4 text-purple-600 focus:ring-purple-500 border-gray-300 rounded"
754
+ />
755
+ <label htmlFor="exact-match-results" className="ml-2 text-sm text-gray-600">
756
+ Exact match in title
757
+ </label>
758
+ </div>
759
+ )}
760
+ </div>
761
+
762
+ <div className="search-results grid grid-cols-1 lg:grid-cols-2 gap-3">
763
+ {!currentSearch.trim() && filteredResults.length === 0 ? (
764
+ // Show when no search term is entered and no results loaded yet
765
+ <div className="col-span-full text-center py-20">
766
+ <svg className="mx-auto h-16 w-16 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
767
+ <path
768
+ strokeLinecap="round"
769
+ strokeLinejoin="round"
770
+ strokeWidth="1.5"
771
+ d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
772
+ />
773
+ </svg>
774
+ <h3 className="mt-4 text-lg font-medium text-gray-900">
775
+ {isLoading ? 'Loading resources...' : 'Discover your EventCatalog'}
776
+ </h3>
777
+ <p className="mt-2 text-sm text-gray-300">
778
+ {isLoading
779
+ ? 'Fetching all available resources from EventCatalog.'
780
+ : 'Start typing to search for domains, services, events, and more.'}
781
+ </p>
782
+ </div>
783
+ ) : currentSearch.trim() && filteredResults.length === 0 ? (
784
+ // Show when search term exists but no results
785
+ <div className="col-span-full text-center py-16">
786
+ <svg className="mx-auto h-12 w-12 text-gray-300" stroke="currentColor" fill="none" viewBox="0 0 48 48">
787
+ <path
788
+ d="M21 21l6-6m-6 6l6 6m-6-6h6m-6 0v6"
789
+ strokeWidth="2"
790
+ strokeLinecap="round"
791
+ strokeLinejoin="round"
792
+ />
793
+ </svg>
794
+ <h3 className="mt-2 text-sm font-medium text-gray-900">No results found</h3>
795
+ <p className="mt-1 text-sm text-gray-500">
796
+ No results found for "<span className="font-medium">{currentSearch}</span>". Try different keywords or
797
+ check your spelling.
798
+ </p>
799
+ </div>
800
+ ) : (
801
+ <SearchResults results={filteredResults} typeConfig={typeConfig} currentSearch={currentSearch} />
802
+ )}
803
+ </div>
804
+ </div>
805
+ </div>
806
+ </>
807
+ )}
808
+ </div>
809
+ </div>
810
+ </div>
811
+ </div>
812
+ );
813
+ };
814
+
815
+ export default SearchModal;