@eventcatalog/core 3.5.2 → 3.6.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.
Files changed (39) hide show
  1. package/dist/analytics/analytics.cjs +1 -1
  2. package/dist/analytics/analytics.js +2 -2
  3. package/dist/analytics/log-build.cjs +1 -1
  4. package/dist/analytics/log-build.js +3 -3
  5. package/dist/{chunk-OKWCSRLE.js → chunk-2DSMO5BZ.js} +1 -1
  6. package/dist/{chunk-YTZSPYJN.js → chunk-O3LNFOFS.js} +1 -1
  7. package/dist/{chunk-YVX5C6L3.js → chunk-O7ZZX4CS.js} +1 -1
  8. package/dist/{chunk-WO3AKJVB.js → chunk-XTN3M6CM.js} +1 -1
  9. package/dist/{chunk-YOFNY2RC.js → chunk-YQ2LO4G6.js} +1 -1
  10. package/dist/constants.cjs +1 -1
  11. package/dist/constants.js +1 -1
  12. package/dist/eventcatalog.cjs +1 -1
  13. package/dist/eventcatalog.js +5 -5
  14. package/dist/generate.cjs +1 -1
  15. package/dist/generate.js +3 -3
  16. package/dist/utils/cli-logger.cjs +1 -1
  17. package/dist/utils/cli-logger.js +2 -2
  18. package/eventcatalog/src/components/EnvironmentDropdown.tsx +1 -1
  19. package/eventcatalog/src/components/Search/SearchDataLoader.astro +23 -11
  20. package/eventcatalog/src/components/Search/SearchModal.tsx +17 -2
  21. package/eventcatalog/src/components/SideNav/NestedSideBar/SearchBar.tsx +12 -6
  22. package/eventcatalog/src/components/SideNav/NestedSideBar/index.tsx +25 -14
  23. package/eventcatalog/src/components/Tables/Discover/DiscoverTable.tsx +816 -0
  24. package/eventcatalog/src/components/Tables/Discover/FilterComponents.tsx +161 -0
  25. package/eventcatalog/src/components/Tables/Discover/columns.tsx +565 -0
  26. package/eventcatalog/src/components/Tables/Discover/index.ts +4 -0
  27. package/eventcatalog/src/components/Tables/columns/ContainersTableColumns.tsx +1 -1
  28. package/eventcatalog/src/components/Tables/columns/DomainTableColumns.tsx +1 -1
  29. package/eventcatalog/src/components/Tables/columns/FlowTableColumns.tsx +1 -1
  30. package/eventcatalog/src/components/Tables/columns/MessageTableColumns.tsx +1 -1
  31. package/eventcatalog/src/components/Tables/columns/ServiceTableColumns.tsx +54 -64
  32. package/eventcatalog/src/components/Tables/columns/SharedColumns.tsx +15 -30
  33. package/eventcatalog/src/layouts/VerticalSideBarLayout.astro +1 -1
  34. package/eventcatalog/src/pages/api/sidebar-data.json.ts +22 -0
  35. package/eventcatalog/src/pages/discover/[type]/_index.data.ts +5 -1
  36. package/eventcatalog/src/pages/discover/[type]/index.astro +360 -41
  37. package/eventcatalog/src/stores/sidebar-store/builders/shared.ts +1 -1
  38. package/eventcatalog/src/stores/sidebar-store/state.ts +25 -22
  39. package/package.json +1 -1
