skillverse 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/.prettierrc +10 -0
- package/README.md +369 -0
- package/client/README.md +73 -0
- package/client/eslint.config.js +23 -0
- package/client/index.html +13 -0
- package/client/package.json +41 -0
- package/client/postcss.config.js +6 -0
- package/client/public/vite.svg +1 -0
- package/client/src/App.css +42 -0
- package/client/src/App.tsx +26 -0
- package/client/src/assets/react.svg +1 -0
- package/client/src/components/AddSkillDialog.tsx +249 -0
- package/client/src/components/Layout.tsx +134 -0
- package/client/src/components/LinkWorkspaceDialog.tsx +196 -0
- package/client/src/components/LoadingSpinner.tsx +57 -0
- package/client/src/components/SkillCard.tsx +269 -0
- package/client/src/components/Toast.tsx +44 -0
- package/client/src/components/Tooltip.tsx +132 -0
- package/client/src/index.css +168 -0
- package/client/src/lib/api.ts +196 -0
- package/client/src/main.tsx +10 -0
- package/client/src/pages/Dashboard.tsx +209 -0
- package/client/src/pages/Marketplace.tsx +282 -0
- package/client/src/pages/Settings.tsx +136 -0
- package/client/src/pages/SkillLibrary.tsx +163 -0
- package/client/src/pages/Workspaces.tsx +662 -0
- package/client/src/stores/appStore.ts +222 -0
- package/client/tailwind.config.js +82 -0
- package/client/tsconfig.app.json +28 -0
- package/client/tsconfig.json +7 -0
- package/client/tsconfig.node.json +26 -0
- package/client/vite.config.ts +26 -0
- package/package.json +34 -0
- package/registry/.env.example +5 -0
- package/registry/Dockerfile +42 -0
- package/registry/docker-compose.yml +33 -0
- package/registry/package.json +37 -0
- package/registry/prisma/schema.prisma +59 -0
- package/registry/src/index.ts +34 -0
- package/registry/src/lib/db.ts +3 -0
- package/registry/src/middleware/errorHandler.ts +35 -0
- package/registry/src/routes/auth.ts +152 -0
- package/registry/src/routes/skills.ts +295 -0
- package/registry/tsconfig.json +23 -0
- package/server/.env.example +11 -0
- package/server/package.json +60 -0
- package/server/prisma/schema.prisma +73 -0
- package/server/public/assets/index-BsYtpZSa.css +1 -0
- package/server/public/assets/index-Dfr_6UV8.js +20 -0
- package/server/public/index.html +14 -0
- package/server/public/vite.svg +1 -0
- package/server/src/bin.ts +428 -0
- package/server/src/config.ts +39 -0
- package/server/src/index.ts +112 -0
- package/server/src/lib/db.ts +14 -0
- package/server/src/middleware/errorHandler.ts +40 -0
- package/server/src/middleware/logger.ts +12 -0
- package/server/src/routes/dashboard.ts +102 -0
- package/server/src/routes/marketplace.ts +273 -0
- package/server/src/routes/skills.ts +294 -0
- package/server/src/routes/workspaces.ts +168 -0
- package/server/src/services/bundleService.ts +123 -0
- package/server/src/services/skillService.ts +722 -0
- package/server/src/services/workspaceService.ts +521 -0
- package/server/src/verify-sync.ts +91 -0
- package/server/tsconfig.json +19 -0
- package/server/tsup.config.ts +18 -0
- package/shared/package.json +21 -0
- package/shared/pnpm-lock.yaml +24 -0
- package/shared/src/index.ts +169 -0
- package/shared/tsconfig.json +10 -0
- package/tsconfig.json +25 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { X, GitBranch, Upload, Loader2 } from 'lucide-react';
|
|
3
|
+
import { clsx } from 'clsx';
|
|
4
|
+
import { useDropzone } from 'react-dropzone';
|
|
5
|
+
import { skillsApi } from '../lib/api';
|
|
6
|
+
import { useAppStore } from '../stores/appStore';
|
|
7
|
+
|
|
8
|
+
interface AddSkillDialogProps {
|
|
9
|
+
isOpen: boolean;
|
|
10
|
+
onClose: () => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export default function AddSkillDialog({ isOpen, onClose }: AddSkillDialogProps) {
|
|
14
|
+
const [tab, setTab] = useState<'git' | 'local'>('git');
|
|
15
|
+
const [gitUrl, setGitUrl] = useState('');
|
|
16
|
+
const [name, setName] = useState('');
|
|
17
|
+
const [description, setDescription] = useState('');
|
|
18
|
+
const [file, setFile] = useState<File | null>(null);
|
|
19
|
+
const [loading, setLoading] = useState(false);
|
|
20
|
+
const [error, setError] = useState('');
|
|
21
|
+
|
|
22
|
+
const { addSkill, showToast } = useAppStore();
|
|
23
|
+
|
|
24
|
+
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
|
25
|
+
accept: { 'application/zip': ['.zip'] },
|
|
26
|
+
maxFiles: 1,
|
|
27
|
+
onDrop: (acceptedFiles) => {
|
|
28
|
+
if (acceptedFiles.length > 0) {
|
|
29
|
+
setFile(acceptedFiles[0]);
|
|
30
|
+
if (!name) {
|
|
31
|
+
setName(acceptedFiles[0].name.replace('.zip', ''));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
38
|
+
e.preventDefault();
|
|
39
|
+
setError('');
|
|
40
|
+
setLoading(true);
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
let skill;
|
|
44
|
+
if (tab === 'git') {
|
|
45
|
+
if (!gitUrl) {
|
|
46
|
+
setError('Git URL is required');
|
|
47
|
+
setLoading(false);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
skill = await skillsApi.createFromGit({ gitUrl, description });
|
|
51
|
+
} else {
|
|
52
|
+
if (!name || !file) {
|
|
53
|
+
setError('Name and file are required');
|
|
54
|
+
setLoading(false);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
skill = await skillsApi.createFromLocal(name, file, description);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
addSkill(skill);
|
|
61
|
+
showToast(`Skill "${skill.name}" added successfully!`, 'success');
|
|
62
|
+
handleClose();
|
|
63
|
+
} catch (err: any) {
|
|
64
|
+
setError(err.message || 'Failed to add skill');
|
|
65
|
+
} finally {
|
|
66
|
+
setLoading(false);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const handleClose = () => {
|
|
71
|
+
setGitUrl('');
|
|
72
|
+
setName('');
|
|
73
|
+
setDescription('');
|
|
74
|
+
setFile(null);
|
|
75
|
+
setError('');
|
|
76
|
+
setTab('git');
|
|
77
|
+
onClose();
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
if (!isOpen) return null;
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center p-2 sm:p-4">
|
|
84
|
+
{/* Backdrop */}
|
|
85
|
+
<div
|
|
86
|
+
className="absolute inset-0 bg-dark-950/80 backdrop-blur-sm"
|
|
87
|
+
onClick={handleClose}
|
|
88
|
+
/>
|
|
89
|
+
|
|
90
|
+
{/* Dialog */}
|
|
91
|
+
<div className="relative w-full max-w-lg bg-dark-800 border border-dark-700 rounded-2xl shadow-2xl animate-scale-in max-h-[90vh] overflow-y-auto">
|
|
92
|
+
{/* Header */}
|
|
93
|
+
<div className="flex items-center justify-between px-4 sm:px-6 py-4 border-b border-dark-700 sticky top-0 bg-dark-800 z-10">
|
|
94
|
+
<h2 className="text-lg font-semibold text-dark-100">Add New Skill</h2>
|
|
95
|
+
<button onClick={handleClose} className="btn-icon">
|
|
96
|
+
<X className="w-5 h-5" />
|
|
97
|
+
</button>
|
|
98
|
+
</div>
|
|
99
|
+
|
|
100
|
+
{/* Tabs */}
|
|
101
|
+
<div className="flex border-b border-dark-700">
|
|
102
|
+
<button
|
|
103
|
+
onClick={() => setTab('git')}
|
|
104
|
+
className={clsx(
|
|
105
|
+
'flex-1 flex items-center justify-center gap-2 px-4 py-3 text-sm font-medium transition-colors',
|
|
106
|
+
tab === 'git'
|
|
107
|
+
? 'text-primary-400 border-b-2 border-primary-400 bg-primary-500/5'
|
|
108
|
+
: 'text-dark-400 hover:text-dark-200'
|
|
109
|
+
)}
|
|
110
|
+
>
|
|
111
|
+
<GitBranch className="w-4 h-4" />
|
|
112
|
+
<span className="hidden sm:inline">From Git</span>
|
|
113
|
+
<span className="sm:hidden">Git</span>
|
|
114
|
+
</button>
|
|
115
|
+
<button
|
|
116
|
+
onClick={() => setTab('local')}
|
|
117
|
+
className={clsx(
|
|
118
|
+
'flex-1 flex items-center justify-center gap-2 px-4 py-3 text-sm font-medium transition-colors',
|
|
119
|
+
tab === 'local'
|
|
120
|
+
? 'text-accent-400 border-b-2 border-accent-400 bg-accent-500/5'
|
|
121
|
+
: 'text-dark-400 hover:text-dark-200'
|
|
122
|
+
)}
|
|
123
|
+
>
|
|
124
|
+
<Upload className="w-4 h-4" />
|
|
125
|
+
<span className="hidden sm:inline">Local Upload</span>
|
|
126
|
+
<span className="sm:hidden">Upload</span>
|
|
127
|
+
</button>
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
{/* Content */}
|
|
131
|
+
<form onSubmit={handleSubmit} className="p-4 sm:p-6 space-y-4">
|
|
132
|
+
{tab === 'git' ? (
|
|
133
|
+
<div>
|
|
134
|
+
<label className="block text-sm font-medium text-dark-300 mb-2">
|
|
135
|
+
Git Repository URL
|
|
136
|
+
</label>
|
|
137
|
+
<input
|
|
138
|
+
type="url"
|
|
139
|
+
value={gitUrl}
|
|
140
|
+
onChange={(e) => setGitUrl(e.target.value)}
|
|
141
|
+
placeholder="https://github.com/user/skill-repo.git"
|
|
142
|
+
className="input"
|
|
143
|
+
disabled={loading}
|
|
144
|
+
/>
|
|
145
|
+
<p className="mt-2 text-xs text-dark-500">
|
|
146
|
+
Enter the Git URL to clone the skill repository
|
|
147
|
+
</p>
|
|
148
|
+
</div>
|
|
149
|
+
) : (
|
|
150
|
+
<>
|
|
151
|
+
<div>
|
|
152
|
+
<label className="block text-sm font-medium text-dark-300 mb-2">
|
|
153
|
+
Skill Name
|
|
154
|
+
</label>
|
|
155
|
+
<input
|
|
156
|
+
type="text"
|
|
157
|
+
value={name}
|
|
158
|
+
onChange={(e) => setName(e.target.value)}
|
|
159
|
+
placeholder="my-awesome-skill"
|
|
160
|
+
className="input"
|
|
161
|
+
disabled={loading}
|
|
162
|
+
/>
|
|
163
|
+
</div>
|
|
164
|
+
|
|
165
|
+
<div>
|
|
166
|
+
<label className="block text-sm font-medium text-dark-300 mb-2">
|
|
167
|
+
Upload ZIP File
|
|
168
|
+
</label>
|
|
169
|
+
<div
|
|
170
|
+
{...getRootProps()}
|
|
171
|
+
className={clsx(
|
|
172
|
+
'border-2 border-dashed rounded-xl p-6 sm:p-8 text-center cursor-pointer transition-all',
|
|
173
|
+
isDragActive
|
|
174
|
+
? 'border-accent-400 bg-accent-500/10'
|
|
175
|
+
: 'border-dark-600 hover:border-dark-500 hover:bg-dark-750',
|
|
176
|
+
loading && 'opacity-50 pointer-events-none'
|
|
177
|
+
)}
|
|
178
|
+
>
|
|
179
|
+
<input {...getInputProps()} />
|
|
180
|
+
<Upload className="w-8 h-8 sm:w-10 sm:h-10 mx-auto text-dark-400 mb-3" />
|
|
181
|
+
{file ? (
|
|
182
|
+
<p className="text-dark-100 font-medium text-sm sm:text-base truncate">{file.name}</p>
|
|
183
|
+
) : isDragActive ? (
|
|
184
|
+
<p className="text-accent-400 text-sm sm:text-base">Drop the file here...</p>
|
|
185
|
+
) : (
|
|
186
|
+
<>
|
|
187
|
+
<p className="text-dark-300 text-sm sm:text-base">
|
|
188
|
+
Drag & drop a ZIP file here, or click to select
|
|
189
|
+
</p>
|
|
190
|
+
<p className="text-xs text-dark-500 mt-1">
|
|
191
|
+
Only .zip files are supported
|
|
192
|
+
</p>
|
|
193
|
+
</>
|
|
194
|
+
)}
|
|
195
|
+
</div>
|
|
196
|
+
</div>
|
|
197
|
+
</>
|
|
198
|
+
)}
|
|
199
|
+
|
|
200
|
+
<div>
|
|
201
|
+
<label className="block text-sm font-medium text-dark-300 mb-2">
|
|
202
|
+
Description (optional)
|
|
203
|
+
</label>
|
|
204
|
+
<textarea
|
|
205
|
+
value={description}
|
|
206
|
+
onChange={(e) => setDescription(e.target.value)}
|
|
207
|
+
placeholder="A brief description of this skill..."
|
|
208
|
+
rows={3}
|
|
209
|
+
className="input resize-none"
|
|
210
|
+
disabled={loading}
|
|
211
|
+
/>
|
|
212
|
+
</div>
|
|
213
|
+
|
|
214
|
+
{error && (
|
|
215
|
+
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400 text-sm">
|
|
216
|
+
{error}
|
|
217
|
+
</div>
|
|
218
|
+
)}
|
|
219
|
+
|
|
220
|
+
{/* Actions */}
|
|
221
|
+
<div className="flex gap-3 pt-2">
|
|
222
|
+
<button
|
|
223
|
+
type="button"
|
|
224
|
+
onClick={handleClose}
|
|
225
|
+
className="btn-secondary flex-1"
|
|
226
|
+
disabled={loading}
|
|
227
|
+
>
|
|
228
|
+
Cancel
|
|
229
|
+
</button>
|
|
230
|
+
<button
|
|
231
|
+
type="submit"
|
|
232
|
+
className="btn-primary flex-1"
|
|
233
|
+
disabled={loading}
|
|
234
|
+
>
|
|
235
|
+
{loading ? (
|
|
236
|
+
<>
|
|
237
|
+
<Loader2 className="w-4 h-4 animate-spin" />
|
|
238
|
+
Adding...
|
|
239
|
+
</>
|
|
240
|
+
) : (
|
|
241
|
+
'Add Skill'
|
|
242
|
+
)}
|
|
243
|
+
</button>
|
|
244
|
+
</div>
|
|
245
|
+
</form>
|
|
246
|
+
</div>
|
|
247
|
+
</div>
|
|
248
|
+
);
|
|
249
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
import { NavLink, Outlet } from 'react-router-dom';
|
|
3
|
+
import {
|
|
4
|
+
LayoutDashboard,
|
|
5
|
+
Package,
|
|
6
|
+
FolderOpen,
|
|
7
|
+
Store,
|
|
8
|
+
Settings,
|
|
9
|
+
Sparkles,
|
|
10
|
+
Menu,
|
|
11
|
+
X,
|
|
12
|
+
} from 'lucide-react';
|
|
13
|
+
import { clsx } from 'clsx';
|
|
14
|
+
import Toast from './Toast';
|
|
15
|
+
|
|
16
|
+
const navItems = [
|
|
17
|
+
{ to: '/', icon: LayoutDashboard, label: 'Dashboard' },
|
|
18
|
+
{ to: '/skills', icon: Package, label: 'Skill Library' },
|
|
19
|
+
{ to: '/workspaces', icon: FolderOpen, label: 'Workspaces' },
|
|
20
|
+
{ to: '/marketplace', icon: Store, label: 'Marketplace' },
|
|
21
|
+
{ to: '/settings', icon: Settings, label: 'Settings' },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
export default function Layout() {
|
|
25
|
+
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
|
26
|
+
|
|
27
|
+
// Close mobile menu when window resizes above mobile breakpoint
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
const handleResize = () => {
|
|
30
|
+
if (window.innerWidth >= 768) {
|
|
31
|
+
setIsMobileMenuOpen(false);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
window.addEventListener('resize', handleResize);
|
|
35
|
+
return () => window.removeEventListener('resize', handleResize);
|
|
36
|
+
}, []);
|
|
37
|
+
|
|
38
|
+
// Close mobile menu on navigation
|
|
39
|
+
const handleNavClick = () => {
|
|
40
|
+
setIsMobileMenuOpen(false);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<div className="flex h-screen bg-dark-900">
|
|
45
|
+
{/* Mobile Menu Toggle */}
|
|
46
|
+
<button
|
|
47
|
+
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
|
48
|
+
className="fixed top-4 left-4 z-50 md:hidden btn-icon bg-dark-800 border border-dark-700 shadow-lg"
|
|
49
|
+
aria-label="Toggle menu"
|
|
50
|
+
>
|
|
51
|
+
{isMobileMenuOpen ? (
|
|
52
|
+
<X className="w-5 h-5" />
|
|
53
|
+
) : (
|
|
54
|
+
<Menu className="w-5 h-5" />
|
|
55
|
+
)}
|
|
56
|
+
</button>
|
|
57
|
+
|
|
58
|
+
{/* Mobile Backdrop */}
|
|
59
|
+
{isMobileMenuOpen && (
|
|
60
|
+
<div
|
|
61
|
+
className="fixed inset-0 z-30 bg-dark-950/80 backdrop-blur-sm md:hidden"
|
|
62
|
+
onClick={() => setIsMobileMenuOpen(false)}
|
|
63
|
+
/>
|
|
64
|
+
)}
|
|
65
|
+
|
|
66
|
+
{/* Sidebar */}
|
|
67
|
+
<aside
|
|
68
|
+
className={clsx(
|
|
69
|
+
'fixed md:relative z-40 h-full bg-dark-800 border-r border-dark-700 flex flex-col',
|
|
70
|
+
'w-64 lg:w-64 md:w-16',
|
|
71
|
+
'transition-transform duration-300 ease-in-out',
|
|
72
|
+
isMobileMenuOpen
|
|
73
|
+
? 'translate-x-0'
|
|
74
|
+
: '-translate-x-full md:translate-x-0'
|
|
75
|
+
)}
|
|
76
|
+
>
|
|
77
|
+
{/* Logo */}
|
|
78
|
+
<div className="h-16 flex items-center gap-3 px-6 md:px-3 lg:px-6 border-b border-dark-700">
|
|
79
|
+
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-primary-500 to-accent-500 flex items-center justify-center shrink-0">
|
|
80
|
+
<Sparkles className="w-5 h-5 text-white" />
|
|
81
|
+
</div>
|
|
82
|
+
<span className="text-xl font-bold bg-gradient-to-r from-primary-400 to-accent-400 bg-clip-text text-transparent md:hidden lg:block">
|
|
83
|
+
SkillVerse
|
|
84
|
+
</span>
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
{/* Navigation */}
|
|
88
|
+
<nav className="flex-1 p-4 md:p-2 lg:p-4 space-y-1">
|
|
89
|
+
{navItems.map((item) => (
|
|
90
|
+
<NavLink
|
|
91
|
+
key={item.to}
|
|
92
|
+
to={item.to}
|
|
93
|
+
onClick={handleNavClick}
|
|
94
|
+
className={({ isActive }) =>
|
|
95
|
+
clsx(
|
|
96
|
+
'flex items-center gap-3 px-4 md:px-3 lg:px-4 py-3 rounded-xl transition-all duration-200',
|
|
97
|
+
'md:justify-center lg:justify-start',
|
|
98
|
+
isActive
|
|
99
|
+
? 'bg-primary-500/10 text-primary-400 shadow-lg shadow-primary-500/5'
|
|
100
|
+
: 'text-dark-400 hover:text-dark-100 hover:bg-dark-700'
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
title={item.label}
|
|
104
|
+
>
|
|
105
|
+
<item.icon className="w-5 h-5 shrink-0" />
|
|
106
|
+
<span className="font-medium md:hidden lg:block">
|
|
107
|
+
{item.label}
|
|
108
|
+
</span>
|
|
109
|
+
</NavLink>
|
|
110
|
+
))}
|
|
111
|
+
</nav>
|
|
112
|
+
|
|
113
|
+
{/* Footer */}
|
|
114
|
+
<div className="p-4 md:p-2 lg:p-4 border-t border-dark-700">
|
|
115
|
+
<div className="glass-light rounded-xl p-4 md:p-2 lg:p-4">
|
|
116
|
+
<p className="text-sm text-dark-400 md:hidden lg:block">Version 0.1.0</p>
|
|
117
|
+
<p className="text-xs text-dark-500 mt-1 md:hidden lg:block">Claude Skills Manager</p>
|
|
118
|
+
<p className="text-xs text-dark-500 md:block lg:hidden hidden">v0.1</p>
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
</aside>
|
|
122
|
+
|
|
123
|
+
{/* Main Content */}
|
|
124
|
+
<main className="flex-1 flex flex-col overflow-hidden md:ml-0">
|
|
125
|
+
<div className="flex-1 overflow-auto p-4 pt-16 md:pt-8 md:p-6 lg:p-8">
|
|
126
|
+
<Outlet />
|
|
127
|
+
</div>
|
|
128
|
+
</main>
|
|
129
|
+
|
|
130
|
+
{/* Toast */}
|
|
131
|
+
<Toast />
|
|
132
|
+
</div>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
import { X, Loader2, Check, FolderOpen } from 'lucide-react';
|
|
3
|
+
import { clsx } from 'clsx';
|
|
4
|
+
import { useAppStore } from '../stores/appStore';
|
|
5
|
+
import { skillsApi } from '../lib/api';
|
|
6
|
+
import Tooltip from './Tooltip';
|
|
7
|
+
import type { Skill } from '@skillverse/shared';
|
|
8
|
+
|
|
9
|
+
interface LinkWorkspaceDialogProps {
|
|
10
|
+
isOpen: boolean;
|
|
11
|
+
skill: Skill | null;
|
|
12
|
+
onClose: () => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export default function LinkWorkspaceDialog({
|
|
16
|
+
isOpen,
|
|
17
|
+
skill,
|
|
18
|
+
onClose,
|
|
19
|
+
}: LinkWorkspaceDialogProps) {
|
|
20
|
+
const { workspaces, fetchWorkspaces, showToast, fetchSkills } = useAppStore();
|
|
21
|
+
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
22
|
+
const [loading, setLoading] = useState(false);
|
|
23
|
+
|
|
24
|
+
// Get linked workspace IDs
|
|
25
|
+
const linkedWorkspaceIds = new Set(
|
|
26
|
+
(skill as any)?.linkedWorkspaces?.map((lw: any) => lw.workspaceId) || []
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (isOpen && workspaces.length === 0) {
|
|
31
|
+
fetchWorkspaces();
|
|
32
|
+
}
|
|
33
|
+
}, [isOpen, workspaces.length, fetchWorkspaces]);
|
|
34
|
+
|
|
35
|
+
const handleLink = async () => {
|
|
36
|
+
if (!skill || !selectedId) return;
|
|
37
|
+
|
|
38
|
+
setLoading(true);
|
|
39
|
+
try {
|
|
40
|
+
await skillsApi.linkToWorkspace(skill.id, selectedId);
|
|
41
|
+
showToast(`Skill linked to workspace successfully!`, 'success');
|
|
42
|
+
fetchSkills(); // Refresh to update linked count
|
|
43
|
+
onClose();
|
|
44
|
+
} catch (err: any) {
|
|
45
|
+
showToast(err.message || 'Failed to link skill', 'error');
|
|
46
|
+
} finally {
|
|
47
|
+
setLoading(false);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const handleUnlink = async (workspaceId: string) => {
|
|
52
|
+
if (!skill) return;
|
|
53
|
+
|
|
54
|
+
setLoading(true);
|
|
55
|
+
try {
|
|
56
|
+
await skillsApi.unlinkFromWorkspace(skill.id, workspaceId);
|
|
57
|
+
showToast('Skill unlinked from workspace', 'success');
|
|
58
|
+
fetchSkills();
|
|
59
|
+
} catch (err: any) {
|
|
60
|
+
showToast(err.message || 'Failed to unlink skill', 'error');
|
|
61
|
+
} finally {
|
|
62
|
+
setLoading(false);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
if (!isOpen || !skill) return null;
|
|
67
|
+
|
|
68
|
+
const availableWorkspaces = workspaces.filter((w) => !linkedWorkspaceIds.has(w.id));
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center p-2 sm:p-4">
|
|
72
|
+
<div
|
|
73
|
+
className="absolute inset-0 bg-dark-950/80 backdrop-blur-sm"
|
|
74
|
+
onClick={onClose}
|
|
75
|
+
/>
|
|
76
|
+
|
|
77
|
+
<div className="relative w-full max-w-md bg-dark-800 border border-dark-700 rounded-2xl shadow-2xl animate-scale-in max-h-[90vh] overflow-y-auto">
|
|
78
|
+
<div className="flex items-center justify-between px-4 sm:px-6 py-4 border-b border-dark-700 sticky top-0 bg-dark-800 z-10">
|
|
79
|
+
<div className="min-w-0 flex-1 pr-4">
|
|
80
|
+
<h2 className="text-lg font-semibold text-dark-100">Link to Workspace</h2>
|
|
81
|
+
<Tooltip content={skill.name} position="bottom">
|
|
82
|
+
<p className="text-sm text-dark-400 mt-0.5 truncate">{skill.name}</p>
|
|
83
|
+
</Tooltip>
|
|
84
|
+
</div>
|
|
85
|
+
<button onClick={onClose} className="btn-icon shrink-0">
|
|
86
|
+
<X className="w-5 h-5" />
|
|
87
|
+
</button>
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
<div className="p-4 sm:p-6 space-y-4">
|
|
91
|
+
{/* Already Linked */}
|
|
92
|
+
{linkedWorkspaceIds.size > 0 && (
|
|
93
|
+
<div>
|
|
94
|
+
<h3 className="text-sm font-medium text-dark-300 mb-2">Linked Workspaces</h3>
|
|
95
|
+
<div className="space-y-2">
|
|
96
|
+
{workspaces
|
|
97
|
+
.filter((w) => linkedWorkspaceIds.has(w.id))
|
|
98
|
+
.map((workspace) => (
|
|
99
|
+
<div
|
|
100
|
+
key={workspace.id}
|
|
101
|
+
className="flex items-center justify-between p-3 rounded-lg bg-primary-500/10 border border-primary-500/30 gap-2"
|
|
102
|
+
>
|
|
103
|
+
<div className="flex items-center gap-3 min-w-0 flex-1">
|
|
104
|
+
<FolderOpen className="w-4 h-4 text-primary-400 shrink-0" />
|
|
105
|
+
<Tooltip content={workspace.name} position="top">
|
|
106
|
+
<span className="text-sm text-dark-100 truncate">{workspace.name}</span>
|
|
107
|
+
</Tooltip>
|
|
108
|
+
</div>
|
|
109
|
+
<button
|
|
110
|
+
onClick={() => handleUnlink(workspace.id)}
|
|
111
|
+
className="text-xs text-dark-400 hover:text-red-400 transition-colors shrink-0"
|
|
112
|
+
disabled={loading}
|
|
113
|
+
>
|
|
114
|
+
Unlink
|
|
115
|
+
</button>
|
|
116
|
+
</div>
|
|
117
|
+
))}
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
)}
|
|
121
|
+
|
|
122
|
+
{/* Available Workspaces */}
|
|
123
|
+
{availableWorkspaces.length > 0 ? (
|
|
124
|
+
<div>
|
|
125
|
+
<h3 className="text-sm font-medium text-dark-300 mb-2">
|
|
126
|
+
Available Workspaces
|
|
127
|
+
</h3>
|
|
128
|
+
<div className="space-y-2 max-h-60 overflow-auto">
|
|
129
|
+
{availableWorkspaces.map((workspace) => (
|
|
130
|
+
<button
|
|
131
|
+
key={workspace.id}
|
|
132
|
+
onClick={() => setSelectedId(workspace.id)}
|
|
133
|
+
className={clsx(
|
|
134
|
+
'w-full flex items-center justify-between p-3 rounded-lg border transition-all gap-2',
|
|
135
|
+
selectedId === workspace.id
|
|
136
|
+
? 'bg-primary-500/10 border-primary-500/50 text-dark-100'
|
|
137
|
+
: 'bg-dark-750 border-dark-600 text-dark-300 hover:border-dark-500'
|
|
138
|
+
)}
|
|
139
|
+
disabled={loading}
|
|
140
|
+
>
|
|
141
|
+
<div className="flex items-center gap-3 min-w-0 flex-1">
|
|
142
|
+
<FolderOpen className="w-4 h-4 shrink-0" />
|
|
143
|
+
<div className="text-left min-w-0 flex-1">
|
|
144
|
+
<p className="text-sm font-medium truncate">{workspace.name}</p>
|
|
145
|
+
<p className="text-xs text-dark-500">{workspace.type}</p>
|
|
146
|
+
</div>
|
|
147
|
+
</div>
|
|
148
|
+
{selectedId === workspace.id && (
|
|
149
|
+
<Check className="w-4 h-4 text-primary-400 shrink-0" />
|
|
150
|
+
)}
|
|
151
|
+
</button>
|
|
152
|
+
))}
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
) : linkedWorkspaceIds.size === 0 ? (
|
|
156
|
+
<div className="text-center py-6 sm:py-8">
|
|
157
|
+
<FolderOpen className="w-10 h-10 sm:w-12 sm:h-12 mx-auto text-dark-600 mb-3" />
|
|
158
|
+
<p className="text-dark-400 text-sm sm:text-base">No workspaces available</p>
|
|
159
|
+
<p className="text-xs sm:text-sm text-dark-500 mt-1">
|
|
160
|
+
Add a workspace first to link skills
|
|
161
|
+
</p>
|
|
162
|
+
</div>
|
|
163
|
+
) : null}
|
|
164
|
+
|
|
165
|
+
{/* Actions */}
|
|
166
|
+
<div className="flex gap-3 pt-2">
|
|
167
|
+
<button
|
|
168
|
+
type="button"
|
|
169
|
+
onClick={onClose}
|
|
170
|
+
className="btn-secondary flex-1"
|
|
171
|
+
disabled={loading}
|
|
172
|
+
>
|
|
173
|
+
Close
|
|
174
|
+
</button>
|
|
175
|
+
{availableWorkspaces.length > 0 && (
|
|
176
|
+
<button
|
|
177
|
+
onClick={handleLink}
|
|
178
|
+
className="btn-primary flex-1"
|
|
179
|
+
disabled={loading || !selectedId}
|
|
180
|
+
>
|
|
181
|
+
{loading ? (
|
|
182
|
+
<>
|
|
183
|
+
<Loader2 className="w-4 h-4 animate-spin" />
|
|
184
|
+
Linking...
|
|
185
|
+
</>
|
|
186
|
+
) : (
|
|
187
|
+
'Link Skill'
|
|
188
|
+
)}
|
|
189
|
+
</button>
|
|
190
|
+
)}
|
|
191
|
+
</div>
|
|
192
|
+
</div>
|
|
193
|
+
</div>
|
|
194
|
+
</div>
|
|
195
|
+
);
|
|
196
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Loader2 } from 'lucide-react';
|
|
2
|
+
import { clsx } from 'clsx';
|
|
3
|
+
|
|
4
|
+
interface LoadingSpinnerProps {
|
|
5
|
+
size?: 'sm' | 'md' | 'lg';
|
|
6
|
+
className?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export default function LoadingSpinner({ size = 'md', className }: LoadingSpinnerProps) {
|
|
10
|
+
const sizes = {
|
|
11
|
+
sm: 'w-4 h-4',
|
|
12
|
+
md: 'w-6 h-6',
|
|
13
|
+
lg: 'w-10 h-10',
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<Loader2
|
|
18
|
+
className={clsx('animate-spin text-primary-400', sizes[size], className)}
|
|
19
|
+
/>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function LoadingPage() {
|
|
24
|
+
return (
|
|
25
|
+
<div className="flex-1 flex items-center justify-center">
|
|
26
|
+
<div className="text-center">
|
|
27
|
+
<LoadingSpinner size="lg" />
|
|
28
|
+
<p className="mt-4 text-dark-400">Loading...</p>
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function EmptyState({
|
|
35
|
+
icon: Icon,
|
|
36
|
+
title,
|
|
37
|
+
description,
|
|
38
|
+
action,
|
|
39
|
+
}: {
|
|
40
|
+
icon: React.ComponentType<{ className?: string }>;
|
|
41
|
+
title: string;
|
|
42
|
+
description: string;
|
|
43
|
+
action?: React.ReactNode;
|
|
44
|
+
}) {
|
|
45
|
+
return (
|
|
46
|
+
<div className="flex-1 flex items-center justify-center">
|
|
47
|
+
<div className="text-center max-w-md">
|
|
48
|
+
<div className="w-16 h-16 mx-auto rounded-2xl bg-dark-800 border border-dark-700 flex items-center justify-center mb-4">
|
|
49
|
+
<Icon className="w-8 h-8 text-dark-400" />
|
|
50
|
+
</div>
|
|
51
|
+
<h3 className="text-lg font-semibold text-dark-100 mb-2">{title}</h3>
|
|
52
|
+
<p className="text-dark-400 mb-6">{description}</p>
|
|
53
|
+
{action}
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
);
|
|
57
|
+
}
|