@anymux/connect 0.1.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 (78) hide show
  1. package/dist/GitBrowser-BLgTNQyd.js +905 -0
  2. package/dist/GitBrowser-BLgTNQyd.js.map +1 -0
  3. package/dist/GitBrowser-CIyWiuX-.js +3 -0
  4. package/dist/ObjectStorageBrowser-B2YkUxMl.js +3 -0
  5. package/dist/ObjectStorageBrowser-B_25Emfu.js +267 -0
  6. package/dist/ObjectStorageBrowser-B_25Emfu.js.map +1 -0
  7. package/dist/RepoPicker-BprFGOn7.js +3 -0
  8. package/dist/RepoPicker-CoHMiJ-3.js +168 -0
  9. package/dist/RepoPicker-CoHMiJ-3.js.map +1 -0
  10. package/dist/index.d.ts +697 -0
  11. package/dist/index.d.ts.map +1 -0
  12. package/dist/index.js +2539 -0
  13. package/dist/index.js.map +1 -0
  14. package/dist/registry.d.ts +2 -0
  15. package/dist/registry.js +3 -0
  16. package/dist/scope-labels-B4VAwoL6.js +582 -0
  17. package/dist/scope-labels-B4VAwoL6.js.map +1 -0
  18. package/dist/scope-labels-DvdJLcSL.d.ts +50 -0
  19. package/dist/scope-labels-DvdJLcSL.d.ts.map +1 -0
  20. package/package.json +87 -0
  21. package/src/adapters/adapter-registry.ts +177 -0
  22. package/src/auth/auth-client.ts +101 -0
  23. package/src/auth/token-manager.ts +27 -0
  24. package/src/components/ActionHistoryPanel.tsx +137 -0
  25. package/src/components/CapabilityCell.tsx +97 -0
  26. package/src/components/CapabilityError.tsx +50 -0
  27. package/src/components/CapabilityPanel.tsx +530 -0
  28. package/src/components/CapabilityPill.tsx +56 -0
  29. package/src/components/ConnectButton.tsx +149 -0
  30. package/src/components/ConnectedMenu.tsx +142 -0
  31. package/src/components/ConnectionStatus.tsx +28 -0
  32. package/src/components/CredentialForm.tsx +246 -0
  33. package/src/components/FullScreenBrowser.tsx +84 -0
  34. package/src/components/GitBrowser.tsx +705 -0
  35. package/src/components/GitHubRepoPicker.tsx +125 -0
  36. package/src/components/ObjectStorageBrowser.tsx +176 -0
  37. package/src/components/RepoPicker.tsx +93 -0
  38. package/src/components/ServiceCard.tsx +77 -0
  39. package/src/components/ServiceCardGrid.tsx +141 -0
  40. package/src/components/ServiceDashboard.tsx +84 -0
  41. package/src/components/ServiceIcon.tsx +37 -0
  42. package/src/components/ServiceRow.tsx +50 -0
  43. package/src/components/useAdapter.ts +33 -0
  44. package/src/demos/ServiceDashboardDemo.tsx +108 -0
  45. package/src/index.ts +68 -0
  46. package/src/models/ActionNotificationModel.ts +72 -0
  47. package/src/models/ConnectionManagerModel.ts +410 -0
  48. package/src/models/CredentialFormModel.ts +111 -0
  49. package/src/models/DashboardModel.ts +157 -0
  50. package/src/models/GitHostBrowserModel.ts +89 -0
  51. package/src/models/GitRepoBrowserModel.ts +285 -0
  52. package/src/models/ObjectStorageBrowserModel.ts +131 -0
  53. package/src/models/RepoPickerModel.ts +132 -0
  54. package/src/registry/service-registry.ts +46 -0
  55. package/src/registry/services/apple.ts +22 -0
  56. package/src/registry/services/bitbucket.ts +24 -0
  57. package/src/registry/services/box.ts +22 -0
  58. package/src/registry/services/browser-fs.ts +19 -0
  59. package/src/registry/services/dropbox.ts +22 -0
  60. package/src/registry/services/flickr.ts +22 -0
  61. package/src/registry/services/gitea.ts +24 -0
  62. package/src/registry/services/github.ts +24 -0
  63. package/src/registry/services/gitlab.ts +24 -0
  64. package/src/registry/services/google.ts +24 -0
  65. package/src/registry/services/icloud.ts +23 -0
  66. package/src/registry/services/indexeddb.ts +19 -0
  67. package/src/registry/services/instagram.ts +22 -0
  68. package/src/registry/services/microsoft.ts +24 -0
  69. package/src/registry/services/s3.ts +21 -0
  70. package/src/registry/services/webdav.ts +21 -0
  71. package/src/registry.ts +4 -0
  72. package/src/types/connection-state.ts +33 -0
  73. package/src/types/connection.ts +11 -0
  74. package/src/types/optional-deps.d.ts +149 -0
  75. package/src/types/service.ts +18 -0
  76. package/src/types/user-profile.ts +21 -0
  77. package/src/utils/action-toast.ts +53 -0
  78. package/src/utils/scope-labels.ts +91 -0