@@ -0,0 +1,816 @@
1
+ import {
2
+ flexRender,
3
+ getCoreRowModel,
4
+ getFacetedMinMaxValues,
5
+ getFacetedRowModel,
6
+ getFacetedUniqueValues,
7
+ getFilteredRowModel,
8
+ getPaginationRowModel,
9
+ useReactTable,
10
+ type ColumnFiltersState,
11
+ } from '@tanstack/react-table';
12
+ import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, SearchX, X, Search, Users } from 'lucide-react';
13
+ import { UserIcon } from '@heroicons/react/24/outline';
14
+ import { useMemo, useState } from 'react';
15
+ import type { TableConfiguration } from '@types';
16
+ import { isSameVersion } from '@utils/collections/util';
17
+ import { FilterDropdown, CheckboxItem } from './FilterComponents';
18
+ import DebouncedInput from '../DebouncedInput';
19
+ import { getDiscoverColumns } from './columns';
20
+
21
+ export type CollectionType = 'events' | 'commands' | 'queries' | 'services' | 'domains' | 'flows' | 'containers';
22
+
23
+ export interface DiscoverTableData {
24
+ collection: string;
25
+ domains?: Array<{ id: string; name: string; version: string }>;
26
+ owners?: Array<{ id: string; name: string; type?: 'user' | 'team' }>;
27
+ hasSpecifications?: boolean;
28
+ hasOwners?: boolean;
29
+ hasRepository?: boolean;
30
+ isDeprecated?: boolean;
31
+ hasDataDependencies?: boolean;
32
+ data: {
33
+ id: string;
34
+ name: string;
35
+ summary?: string;
36
+ version: string;
37
+ latestVersion?: string;
38
+ draft?: boolean | { title?: string; message: string };
39
+ badges?: Array<{
40
+ id?: string;
41
+ content: string;
42
+ backgroundColor?: string;
43
+ textColor?: string;
44
+ }>;
45
+ producers?: Array<any>;
46
+ consumers?: Array<any>;
47
+ receives?: Array<any>;
48
+ sends?: Array<any>;
49
+ services?: Array<any>;
50
+ servicesThatWriteToContainer?: Array<any>;
51
+ servicesThatReadFromContainer?: Array<any>;
52
+ };
53
+ }
54
+
55
+ export interface PropertyOption {
56
+ id: string;
57
+ label: string;
58
+ }
59
+
60
+ export interface DiscoverTableProps<T extends DiscoverTableData> {
61
+ data: T[];
62
+ collectionType: CollectionType;
63
+ collectionLabel: string;
64
+ domains?: Array<{ id: string; name: string; version: string }>;
65
+ owners?: Array<{ id: string; name: string; type?: 'user' | 'team' }>;
66
+ producers?: Array<{ id: string; name: string }>;
67
+ consumers?: Array<{ id: string; name: string }>;
68
+ propertyOptions?: PropertyOption[];
69
+ tableConfiguration?: TableConfiguration;
70
+ showDomainsFilter?: boolean;
71
+ showOwnersFilter?: boolean;
72
+ showProducersFilter?: boolean;
73
+ showConsumersFilter?: boolean;
74
+ }
75
+
76
+ export function DiscoverTable<T extends DiscoverTableData>({
77
+ data: initialData,
78
+ collectionType,
79
+ collectionLabel,
80
+ domains = [],
81
+ owners = [],
82
+ producers = [],
83
+ consumers = [],
84
+ propertyOptions = [],
85
+ tableConfiguration,
86
+ showDomainsFilter = true,
87
+ showOwnersFilter = true,
88
+ showProducersFilter = false,
89
+ showConsumersFilter = false,
90
+ }: DiscoverTableProps<T>) {
91
+ // Generate columns inside the React component to avoid hydration issues
92
+ const columns = useMemo(
93
+ () => getDiscoverColumns(collectionType, tableConfiguration ?? { columns: {} }),
94
+ [collectionType, tableConfiguration]
95
+ );
96
+ const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
97
+ const [globalFilter, setGlobalFilter] = useState('');
98
+ const [tableFilter, setTableFilter] = useState('');
99
+ const [showOnlyLatest, setShowOnlyLatest] = useState(true);
100
+ const [onlyShowDrafts, setOnlyShowDrafts] = useState(false);
101
+ const [selectedDomains, setSelectedDomains] = useState<string[]>([]);
102
+ const [selectedOwners, setSelectedOwners] = useState<string[]>([]);
103
+ const [selectedProducers, setSelectedProducers] = useState<string[]>([]);
104
+ const [selectedConsumers, setSelectedConsumers] = useState<string[]>([]);
105
+ const [selectedBadges, setSelectedBadges] = useState<string[]>([]);
106
+ const [selectedProperties, setSelectedProperties] = useState<string[]>([]);
107
+
108
+ // Collect unique badges from all items
109
+ const allBadges = useMemo(() => {
110
+ const badgeMap = new Map<string, { content: string; backgroundColor?: string; textColor?: string; count: number }>();
111
+ initialData.forEach((item) => {
112
+ const badges = item.data?.badges || [];
113
+ badges.forEach((badge: any) => {
114
+ const existing = badgeMap.get(badge.content);
115
+ if (existing) {
116
+ existing.count++;
117
+ } else {
118
+ badgeMap.set(badge.content, {
119
+ content: badge.content,
120
+ backgroundColor: badge.backgroundColor,
121
+ textColor: badge.textColor,
122
+ count: 1,
123
+ });
124
+ }
125
+ });
126
+ });
127
+ return Array.from(badgeMap.values()).sort((a, b) => b.count - a.count);
128
+ }, [initialData]);
129
+
130
+ // Filter data based on all filter states
131
+ const filteredData = useMemo(() => {
132
+ return initialData.filter((row) => {
133
+ // Check if item is a draft
134
+ const isDraft = row.data.draft === true || (typeof row.data.draft === 'object' && row.data.draft !== null);
135
+
136
+ // Draft filter
137
+ if (onlyShowDrafts && !isDraft) {
138
+ return false;
139
+ }
140
+
141
+ if (onlyShowDrafts) {
142
+ return true;
143
+ }
144
+
145
+ // Latest version filter
146
+ if (showOnlyLatest && !isSameVersion(row.data.version, row.data.latestVersion)) {
147
+ return false;
148
+ }
149
+
150
+ // Domain filter
151
+ if (selectedDomains.length > 0) {
152
+ const itemDomains = row.domains || [];
153
+ const hasMatchingDomain = itemDomains.some((d) => selectedDomains.includes(d.id));
154
+ if (!hasMatchingDomain) {
155
+ return false;
156
+ }
157
+ }
158
+
159
+ // Owner filter
160
+ if (selectedOwners.length > 0) {
161
+ const itemOwners = row.owners || [];
162
+ const hasMatchingOwner = itemOwners.some((o) => selectedOwners.includes(o.id));
163
+ if (!hasMatchingOwner) {
164
+ return false;
165
+ }
166
+ }
167
+
168
+ // Producer filter
169
+ if (selectedProducers.length > 0) {
170
+ const itemProducers = row.data.producers || [];
171
+ const hasMatchingProducer = itemProducers.some((p: any) => selectedProducers.includes(p.data?.id || p.id));
172
+ if (!hasMatchingProducer) {
173
+ return false;
174
+ }
175
+ }
176
+
177
+ // Consumer filter
178
+ if (selectedConsumers.length > 0) {
179
+ const itemConsumers = row.data.consumers || [];
180
+ const hasMatchingConsumer = itemConsumers.some((c: any) => selectedConsumers.includes(c.data?.id || c.id));
181
+ if (!hasMatchingConsumer) {
182
+ return false;
183
+ }
184
+ }
185
+
186
+ // Badge filter
187
+ if (selectedBadges.length > 0) {
188
+ const itemBadges = row.data?.badges || [];
189
+ const hasMatchingBadge = itemBadges.some((badge: any) => selectedBadges.includes(badge.content));
190
+ if (!hasMatchingBadge) {
191
+ return false;
192
+ }
193
+ }
194
+
195
+ // Property filters
196
+ if (selectedProperties.length > 0) {
197
+ for (const prop of selectedProperties) {
198
+ // Generic property checks
199
+ if (prop === 'hasSpecifications' && !row.hasSpecifications) return false;
200
+ if (prop === 'hasOwners' && !row.hasOwners) return false;
201
+ if (prop === 'hasRepository' && !row.hasRepository) return false;
202
+ if (prop === 'hasDataDependencies' && !row.hasDataDependencies) return false;
203
+ if (prop === 'isDeprecated' && !row.isDeprecated) return false;
204
+
205
+ // Message-specific checks
206
+ if (prop === 'hasProducers') {
207
+ const producers = row.data.producers || [];
208
+ if (producers.length === 0) return false;
209
+ }
210
+ if (prop === 'hasConsumers') {
211
+ const consumers = row.data.consumers || [];
212
+ if (consumers.length === 0) return false;
213
+ }
214
+ if (prop === 'hasMessages') {
215
+ const sends = row.data.sends || [];
216
+ const receives = row.data.receives || [];
217
+ if (sends.length === 0 && receives.length === 0) return false;
218
+ }
219
+
220
+ // Service-specific checks
221
+ if (prop === 'hasServices') {
222
+ const services = row.data.services || [];
223
+ if (services.length === 0) return false;
224
+ }
225
+
226
+ // Container-specific checks
227
+ if (prop === 'hasWriters') {
228
+ const writers = row.data.servicesThatWriteToContainer || [];
229
+ if (writers.length === 0) return false;
230
+ }
231
+ if (prop === 'hasReaders') {
232
+ const readers = row.data.servicesThatReadFromContainer || [];
233
+ if (readers.length === 0) return false;
234
+ }
235
+ }
236
+ }
237
+
238
+ // Global search filter (sidebar)
239
+ if (globalFilter) {
240
+ const searchLower = globalFilter.toLowerCase();
241
+ const nameMatch = row.data.name.toLowerCase().includes(searchLower);
242
+ const summaryMatch = row.data.summary?.toLowerCase().includes(searchLower);
243
+ if (!nameMatch && !summaryMatch) {
244
+ return false;
245
+ }
246
+ }
247
+
248
+ // Table filter (header)
249
+ if (tableFilter) {
250
+ const searchLower = tableFilter.toLowerCase();
251
+ const nameMatch = row.data.name.toLowerCase().includes(searchLower);
252
+ const summaryMatch = row.data.summary?.toLowerCase().includes(searchLower);
253
+ if (!nameMatch && !summaryMatch) {
254
+ return false;
255
+ }
256
+ }
257
+
258
+ return true;
259
+ });
260
+ }, [
261
+ initialData,
262
+ showOnlyLatest,
263
+ onlyShowDrafts,
264
+ selectedDomains,
265
+ selectedOwners,
266
+ selectedProducers,
267
+ selectedConsumers,
268
+ selectedBadges,
269
+ selectedProperties,
270
+ globalFilter,
271
+ tableFilter,
272
+ ]);
273
+
274
+ const table = useReactTable({
275
+ data: filteredData,
276
+ columns,
277
+ onColumnFiltersChange: setColumnFilters,
278
+ getCoreRowModel: getCoreRowModel(),
279
+ getFilteredRowModel: getFilteredRowModel(),
280
+ getFacetedRowModel: getFacetedRowModel(),
281
+ getFacetedUniqueValues: getFacetedUniqueValues(),
282
+ getFacetedMinMaxValues: getFacetedMinMaxValues(),
283
+ getPaginationRowModel: getPaginationRowModel(),
284
+ state: {
285
+ columnFilters,
286
+ columnVisibility: Object.fromEntries(
287
+ Object.entries(tableConfiguration?.columns ?? {}).map(([key, value]) => [key, value.visible ?? true])
288
+ ),
289
+ },
290
+ });
291
+
292
+ const totalResults = table.getPrePaginationRowModel().rows.length;
293
+ const hasResults = table.getRowModel().rows.length > 0;
294
+
295
+ // Count items per domain for the filter
296
+ const domainCounts = useMemo(() => {
297
+ const counts: Record<string, number> = {};
298
+ initialData.forEach((item) => {
299
+ const itemDomains = item.domains || [];
300
+ itemDomains.forEach((d) => {
301
+ counts[d.id] = (counts[d.id] || 0) + 1;
302
+ });
303
+ });
304
+ return counts;
305
+ }, [initialData]);
306
+
307
+ // Count items per owner for the filter
308
+ const ownerCounts = useMemo(() => {
309
+ const counts: Record<string, number> = {};
310
+ initialData.forEach((item) => {
311
+ const itemOwners = item.owners || [];
312
+ itemOwners.forEach((o) => {
313
+ counts[o.id] = (counts[o.id] || 0) + 1;
314
+ });
315
+ });
316
+ return counts;
317
+ }, [initialData]);
318
+
319
+ // Count items per producer for the filter
320
+ const producerCounts = useMemo(() => {
321
+ const counts: Record<string, number> = {};
322
+ initialData.forEach((item) => {
323
+ const itemProducers = item.data.producers || [];
324
+ itemProducers.forEach((p: any) => {
325
+ const id = p.data?.id || p.id;
326
+ if (id) counts[id] = (counts[id] || 0) + 1;
327
+ });
328
+ });
329
+ return counts;
330
+ }, [initialData]);
331
+
332
+ // Count items per consumer for the filter
333
+ const consumerCounts = useMemo(() => {
334
+ const counts: Record<string, number> = {};
335
+ initialData.forEach((item) => {
336
+ const itemConsumers = item.data.consumers || [];
337
+ itemConsumers.forEach((c: any) => {
338
+ const id = c.data?.id || c.id;
339
+ if (id) counts[id] = (counts[id] || 0) + 1;
340
+ });
341
+ });
342
+ return counts;
343
+ }, [initialData]);
344
+
345
+ const toggleDomain = (domainId: string) => {
346
+ setSelectedDomains((prev) => (prev.includes(domainId) ? prev.filter((id) => id !== domainId) : [...prev, domainId]));
347
+ };
348
+
349
+ const toggleOwner = (ownerId: string) => {
350
+ setSelectedOwners((prev) => (prev.includes(ownerId) ? prev.filter((id) => id !== ownerId) : [...prev, ownerId]));
351
+ };
352
+
353
+ const toggleProducer = (producerId: string) => {
354
+ setSelectedProducers((prev) => (prev.includes(producerId) ? prev.filter((id) => id !== producerId) : [...prev, producerId]));
355
+ };
356
+
357
+ const toggleConsumer = (consumerId: string) => {
358
+ setSelectedConsumers((prev) => (prev.includes(consumerId) ? prev.filter((id) => id !== consumerId) : [...prev, consumerId]));
359
+ };
360
+
361
+ const toggleBadge = (badgeContent: string) => {
362
+ setSelectedBadges((prev) => (prev.includes(badgeContent) ? prev.filter((b) => b !== badgeContent) : [...prev, badgeContent]));
363
+ };
364
+
365
+ const toggleProperty = (propertyId: string) => {
366
+ setSelectedProperties((prev) => (prev.includes(propertyId) ? prev.filter((p) => p !== propertyId) : [...prev, propertyId]));
367
+ };
368
+
369
+ const clearAllFilters = () => {
370
+ setSelectedDomains([]);
371
+ setSelectedOwners([]);
372
+ setSelectedProducers([]);
373
+ setSelectedConsumers([]);
374
+ setSelectedBadges([]);
375
+ setSelectedProperties([]);
376
+ setShowOnlyLatest(true);
377
+ setOnlyShowDrafts(false);
378
+ setGlobalFilter('');
379
+ setTableFilter('');
380
+ setColumnFilters([]);
381
+ };
382
+
383
+ const activeFilterCount =
384
+ selectedDomains.length +
385
+ selectedOwners.length +
386
+ selectedProducers.length +
387
+ selectedConsumers.length +
388
+ selectedBadges.length +
389
+ selectedProperties.length +
390
+ (!showOnlyLatest ? 1 : 0) +
391
+ (onlyShowDrafts ? 1 : 0);
392
+
393
+ // Get selected domain names for display
394
+ const selectedDomainNames = selectedDomains.map((id) => domains.find((d) => d.id === id)?.name || id);
395
+
396
+ // Get selected owner names for display
397
+ const selectedOwnerNames = selectedOwners.map((id) => owners.find((o) => o.id === id)?.name || id);
398
+
399
+ // Get selected producer names for display
400
+ const selectedProducerNames = selectedProducers.map((id) => producers.find((p) => p.id === id)?.name || id);
401
+
402
+ // Get selected consumer names for display
403
+ const selectedConsumerNames = selectedConsumers.map((id) => consumers.find((c) => c.id === id)?.name || id);
404
+
405
+ // Filter producers/consumers to only show those with count > 0
406
+ const filteredProducers = producers.filter((p) => (producerCounts[p.id] || 0) > 0);
407
+ const filteredConsumers = consumers.filter((c) => (consumerCounts[c.id] || 0) > 0);
408
+
409
+ // Get selected property labels for display
410
+ const selectedPropertyLabels = selectedProperties.map((id) => propertyOptions.find((p) => p.id === id)?.label || id);
411
+
412
+ return (
413
+ <div className="flex gap-8 items-start">
414
+ {/* Filter Sidebar */}
415
+ <div className="w-72 flex-shrink-0 space-y-6 p-4 bg-[rgb(var(--ec-card-bg,var(--ec-page-bg)))] border border-[rgb(var(--ec-page-border))] rounded-xl">
416
+ {/* Search */}
417
+ <DebouncedInput
418
+ type="text"
419
+ value={globalFilter}
420
+ onChange={(value) => setGlobalFilter(String(value))}
421
+ placeholder={`Search ${collectionLabel.toLowerCase()}...`}
422
+ className="w-full px-3 py-2 text-sm bg-[rgb(var(--ec-input-bg))] text-[rgb(var(--ec-input-text))] border border-[rgb(var(--ec-page-border))] rounded-lg placeholder:text-[rgb(var(--ec-input-placeholder))] focus:outline-none focus:ring-2 focus:ring-[rgb(var(--ec-accent)/0.2)] focus:border-[rgb(var(--ec-accent))] transition-colors"
423
+ />
424
+
425
+ {/* Message Filters Section */}
426
+ {(showProducersFilter || showConsumersFilter) && (filteredProducers.length > 0 || filteredConsumers.length > 0) && (
427
+ <div className="space-y-2">
428
+ <h3 className="text-[11px] font-bold uppercase tracking-widest text-[rgb(var(--ec-page-text-muted))]">
429
+ Message Filters
430
+ </h3>
431
+
432
+ {/* Producers Filter */}
433
+ {showProducersFilter && filteredProducers.length > 0 && (
434
+ <div>
435
+ <label className="block text-xs font-medium text-[rgb(var(--ec-page-text-muted))] mb-1.5">Producers</label>
436
+ <FilterDropdown
437
+ label="Select producers..."
438
+ selectedItems={selectedProducerNames}
439
+ onClear={() => setSelectedProducers([])}
440
+ onRemoveItem={(name) => {
441
+ const producer = filteredProducers.find((p) => p.name === name);
442
+ if (producer) toggleProducer(producer.id);
443
+ }}
444
+ >
445
+ {filteredProducers.map((producer) => (
446
+ <CheckboxItem
447
+ key={producer.id}
448
+ label={producer.name}
449
+ checked={selectedProducers.includes(producer.id)}
450
+ onChange={() => toggleProducer(producer.id)}
451
+ count={producerCounts[producer.id] || 0}
452
+ />
453
+ ))}
454
+ </FilterDropdown>
455
+ </div>
456
+ )}
457
+
458
+ {/* Consumers Filter */}
459
+ {showConsumersFilter && filteredConsumers.length > 0 && (
460
+ <div>
461
+ <label className="block text-xs font-medium text-[rgb(var(--ec-page-text-muted))] mb-1.5">Consumers</label>
462
+ <FilterDropdown
463
+ label="Select consumers..."
464
+ selectedItems={selectedConsumerNames}
465
+ onClear={() => setSelectedConsumers([])}
466
+ onRemoveItem={(name) => {
467
+ const consumer = filteredConsumers.find((c) => c.name === name);
468
+ if (consumer) toggleConsumer(consumer.id);
469
+ }}
470
+ >
471
+ {filteredConsumers.map((consumer) => (
472
+ <CheckboxItem
473
+ key={consumer.id}
474
+ label={consumer.name}
475
+ checked={selectedConsumers.includes(consumer.id)}
476
+ onChange={() => toggleConsumer(consumer.id)}
477
+ count={consumerCounts[consumer.id] || 0}
478
+ />
479
+ ))}
480
+ </FilterDropdown>
481
+ </div>
482
+ )}
483
+ </div>
484
+ )}
485
+
486
+ {/* Catalog Filters Section */}
487
+ <div className="space-y-2">
488
+ <h3 className="text-[11px] font-bold uppercase tracking-widest text-[rgb(var(--ec-page-text-muted))]">
489
+ Catalog Filters
490
+ </h3>
491
+
492
+ {/* Domains Filter */}
493
+ {showDomainsFilter && domains.length > 0 && (
494
+ <div>
495
+ <label className="block text-xs font-medium text-[rgb(var(--ec-page-text-muted))] mb-1.5">Domains</label>
496
+ <FilterDropdown
497
+ label="Select domains..."
498
+ selectedItems={selectedDomainNames}
499
+ onClear={() => setSelectedDomains([])}
500
+ onRemoveItem={(name) => {
501
+ const domain = domains.find((d) => d.name === name);
502
+ if (domain) toggleDomain(domain.id);
503
+ }}
504
+ >
505
+ {domains.map((domain) => (
506
+ <CheckboxItem
507
+ key={domain.id}
508
+ label={domain.name}
509
+ checked={selectedDomains.includes(domain.id)}
510
+ onChange={() => toggleDomain(domain.id)}
511
+ count={domainCounts[domain.id] || 0}
512
+ />
513
+ ))}
514
+ </FilterDropdown>
515
+ </div>
516
+ )}
517
+
518
+ {/* Owners Filter */}
519
+ {showOwnersFilter && owners.length > 0 && (
520
+ <div>
521
+ <label className="block text-xs font-medium text-[rgb(var(--ec-page-text-muted))] mb-1.5">Owners</label>
522
+ <FilterDropdown
523
+ label="Select owners..."
524
+ selectedItems={selectedOwnerNames}
525
+ onClear={() => setSelectedOwners([])}
526
+ onRemoveItem={(name) => {
527
+ const owner = owners.find((o) => o.name === name);
528
+ if (owner) toggleOwner(owner.id);
529
+ }}
530
+ >
531
+ {/* Users section */}
532
+ {owners.filter((o) => o.type !== 'team').length > 0 && (
533
+ <>
534
+ <div className="px-2 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-[rgb(var(--ec-page-text-muted))] flex items-center gap-1.5">
535
+ <UserIcon className="w-3 h-3" />
536
+ Users
537
+ </div>
538
+ {owners
539
+ .filter((o) => o.type !== 'team')
540
+ .map((owner) => (
541
+ <CheckboxItem
542
+ key={owner.id}
543
+ label={owner.name}
544
+ checked={selectedOwners.includes(owner.id)}
545
+ onChange={() => toggleOwner(owner.id)}
546
+ count={ownerCounts[owner.id] || 0}
547
+ />
548
+ ))}
549
+ </>
550
+ )}
551
+ {/* Teams section */}
552
+ {owners.filter((o) => o.type === 'team').length > 0 && (
553
+ <>
554
+ <div className="px-2 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-[rgb(var(--ec-page-text-muted))] flex items-center gap-1.5 mt-2 border-t border-[rgb(var(--ec-page-border))] pt-2">
555
+ <Users className="w-3 h-3" />
556
+ Teams
557
+ </div>
558
+ {owners
559
+ .filter((o) => o.type === 'team')
560
+ .map((owner) => (
561
+ <CheckboxItem
562
+ key={owner.id}
563
+ label={owner.name}
564
+ checked={selectedOwners.includes(owner.id)}
565
+ onChange={() => toggleOwner(owner.id)}
566
+ count={ownerCounts[owner.id] || 0}
567
+ />
568
+ ))}
569
+ </>
570
+ )}
571
+ </FilterDropdown>
572
+ </div>
573
+ )}
574
+
575
+ {/* Badges Filter */}
576
+ {allBadges.length > 0 && (
577
+ <div>
578
+ <label className="block text-xs font-medium text-[rgb(var(--ec-page-text-muted))] mb-1.5">Badges</label>
579
+ <FilterDropdown
580
+ label="Select badges..."
581
+ selectedItems={selectedBadges}
582
+ onClear={() => setSelectedBadges([])}
583
+ onRemoveItem={(badge) => toggleBadge(badge)}
584
+ >
585
+ {allBadges.map((badge) => (
586
+ <CheckboxItem
587
+ key={badge.content}
588
+ label={badge.content}
589
+ checked={selectedBadges.includes(badge.content)}
590
+ onChange={() => toggleBadge(badge.content)}
591
+ count={badge.count}
592
+ />
593
+ ))}
594
+ </FilterDropdown>
595
+ </div>
596
+ )}
597
+
598
+ {/* Properties Filter */}
599
+ {propertyOptions.length > 0 && (
600
+ <div>
601
+ <label className="block text-xs font-medium text-[rgb(var(--ec-page-text-muted))] mb-1.5">Properties</label>
602
+ <FilterDropdown
603
+ label="Select properties..."
604
+ selectedItems={selectedPropertyLabels}
605
+ onClear={() => setSelectedProperties([])}
606
+ onRemoveItem={(label) => {
607
+ const prop = propertyOptions.find((p) => p.label === label);
608
+ if (prop) toggleProperty(prop.id);
609
+ }}
610
+ >
611
+ {propertyOptions.map((option) => (
612
+ <CheckboxItem
613
+ key={option.id}
614
+ label={option.label}
615
+ checked={selectedProperties.includes(option.id)}
616
+ onChange={() => toggleProperty(option.id)}
617
+ />
618
+ ))}
619
+ </FilterDropdown>
620
+ </div>
621
+ )}
622
+
623
+ {/* Version Filter */}
624
+ <div>
625
+ <label className="block text-xs font-medium text-[rgb(var(--ec-page-text-muted))] mb-1.5">Version</label>
626
+ <FilterDropdown
627
+ label="Select version..."
628
+ selectedItems={[...(showOnlyLatest ? ['Latest only'] : []), ...(onlyShowDrafts ? ['Drafts only'] : [])]}
629
+ onClear={() => {
630
+ setShowOnlyLatest(true);
631
+ setOnlyShowDrafts(false);
632
+ }}
633
+ onRemoveItem={(item) => {
634
+ if (item === 'Latest only') setShowOnlyLatest(false);
635
+ if (item === 'Drafts only') setOnlyShowDrafts(false);
636
+ }}
637
+ >
638
+ <CheckboxItem
639
+ label="Latest version only"
640
+ checked={showOnlyLatest}
641
+ onChange={() => setShowOnlyLatest(!showOnlyLatest)}
642
+ />
643
+ <CheckboxItem label="Drafts only" checked={onlyShowDrafts} onChange={() => setOnlyShowDrafts(!onlyShowDrafts)} />
644
+ </FilterDropdown>
645
+ </div>
646
+ </div>
647
+
648
+ {/* Results & Clear */}
649
+ <div className="flex items-center justify-between pt-4 mt-2 border-t border-[rgb(var(--ec-page-border))]">
650
+ <span className="text-sm text-[rgb(var(--ec-page-text-muted))]">
651
+ <span className="font-semibold text-[rgb(var(--ec-page-text))]">{totalResults}</span> results
652
+ </span>
653
+ {activeFilterCount > 0 && (
654
+ <button onClick={clearAllFilters} className="text-xs font-medium text-[rgb(var(--ec-accent))] hover:underline">
655
+ Clear all
656
+ </button>
657
+ )}
658
+ </div>
659
+ </div>
660
+
661
+ {/* Main Table */}
662
+ <div className="flex-1 min-w-0">
663
+ {/* Table Header */}
664
+ <div className="flex items-center justify-between mb-5">
665
+ <h2 className="text-xl font-bold text-[rgb(var(--ec-page-text))]">
666
+ {collectionLabel}{' '}
667
+ <span className="text-base text-[rgb(var(--ec-page-text-muted))] font-normal ml-1">({totalResults})</span>
668
+ </h2>
669
+ <div className="relative">
670
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[rgb(var(--ec-icon-color))]" />
671
+ <input
672
+ type="text"
673
+ value={tableFilter}
674
+ onChange={(e) => setTableFilter(e.target.value)}
675
+ placeholder="Filter..."
676
+ className="pl-9 pr-3 py-1.5 text-sm w-48 bg-[rgb(var(--ec-input-bg))] text-[rgb(var(--ec-input-text))] border border-[rgb(var(--ec-page-border))] rounded-lg placeholder:text-[rgb(var(--ec-input-placeholder))] focus:outline-none focus:ring-2 focus:ring-[rgb(var(--ec-accent)/0.2)] focus:border-[rgb(var(--ec-accent))] transition-colors"
677
+ />
678
+ {tableFilter && (
679
+ <button
680
+ onClick={() => setTableFilter('')}
681
+ className="absolute right-2 top-1/2 -translate-y-1/2 p-0.5 text-[rgb(var(--ec-icon-color))] hover:text-[rgb(var(--ec-page-text))]"
682
+ >
683
+ <X className="w-3.5 h-3.5" />
684
+ </button>
685
+ )}
686
+ </div>
687
+ </div>
688
+
689
+ {/* Table */}
690
+ <div className="rounded-xl border border-[rgb(var(--ec-page-border))] overflow-hidden shadow-sm">
691
+ <table className="min-w-full divide-y divide-[rgb(var(--ec-page-border))]">
692
+ <thead className="bg-[rgb(var(--ec-content-hover))] sticky top-0 z-10 border-b-2 border-[rgb(var(--ec-page-border))]">
693
+ {table.getHeaderGroups().map((headerGroup, index) => (
694
+ <tr key={`${headerGroup}-${index}`}>
695
+ {headerGroup.headers.map((header) => (
696
+ <th
697
+ key={`${header.id}`}
698
+ className="px-4 py-3.5 text-left text-[11px] font-bold text-[rgb(var(--ec-page-text-muted))] uppercase tracking-widest"
699
+ >
700
+ <div className="flex flex-col gap-2">
701
+ <div>{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}</div>
702
+ </div>
703
+ </th>
704
+ ))}
705
+ </tr>
706
+ ))}
707
+ </thead>
708
+
709
+ <tbody className="bg-[rgb(var(--ec-card-bg,var(--ec-page-bg)))] divide-y divide-[rgb(var(--ec-page-border)/0.5)]">
710
+ {hasResults ? (
711
+ table.getRowModel().rows.map((row, index) => (
712
+ <tr
713
+ key={`${row.id}-${index}`}
714
+ className={`group hover:bg-[rgb(var(--ec-accent)/0.04)] transition-all duration-150 border-l-2 border-transparent hover:border-[rgb(var(--ec-accent))] ${
715
+ index % 2 === 1 ? 'bg-[rgb(var(--ec-page-bg)/0.5)]' : ''
716
+ }`}
717
+ >
718
+ {row.getVisibleCells().map((cell) => (
719
+ <td
720
+ key={cell.id}
721
+ className={`px-4 py-4 text-sm text-[rgb(var(--ec-page-text))] ${cell.column.columnDef.meta?.className || ''}`}
722
+ >
723
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
724
+ </td>
725
+ ))}
726
+ </tr>
727
+ ))
728
+ ) : (
729
+ <tr>
730
+ <td colSpan={table.getAllColumns().length} className="px-4 py-12 text-center">
731
+ <div className="flex flex-col items-center justify-center text-[rgb(var(--ec-page-text-muted))]">
732
+ <SearchX className="w-10 h-10 text-[rgb(var(--ec-icon-color))] mb-3 opacity-50" />
733
+ <p className="text-sm font-medium text-[rgb(var(--ec-page-text-muted))]">No results found</p>
734
+ <p className="text-xs text-[rgb(var(--ec-icon-color))] mt-1">Try adjusting your search or filters</p>
735
+ {activeFilterCount > 0 && (
736
+ <button onClick={clearAllFilters} className="mt-3 text-sm text-[rgb(var(--ec-accent))] hover:underline">
737
+ Clear all filters
738
+ </button>
739
+ )}
740
+ </div>
741
+ </td>
742
+ </tr>
743
+ )}
744
+ </tbody>
745
+ </table>
746
+ </div>
747
+
748
+ {/* Pagination */}
749
+ <div className="flex items-center justify-between px-1 py-4">
750
+ <div className="text-sm text-[rgb(var(--ec-page-text-muted))]">
751
+ {totalResults > 0 && (
752
+ <span>
753
+ Showing <span className="font-medium text-[rgb(var(--ec-page-text))]">{table.getRowModel().rows.length}</span> of{' '}
754
+ <span className="font-medium text-[rgb(var(--ec-page-text))]">{totalResults}</span> results
755
+ </span>
756
+ )}
757
+ </div>
758
+ <div className="flex items-center gap-2">
759
+ <div className="flex items-center rounded-lg border border-[rgb(var(--ec-page-border))] bg-[rgb(var(--ec-card-bg,var(--ec-page-bg)))]">
760
+ <button
761
+ className="p-2 text-[rgb(var(--ec-icon-color))] hover:text-[rgb(var(--ec-page-text))] hover:bg-[rgb(var(--ec-content-hover))] disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-transparent transition-colors rounded-l-lg"
762
+ onClick={() => table.setPageIndex(0)}
763
+ disabled={!table.getCanPreviousPage()}
764
+ title="First page"
765
+ >
766
+ <ChevronsLeft className="w-4 h-4" />
767
+ </button>
768
+ <button
769
+ className="p-2 text-[rgb(var(--ec-icon-color))] hover:text-[rgb(var(--ec-page-text))] hover:bg-[rgb(var(--ec-content-hover))] disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-transparent transition-colors border-l border-[rgb(var(--ec-page-border))]"
770
+ onClick={() => table.previousPage()}
771
+ disabled={!table.getCanPreviousPage()}
772
+ title="Previous page"
773
+ >
774
+ <ChevronLeft className="w-4 h-4" />
775
+ </button>
776
+ <span className="px-3 py-2 text-sm text-[rgb(var(--ec-page-text-muted))] border-l border-r border-[rgb(var(--ec-page-border))] min-w-[100px] text-center">
777
+ Page{' '}
778
+ <span className="font-medium text-[rgb(var(--ec-page-text))]">{table.getState().pagination.pageIndex + 1}</span>{' '}
779
+ of <span className="font-medium text-[rgb(var(--ec-page-text))]">{table.getPageCount() || 1}</span>
780
+ </span>
781
+ <button
782
+ className="p-2 text-[rgb(var(--ec-icon-color))] hover:text-[rgb(var(--ec-page-text))] hover:bg-[rgb(var(--ec-content-hover))] disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-transparent transition-colors border-r border-[rgb(var(--ec-page-border))]"
783
+ onClick={() => table.nextPage()}
784
+ disabled={!table.getCanNextPage()}
785
+ title="Next page"
786
+ >
787
+ <ChevronRight className="w-4 h-4" />
788
+ </button>
789
+ <button
790
+ className="p-2 text-[rgb(var(--ec-icon-color))] hover:text-[rgb(var(--ec-page-text))] hover:bg-[rgb(var(--ec-content-hover))] disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-transparent transition-colors rounded-r-lg"
791
+ onClick={() => table.setPageIndex(table.getPageCount() - 1)}
792
+ disabled={!table.getCanNextPage()}
793
+ title="Last page"
794
+ >
795
+ <ChevronsRight className="w-4 h-4" />
796
+ </button>
797
+ </div>
798
+ <select
799
+ value={table.getState().pagination.pageSize}
800
+ onChange={(e) => {
801
+ table.setPageSize(Number(e.target.value));
802
+ }}
803
+ className="px-3 py-2 text-sm text-[rgb(var(--ec-page-text-muted))] bg-[rgb(var(--ec-card-bg,var(--ec-page-bg)))] border border-[rgb(var(--ec-page-border))] rounded-lg hover:border-[rgb(var(--ec-icon-color))] focus:outline-none focus:ring-2 focus:ring-[rgb(var(--ec-accent)/0.2)] transition-colors"
804
+ >
805
+ {[10, 20, 30, 40, 50].map((pageSize) => (
806
+ <option key={pageSize} value={pageSize}>
807
+ {pageSize} per page
808
+ </option>
809
+ ))}
810
+ </select>
811
+ </div>
812
+ </div>
813
+ </div>
814
+ </div>
815
+ );
816
+ }