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.
Files changed (72) hide show
  1. package/.prettierrc +10 -0
  2. package/README.md +369 -0
  3. package/client/README.md +73 -0
  4. package/client/eslint.config.js +23 -0
  5. package/client/index.html +13 -0
  6. package/client/package.json +41 -0
  7. package/client/postcss.config.js +6 -0
  8. package/client/public/vite.svg +1 -0
  9. package/client/src/App.css +42 -0
  10. package/client/src/App.tsx +26 -0
  11. package/client/src/assets/react.svg +1 -0
  12. package/client/src/components/AddSkillDialog.tsx +249 -0
  13. package/client/src/components/Layout.tsx +134 -0
  14. package/client/src/components/LinkWorkspaceDialog.tsx +196 -0
  15. package/client/src/components/LoadingSpinner.tsx +57 -0
  16. package/client/src/components/SkillCard.tsx +269 -0
  17. package/client/src/components/Toast.tsx +44 -0
  18. package/client/src/components/Tooltip.tsx +132 -0
  19. package/client/src/index.css +168 -0
  20. package/client/src/lib/api.ts +196 -0
  21. package/client/src/main.tsx +10 -0
  22. package/client/src/pages/Dashboard.tsx +209 -0
  23. package/client/src/pages/Marketplace.tsx +282 -0
  24. package/client/src/pages/Settings.tsx +136 -0
  25. package/client/src/pages/SkillLibrary.tsx +163 -0
  26. package/client/src/pages/Workspaces.tsx +662 -0
  27. package/client/src/stores/appStore.ts +222 -0
  28. package/client/tailwind.config.js +82 -0
  29. package/client/tsconfig.app.json +28 -0
  30. package/client/tsconfig.json +7 -0
  31. package/client/tsconfig.node.json +26 -0
  32. package/client/vite.config.ts +26 -0
  33. package/package.json +34 -0
  34. package/registry/.env.example +5 -0
  35. package/registry/Dockerfile +42 -0
  36. package/registry/docker-compose.yml +33 -0
  37. package/registry/package.json +37 -0
  38. package/registry/prisma/schema.prisma +59 -0
  39. package/registry/src/index.ts +34 -0
  40. package/registry/src/lib/db.ts +3 -0
  41. package/registry/src/middleware/errorHandler.ts +35 -0
  42. package/registry/src/routes/auth.ts +152 -0
  43. package/registry/src/routes/skills.ts +295 -0
  44. package/registry/tsconfig.json +23 -0
  45. package/server/.env.example +11 -0
  46. package/server/package.json +60 -0
  47. package/server/prisma/schema.prisma +73 -0
  48. package/server/public/assets/index-BsYtpZSa.css +1 -0
  49. package/server/public/assets/index-Dfr_6UV8.js +20 -0
  50. package/server/public/index.html +14 -0
  51. package/server/public/vite.svg +1 -0
  52. package/server/src/bin.ts +428 -0
  53. package/server/src/config.ts +39 -0
  54. package/server/src/index.ts +112 -0
  55. package/server/src/lib/db.ts +14 -0
  56. package/server/src/middleware/errorHandler.ts +40 -0
  57. package/server/src/middleware/logger.ts +12 -0
  58. package/server/src/routes/dashboard.ts +102 -0
  59. package/server/src/routes/marketplace.ts +273 -0
  60. package/server/src/routes/skills.ts +294 -0
  61. package/server/src/routes/workspaces.ts +168 -0
  62. package/server/src/services/bundleService.ts +123 -0
  63. package/server/src/services/skillService.ts +722 -0
  64. package/server/src/services/workspaceService.ts +521 -0
  65. package/server/src/verify-sync.ts +91 -0
  66. package/server/tsconfig.json +19 -0
  67. package/server/tsup.config.ts +18 -0
  68. package/shared/package.json +21 -0
  69. package/shared/pnpm-lock.yaml +24 -0
  70. package/shared/src/index.ts +169 -0
  71. package/shared/tsconfig.json +10 -0
  72. 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
+ }