@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.
- package/dist/GitBrowser-BLgTNQyd.js +905 -0
- package/dist/GitBrowser-BLgTNQyd.js.map +1 -0
- package/dist/GitBrowser-CIyWiuX-.js +3 -0
- package/dist/ObjectStorageBrowser-B2YkUxMl.js +3 -0
- package/dist/ObjectStorageBrowser-B_25Emfu.js +267 -0
- package/dist/ObjectStorageBrowser-B_25Emfu.js.map +1 -0
- package/dist/RepoPicker-BprFGOn7.js +3 -0
- package/dist/RepoPicker-CoHMiJ-3.js +168 -0
- package/dist/RepoPicker-CoHMiJ-3.js.map +1 -0
- package/dist/index.d.ts +697 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2539 -0
- package/dist/index.js.map +1 -0
- package/dist/registry.d.ts +2 -0
- package/dist/registry.js +3 -0
- package/dist/scope-labels-B4VAwoL6.js +582 -0
- package/dist/scope-labels-B4VAwoL6.js.map +1 -0
- package/dist/scope-labels-DvdJLcSL.d.ts +50 -0
- package/dist/scope-labels-DvdJLcSL.d.ts.map +1 -0
- package/package.json +87 -0
- package/src/adapters/adapter-registry.ts +177 -0
- package/src/auth/auth-client.ts +101 -0
- package/src/auth/token-manager.ts +27 -0
- package/src/components/ActionHistoryPanel.tsx +137 -0
- package/src/components/CapabilityCell.tsx +97 -0
- package/src/components/CapabilityError.tsx +50 -0
- package/src/components/CapabilityPanel.tsx +530 -0
- package/src/components/CapabilityPill.tsx +56 -0
- package/src/components/ConnectButton.tsx +149 -0
- package/src/components/ConnectedMenu.tsx +142 -0
- package/src/components/ConnectionStatus.tsx +28 -0
- package/src/components/CredentialForm.tsx +246 -0
- package/src/components/FullScreenBrowser.tsx +84 -0
- package/src/components/GitBrowser.tsx +705 -0
- package/src/components/GitHubRepoPicker.tsx +125 -0
- package/src/components/ObjectStorageBrowser.tsx +176 -0
- package/src/components/RepoPicker.tsx +93 -0
- package/src/components/ServiceCard.tsx +77 -0
- package/src/components/ServiceCardGrid.tsx +141 -0
- package/src/components/ServiceDashboard.tsx +84 -0
- package/src/components/ServiceIcon.tsx +37 -0
- package/src/components/ServiceRow.tsx +50 -0
- package/src/components/useAdapter.ts +33 -0
- package/src/demos/ServiceDashboardDemo.tsx +108 -0
- package/src/index.ts +68 -0
- package/src/models/ActionNotificationModel.ts +72 -0
- package/src/models/ConnectionManagerModel.ts +410 -0
- package/src/models/CredentialFormModel.ts +111 -0
- package/src/models/DashboardModel.ts +157 -0
- package/src/models/GitHostBrowserModel.ts +89 -0
- package/src/models/GitRepoBrowserModel.ts +285 -0
- package/src/models/ObjectStorageBrowserModel.ts +131 -0
- package/src/models/RepoPickerModel.ts +132 -0
- package/src/registry/service-registry.ts +46 -0
- package/src/registry/services/apple.ts +22 -0
- package/src/registry/services/bitbucket.ts +24 -0
- package/src/registry/services/box.ts +22 -0
- package/src/registry/services/browser-fs.ts +19 -0
- package/src/registry/services/dropbox.ts +22 -0
- package/src/registry/services/flickr.ts +22 -0
- package/src/registry/services/gitea.ts +24 -0
- package/src/registry/services/github.ts +24 -0
- package/src/registry/services/gitlab.ts +24 -0
- package/src/registry/services/google.ts +24 -0
- package/src/registry/services/icloud.ts +23 -0
- package/src/registry/services/indexeddb.ts +19 -0
- package/src/registry/services/instagram.ts +22 -0
- package/src/registry/services/microsoft.ts +24 -0
- package/src/registry/services/s3.ts +21 -0
- package/src/registry/services/webdav.ts +21 -0
- package/src/registry.ts +4 -0
- package/src/types/connection-state.ts +33 -0
- package/src/types/connection.ts +11 -0
- package/src/types/optional-deps.d.ts +149 -0
- package/src/types/service.ts +18 -0
- package/src/types/user-profile.ts +21 -0
- package/src/utils/action-toast.ts +53 -0
- 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
|
+
);
|