@@ -0,0 +1,125 @@
1
+ import React, { useState, useEffect, useMemo } from 'react';
2
+ import { Search, Lock, Loader2 } from 'lucide-react';
3
+
4
+ interface GitHubRepoPickerProps {
5
+ accessToken: string;
6
+ onSelectRepo: (repo: { owner: string; repo: string }) => void;
7
+ }
8
+
9
+ interface GitHubRepoInfo {
10
+ full_name: string;
11
+ description: string | null;
12
+ language: string | null;
13
+ private: boolean;
14
+ stargazers_count: number;
15
+ }
16
+
17
+ export function GitHubRepoPicker({ accessToken, onSelectRepo }: GitHubRepoPickerProps) {
18
+ const [repos, setRepos] = useState<GitHubRepoInfo[]>([]);
19
+ const [loading, setLoading] = useState(true);
20
+ const [error, setError] = useState<string | null>(null);
21
+ const [search, setSearch] = useState('');
22
+
23
+ useEffect(() => {
24
+ let cancelled = false;
25
+
26
+ async function fetchRepos() {
27
+ setLoading(true);
28
+ setError(null);
29
+ try {
30
+ const res = await fetch('https://api.github.com/user/repos?sort=updated&per_page=50', {
31
+ headers: { Authorization: `Bearer ${accessToken}` },
32
+ });
33
+ if (!res.ok) throw new Error(`GitHub API error: ${res.status}`);
34
+ const data: GitHubRepoInfo[] = await res.json();
35
+ if (!cancelled) setRepos(data);
36
+ } catch (err) {
37
+ if (!cancelled) setError(err instanceof Error ? err.message : 'Failed to fetch repos');
38
+ } finally {
39
+ if (!cancelled) setLoading(false);
40
+ }
41
+ }
42
+
43
+ fetchRepos();
44
+ return () => { cancelled = true; };
45
+ }, [accessToken]);
46
+
47
+ const filtered = useMemo(
48
+ () => repos.filter((r) => r.full_name.toLowerCase().includes(search.toLowerCase())),
49
+ [repos, search],
50
+ );
51
+
52
+ if (loading) {
53
+ return (
54
+ <div className="flex items-center justify-center py-8 text-gray-400">
55
+ <Loader2 className="h-5 w-5 animate-spin mr-2" />
56
+ Loading repositories...
57
+ </div>
58
+ );
59
+ }
60
+
61
+ if (error) {
62
+ return (
63
+ <div className="rounded-lg border border-red-800 bg-red-950/50 p-4 text-red-300 text-sm">
64
+ {error}
65
+ </div>
66
+ );
67
+ }
68
+
69
+ return (
70
+ <div className="flex flex-col gap-2">
71
+ <div className="relative">
72
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500" />
73
+ <input
74
+ type="text"
75
+ placeholder="Search repositories..."
76
+ value={search}
77
+ onChange={(e) => setSearch(e.target.value)}
78
+ className="w-full rounded-md border border-gray-700 bg-gray-900 py-2 pl-9 pr-3 text-sm text-gray-200 placeholder-gray-500 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
79
+ />
80
+ </div>
81
+ <div className="max-h-64 overflow-y-auto rounded-md border border-gray-700">
82
+ {filtered.length === 0 ? (
83
+ <div className="px-4 py-6 text-center text-sm text-gray-500">No repositories found</div>
84
+ ) : (
85
+ filtered.map((repo) => {
86
+ const parts = repo.full_name.split('/');
87
+ const owner = parts[0] ?? '';
88
+ const name = parts[1] ?? '';
89
+ return (
90
+ <button
91
+ key={repo.full_name}
92
+ type="button"
93
+ onClick={() => onSelectRepo({ owner, repo: name })}
94
+ className="flex w-full items-start gap-3 border-b border-gray-800 px-4 py-3 text-left hover:bg-gray-800/60 last:border-b-0 transition-colors"
95
+ >
96
+ <div className="min-w-0 flex-1">
97
+ <div className="flex items-center gap-2">
98
+ <span className="text-sm font-medium text-blue-400 truncate" title={repo.full_name}>
99
+ {repo.full_name}
100
+ </span>
101
+ {repo.private && (
102
+ <Lock className="h-3 w-3 flex-shrink-0 text-gray-500" />
103
+ )}
104
+ </div>
105
+ {repo.description && (
106
+ <p className="mt-0.5 text-xs text-gray-500 truncate" title={repo.description}>
107
+ {repo.description.length > 80
108
+ ? `${repo.description.slice(0, 80)}...`
109
+ : repo.description}
110
+ </p>
111
+ )}
112
+ </div>
113
+ {repo.language && (
114
+ <span className="flex-shrink-0 rounded-full bg-gray-800 px-2 py-0.5 text-xs text-gray-400">
115
+ {repo.language}
116
+ </span>
117
+ )}
118
+ </button>
119
+ );
120
+ })
121
+ )}
122
+ </div>
123
+ </div>
124
+ );
125
+ }
@@ -0,0 +1,176 @@
1
+ import React, { useState, useEffect } from 'react';
2
+ import { observer } from 'mobx-react-lite';
3
+ import { Database, ArrowLeft, Download, RefreshCw, Folder, File, ChevronLeft, ChevronRight } from 'lucide-react';
4
+ import { BrowserError } from '@anymux/ui/components/browser-error';
5
+ import type { IObjectStorage } from '@anymux/file-system';
6
+ import { ObjectStorageBrowserModel } from '../models/ObjectStorageBrowserModel';
7
+
8
+ interface ObjectStorageBrowserProps {
9
+ storage: IObjectStorage;
10
+ bucket: string;
11
+ }
12
+
13
+ const formatSize = (bytes?: number) => {
14
+ if (bytes === undefined) return '';
15
+ if (bytes < 1024) return `${bytes} B`;
16
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
17
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
18
+ };
19
+
20
+ export const ObjectStorageBrowser: React.FC<ObjectStorageBrowserProps> = observer(({ storage, bucket }) => {
21
+ const [model] = useState(() => new ObjectStorageBrowserModel(storage, bucket));
22
+
23
+ useEffect(() => {
24
+ model.initialize();
25
+ }, [model]);
26
+
27
+ return (
28
+ <div className="flex flex-col h-full">
29
+ {/* Toolbar */}
30
+ <div className="flex items-center gap-2 px-4 py-2 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
31
+ <Database className="h-4 w-4 text-gray-500" />
32
+ <span className="text-xs font-medium text-gray-700 dark:text-gray-300">{model.bucket}</span>
33
+ <div className="flex-1" />
34
+ {model.prefix && (
35
+ <button
36
+ onClick={() => model.navigateUp()}
37
+ className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
38
+ title="Go up"
39
+ >
40
+ <ArrowLeft className="h-3.5 w-3.5" />
41
+ </button>
42
+ )}
43
+ <button
44
+ onClick={() => model.refresh()}
45
+ disabled={model.loading}
46
+ className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
47
+ title="Refresh"
48
+ >
49
+ <RefreshCw className={`h-3.5 w-3.5 ${model.loading ? 'animate-spin' : ''}`} />
50
+ </button>
51
+ </div>
52
+
53
+ {/* Breadcrumbs */}
54
+ <div className="flex items-center gap-1 px-4 py-1.5 text-xs text-gray-500 dark:text-gray-400 border-b border-gray-100 dark:border-gray-800">
55
+ <button
56
+ onClick={() => model.navigateToPrefix('')}
57
+ className="hover:text-gray-900 dark:hover:text-white"
58
+ >
59
+ /
60
+ </button>
61
+ {model.breadcrumbs.map((crumb) => (
62
+ <React.Fragment key={crumb.prefix}>
63
+ <span>/</span>
64
+ <button
65
+ onClick={() => model.navigateToPrefix(crumb.prefix)}
66
+ className="hover:text-gray-900 dark:hover:text-white"
67
+ >
68
+ {crumb.label}
69
+ </button>
70
+ </React.Fragment>
71
+ ))}
72
+ </div>
73
+
74
+ {/* Content */}
75
+ <div className="flex-1 overflow-auto">
76
+ {model.error && model.objects.length === 0 ? (
77
+ <BrowserError
78
+ error={model.error}
79
+ context={`Object Storage · ${model.bucket}`}
80
+ onRetry={() => model.refresh()}
81
+ onGoBack={model.prefix ? () => model.navigateUp() : undefined}
82
+ />
83
+ ) : model.error ? (
84
+ <div className="px-4 py-2 bg-red-50 dark:bg-red-900/20 text-xs text-red-600 dark:text-red-400 border-b border-red-200 dark:border-red-800">
85
+ {model.error}
86
+ </div>
87
+ ) : null}
88
+
89
+ {model.loading && model.objects.length === 0 && !model.error && (
90
+ <div className="flex items-center justify-center h-32 text-sm text-gray-400">
91
+ Loading...
92
+ </div>
93
+ )}
94
+
95
+ {!model.error && model.objects.length === 0 && !model.loading && (
96
+ <div className="flex items-center justify-center h-32 text-sm text-gray-400">
97
+ No objects found
98
+ </div>
99
+ )}
100
+
101
+ {model.objects.length > 0 && (
102
+ <table className="w-full text-sm">
103
+ <thead className="text-xs text-gray-500 dark:text-gray-400 border-b border-gray-100 dark:border-gray-800 sticky top-0 bg-white dark:bg-gray-900">
104
+ <tr>
105
+ <th className="text-left px-4 py-2 font-medium">Name</th>
106
+ <th className="text-right px-4 py-2 font-medium w-24">Size</th>
107
+ <th className="text-right px-4 py-2 font-medium w-40">Modified</th>
108
+ <th className="w-10" />
109
+ </tr>
110
+ </thead>
111
+ <tbody>
112
+ {model.objects.map((obj) => (
113
+ <tr
114
+ key={obj.key}
115
+ className="border-b border-gray-50 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/50 cursor-pointer"
116
+ onClick={() => obj.isPrefix && model.navigateToPrefix(obj.key)}
117
+ >
118
+ <td className="px-4 py-2 flex items-center gap-2">
119
+ {obj.isPrefix ? (
120
+ <Folder className="h-4 w-4 text-blue-500 shrink-0" />
121
+ ) : (
122
+ <File className="h-4 w-4 text-gray-400 shrink-0" />
123
+ )}
124
+ <span className="truncate" title={obj.key}>{model.displayName(obj.key)}</span>
125
+ </td>
126
+ <td className="px-4 py-2 text-right text-gray-500">
127
+ {!obj.isPrefix && formatSize(obj.size)}
128
+ </td>
129
+ <td className="px-4 py-2 text-right text-gray-500">
130
+ {obj.lastModified && obj.lastModified.toLocaleDateString()}
131
+ </td>
132
+ <td className="px-2 py-2">
133
+ {!obj.isPrefix && (
134
+ <button
135
+ onClick={(e) => {
136
+ e.stopPropagation();
137
+ model.download(obj.key);
138
+ }}
139
+ className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700"
140
+ title="Download"
141
+ >
142
+ <Download className="h-3.5 w-3.5" />
143
+ </button>
144
+ )}
145
+ </td>
146
+ </tr>
147
+ ))}
148
+ </tbody>
149
+ </table>
150
+ )}
151
+ </div>
152
+
153
+ {(model.page > 0 || model.hasMore) && !model.loading && model.objects.length > 0 && (
154
+ <div className="flex items-center justify-between px-4 py-2 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
155
+ <button
156
+ onClick={() => model.prevPage()}
157
+ disabled={model.page === 0}
158
+ className="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors disabled:opacity-50 disabled:pointer-events-none"
159
+ >
160
+ <ChevronLeft className="h-3.5 w-3.5" />
161
+ Previous
162
+ </button>
163
+ <span className="text-xs text-gray-500">Page {model.page + 1}</span>
164
+ <button
165
+ onClick={() => model.nextPage()}
166
+ disabled={!model.hasMore}
167
+ className="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors disabled:opacity-50 disabled:pointer-events-none"
168
+ >
169
+ Next
170
+ <ChevronRight className="h-3.5 w-3.5" />
171
+ </button>
172
+ </div>
173
+ )}
174
+ </div>
175
+ );
176
+ });
@@ -0,0 +1,93 @@
1
+ import React, { useState, useEffect } from 'react';
2
+ import { observer } from 'mobx-react-lite';
3
+ import { Search, Lock, Loader2, AlertCircle } from 'lucide-react';
4
+ import { RepoPickerModel } from '../models/RepoPickerModel';
5
+
6
+ interface RepoPickerProps {
7
+ serviceId: string;
8
+ accessToken: string;
9
+ onSelectRepo: (repo: { owner: string; repo: string }) => void;
10
+ }
11
+
12
+ export const RepoPicker: React.FC<RepoPickerProps> = observer(({ serviceId, accessToken, onSelectRepo }) => {
13
+ const [model] = useState(() => new RepoPickerModel(serviceId, accessToken));
14
+
15
+ useEffect(() => {
16
+ model.loadRepos();
17
+ }, [model]);
18
+
19
+ if (model.loading) {
20
+ return (
21
+ <div className="flex items-center justify-center py-8 text-gray-400">
22
+ <Loader2 className="h-5 w-5 animate-spin mr-2" />
23
+ Loading repositories...
24
+ </div>
25
+ );
26
+ }
27
+
28
+ if (model.error) {
29
+ return (
30
+ <div className="flex items-center gap-2 justify-center py-8 text-sm text-red-600 dark:text-red-400">
31
+ <AlertCircle className="h-4 w-4" />
32
+ {model.error}
33
+ </div>
34
+ );
35
+ }
36
+
37
+ return (
38
+ <div className="flex flex-col gap-2">
39
+ <div className="relative">
40
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500" />
41
+ <input
42
+ type="text"
43
+ placeholder="Search repositories..."
44
+ value={model.search}
45
+ onChange={(e) => model.setSearch(e.target.value)}
46
+ className="w-full rounded-md border border-gray-700 bg-gray-900 py-2 pl-9 pr-3 text-sm text-gray-200 placeholder-gray-500 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
47
+ />
48
+ </div>
49
+ <div className="max-h-64 overflow-y-auto rounded-md border border-gray-700">
50
+ {model.filtered.length === 0 ? (
51
+ <div className="px-4 py-6 text-center text-sm text-gray-500">No repositories found</div>
52
+ ) : (
53
+ model.filtered.map((repo) => {
54
+ const parts = repo.fullName.split('/');
55
+ const owner = parts[0] ?? '';
56
+ const name = parts[1] ?? '';
57
+ return (
58
+ <button
59
+ key={repo.fullName}
60
+ type="button"
61
+ onClick={() => onSelectRepo({ owner, repo: name })}
62
+ className="flex w-full items-start gap-3 border-b border-gray-800 px-4 py-3 text-left hover:bg-gray-800/60 last:border-b-0 transition-colors"
63
+ >
64
+ <div className="min-w-0 flex-1">
65
+ <div className="flex items-center gap-2">
66
+ <span className="text-sm font-medium text-blue-400 truncate" title={repo.fullName}>
67
+ {repo.fullName}
68
+ </span>
69
+ {repo.isPrivate && (
70
+ <Lock className="h-3 w-3 flex-shrink-0 text-gray-500" />
71
+ )}
72
+ </div>
73
+ {repo.description && (
74
+ <p className="mt-0.5 text-xs text-gray-500 truncate" title={repo.description}>
75
+ {repo.description.length > 80
76
+ ? `${repo.description.slice(0, 80)}...`
77
+ : repo.description}
78
+ </p>
79
+ )}
80
+ </div>
81
+ {repo.language && (
82
+ <span className="flex-shrink-0 rounded-full bg-gray-800 px-2 py-0.5 text-xs text-gray-400">
83
+ {repo.language}
84
+ </span>
85
+ )}
86
+ </button>
87
+ );
88
+ })
89
+ )}
90
+ </div>
91
+ </div>
92
+ );
93
+ });
@@ -0,0 +1,77 @@
1
+ import React from 'react';
2
+ import { observer } from 'mobx-react-lite';
3
+ import type { CapabilityId, ServiceDefinition } from '../types/service';
4
+ import type { ConnectionManagerModel } from '../models/ConnectionManagerModel';
5
+ import type { DashboardModel } from '../models/DashboardModel';
6
+ import { ServiceIcon } from './ServiceIcon';
7
+ import { ConnectButton } from './ConnectButton';
8
+ import { CapabilityPill } from './CapabilityPill';
9
+
10
+ const CAPABILITY_COLUMNS: { id: CapabilityId; label: string }[] = [
11
+ { id: 'file-system', label: 'FS' },
12
+ { id: 'object-storage', label: 'Obj' },
13
+ { id: 'git-repo', label: 'Git' },
14
+ { id: 'media', label: 'Media' },
15
+ { id: 'contacts', label: 'Contacts' },
16
+ { id: 'calendar', label: 'Cal' },
17
+ ];
18
+
19
+ interface ServiceCardProps {
20
+ service: ServiceDefinition;
21
+ connectionManager: ConnectionManagerModel;
22
+ dashboardModel: DashboardModel;
23
+ }
24
+
25
+ export const ServiceCard: React.FC<ServiceCardProps> = observer(
26
+ ({ service, connectionManager, dashboardModel }) => {
27
+ const status = connectionManager.getStatus(service.id);
28
+ const connected = status === 'connected';
29
+ const supportedCaps = CAPABILITY_COLUMNS.filter((cap) => {
30
+ const supported = service.capabilities.find((c) => c.id === cap.id)?.supported;
31
+ // For merged git column: also check git-host
32
+ if (cap.id === 'git-repo' && !supported) {
33
+ return service.capabilities.find((c) => c.id === 'git-host')?.supported ?? false;
34
+ }
35
+ return supported;
36
+ });
37
+
38
+ return (
39
+ <div className="rounded-lg border border-border bg-card p-3">
40
+ {/* Top row: icon + name + connect button */}
41
+ <div className="flex items-center justify-between gap-2 mb-2">
42
+ <div className="flex items-center gap-2 min-w-0">
43
+ <ServiceIcon name={service.icon} className="h-5 w-5 flex-shrink-0" style={{ color: service.color }} />
44
+ <span className="text-sm font-medium truncate" title={service.name}>{service.name}</span>
45
+ </div>
46
+ <ConnectButton service={service} connectionManager={connectionManager} />
47
+ </div>
48
+
49
+ {/* Capability pills */}
50
+ {supportedCaps.length > 0 && (
51
+ <div className="flex flex-wrap gap-1.5">
52
+ {supportedCaps.map((cap) => {
53
+ const isSelected =
54
+ dashboardModel.selectedCell?.serviceId === service.id &&
55
+ dashboardModel.selectedCell?.capabilityId === cap.id;
56
+ const hasScopes = connected && connectionManager.hasCapabilityScopes(service.id, cap.id);
57
+
58
+ return (
59
+ <CapabilityPill
60
+ key={cap.id}
61
+ label={cap.label}
62
+ capabilityId={cap.id}
63
+ status={status}
64
+ isSelected={isSelected}
65
+ hasScopes={hasScopes}
66
+ onSelect={() => {
67
+ if (connected) dashboardModel.selectCell(service.id, cap.id);
68
+ }}
69
+ />
70
+ );
71
+ })}
72
+ </div>
73
+ )}
74
+ </div>
75
+ );
76
+ }
77
+ );
@@ -0,0 +1,141 @@
1
+ import React from 'react';
2
+ import { observer } from 'mobx-react-lite';
3
+ import { match } from 'ts-pattern';
4
+ import type { CapabilityId, ServiceDefinition } from '../types/service';
5
+ import type { ConnectionManagerModel } from '../models/ConnectionManagerModel';
6
+ import type { DashboardModel } from '../models/DashboardModel';
7
+ import type { ConnectionStatus } from '../types/connection';
8
+ import { ServiceIcon } from './ServiceIcon';
9
+ import { ConnectButton } from './ConnectButton';
10
+
11
+ const CAPABILITY_LABELS: { id: CapabilityId; label: string }[] = [
12
+ { id: 'file-system', label: 'FS' },
13
+ { id: 'object-storage', label: 'Obj Storage' },
14
+ { id: 'git-repo', label: 'Git' },
15
+ { id: 'git-host', label: 'Git Host' },
16
+ { id: 'media', label: 'Media' },
17
+ { id: 'contacts', label: 'Contacts' },
18
+ { id: 'calendar', label: 'Calendar' },
19
+ ];
20
+
21
+ interface ServiceGridCardProps {
22
+ service: ServiceDefinition;
23
+ connectionManager: ConnectionManagerModel;
24
+ dashboardModel: DashboardModel;
25
+ }
26
+
27
+ const StatusDot: React.FC<{ status: ConnectionStatus }> = ({ status }) =>
28
+ match(status)
29
+ .with('connected', () => (
30
+ <span className="relative flex h-2.5 w-2.5">
31
+ <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75" />
32
+ <span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-green-500" />
33
+ </span>
34
+ ))
35
+ .with('connecting', () => (
36
+ <span className="h-2.5 w-2.5 rounded-full bg-muted-foreground animate-pulse" />
37
+ ))
38
+ .with('error', () => (
39
+ <span className="h-2.5 w-2.5 rounded-full bg-destructive" />
40
+ ))
41
+ .with('expired', () => (
42
+ <span className="h-2.5 w-2.5 rounded-full bg-orange-400" />
43
+ ))
44
+ .otherwise(() => (
45
+ <span className="h-2.5 w-2.5 rounded-full bg-muted-foreground/30" />
46
+ ));
47
+
48
+ const ServiceGridCard: React.FC<ServiceGridCardProps> = observer(
49
+ ({ service, connectionManager, dashboardModel }) => {
50
+ const status = connectionManager.getStatus(service.id);
51
+ const connected = status === 'connected';
52
+ const supportedCaps = CAPABILITY_LABELS.filter(
53
+ (cap) => service.capabilities.find((c) => c.id === cap.id)?.supported
54
+ );
55
+
56
+ return (
57
+ <div className="group relative rounded-xl border border-border bg-card overflow-hidden transition-shadow hover:shadow-md">
58
+ {/* Color accent top bar */}
59
+ <div className="h-1" style={{ backgroundColor: service.color }} />
60
+
61
+ {/* Icon area */}
62
+ <div className="flex items-center justify-center pt-6 pb-4">
63
+ <div
64
+ className="flex items-center justify-center w-16 h-16 rounded-xl"
65
+ style={{ backgroundColor: `${service.color}15` }}
66
+ >
67
+ <ServiceIcon
68
+ name={service.icon}
69
+ className="h-8 w-8"
70
+ style={{ color: service.color }}
71
+ />
72
+ </div>
73
+ </div>
74
+
75
+ {/* Status dot — top right */}
76
+ <div className="absolute top-3 right-3">
77
+ <StatusDot status={status} />
78
+ </div>
79
+
80
+ {/* Name + status */}
81
+ <div className="px-4 pb-3 text-center">
82
+ <h3 className="text-sm font-semibold truncate">{service.name}</h3>
83
+ </div>
84
+
85
+ {/* Capability pills */}
86
+ {supportedCaps.length > 0 && (
87
+ <div className="px-4 pb-3 flex flex-wrap justify-center gap-1">
88
+ {supportedCaps.map((cap) => {
89
+ const hasScopes = connected && connectionManager.hasCapabilityScopes(service.id, cap.id);
90
+ const clickable = connected;
91
+ return (
92
+ <button
93
+ key={cap.id}
94
+ disabled={!clickable}
95
+ onClick={() => {
96
+ if (clickable) dashboardModel.selectCell(service.id, cap.id);
97
+ }}
98
+ className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-medium transition-colors ${
99
+ clickable
100
+ ? hasScopes
101
+ ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400 hover:bg-green-200 dark:hover:bg-green-900/50 cursor-pointer'
102
+ : 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400 hover:bg-orange-200 cursor-pointer'
103
+ : 'bg-muted text-muted-foreground'
104
+ }`}
105
+ >
106
+ {cap.label}
107
+ </button>
108
+ );
109
+ })}
110
+ </div>
111
+ )}
112
+
113
+ {/* Connect button */}
114
+ <div className="px-4 pb-4 flex justify-center">
115
+ <ConnectButton service={service} connectionManager={connectionManager} />
116
+ </div>
117
+ </div>
118
+ );
119
+ }
120
+ );
121
+
122
+ interface ServiceCardGridProps {
123
+ connectionManager: ConnectionManagerModel;
124
+ dashboardModel: DashboardModel;
125
+ services: ServiceDefinition[];
126
+ }
127
+
128
+ export const ServiceCardGrid: React.FC<ServiceCardGridProps> = observer(
129
+ ({ connectionManager, dashboardModel, services }) => (
130
+ <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
131
+ {services.map((service) => (
132
+ <ServiceGridCard
133
+ key={service.id}
134
+ service={service}
135
+ connectionManager={connectionManager}
136
+ dashboardModel={dashboardModel}
137
+ />
138
+ ))}
139
+ </div>
140
+ )
141
+ );