groove-dev 0.16.3 → 0.17.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 (38) hide show
  1. package/README.md +18 -16
  2. package/node_modules/@groove-dev/daemon/integrations-registry.json +321 -0
  3. package/node_modules/@groove-dev/daemon/src/api.js +152 -0
  4. package/node_modules/@groove-dev/daemon/src/index.js +13 -1
  5. package/node_modules/@groove-dev/daemon/src/integrations.js +389 -0
  6. package/node_modules/@groove-dev/daemon/src/introducer.js +23 -0
  7. package/node_modules/@groove-dev/daemon/src/process.js +59 -0
  8. package/node_modules/@groove-dev/daemon/src/registry.js +2 -1
  9. package/node_modules/@groove-dev/daemon/src/scheduler.js +336 -0
  10. package/node_modules/@groove-dev/daemon/src/terminal-pty.js +119 -54
  11. package/node_modules/@groove-dev/daemon/src/validate.js +10 -0
  12. package/node_modules/@groove-dev/gui/dist/assets/index-C5k-qSwi.js +153 -0
  13. package/node_modules/@groove-dev/gui/dist/index.html +1 -1
  14. package/node_modules/@groove-dev/gui/src/App.jsx +6 -0
  15. package/node_modules/@groove-dev/gui/src/components/SpawnPanel.jsx +98 -7
  16. package/node_modules/@groove-dev/gui/src/components/Terminal.jsx +29 -12
  17. package/node_modules/@groove-dev/gui/src/views/IntegrationsStore.jsx +954 -0
  18. package/node_modules/@groove-dev/gui/src/views/ScheduleManager.jsx +614 -0
  19. package/package.json +2 -2
  20. package/packages/daemon/integrations-registry.json +321 -0
  21. package/packages/daemon/src/api.js +152 -0
  22. package/packages/daemon/src/index.js +13 -1
  23. package/packages/daemon/src/integrations.js +389 -0
  24. package/packages/daemon/src/introducer.js +23 -0
  25. package/packages/daemon/src/process.js +59 -0
  26. package/packages/daemon/src/registry.js +2 -1
  27. package/packages/daemon/src/scheduler.js +336 -0
  28. package/packages/daemon/src/terminal-pty.js +119 -54
  29. package/packages/daemon/src/validate.js +10 -0
  30. package/packages/gui/dist/assets/index-C5k-qSwi.js +153 -0
  31. package/packages/gui/dist/index.html +1 -1
  32. package/packages/gui/src/App.jsx +6 -0
  33. package/packages/gui/src/components/SpawnPanel.jsx +98 -7
  34. package/packages/gui/src/components/Terminal.jsx +29 -12
  35. package/packages/gui/src/views/IntegrationsStore.jsx +954 -0
  36. package/packages/gui/src/views/ScheduleManager.jsx +614 -0
  37. package/node_modules/@groove-dev/gui/dist/assets/index-CFeltwTB.js +0 -153
  38. package/packages/gui/dist/assets/index-CFeltwTB.js +0 -153
