@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,149 @@
|
|
|
1
|
+
import React, { useEffect, useState } from 'react';
|
|
2
|
+
import { observer } from 'mobx-react-lite';
|
|
3
|
+
import { match } from 'ts-pattern';
|
|
4
|
+
import { ConnectionBadge } from '@anymux/ui/components/connection-badge';
|
|
5
|
+
import type { ConnectionManagerModel } from '../models/ConnectionManagerModel';
|
|
6
|
+
import type { ServiceDefinition } from '../types/service';
|
|
7
|
+
import { getServiceConnectionState } from '../types/connection-state';
|
|
8
|
+
import { CredentialFormModel, type CredentialServiceType } from '../models/CredentialFormModel';
|
|
9
|
+
import { CredentialForm } from './CredentialForm';
|
|
10
|
+
import { ConnectedMenu } from './ConnectedMenu';
|
|
11
|
+
|
|
12
|
+
interface ConnectButtonProps {
|
|
13
|
+
service: ServiceDefinition;
|
|
14
|
+
connectionManager: ConnectionManagerModel;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const ConnectButton: React.FC<ConnectButtonProps> = observer(({ service, connectionManager }) => {
|
|
18
|
+
const [formModel] = useState(() => new CredentialFormModel());
|
|
19
|
+
const state = getServiceConnectionState(connectionManager, service.id);
|
|
20
|
+
|
|
21
|
+
const needsCredentialForm = service.authProvider === 's3' || service.authProvider === 'webdav' || service.authProvider === 'gitea' || service.authProvider === 'icloud';
|
|
22
|
+
const isBrowserFs = service.authProvider === 'browser-fs';
|
|
23
|
+
const isIndexedDb = service.authProvider === 'indexeddb';
|
|
24
|
+
|
|
25
|
+
// Auto-trigger connect flow when reconnect is requested (e.g. from CapabilityPanel)
|
|
26
|
+
const pendingReconnect = connectionManager.pendingReconnect.has(service.id);
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
if (pendingReconnect) {
|
|
29
|
+
connectionManager.clearReconnectRequest(service.id);
|
|
30
|
+
handleConnect();
|
|
31
|
+
}
|
|
32
|
+
}, [pendingReconnect]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
33
|
+
|
|
34
|
+
const handleConnect = async () => {
|
|
35
|
+
if (isBrowserFs) {
|
|
36
|
+
try {
|
|
37
|
+
const { BrowserFileSystemFactory } = await import('@anymux/browser-fs');
|
|
38
|
+
const factory = new BrowserFileSystemFactory();
|
|
39
|
+
const [_fs, handleInfo] = await factory.createFromPicker();
|
|
40
|
+
connectionManager.connectWithCredentials(service.id, JSON.stringify({ id: handleInfo.id, name: handleInfo.name }));
|
|
41
|
+
} catch (err: any) {
|
|
42
|
+
if (err.name !== 'AbortError') {
|
|
43
|
+
console.error('Failed to pick directory:', err);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (isIndexedDb) {
|
|
49
|
+
connectionManager.connectWithCredentials(service.id, JSON.stringify({ type: 'indexeddb' }));
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (needsCredentialForm) {
|
|
53
|
+
formModel.openForm(service.authProvider as CredentialServiceType);
|
|
54
|
+
} else {
|
|
55
|
+
connectionManager.connect(service.id);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const handleCredentialSubmit = (credentialToken: string) => {
|
|
60
|
+
connectionManager.connectWithCredentials(service.id, credentialToken);
|
|
61
|
+
formModel.closeForm();
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const credentialFormElement = needsCredentialForm ? (
|
|
65
|
+
<CredentialForm model={formModel} onSubmit={handleCredentialSubmit} />
|
|
66
|
+
) : null;
|
|
67
|
+
|
|
68
|
+
return match(state)
|
|
69
|
+
.with({ status: 'loading' }, () => (
|
|
70
|
+
<ConnectionBadge status="loading" />
|
|
71
|
+
))
|
|
72
|
+
.with({ status: 'not_configured' }, () => (
|
|
73
|
+
<ConnectionBadge status="not-configured" />
|
|
74
|
+
))
|
|
75
|
+
.with({ status: 'connecting' }, () => (
|
|
76
|
+
<ConnectionBadge status="connecting" />
|
|
77
|
+
))
|
|
78
|
+
.with({ status: 'connected' }, (s) => {
|
|
79
|
+
const isOAuth = !needsCredentialForm && !isBrowserFs && !isIndexedDb;
|
|
80
|
+
const user = isOAuth ? s.user : null;
|
|
81
|
+
return (
|
|
82
|
+
<ConnectedMenu
|
|
83
|
+
service={service}
|
|
84
|
+
connectionManager={connectionManager}
|
|
85
|
+
user={user}
|
|
86
|
+
{...(s.profile ? { profile: s.profile } : {})}
|
|
87
|
+
isOAuth={isOAuth}
|
|
88
|
+
/>
|
|
89
|
+
);
|
|
90
|
+
})
|
|
91
|
+
.with({ status: 'error' }, () => (
|
|
92
|
+
<>
|
|
93
|
+
<button
|
|
94
|
+
onClick={handleConnect}
|
|
95
|
+
className="rounded-md px-3 py-1.5 text-xs font-medium text-white bg-destructive hover:bg-destructive/90 transition-colors"
|
|
96
|
+
>
|
|
97
|
+
Retry
|
|
98
|
+
</button>
|
|
99
|
+
{credentialFormElement}
|
|
100
|
+
</>
|
|
101
|
+
))
|
|
102
|
+
.with({ status: 'expired' }, () => (
|
|
103
|
+
<>
|
|
104
|
+
<button
|
|
105
|
+
onClick={handleConnect}
|
|
106
|
+
className="rounded-md px-3 py-1.5 text-xs font-medium text-white bg-destructive hover:bg-destructive/90 transition-colors"
|
|
107
|
+
>
|
|
108
|
+
Reconnect
|
|
109
|
+
</button>
|
|
110
|
+
{credentialFormElement}
|
|
111
|
+
</>
|
|
112
|
+
))
|
|
113
|
+
.with({ status: 'disconnected' }, () => {
|
|
114
|
+
const testCreds = connectionManager.testCredentials[service.authProvider] as Record<string, unknown> | undefined;
|
|
115
|
+
return (
|
|
116
|
+
<>
|
|
117
|
+
<div className="inline-flex items-center gap-1.5">
|
|
118
|
+
<button
|
|
119
|
+
onClick={handleConnect}
|
|
120
|
+
className="rounded-md px-3 py-1.5 text-xs font-medium text-white transition-colors"
|
|
121
|
+
style={{ backgroundColor: service.color }}
|
|
122
|
+
>
|
|
123
|
+
Connect
|
|
124
|
+
</button>
|
|
125
|
+
{testCreds && (
|
|
126
|
+
<button
|
|
127
|
+
onClick={() => {
|
|
128
|
+
if (needsCredentialForm) {
|
|
129
|
+
const values: Record<string, string> = {};
|
|
130
|
+
for (const [k, v] of Object.entries(testCreds)) {
|
|
131
|
+
if (typeof v === 'string') values[k] = v;
|
|
132
|
+
}
|
|
133
|
+
formModel.openForm(service.authProvider as CredentialServiceType, values);
|
|
134
|
+
} else {
|
|
135
|
+
connectionManager.connectWithCredentials(service.id, JSON.stringify(testCreds));
|
|
136
|
+
}
|
|
137
|
+
}}
|
|
138
|
+
className="rounded-md px-2 py-1.5 text-xs font-medium text-muted-foreground bg-muted hover:bg-muted/80 transition-colors"
|
|
139
|
+
>
|
|
140
|
+
Quick Test
|
|
141
|
+
</button>
|
|
142
|
+
)}
|
|
143
|
+
</div>
|
|
144
|
+
{credentialFormElement}
|
|
145
|
+
</>
|
|
146
|
+
);
|
|
147
|
+
})
|
|
148
|
+
.exhaustive();
|
|
149
|
+
});
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { ExternalLink, Info, LogOut, MoreHorizontal, RefreshCw, Clock, Shield } from 'lucide-react';
|
|
3
|
+
import { ConnectionBadge } from '@anymux/ui/components/connection-badge';
|
|
4
|
+
import {
|
|
5
|
+
DropdownMenu,
|
|
6
|
+
DropdownMenuContent,
|
|
7
|
+
DropdownMenuItem,
|
|
8
|
+
DropdownMenuLabel,
|
|
9
|
+
DropdownMenuSeparator,
|
|
10
|
+
DropdownMenuTrigger,
|
|
11
|
+
} from '@anymux/ui/components/dropdown-menu';
|
|
12
|
+
import type { ConnectionManagerModel } from '../models/ConnectionManagerModel';
|
|
13
|
+
import type { ServiceDefinition } from '../types/service';
|
|
14
|
+
import type { IUserProfile } from '../types/user-profile';
|
|
15
|
+
import { ServiceIcon } from './ServiceIcon';
|
|
16
|
+
|
|
17
|
+
interface ConnectedMenuProps {
|
|
18
|
+
service: ServiceDefinition;
|
|
19
|
+
connectionManager: ConnectionManagerModel;
|
|
20
|
+
/** @deprecated Use profile instead */
|
|
21
|
+
user: { name: string; image?: string } | null;
|
|
22
|
+
profile?: IUserProfile;
|
|
23
|
+
isOAuth: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function formatConnectedDate(date: Date | undefined): string | null {
|
|
27
|
+
if (!date) return null;
|
|
28
|
+
const d = date instanceof Date ? date : new Date(date);
|
|
29
|
+
if (isNaN(d.getTime())) return null;
|
|
30
|
+
return d.toLocaleDateString(undefined, {
|
|
31
|
+
month: 'short',
|
|
32
|
+
day: 'numeric',
|
|
33
|
+
year: 'numeric',
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function ConnectedMenu({
|
|
38
|
+
service,
|
|
39
|
+
connectionManager,
|
|
40
|
+
user,
|
|
41
|
+
profile,
|
|
42
|
+
isOAuth,
|
|
43
|
+
}: ConnectedMenuProps) {
|
|
44
|
+
// Prefer profile data (provider-specific) over generic session user data
|
|
45
|
+
const displayName = profile?.name ?? user?.name;
|
|
46
|
+
const avatarUrl = profile?.avatarUrl ?? user?.image;
|
|
47
|
+
const profileUrl = profile?.profileUrl;
|
|
48
|
+
const email = profile?.email;
|
|
49
|
+
|
|
50
|
+
const handleReconnect = async () => {
|
|
51
|
+
await connectionManager.disconnect(service.id);
|
|
52
|
+
await connectionManager.connect(service.id);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const handleDisconnect = () => {
|
|
56
|
+
connectionManager.disconnect(service.id);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const connection = connectionManager.connections.get(service.id);
|
|
60
|
+
const connectedDate = formatConnectedDate(connection?.connectedAt);
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<div className="inline-flex items-center gap-1">
|
|
64
|
+
<ConnectionBadge
|
|
65
|
+
status="connected"
|
|
66
|
+
{...(displayName ? { name: displayName } : {})}
|
|
67
|
+
{...(avatarUrl ? { avatarUrl } : {})}
|
|
68
|
+
/>
|
|
69
|
+
<DropdownMenu>
|
|
70
|
+
<DropdownMenuTrigger asChild>
|
|
71
|
+
<button className="inline-flex items-center justify-center rounded-md p-1 text-muted-foreground hover:text-foreground hover:bg-muted transition-colors">
|
|
72
|
+
<MoreHorizontal className="h-4 w-4" />
|
|
73
|
+
</button>
|
|
74
|
+
</DropdownMenuTrigger>
|
|
75
|
+
<DropdownMenuContent align="end" className="min-w-[200px]">
|
|
76
|
+
{/* Provider header with per-service profile info */}
|
|
77
|
+
<DropdownMenuLabel className="flex items-center gap-2">
|
|
78
|
+
{avatarUrl ? (
|
|
79
|
+
<img src={avatarUrl} alt="" className="h-5 w-5 rounded-full shrink-0" />
|
|
80
|
+
) : (
|
|
81
|
+
<ServiceIcon
|
|
82
|
+
name={service.icon}
|
|
83
|
+
className="h-4 w-4 shrink-0"
|
|
84
|
+
style={{ color: service.color }}
|
|
85
|
+
/>
|
|
86
|
+
)}
|
|
87
|
+
<div className="flex flex-col min-w-0">
|
|
88
|
+
<span className="truncate">{displayName ?? service.name}</span>
|
|
89
|
+
{email && (
|
|
90
|
+
<span className="text-xs font-normal text-muted-foreground truncate">{email}</span>
|
|
91
|
+
)}
|
|
92
|
+
</div>
|
|
93
|
+
</DropdownMenuLabel>
|
|
94
|
+
|
|
95
|
+
{/* Connection timestamp */}
|
|
96
|
+
{connectedDate && (
|
|
97
|
+
<div className="flex items-center gap-2 px-2 pb-1.5 text-xs text-muted-foreground">
|
|
98
|
+
<Clock className="h-3 w-3 shrink-0" />
|
|
99
|
+
<span>Connected {connectedDate}</span>
|
|
100
|
+
</div>
|
|
101
|
+
)}
|
|
102
|
+
|
|
103
|
+
<DropdownMenuSeparator />
|
|
104
|
+
|
|
105
|
+
{/* Actions */}
|
|
106
|
+
{profileUrl && (
|
|
107
|
+
<DropdownMenuItem asChild>
|
|
108
|
+
<a href={profileUrl} target="_blank" rel="noopener noreferrer">
|
|
109
|
+
<ExternalLink />
|
|
110
|
+
Open Profile
|
|
111
|
+
</a>
|
|
112
|
+
</DropdownMenuItem>
|
|
113
|
+
)}
|
|
114
|
+
{service.grantsUrl && (
|
|
115
|
+
<DropdownMenuItem asChild>
|
|
116
|
+
<a href={service.grantsUrl} target="_blank" rel="noopener noreferrer">
|
|
117
|
+
<Shield />
|
|
118
|
+
Manage Permissions
|
|
119
|
+
</a>
|
|
120
|
+
</DropdownMenuItem>
|
|
121
|
+
)}
|
|
122
|
+
<DropdownMenuItem asChild>
|
|
123
|
+
<a href={`/providers/${service.id}`}>
|
|
124
|
+
<Info />
|
|
125
|
+
About {service.name}
|
|
126
|
+
</a>
|
|
127
|
+
</DropdownMenuItem>
|
|
128
|
+
{isOAuth && (
|
|
129
|
+
<DropdownMenuItem onClick={handleReconnect}>
|
|
130
|
+
<RefreshCw />
|
|
131
|
+
Reconnect
|
|
132
|
+
</DropdownMenuItem>
|
|
133
|
+
)}
|
|
134
|
+
<DropdownMenuItem variant="destructive" onClick={handleDisconnect}>
|
|
135
|
+
<LogOut />
|
|
136
|
+
Disconnect
|
|
137
|
+
</DropdownMenuItem>
|
|
138
|
+
</DropdownMenuContent>
|
|
139
|
+
</DropdownMenu>
|
|
140
|
+
</div>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { StatusIndicator } from '@anymux/ui/components/status-indicator';
|
|
3
|
+
import { match } from 'ts-pattern';
|
|
4
|
+
import type { ConnectionStatus as Status } from '../types/connection';
|
|
5
|
+
|
|
6
|
+
interface ConnectionStatusProps {
|
|
7
|
+
status: Status;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const ConnectionStatusIndicator: React.FC<ConnectionStatusProps> = ({ status }) => {
|
|
11
|
+
const config = match(status)
|
|
12
|
+
.with('disconnected', () => ({ status: 'neutral' as const, label: 'Disconnected' }))
|
|
13
|
+
.with('connecting', () => ({ status: 'warning' as const, label: 'Connecting...', pulse: true }))
|
|
14
|
+
.with('connected', () => ({ status: 'success' as const, label: 'Connected' }))
|
|
15
|
+
.with('expired', () => ({ status: 'warning' as const, label: 'Expired' }))
|
|
16
|
+
.with('error', () => ({ status: 'error' as const, label: 'Error' }))
|
|
17
|
+
.with('not_configured', () => ({ status: 'neutral' as const, label: 'Not Configured' }))
|
|
18
|
+
.with('loading', () => ({ status: 'neutral' as const, label: 'Loading...' }))
|
|
19
|
+
.exhaustive();
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<StatusIndicator
|
|
23
|
+
status={config.status}
|
|
24
|
+
label={config.label}
|
|
25
|
+
pulse={config.status === 'warning' && status === 'connecting'}
|
|
26
|
+
/>
|
|
27
|
+
);
|
|
28
|
+
};
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { observer } from 'mobx-react-lite';
|
|
3
|
+
import type { CredentialFormModel } from '../models/CredentialFormModel';
|
|
4
|
+
|
|
5
|
+
interface CredentialFormProps {
|
|
6
|
+
model: CredentialFormModel;
|
|
7
|
+
onSubmit: (credentials: string) => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const CredentialForm: React.FC<CredentialFormProps> = observer(({ model, onSubmit }) => {
|
|
11
|
+
if (!model.open) return null;
|
|
12
|
+
|
|
13
|
+
const handleSubmit = (e: React.FormEvent) => {
|
|
14
|
+
e.preventDefault();
|
|
15
|
+
onSubmit(model.serialize());
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const inputClass =
|
|
19
|
+
'w-full rounded-md border border-border px-3 py-2 text-base sm:text-sm bg-card text-foreground focus:border-ring focus:outline-none focus:ring-1 focus:ring-ring';
|
|
20
|
+
const labelClass = 'block text-sm font-medium text-foreground mb-1';
|
|
21
|
+
|
|
22
|
+
const title = model.serviceType === 's3' ? 'S3 Credentials' : model.serviceType === 'webdav' ? 'WebDAV Credentials' : model.serviceType === 'icloud' ? 'iCloud Credentials' : 'Gitea Credentials';
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => model.closeForm()}>
|
|
26
|
+
<div
|
|
27
|
+
className="w-[calc(100%-2rem)] max-w-md max-h-[80vh] flex flex-col rounded-lg bg-card shadow-xl"
|
|
28
|
+
onClick={(e) => e.stopPropagation()}
|
|
29
|
+
>
|
|
30
|
+
<h2 className="px-4 sm:px-6 pt-4 sm:pt-6 pb-3 text-lg font-semibold text-foreground flex-shrink-0">{title}</h2>
|
|
31
|
+
|
|
32
|
+
<form onSubmit={handleSubmit} className="flex flex-col flex-1 min-h-0">
|
|
33
|
+
<div className="flex-1 overflow-y-auto px-4 sm:px-6 space-y-3">
|
|
34
|
+
{model.serviceType === 's3' ? (
|
|
35
|
+
<>
|
|
36
|
+
<div>
|
|
37
|
+
<label className={labelClass}>Access Key ID</label>
|
|
38
|
+
<input
|
|
39
|
+
type="text"
|
|
40
|
+
required
|
|
41
|
+
className={inputClass}
|
|
42
|
+
value={model.accessKeyId}
|
|
43
|
+
onChange={(e) => model.setField('accessKeyId', e.target.value)}
|
|
44
|
+
/>
|
|
45
|
+
</div>
|
|
46
|
+
<div>
|
|
47
|
+
<label className={labelClass}>Secret Access Key</label>
|
|
48
|
+
<input
|
|
49
|
+
type="password"
|
|
50
|
+
required
|
|
51
|
+
className={inputClass}
|
|
52
|
+
value={model.secretAccessKey}
|
|
53
|
+
onChange={(e) => model.setField('secretAccessKey', e.target.value)}
|
|
54
|
+
/>
|
|
55
|
+
</div>
|
|
56
|
+
<div>
|
|
57
|
+
<label className={labelClass}>Region</label>
|
|
58
|
+
<input
|
|
59
|
+
type="text"
|
|
60
|
+
required
|
|
61
|
+
className={inputClass}
|
|
62
|
+
value={model.region}
|
|
63
|
+
onChange={(e) => model.setField('region', e.target.value)}
|
|
64
|
+
/>
|
|
65
|
+
</div>
|
|
66
|
+
<div>
|
|
67
|
+
<label className={labelClass}>Bucket</label>
|
|
68
|
+
<input
|
|
69
|
+
type="text"
|
|
70
|
+
required
|
|
71
|
+
className={inputClass}
|
|
72
|
+
value={model.bucket}
|
|
73
|
+
onChange={(e) => model.setField('bucket', e.target.value)}
|
|
74
|
+
/>
|
|
75
|
+
</div>
|
|
76
|
+
<div>
|
|
77
|
+
<label className={labelClass}>Endpoint (optional)</label>
|
|
78
|
+
<input
|
|
79
|
+
type="text"
|
|
80
|
+
className={inputClass}
|
|
81
|
+
value={model.endpoint}
|
|
82
|
+
onChange={(e) => model.setField('endpoint', e.target.value)}
|
|
83
|
+
placeholder="https://s3.amazonaws.com"
|
|
84
|
+
/>
|
|
85
|
+
</div>
|
|
86
|
+
</>
|
|
87
|
+
) : model.serviceType === 'webdav' ? (
|
|
88
|
+
<>
|
|
89
|
+
<div>
|
|
90
|
+
<label className={labelClass}>URL</label>
|
|
91
|
+
<input
|
|
92
|
+
type="url"
|
|
93
|
+
required
|
|
94
|
+
className={inputClass}
|
|
95
|
+
value={model.url}
|
|
96
|
+
onChange={(e) => model.setField('url', e.target.value)}
|
|
97
|
+
placeholder="https://example.com/webdav"
|
|
98
|
+
/>
|
|
99
|
+
</div>
|
|
100
|
+
<div>
|
|
101
|
+
<label className={labelClass}>Username</label>
|
|
102
|
+
<input
|
|
103
|
+
type="text"
|
|
104
|
+
required
|
|
105
|
+
className={inputClass}
|
|
106
|
+
value={model.username}
|
|
107
|
+
onChange={(e) => model.setField('username', e.target.value)}
|
|
108
|
+
/>
|
|
109
|
+
</div>
|
|
110
|
+
<div>
|
|
111
|
+
<label className={labelClass}>Password</label>
|
|
112
|
+
<input
|
|
113
|
+
type="password"
|
|
114
|
+
required
|
|
115
|
+
className={inputClass}
|
|
116
|
+
value={model.password}
|
|
117
|
+
onChange={(e) => model.setField('password', e.target.value)}
|
|
118
|
+
/>
|
|
119
|
+
</div>
|
|
120
|
+
</>
|
|
121
|
+
) : model.serviceType === 'icloud' ? (
|
|
122
|
+
<>
|
|
123
|
+
<div>
|
|
124
|
+
<label className={labelClass}>Apple ID Email</label>
|
|
125
|
+
<input
|
|
126
|
+
type="email"
|
|
127
|
+
required
|
|
128
|
+
className={inputClass}
|
|
129
|
+
value={model.email}
|
|
130
|
+
onChange={(e) => model.setField('email', e.target.value)}
|
|
131
|
+
placeholder="you@icloud.com"
|
|
132
|
+
/>
|
|
133
|
+
</div>
|
|
134
|
+
<div>
|
|
135
|
+
<label className={labelClass}>App-Specific Password</label>
|
|
136
|
+
<input
|
|
137
|
+
type="password"
|
|
138
|
+
required
|
|
139
|
+
className={inputClass}
|
|
140
|
+
value={model.appPassword}
|
|
141
|
+
onChange={(e) => model.setField('appPassword', e.target.value)}
|
|
142
|
+
placeholder="xxxx-xxxx-xxxx-xxxx"
|
|
143
|
+
/>
|
|
144
|
+
</div>
|
|
145
|
+
<p className="text-xs text-muted-foreground">
|
|
146
|
+
Generate an app-specific password at{' '}
|
|
147
|
+
<a
|
|
148
|
+
href="https://appleid.apple.com/account/manage"
|
|
149
|
+
target="_blank"
|
|
150
|
+
rel="noopener noreferrer"
|
|
151
|
+
className="underline text-primary hover:text-primary/80"
|
|
152
|
+
>
|
|
153
|
+
appleid.apple.com
|
|
154
|
+
</a>
|
|
155
|
+
{' '}under Sign-In and Security → App-Specific Passwords.
|
|
156
|
+
</p>
|
|
157
|
+
</>
|
|
158
|
+
) : (
|
|
159
|
+
<>
|
|
160
|
+
<div>
|
|
161
|
+
<label className={labelClass}>URL</label>
|
|
162
|
+
<input
|
|
163
|
+
type="url"
|
|
164
|
+
required
|
|
165
|
+
className={inputClass}
|
|
166
|
+
value={model.url}
|
|
167
|
+
onChange={(e) => model.setField('url', e.target.value)}
|
|
168
|
+
placeholder="https://gitea.example.com"
|
|
169
|
+
/>
|
|
170
|
+
</div>
|
|
171
|
+
<div>
|
|
172
|
+
<label className={labelClass}>Token (or use username/password below)</label>
|
|
173
|
+
<input
|
|
174
|
+
type="password"
|
|
175
|
+
className={inputClass}
|
|
176
|
+
value={model.token}
|
|
177
|
+
onChange={(e) => model.setField('token', e.target.value)}
|
|
178
|
+
placeholder="API token"
|
|
179
|
+
/>
|
|
180
|
+
</div>
|
|
181
|
+
{!model.token && (
|
|
182
|
+
<>
|
|
183
|
+
<div>
|
|
184
|
+
<label className={labelClass}>Username</label>
|
|
185
|
+
<input
|
|
186
|
+
type="text"
|
|
187
|
+
className={inputClass}
|
|
188
|
+
value={model.username}
|
|
189
|
+
onChange={(e) => model.setField('username', e.target.value)}
|
|
190
|
+
/>
|
|
191
|
+
</div>
|
|
192
|
+
<div>
|
|
193
|
+
<label className={labelClass}>Password</label>
|
|
194
|
+
<input
|
|
195
|
+
type="password"
|
|
196
|
+
className={inputClass}
|
|
197
|
+
value={model.password}
|
|
198
|
+
onChange={(e) => model.setField('password', e.target.value)}
|
|
199
|
+
/>
|
|
200
|
+
</div>
|
|
201
|
+
</>
|
|
202
|
+
)}
|
|
203
|
+
<div>
|
|
204
|
+
<label className={labelClass}>Owner</label>
|
|
205
|
+
<input
|
|
206
|
+
type="text"
|
|
207
|
+
required
|
|
208
|
+
className={inputClass}
|
|
209
|
+
value={model.owner}
|
|
210
|
+
onChange={(e) => model.setField('owner', e.target.value)}
|
|
211
|
+
/>
|
|
212
|
+
</div>
|
|
213
|
+
<div>
|
|
214
|
+
<label className={labelClass}>Repo</label>
|
|
215
|
+
<input
|
|
216
|
+
type="text"
|
|
217
|
+
required
|
|
218
|
+
className={inputClass}
|
|
219
|
+
value={model.repo}
|
|
220
|
+
onChange={(e) => model.setField('repo', e.target.value)}
|
|
221
|
+
/>
|
|
222
|
+
</div>
|
|
223
|
+
</>
|
|
224
|
+
)}
|
|
225
|
+
</div>
|
|
226
|
+
|
|
227
|
+
<div className="flex justify-end gap-2 px-4 sm:px-6 py-3 border-t border-border flex-shrink-0">
|
|
228
|
+
<button
|
|
229
|
+
type="button"
|
|
230
|
+
onClick={() => model.closeForm()}
|
|
231
|
+
className="rounded-md px-4 py-2 text-sm font-medium text-muted-foreground hover:bg-muted"
|
|
232
|
+
>
|
|
233
|
+
Cancel
|
|
234
|
+
</button>
|
|
235
|
+
<button
|
|
236
|
+
type="submit"
|
|
237
|
+
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
|
238
|
+
>
|
|
239
|
+
Connect
|
|
240
|
+
</button>
|
|
241
|
+
</div>
|
|
242
|
+
</form>
|
|
243
|
+
</div>
|
|
244
|
+
</div>
|
|
245
|
+
);
|
|
246
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import React, { Suspense } from 'react';
|
|
2
|
+
import { observer } from 'mobx-react-lite';
|
|
3
|
+
import { ArrowLeft } from 'lucide-react';
|
|
4
|
+
import { PathBreadcrumb } from '@anymux/ui/components/path-breadcrumb';
|
|
5
|
+
import { LoadingSpinner } from '@anymux/ui/components/loading-spinner';
|
|
6
|
+
import type { DashboardModel } from '../models/DashboardModel';
|
|
7
|
+
import type { ConnectionManagerModel } from '../models/ConnectionManagerModel';
|
|
8
|
+
import { CapabilityContent, CAPABILITY_LABELS, ScopeWarningBanner } from './CapabilityPanel';
|
|
9
|
+
import { ActionHistoryPanel } from './ActionHistoryPanel';
|
|
10
|
+
|
|
11
|
+
const REPO_SERVICES = new Set(['github', 'gitlab', 'bitbucket', 'gitea']);
|
|
12
|
+
|
|
13
|
+
interface FullScreenBrowserProps {
|
|
14
|
+
dashboardModel: DashboardModel;
|
|
15
|
+
connectionManager: ConnectionManagerModel;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const FullScreenBrowser: React.FC<FullScreenBrowserProps> = observer(
|
|
19
|
+
({ dashboardModel, connectionManager }) => {
|
|
20
|
+
const cell = dashboardModel.selectedCell;
|
|
21
|
+
if (!cell) return null;
|
|
22
|
+
|
|
23
|
+
const { serviceId, capabilityId } = cell;
|
|
24
|
+
const service = dashboardModel.selectedService;
|
|
25
|
+
if (!service) return null;
|
|
26
|
+
|
|
27
|
+
const selectedRepo = dashboardModel.getSelectedRepo(serviceId);
|
|
28
|
+
const hasRepo = REPO_SERVICES.has(serviceId) && selectedRepo;
|
|
29
|
+
const browserPath = dashboardModel.browserPath;
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<div className="flex flex-col h-full flex-1 min-h-0">
|
|
33
|
+
{/* Breadcrumb bar */}
|
|
34
|
+
<div className="flex items-center gap-1 px-2 sm:px-4 py-2 bg-muted border-b border-border text-xs sm:text-sm flex-shrink-0 min-h-[40px]">
|
|
35
|
+
<div className="flex items-center gap-1 flex-1 min-w-0 flex-wrap">
|
|
36
|
+
<PathBreadcrumb
|
|
37
|
+
path={browserPath}
|
|
38
|
+
onNavigate={(path) => dashboardModel.setBrowserPath(path)}
|
|
39
|
+
showHome={false}
|
|
40
|
+
editable
|
|
41
|
+
prefixSegments={[
|
|
42
|
+
{ label: 'Dashboard', onClick: () => dashboardModel.closePanel() },
|
|
43
|
+
{ label: service.name, onClick: () => dashboardModel.closePanel() },
|
|
44
|
+
{ label: CAPABILITY_LABELS[capabilityId], onClick: () => dashboardModel.setBrowserPath('/') },
|
|
45
|
+
...(hasRepo ? [{ label: `${selectedRepo.owner}/${selectedRepo.repo}` }] : []),
|
|
46
|
+
]}
|
|
47
|
+
/>
|
|
48
|
+
{hasRepo && (
|
|
49
|
+
<button
|
|
50
|
+
onClick={() => dashboardModel.clearSelectedRepo(serviceId)}
|
|
51
|
+
className="ml-1 px-1.5 py-0.5 text-[10px] font-medium rounded bg-muted hover:bg-muted/80 transition-colors"
|
|
52
|
+
>
|
|
53
|
+
<span className="hidden sm:inline">Change</span>
|
|
54
|
+
<ArrowLeft className="h-3 w-3 sm:hidden" />
|
|
55
|
+
</button>
|
|
56
|
+
)}
|
|
57
|
+
</div>
|
|
58
|
+
<ActionHistoryPanel model={dashboardModel.actionNotifications} />
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
{/* Scope warning */}
|
|
62
|
+
<ScopeWarningBanner serviceId={serviceId} capabilityId={capabilityId} connectionManager={connectionManager} />
|
|
63
|
+
|
|
64
|
+
{/* Browser content */}
|
|
65
|
+
<div className="flex-1 min-h-0 overflow-hidden">
|
|
66
|
+
<Suspense
|
|
67
|
+
fallback={
|
|
68
|
+
<div className="flex items-center justify-center h-64">
|
|
69
|
+
<LoadingSpinner label="Loading..." />
|
|
70
|
+
</div>
|
|
71
|
+
}
|
|
72
|
+
>
|
|
73
|
+
<CapabilityContent
|
|
74
|
+
serviceId={serviceId}
|
|
75
|
+
capabilityId={capabilityId}
|
|
76
|
+
connectionManager={connectionManager}
|
|
77
|
+
dashboardModel={dashboardModel}
|
|
78
|
+
/>
|
|
79
|
+
</Suspense>
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
);
|