@eventcatalog/core 2.53.1 → 2.54.1

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