@@ -0,0 +1,954 @@
1
+ // GROOVE GUI — Integrations Store (MCP Marketplace)
2
+ // FSL-1.1-Apache-2.0 — see LICENSE
3
+
4
+ import React, { useState, useEffect, useCallback } from 'react';
5
+
6
+ const CATEGORY_LABELS = {
7
+ all: 'All',
8
+ communication: 'Communication',
9
+ productivity: 'Productivity',
10
+ finance: 'Finance',
11
+ developer: 'Developer',
12
+ database: 'Database',
13
+ 'smart-home': 'Smart Home',
14
+ analytics: 'Analytics',
15
+ };
16
+
17
+ const CATEGORY_ICONS = {
18
+ communication: '\u2709',
19
+ productivity: '\u2699',
20
+ finance: '\u2696',
21
+ developer: '\u2318',
22
+ database: '\u2261',
23
+ 'smart-home': '\u2302',
24
+ analytics: '\u2315',
25
+ };
26
+
27
+ const ICON_MAP = {
28
+ slack: 'S', github: 'G', stripe: '$', calendar: 'C', email: '@',
29
+ gmail: '@', database: 'D', search: '?', drive: 'D', linear: 'L',
30
+ notion: 'N', discord: 'D', home: 'H', folder: 'F', map: 'M',
31
+ };
32
+
33
+ const SORT_OPTIONS = [
34
+ { id: 'popular', label: 'Popular' },
35
+ { id: 'name', label: 'A\u2013Z' },
36
+ { id: 'category', label: 'Category' },
37
+ ];
38
+
39
+ // Verification badges
40
+ function getVerification(item) {
41
+ if (item.verified === 'mcp-official') return { label: 'Official', color: 'var(--accent)', bg: 'rgba(51, 175, 188, 0.12)' };
42
+ if (item.verified === 'verified') return { label: 'Verified', color: 'var(--green)', bg: 'rgba(74, 225, 104, 0.10)' };
43
+ return null;
44
+ }
45
+
46
+ function VerifiedBadge({ item, size = 'small' }) {
47
+ const v = getVerification(item);
48
+ if (!v) return null;
49
+ const isSmall = size === 'small';
50
+ return (
51
+ <span
52
+ title={`${v.label} MCP server`}
53
+ style={{
54
+ display: 'inline-flex', alignItems: 'center', gap: 3,
55
+ fontSize: isSmall ? 9 : 10, fontWeight: 600,
56
+ color: v.color, background: v.bg,
57
+ padding: isSmall ? '1px 6px' : '2px 8px',
58
+ borderRadius: 3, letterSpacing: 0.3,
59
+ flexShrink: 0, cursor: 'default',
60
+ }}
61
+ >
62
+ <span style={{ fontSize: isSmall ? 8 : 10, lineHeight: 1 }}>{'\u2713'}</span>
63
+ {v.label}
64
+ </span>
65
+ );
66
+ }
67
+
68
+ function sortIntegrations(items, sortBy) {
69
+ const sorted = [...items];
70
+ switch (sortBy) {
71
+ case 'popular': return sorted.sort((a, b) => (b.featured ? 1 : 0) - (a.featured ? 1 : 0) || a.name.localeCompare(b.name));
72
+ case 'name': return sorted.sort((a, b) => a.name.localeCompare(b.name));
73
+ case 'category': return sorted.sort((a, b) => (a.category || '').localeCompare(b.category || '') || a.name.localeCompare(b.name));
74
+ default: return sorted;
75
+ }
76
+ }
77
+
78
+ // -- Credential Setup Modal --
79
+ function CredentialModal({ integration, onSave, onClose }) {
80
+ const [values, setValues] = useState({});
81
+ const [saving, setSaving] = useState(false);
82
+ const [saved, setSaved] = useState({});
83
+
84
+ if (!integration) return null;
85
+
86
+ const envKeys = integration.envKeys || [];
87
+ if (envKeys.length === 0) return null;
88
+
89
+ async function handleSave(key) {
90
+ if (!values[key]) return;
91
+ setSaving(true);
92
+ try {
93
+ const res = await fetch(`/api/integrations/${integration.id}/credentials`, {
94
+ method: 'POST',
95
+ headers: { 'Content-Type': 'application/json' },
96
+ body: JSON.stringify({ key, value: values[key] }),
97
+ });
98
+ if (res.ok) {
99
+ setSaved((prev) => ({ ...prev, [key]: true }));
100
+ setValues((prev) => ({ ...prev, [key]: '' }));
101
+ }
102
+ } catch { /* ignore */ }
103
+ setSaving(false);
104
+ }
105
+
106
+ return (
107
+ <div style={modal.overlay} onClick={onClose}>
108
+ <div style={modal.container} onClick={(e) => e.stopPropagation()}>
109
+ <div style={modal.topBar}>
110
+ <span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-bright)' }}>
111
+ Configure {integration.name}
112
+ </span>
113
+ <button onClick={onClose} style={modal.closeBtn}>&times;</button>
114
+ </div>
115
+
116
+ <div style={{ padding: '16px 0' }}>
117
+ <div style={{ fontSize: 11, color: 'var(--text-dim)', marginBottom: 16, lineHeight: 1.5 }}>
118
+ Enter the credentials required for this integration. Values are encrypted and stored locally.
119
+ </div>
120
+
121
+ {envKeys.map((ek) => (
122
+ <div key={ek.key} style={{ marginBottom: 14 }}>
123
+ <label style={modal.label}>
124
+ {ek.label || ek.key}
125
+ {ek.required && <span style={{ color: 'var(--red)', marginLeft: 4 }}>*</span>}
126
+ {saved[ek.key] && (
127
+ <span style={{ color: 'var(--green)', marginLeft: 8, fontSize: 10, fontWeight: 500 }}>
128
+ {'\u2713'} saved
129
+ </span>
130
+ )}
131
+ </label>
132
+ <div style={{ display: 'flex', gap: 6 }}>
133
+ <input
134
+ type="password"
135
+ value={values[ek.key] || ''}
136
+ placeholder={ek.placeholder || ek.key}
137
+ onChange={(e) => setValues((prev) => ({ ...prev, [ek.key]: e.target.value }))}
138
+ onKeyDown={(e) => e.key === 'Enter' && handleSave(ek.key)}
139
+ style={modal.input}
140
+ />
141
+ <button
142
+ onClick={() => handleSave(ek.key)}
143
+ disabled={saving || !values[ek.key]}
144
+ style={{
145
+ ...modal.saveBtn,
146
+ opacity: saving || !values[ek.key] ? 0.4 : 1,
147
+ }}
148
+ >
149
+ Save
150
+ </button>
151
+ </div>
152
+ </div>
153
+ ))}
154
+ </div>
155
+ </div>
156
+ </div>
157
+ );
158
+ }
159
+
160
+ // -- Integration Detail Modal --
161
+ function IntegrationDetailModal({ integration, installing, onInstall, onUninstall, onConfigure, onClose }) {
162
+ if (!integration) return null;
163
+
164
+ return (
165
+ <div style={modal.overlay} onClick={onClose}>
166
+ <div style={modal.container} onClick={(e) => e.stopPropagation()}>
167
+ <div style={modal.topBar}>
168
+ <VerifiedBadge item={integration} size="large" />
169
+ <button onClick={onClose} style={modal.closeBtn}>&times;</button>
170
+ </div>
171
+
172
+ {/* Header */}
173
+ <div style={modal.header}>
174
+ <div style={{
175
+ ...modal.icon,
176
+ background: integration.installed && integration.configured
177
+ ? 'var(--green)'
178
+ : integration.installed ? 'var(--amber)' : 'var(--accent)',
179
+ }}>
180
+ {ICON_MAP[integration.icon] || integration.name.charAt(0)}
181
+ </div>
182
+ <div style={modal.headerInfo}>
183
+ <div style={modal.name}>{integration.name}</div>
184
+ <div style={modal.meta}>
185
+ <span style={modal.metaItem}>
186
+ {CATEGORY_ICONS[integration.category] || ''} {CATEGORY_LABELS[integration.category] || integration.category}
187
+ </span>
188
+ <span style={modal.metaItem}>MCP Server</span>
189
+ </div>
190
+ </div>
191
+ </div>
192
+
193
+ {/* Action bar */}
194
+ <div style={modal.actionBar}>
195
+ {integration.installed ? (
196
+ <div style={{ display: 'flex', gap: 8, flex: 1 }}>
197
+ <button
198
+ onClick={() => onConfigure(integration)}
199
+ style={modal.configureBtn}
200
+ disabled={(integration.envKeys || []).length === 0}
201
+ >
202
+ {integration.configured ? 'Reconfigure' : 'Configure'}
203
+ </button>
204
+ <button
205
+ onClick={() => onUninstall(integration.id)}
206
+ disabled={installing === integration.id}
207
+ style={modal.uninstallBtn}
208
+ >
209
+ {installing === integration.id ? 'Removing...' : 'Uninstall'}
210
+ </button>
211
+ </div>
212
+ ) : (
213
+ <button
214
+ onClick={() => onInstall(integration.id)}
215
+ disabled={installing === integration.id}
216
+ style={modal.installBtn}
217
+ >
218
+ {installing === integration.id ? 'Installing...' : 'Install'}
219
+ </button>
220
+ )}
221
+ </div>
222
+
223
+ {/* Status */}
224
+ {integration.installed && (
225
+ <div style={modal.section}>
226
+ <div style={modal.sectionTitle}>Status</div>
227
+ <div style={{
228
+ display: 'flex', alignItems: 'center', gap: 8,
229
+ padding: '8px 12px', borderRadius: 6,
230
+ background: integration.configured ? 'rgba(74, 225, 104, 0.06)' : 'rgba(229, 192, 123, 0.06)',
231
+ border: `1px solid ${integration.configured ? 'var(--green)' : 'var(--amber)'}`,
232
+ }}>
233
+ <span style={{
234
+ width: 8, height: 8, borderRadius: '50%',
235
+ background: integration.configured ? 'var(--green)' : 'var(--amber)',
236
+ }} />
237
+ <span style={{ fontSize: 11, color: integration.configured ? 'var(--green)' : 'var(--amber)' }}>
238
+ {integration.configured ? 'Connected and ready' : 'Credentials needed'}
239
+ </span>
240
+ </div>
241
+ </div>
242
+ )}
243
+
244
+ {/* Description */}
245
+ <div style={modal.section}>
246
+ <div style={modal.sectionTitle}>About</div>
247
+ <div style={modal.description}>{integration.description}</div>
248
+ </div>
249
+
250
+ {/* Required Credentials */}
251
+ {(integration.envKeys || []).length > 0 && (
252
+ <div style={modal.section}>
253
+ <div style={modal.sectionTitle}>Required Credentials</div>
254
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
255
+ {integration.envKeys.map((ek) => (
256
+ <div key={ek.key} style={{
257
+ display: 'flex', alignItems: 'center', gap: 8,
258
+ fontSize: 11, color: 'var(--text-dim)',
259
+ }}>
260
+ <code style={{
261
+ fontSize: 10, padding: '1px 6px', borderRadius: 3,
262
+ background: 'var(--bg-active)', color: 'var(--text-primary)',
263
+ fontFamily: 'var(--font)',
264
+ }}>
265
+ {ek.key}
266
+ </code>
267
+ <span>{ek.label}</span>
268
+ {ek.required && <span style={{ color: 'var(--amber)', fontSize: 9 }}>required</span>}
269
+ </div>
270
+ ))}
271
+ </div>
272
+ </div>
273
+ )}
274
+
275
+ {/* Tags */}
276
+ <div style={modal.section}>
277
+ <div style={modal.sectionTitle}>Tags</div>
278
+ <div style={modal.tagRow}>
279
+ {(integration.tags || []).map((t) => (
280
+ <span key={t} style={modal.tag}>{t}</span>
281
+ ))}
282
+ {(integration.roles || []).map((r) => (
283
+ <span key={r} style={modal.roleTag}>{r}</span>
284
+ ))}
285
+ </div>
286
+ </div>
287
+
288
+ {/* Package Info */}
289
+ {integration.npmPackage && (
290
+ <div style={modal.section}>
291
+ <div style={modal.sectionTitle}>Package</div>
292
+ <code style={{
293
+ fontSize: 11, padding: '6px 10px', borderRadius: 4,
294
+ background: 'var(--bg-active)', color: 'var(--text-primary)',
295
+ display: 'block', fontFamily: 'var(--font)',
296
+ }}>
297
+ {integration.npmPackage}
298
+ </code>
299
+ </div>
300
+ )}
301
+ </div>
302
+ </div>
303
+ );
304
+ }
305
+
306
+ // -- Featured Banner --
307
+ function FeaturedBanner({ integrations, onSelect }) {
308
+ const featured = integrations.filter((s) => s.featured).slice(0, 3);
309
+ if (featured.length === 0) return null;
310
+
311
+ return (
312
+ <div style={styles.featuredSection}>
313
+ <div style={styles.featuredLabel}>Featured Integrations</div>
314
+ <div style={styles.featuredRow}>
315
+ {featured.map((item) => (
316
+ <div
317
+ key={item.id}
318
+ onClick={() => onSelect(item)}
319
+ style={styles.featuredCard}
320
+ onMouseEnter={(e) => { e.currentTarget.style.borderColor = 'var(--accent)'; e.currentTarget.style.transform = 'translateY(-1px)'; }}
321
+ onMouseLeave={(e) => { e.currentTarget.style.borderColor = 'var(--border)'; e.currentTarget.style.transform = 'none'; }}
322
+ >
323
+ <div style={styles.featuredGradient}>
324
+ <div style={{
325
+ ...styles.featuredIcon,
326
+ background: item.installed && item.configured ? 'var(--green)'
327
+ : item.installed ? 'var(--amber)' : 'var(--accent)',
328
+ }}>
329
+ {ICON_MAP[item.icon] || item.name.charAt(0)}
330
+ </div>
331
+ </div>
332
+ <div style={styles.featuredInfo}>
333
+ <div style={styles.featuredName}>{item.name}</div>
334
+ <div style={styles.featuredAuthorRow}>
335
+ <VerifiedBadge item={item} />
336
+ </div>
337
+ <div style={styles.featuredDesc}>{item.description}</div>
338
+ </div>
339
+ {item.installed && item.configured && (
340
+ <div style={styles.connectedBadge}>connected</div>
341
+ )}
342
+ {item.installed && !item.configured && (
343
+ <div style={styles.setupBadge}>needs setup</div>
344
+ )}
345
+ </div>
346
+ ))}
347
+ </div>
348
+ </div>
349
+ );
350
+ }
351
+
352
+ // -- Integration Card --
353
+ function IntegrationCard({ item, onSelect, hovered, onHover }) {
354
+ return (
355
+ <div
356
+ onClick={() => onSelect(item)}
357
+ onMouseEnter={() => onHover(item.id)}
358
+ onMouseLeave={() => onHover(null)}
359
+ style={{
360
+ ...styles.card,
361
+ borderColor: item.installed && item.configured
362
+ ? 'var(--green)'
363
+ : item.installed ? 'var(--amber)'
364
+ : hovered ? 'var(--accent)' : 'var(--border)',
365
+ background: hovered ? 'var(--bg-hover)' : 'var(--bg-surface)',
366
+ transform: hovered ? 'translateY(-1px)' : 'none',
367
+ }}
368
+ >
369
+ {/* Top row */}
370
+ <div style={styles.cardTop}>
371
+ <div style={{
372
+ ...styles.cardIcon,
373
+ background: item.installed && item.configured
374
+ ? 'var(--green)'
375
+ : item.installed ? 'var(--amber)' : 'var(--accent)',
376
+ }}>
377
+ {ICON_MAP[item.icon] || item.name.charAt(0)}
378
+ </div>
379
+ <div style={styles.cardInfo}>
380
+ <div style={styles.cardName}>{item.name}</div>
381
+ <div style={styles.cardAuthorRow}>
382
+ <VerifiedBadge item={item} />
383
+ </div>
384
+ </div>
385
+ {item.installed && item.configured && (
386
+ <div style={styles.connectedBadgeSm}>connected</div>
387
+ )}
388
+ {item.installed && !item.configured && (
389
+ <div style={styles.setupBadgeSm}>setup</div>
390
+ )}
391
+ {!item.installed && (
392
+ <div style={styles.freeBadge}>MCP</div>
393
+ )}
394
+ </div>
395
+
396
+ {/* Description */}
397
+ <div style={styles.cardDesc}>{item.description}</div>
398
+
399
+ {/* Bottom */}
400
+ <div style={styles.cardBottom}>
401
+ <div style={styles.cardStats}>
402
+ {(item.envKeys || []).length > 0 && (
403
+ <span style={styles.cardCredCount}>
404
+ {item.envKeys.length} credential{item.envKeys.length > 1 ? 's' : ''}
405
+ </span>
406
+ )}
407
+ </div>
408
+ <span style={styles.catTag}>
409
+ {CATEGORY_ICONS[item.category] || ''} {CATEGORY_LABELS[item.category] || item.category}
410
+ </span>
411
+ </div>
412
+ </div>
413
+ );
414
+ }
415
+
416
+ // -- Main Store --
417
+ export default function IntegrationsStore() {
418
+ const [integrations, setIntegrations] = useState([]);
419
+ const [categories, setCategories] = useState([]);
420
+ const [search, setSearch] = useState('');
421
+ const [category, setCategory] = useState('all');
422
+ const [sortBy, setSortBy] = useState('popular');
423
+ const [tab, setTab] = useState('browse');
424
+ const [loading, setLoading] = useState(true);
425
+ const [installing, setInstalling] = useState(null);
426
+ const [selectedItem, setSelectedItem] = useState(null);
427
+ const [configuring, setConfiguring] = useState(null);
428
+ const [hovered, setHovered] = useState(null);
429
+
430
+ const fetchIntegrations = useCallback(async () => {
431
+ setLoading(true);
432
+ try {
433
+ const params = new URLSearchParams();
434
+ if (search) params.set('search', search);
435
+ if (category !== 'all') params.set('category', category);
436
+ const res = await fetch(`/api/integrations/registry?${params}`);
437
+ const data = await res.json();
438
+ setIntegrations(data.integrations || []);
439
+ setCategories(data.categories || []);
440
+ } catch { /* ignore */ }
441
+ setLoading(false);
442
+ }, [search, category]);
443
+
444
+ useEffect(() => { fetchIntegrations(); }, [fetchIntegrations]);
445
+
446
+ async function handleInstall(id) {
447
+ setInstalling(id);
448
+ try {
449
+ await fetch(`/api/integrations/${id}/install`, { method: 'POST' });
450
+ await fetchIntegrations();
451
+ // After install, refresh selected item
452
+ if (selectedItem?.id === id) {
453
+ const updated = integrations.find((s) => s.id === id);
454
+ if (updated) setSelectedItem({ ...updated, installed: true });
455
+ }
456
+ } catch { /* ignore */ }
457
+ setInstalling(null);
458
+ }
459
+
460
+ async function handleUninstall(id) {
461
+ setInstalling(id);
462
+ try {
463
+ await fetch(`/api/integrations/${id}`, { method: 'DELETE' });
464
+ await fetchIntegrations();
465
+ if (selectedItem?.id === id) {
466
+ setSelectedItem((prev) => prev ? { ...prev, installed: false, configured: false } : null);
467
+ }
468
+ } catch { /* ignore */ }
469
+ setInstalling(null);
470
+ }
471
+
472
+ function handleSelect(item) {
473
+ setSelectedItem(item);
474
+ }
475
+
476
+ function handleConfigure(item) {
477
+ setSelectedItem(null);
478
+ setConfiguring(item);
479
+ }
480
+
481
+ function handleConfigureClose() {
482
+ setConfiguring(null);
483
+ fetchIntegrations();
484
+ }
485
+
486
+ const installedItems = integrations.filter((s) => s.installed);
487
+ const displayItems = tab === 'installed'
488
+ ? sortIntegrations(installedItems, sortBy)
489
+ : sortIntegrations(integrations, sortBy);
490
+
491
+ return (
492
+ <div style={styles.root}>
493
+ {/* Header */}
494
+ <div style={styles.headerBar}>
495
+ <div style={styles.headerLeft}>
496
+ <div style={styles.title}>Integrations</div>
497
+ <div style={styles.headerTabs}>
498
+ <button
499
+ onClick={() => setTab('browse')}
500
+ style={{
501
+ ...styles.headerTab,
502
+ color: tab === 'browse' ? 'var(--text-bright)' : 'var(--text-dim)',
503
+ borderBottom: tab === 'browse' ? '2px solid var(--accent)' : '2px solid transparent',
504
+ }}
505
+ >
506
+ Browse
507
+ </button>
508
+ <button
509
+ onClick={() => setTab('installed')}
510
+ style={{
511
+ ...styles.headerTab,
512
+ color: tab === 'installed' ? 'var(--text-bright)' : 'var(--text-dim)',
513
+ borderBottom: tab === 'installed' ? '2px solid var(--green)' : '2px solid transparent',
514
+ }}
515
+ >
516
+ Installed ({installedItems.length})
517
+ </button>
518
+ </div>
519
+ </div>
520
+ <div style={styles.headerRight}>
521
+ <span style={styles.headerCount}>
522
+ {integrations.length} integrations
523
+ </span>
524
+ </div>
525
+ </div>
526
+
527
+ {/* Toolbar */}
528
+ <div style={styles.toolbar}>
529
+ <div style={styles.searchRow}>
530
+ <div style={styles.searchWrap}>
531
+ <span style={styles.searchIcon}>{'\u2315'}</span>
532
+ <input
533
+ style={styles.search}
534
+ placeholder="Search integrations..."
535
+ value={search}
536
+ onChange={(e) => setSearch(e.target.value)}
537
+ />
538
+ </div>
539
+ <div style={styles.sortWrap}>
540
+ {SORT_OPTIONS.map((opt) => (
541
+ <button
542
+ key={opt.id}
543
+ onClick={() => setSortBy(opt.id)}
544
+ style={{
545
+ ...styles.sortBtn,
546
+ color: sortBy === opt.id ? 'var(--text-bright)' : 'var(--text-muted)',
547
+ background: sortBy === opt.id ? 'var(--bg-active)' : 'transparent',
548
+ }}
549
+ >
550
+ {opt.label}
551
+ </button>
552
+ ))}
553
+ </div>
554
+ </div>
555
+ {tab === 'browse' && (
556
+ <div style={styles.catRow}>
557
+ <button
558
+ onClick={() => setCategory('all')}
559
+ style={{
560
+ ...styles.catBtn,
561
+ ...(category === 'all' ? styles.catBtnActive : {}),
562
+ }}
563
+ >
564
+ All
565
+ </button>
566
+ {categories.map((cat) => (
567
+ <button
568
+ key={cat.id}
569
+ onClick={() => setCategory(cat.id)}
570
+ style={{
571
+ ...styles.catBtn,
572
+ ...(category === cat.id ? styles.catBtnActive : {}),
573
+ }}
574
+ >
575
+ {CATEGORY_ICONS[cat.id] || ''} {CATEGORY_LABELS[cat.id] || cat.id} ({cat.count})
576
+ </button>
577
+ ))}
578
+ </div>
579
+ )}
580
+ </div>
581
+
582
+ {/* Content */}
583
+ <div style={styles.scrollArea}>
584
+ {tab === 'browse' && !search && category === 'all' && (
585
+ <FeaturedBanner integrations={integrations} onSelect={handleSelect} />
586
+ )}
587
+
588
+ {loading && integrations.length === 0 && (
589
+ <div style={styles.empty}>Loading integrations...</div>
590
+ )}
591
+
592
+ {!loading && displayItems.length === 0 && (
593
+ <div style={styles.empty}>
594
+ {tab === 'installed'
595
+ ? 'No integrations installed yet. Browse to connect your tools.'
596
+ : 'No integrations match your search.'}
597
+ </div>
598
+ )}
599
+
600
+ <div style={styles.grid}>
601
+ {displayItems.map((item) => (
602
+ <IntegrationCard
603
+ key={item.id}
604
+ item={item}
605
+ onSelect={handleSelect}
606
+ hovered={hovered === item.id}
607
+ onHover={setHovered}
608
+ />
609
+ ))}
610
+ </div>
611
+ </div>
612
+
613
+ {/* Detail Modal */}
614
+ <IntegrationDetailModal
615
+ integration={selectedItem}
616
+ installing={installing}
617
+ onInstall={handleInstall}
618
+ onUninstall={handleUninstall}
619
+ onConfigure={handleConfigure}
620
+ onClose={() => setSelectedItem(null)}
621
+ />
622
+
623
+ {/* Credential Setup Modal */}
624
+ <CredentialModal
625
+ integration={configuring}
626
+ onClose={handleConfigureClose}
627
+ />
628
+ </div>
629
+ );
630
+ }
631
+
632
+ // -- Styles --
633
+
634
+ const styles = {
635
+ root: {
636
+ height: '100%', display: 'flex', flexDirection: 'column',
637
+ overflow: 'hidden',
638
+ },
639
+ headerBar: {
640
+ padding: '12px 20px 0',
641
+ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
642
+ flexShrink: 0,
643
+ },
644
+ headerLeft: {
645
+ display: 'flex', alignItems: 'center', gap: 20,
646
+ },
647
+ title: {
648
+ fontSize: 15, fontWeight: 700, color: 'var(--text-bright)',
649
+ letterSpacing: 0.3,
650
+ },
651
+ headerTabs: {
652
+ display: 'flex', gap: 0,
653
+ },
654
+ headerTab: {
655
+ padding: '6px 14px',
656
+ background: 'none', border: 'none',
657
+ fontSize: 12, fontWeight: 500,
658
+ fontFamily: 'var(--font)', cursor: 'pointer',
659
+ transition: 'color 0.1s',
660
+ },
661
+ headerRight: {
662
+ display: 'flex', alignItems: 'center', gap: 10,
663
+ },
664
+ headerCount: {
665
+ fontSize: 11, color: 'var(--text-muted)',
666
+ },
667
+ toolbar: {
668
+ padding: '10px 20px',
669
+ flexShrink: 0,
670
+ },
671
+ searchRow: {
672
+ display: 'flex', gap: 10, alignItems: 'center',
673
+ },
674
+ searchWrap: {
675
+ flex: 1, position: 'relative',
676
+ },
677
+ searchIcon: {
678
+ position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)',
679
+ color: 'var(--text-muted)', fontSize: 13, pointerEvents: 'none',
680
+ },
681
+ search: {
682
+ width: '100%', padding: '7px 12px 7px 30px',
683
+ background: 'var(--bg-surface)', border: '1px solid var(--border)',
684
+ borderRadius: 6, color: 'var(--text-primary)', fontSize: 12,
685
+ fontFamily: 'var(--font)', outline: 'none',
686
+ },
687
+ sortWrap: {
688
+ display: 'flex', gap: 2,
689
+ },
690
+ sortBtn: {
691
+ padding: '5px 10px',
692
+ background: 'transparent', border: '1px solid transparent',
693
+ borderRadius: 4, fontSize: 10, fontWeight: 500,
694
+ fontFamily: 'var(--font)', cursor: 'pointer',
695
+ transition: 'all 0.1s',
696
+ },
697
+ catRow: {
698
+ display: 'flex', gap: 4, marginTop: 8, flexWrap: 'wrap',
699
+ },
700
+ catBtn: {
701
+ padding: '4px 12px',
702
+ background: 'transparent', border: '1px solid var(--border)',
703
+ borderRadius: 14, color: 'var(--text-dim)', fontSize: 10,
704
+ fontFamily: 'var(--font)', cursor: 'pointer',
705
+ transition: 'all 0.1s',
706
+ },
707
+ catBtnActive: {
708
+ borderColor: 'var(--accent)', color: 'var(--accent)',
709
+ background: 'rgba(51, 175, 188, 0.08)',
710
+ },
711
+ scrollArea: {
712
+ flex: 1, overflowY: 'auto', padding: '4px 20px 20px',
713
+ },
714
+
715
+ // Featured
716
+ featuredSection: { marginBottom: 16 },
717
+ featuredLabel: {
718
+ fontSize: 11, fontWeight: 700, color: 'var(--text-dim)',
719
+ textTransform: 'uppercase', letterSpacing: 1, marginBottom: 8,
720
+ },
721
+ featuredRow: {
722
+ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 8,
723
+ },
724
+ featuredCard: {
725
+ padding: '14px',
726
+ background: 'var(--bg-surface)', border: '1px solid var(--border)',
727
+ borderRadius: 8, cursor: 'pointer',
728
+ transition: 'border-color 0.15s, transform 0.15s',
729
+ position: 'relative', overflow: 'hidden',
730
+ },
731
+ featuredGradient: {
732
+ display: 'flex', alignItems: 'center', marginBottom: 10,
733
+ },
734
+ featuredIcon: {
735
+ width: 40, height: 40, borderRadius: 10,
736
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
737
+ fontSize: 16, fontWeight: 700, color: 'var(--bg-base)',
738
+ },
739
+ featuredInfo: { flex: 1 },
740
+ featuredName: {
741
+ fontSize: 13, fontWeight: 700, color: 'var(--text-bright)',
742
+ },
743
+ featuredAuthorRow: {
744
+ display: 'flex', alignItems: 'center', gap: 6, marginTop: 2,
745
+ },
746
+ featuredDesc: {
747
+ fontSize: 10, color: 'var(--text-dim)', marginTop: 6,
748
+ lineHeight: 1.45, display: '-webkit-box',
749
+ WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden',
750
+ },
751
+ connectedBadge: {
752
+ position: 'absolute', top: 8, right: 8,
753
+ fontSize: 8, fontWeight: 600, color: 'var(--green)',
754
+ border: '1px solid var(--green)', borderRadius: 3,
755
+ padding: '1px 5px', textTransform: 'uppercase', letterSpacing: 0.5,
756
+ },
757
+ setupBadge: {
758
+ position: 'absolute', top: 8, right: 8,
759
+ fontSize: 8, fontWeight: 600, color: 'var(--amber)',
760
+ border: '1px solid var(--amber)', borderRadius: 3,
761
+ padding: '1px 5px', textTransform: 'uppercase', letterSpacing: 0.5,
762
+ },
763
+
764
+ // Grid
765
+ grid: {
766
+ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 8,
767
+ },
768
+ empty: {
769
+ padding: '60px 0', textAlign: 'center',
770
+ color: 'var(--text-dim)', fontSize: 12,
771
+ gridColumn: '1 / -1',
772
+ },
773
+
774
+ // Card
775
+ card: {
776
+ padding: '14px',
777
+ background: 'var(--bg-surface)', border: '1px solid var(--border)',
778
+ borderRadius: 8, cursor: 'pointer',
779
+ transition: 'border-color 0.12s, background 0.12s, transform 0.12s',
780
+ display: 'flex', flexDirection: 'column',
781
+ },
782
+ cardTop: {
783
+ display: 'flex', alignItems: 'center', gap: 10,
784
+ },
785
+ cardIcon: {
786
+ width: 36, height: 36, borderRadius: 8,
787
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
788
+ fontSize: 14, fontWeight: 700, color: 'var(--bg-base)',
789
+ flexShrink: 0,
790
+ },
791
+ cardInfo: {
792
+ flex: 1, minWidth: 0,
793
+ },
794
+ cardName: {
795
+ fontSize: 12, fontWeight: 600, color: 'var(--text-bright)',
796
+ },
797
+ cardAuthorRow: {
798
+ display: 'flex', alignItems: 'center', gap: 6, marginTop: 2,
799
+ },
800
+ connectedBadgeSm: {
801
+ fontSize: 8, fontWeight: 600, color: 'var(--green)',
802
+ border: '1px solid var(--green)', borderRadius: 3,
803
+ padding: '1px 6px', textTransform: 'uppercase', letterSpacing: 0.5,
804
+ flexShrink: 0,
805
+ },
806
+ setupBadgeSm: {
807
+ fontSize: 8, fontWeight: 600, color: 'var(--amber)',
808
+ border: '1px solid var(--amber)', borderRadius: 3,
809
+ padding: '1px 6px', textTransform: 'uppercase', letterSpacing: 0.5,
810
+ flexShrink: 0,
811
+ },
812
+ freeBadge: {
813
+ fontSize: 9, fontWeight: 500, color: 'var(--text-muted)',
814
+ flexShrink: 0,
815
+ },
816
+ cardDesc: {
817
+ fontSize: 11, color: 'var(--text-dim)', marginTop: 10,
818
+ lineHeight: 1.5, flex: 1,
819
+ display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical',
820
+ overflow: 'hidden',
821
+ },
822
+ cardBottom: {
823
+ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
824
+ marginTop: 10, paddingTop: 8,
825
+ borderTop: '1px solid var(--border)',
826
+ },
827
+ cardStats: {
828
+ display: 'flex', gap: 10,
829
+ },
830
+ cardCredCount: {
831
+ fontSize: 10, color: 'var(--text-muted)',
832
+ },
833
+ catTag: {
834
+ fontSize: 9, padding: '2px 8px', borderRadius: 4,
835
+ background: 'var(--bg-active)', color: 'var(--text-dim)',
836
+ fontWeight: 500,
837
+ },
838
+ };
839
+
840
+ // -- Modal Styles --
841
+ const modal = {
842
+ overlay: {
843
+ position: 'fixed', inset: 0,
844
+ background: 'rgba(0, 0, 0, 0.6)',
845
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
846
+ zIndex: 1000,
847
+ backdropFilter: 'blur(2px)',
848
+ },
849
+ container: {
850
+ width: '90%', maxWidth: 520, maxHeight: '85vh',
851
+ background: 'var(--bg-chrome)', border: '1px solid var(--border)',
852
+ borderRadius: 10, overflowY: 'auto',
853
+ padding: '0 24px 24px', position: 'relative',
854
+ },
855
+ topBar: {
856
+ position: 'sticky', top: 0,
857
+ background: 'var(--bg-chrome)',
858
+ padding: '16px 0 8px',
859
+ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
860
+ zIndex: 1,
861
+ },
862
+ closeBtn: {
863
+ background: 'none', border: 'none',
864
+ color: 'var(--text-muted)', fontSize: 20,
865
+ cursor: 'pointer', padding: '2px 6px',
866
+ fontFamily: 'var(--font)',
867
+ },
868
+ header: {
869
+ display: 'flex', gap: 14, alignItems: 'flex-start',
870
+ marginBottom: 16,
871
+ },
872
+ icon: {
873
+ width: 52, height: 52, borderRadius: 12,
874
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
875
+ fontSize: 20, fontWeight: 700, color: 'var(--bg-base)',
876
+ flexShrink: 0,
877
+ },
878
+ headerInfo: { flex: 1 },
879
+ name: {
880
+ fontSize: 17, fontWeight: 700, color: 'var(--text-bright)',
881
+ },
882
+ meta: {
883
+ display: 'flex', gap: 10, marginTop: 6, flexWrap: 'wrap',
884
+ },
885
+ metaItem: {
886
+ fontSize: 11, color: 'var(--text-dim)',
887
+ },
888
+ actionBar: {
889
+ display: 'flex', alignItems: 'center', gap: 8,
890
+ padding: '12px 0', borderTop: '1px solid var(--border)',
891
+ borderBottom: '1px solid var(--border)',
892
+ marginBottom: 16,
893
+ },
894
+ installBtn: {
895
+ flex: 1, padding: '8px 16px',
896
+ background: 'var(--accent)', color: 'var(--bg-base)',
897
+ border: 'none', borderRadius: 6,
898
+ fontSize: 12, fontWeight: 600, cursor: 'pointer',
899
+ fontFamily: 'var(--font)',
900
+ },
901
+ configureBtn: {
902
+ flex: 1, padding: '8px 16px',
903
+ background: 'var(--bg-active)', color: 'var(--text-bright)',
904
+ border: '1px solid var(--border)', borderRadius: 6,
905
+ fontSize: 12, fontWeight: 600, cursor: 'pointer',
906
+ fontFamily: 'var(--font)',
907
+ },
908
+ uninstallBtn: {
909
+ padding: '8px 16px',
910
+ background: 'transparent', color: 'var(--red)',
911
+ border: '1px solid var(--red)', borderRadius: 6,
912
+ fontSize: 12, fontWeight: 500, cursor: 'pointer',
913
+ fontFamily: 'var(--font)',
914
+ },
915
+ section: {
916
+ marginBottom: 16,
917
+ },
918
+ sectionTitle: {
919
+ fontSize: 11, fontWeight: 700, color: 'var(--text-dim)',
920
+ textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 8,
921
+ },
922
+ description: {
923
+ fontSize: 12, color: 'var(--text-primary)', lineHeight: 1.6,
924
+ },
925
+ tagRow: {
926
+ display: 'flex', gap: 4, flexWrap: 'wrap',
927
+ },
928
+ tag: {
929
+ fontSize: 10, padding: '2px 8px', borderRadius: 4,
930
+ background: 'var(--bg-active)', color: 'var(--text-dim)',
931
+ },
932
+ roleTag: {
933
+ fontSize: 10, padding: '2px 8px', borderRadius: 4,
934
+ background: 'rgba(51, 175, 188, 0.08)', color: 'var(--accent)',
935
+ border: '1px solid rgba(51, 175, 188, 0.2)',
936
+ },
937
+ label: {
938
+ display: 'block', fontSize: 11, fontWeight: 600,
939
+ color: 'var(--text-primary)', marginBottom: 4,
940
+ },
941
+ input: {
942
+ flex: 1, padding: '7px 10px',
943
+ background: 'var(--bg-surface)', border: '1px solid var(--border)',
944
+ borderRadius: 5, color: 'var(--text-primary)', fontSize: 12,
945
+ fontFamily: 'var(--font)', outline: 'none',
946
+ },
947
+ saveBtn: {
948
+ padding: '7px 14px',
949
+ background: 'var(--accent)', color: 'var(--bg-base)',
950
+ border: 'none', borderRadius: 5,
951
+ fontSize: 11, fontWeight: 600, cursor: 'pointer',
952
+ fontFamily: 'var(--font)',
953
+ },
954
+ };