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,282 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { useAppStore } from '../stores/appStore';
|
|
3
|
+
import {
|
|
4
|
+
Store,
|
|
5
|
+
Search,
|
|
6
|
+
Download,
|
|
7
|
+
GitBranch,
|
|
8
|
+
Upload,
|
|
9
|
+
Loader2,
|
|
10
|
+
Share2,
|
|
11
|
+
} from 'lucide-react';
|
|
12
|
+
import { LoadingPage, EmptyState } from '../components/LoadingSpinner';
|
|
13
|
+
import { marketplaceApi } from '../lib/api';
|
|
14
|
+
import { clsx } from 'clsx';
|
|
15
|
+
import Tooltip from '../components/Tooltip';
|
|
16
|
+
import type { Skill, MarketplaceSkill } from '@skillverse/shared';
|
|
17
|
+
|
|
18
|
+
export default function Marketplace() {
|
|
19
|
+
const {
|
|
20
|
+
marketplaceSkills,
|
|
21
|
+
marketplaceLoading,
|
|
22
|
+
fetchMarketplace,
|
|
23
|
+
skills,
|
|
24
|
+
fetchSkills,
|
|
25
|
+
showToast,
|
|
26
|
+
addSkill,
|
|
27
|
+
} = useAppStore();
|
|
28
|
+
const [search, setSearch] = useState('');
|
|
29
|
+
const [tab, setTab] = useState<'browse' | 'publish'>('browse');
|
|
30
|
+
const [installing, setInstalling] = useState<string | null>(null);
|
|
31
|
+
const [publishing, setPublishing] = useState<string | null>(null);
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
fetchMarketplace(1, search || undefined);
|
|
35
|
+
}, [fetchMarketplace, search]);
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (tab === 'publish' && skills.length === 0) {
|
|
39
|
+
fetchSkills();
|
|
40
|
+
}
|
|
41
|
+
}, [tab, skills.length, fetchSkills]);
|
|
42
|
+
|
|
43
|
+
const handleInstall = async (item: MarketplaceSkill) => {
|
|
44
|
+
setInstalling(item.id);
|
|
45
|
+
try {
|
|
46
|
+
const skill = await marketplaceApi.install(item.id);
|
|
47
|
+
addSkill(skill);
|
|
48
|
+
showToast(`Installed "${item.skill.name}" successfully!`, 'success');
|
|
49
|
+
} catch (err: any) {
|
|
50
|
+
showToast(err.message || 'Failed to install skill', 'error');
|
|
51
|
+
} finally {
|
|
52
|
+
setInstalling(null);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const handlePublish = async (skill: Skill) => {
|
|
57
|
+
setPublishing(skill.id);
|
|
58
|
+
try {
|
|
59
|
+
await marketplaceApi.publish({ skillId: skill.id });
|
|
60
|
+
showToast(`Published "${skill.name}" to marketplace!`, 'success');
|
|
61
|
+
fetchMarketplace();
|
|
62
|
+
} catch (err: any) {
|
|
63
|
+
showToast(err.message || 'Failed to publish skill', 'error');
|
|
64
|
+
} finally {
|
|
65
|
+
setPublishing(null);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const publishedSkillIds = new Set(marketplaceSkills.map((m) => m.skillId));
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div className="space-y-6 animate-fade-in">
|
|
73
|
+
{/* Header */}
|
|
74
|
+
<div>
|
|
75
|
+
<h1 className="text-2xl sm:text-3xl font-bold text-dark-100">Marketplace</h1>
|
|
76
|
+
<p className="text-dark-400 mt-1 text-sm sm:text-base">
|
|
77
|
+
Browse and share skills with your team
|
|
78
|
+
</p>
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
{/* Tabs */}
|
|
82
|
+
<div className="flex border-b border-dark-700 overflow-x-auto">
|
|
83
|
+
<button
|
|
84
|
+
onClick={() => setTab('browse')}
|
|
85
|
+
className={clsx(
|
|
86
|
+
'flex items-center gap-2 px-4 sm:px-6 py-3 text-sm font-medium transition-colors whitespace-nowrap',
|
|
87
|
+
tab === 'browse'
|
|
88
|
+
? 'text-primary-400 border-b-2 border-primary-400'
|
|
89
|
+
: 'text-dark-400 hover:text-dark-200'
|
|
90
|
+
)}
|
|
91
|
+
>
|
|
92
|
+
<Store className="w-4 h-4" />
|
|
93
|
+
Browse
|
|
94
|
+
</button>
|
|
95
|
+
<button
|
|
96
|
+
onClick={() => setTab('publish')}
|
|
97
|
+
className={clsx(
|
|
98
|
+
'flex items-center gap-2 px-4 sm:px-6 py-3 text-sm font-medium transition-colors whitespace-nowrap',
|
|
99
|
+
tab === 'publish'
|
|
100
|
+
? 'text-accent-400 border-b-2 border-accent-400'
|
|
101
|
+
: 'text-dark-400 hover:text-dark-200'
|
|
102
|
+
)}
|
|
103
|
+
>
|
|
104
|
+
<Share2 className="w-4 h-4" />
|
|
105
|
+
Publish
|
|
106
|
+
</button>
|
|
107
|
+
</div>
|
|
108
|
+
|
|
109
|
+
{tab === 'browse' ? (
|
|
110
|
+
<>
|
|
111
|
+
{/* Search */}
|
|
112
|
+
<div className="relative max-w-md">
|
|
113
|
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-dark-400" />
|
|
114
|
+
<input
|
|
115
|
+
type="text"
|
|
116
|
+
value={search}
|
|
117
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
118
|
+
placeholder="Search marketplace..."
|
|
119
|
+
className="input pl-10"
|
|
120
|
+
/>
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
{/* Marketplace Grid - Responsive */}
|
|
124
|
+
{marketplaceLoading && marketplaceSkills.length === 0 ? (
|
|
125
|
+
<LoadingPage />
|
|
126
|
+
) : marketplaceSkills.length > 0 ? (
|
|
127
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4 sm:gap-6">
|
|
128
|
+
{marketplaceSkills.map((item) => (
|
|
129
|
+
<div key={item.id} className="card group">
|
|
130
|
+
<div className="flex items-start justify-between gap-2">
|
|
131
|
+
<div className="flex items-center gap-3 min-w-0 flex-1">
|
|
132
|
+
<div
|
|
133
|
+
className={clsx(
|
|
134
|
+
'w-10 h-10 rounded-xl flex items-center justify-center shrink-0',
|
|
135
|
+
item.skill.source === 'git'
|
|
136
|
+
? 'bg-primary-500/10 text-primary-400'
|
|
137
|
+
: 'bg-accent-500/10 text-accent-400'
|
|
138
|
+
)}
|
|
139
|
+
>
|
|
140
|
+
{item.skill.source === 'git' ? (
|
|
141
|
+
<GitBranch className="w-5 h-5" />
|
|
142
|
+
) : (
|
|
143
|
+
<Upload className="w-5 h-5" />
|
|
144
|
+
)}
|
|
145
|
+
</div>
|
|
146
|
+
<div className="min-w-0 flex-1">
|
|
147
|
+
<Tooltip content={item.skill.name} position="top">
|
|
148
|
+
<h3 className="font-semibold text-dark-100 truncate max-w-[180px] sm:max-w-[200px]">
|
|
149
|
+
{item.skill.name}
|
|
150
|
+
</h3>
|
|
151
|
+
</Tooltip>
|
|
152
|
+
<p className="text-xs text-dark-500 truncate">
|
|
153
|
+
by {item.publisherName || 'Anonymous'}
|
|
154
|
+
</p>
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
|
|
159
|
+
{item.skill.description && (
|
|
160
|
+
<Tooltip content={item.skill.description} position="bottom">
|
|
161
|
+
<p className="mt-3 text-sm text-dark-400 line-clamp-2 text-justified">
|
|
162
|
+
{item.skill.description}
|
|
163
|
+
</p>
|
|
164
|
+
</Tooltip>
|
|
165
|
+
)}
|
|
166
|
+
|
|
167
|
+
<div className="mt-4 pt-4 border-t border-dark-700 flex items-center justify-between gap-2">
|
|
168
|
+
<div className="flex items-center gap-2 text-xs text-dark-500">
|
|
169
|
+
<Download className="w-3.5 h-3.5" />
|
|
170
|
+
<span>{item.downloads} downloads</span>
|
|
171
|
+
</div>
|
|
172
|
+
<button
|
|
173
|
+
onClick={() => handleInstall(item)}
|
|
174
|
+
disabled={installing === item.id || item.skill.source !== 'git'}
|
|
175
|
+
className="btn-primary text-xs py-1.5 px-3 shrink-0"
|
|
176
|
+
>
|
|
177
|
+
{installing === item.id ? (
|
|
178
|
+
<Loader2 className="w-3 h-3 animate-spin" />
|
|
179
|
+
) : (
|
|
180
|
+
<>
|
|
181
|
+
<Download className="w-3 h-3" />
|
|
182
|
+
Install
|
|
183
|
+
</>
|
|
184
|
+
)}
|
|
185
|
+
</button>
|
|
186
|
+
</div>
|
|
187
|
+
</div>
|
|
188
|
+
))}
|
|
189
|
+
</div>
|
|
190
|
+
) : (
|
|
191
|
+
<EmptyState
|
|
192
|
+
icon={Store}
|
|
193
|
+
title="Marketplace is empty"
|
|
194
|
+
description="No skills have been published yet. Be the first to share!"
|
|
195
|
+
/>
|
|
196
|
+
)}
|
|
197
|
+
</>
|
|
198
|
+
) : (
|
|
199
|
+
/* Publish Tab */
|
|
200
|
+
<div>
|
|
201
|
+
<p className="text-dark-400 mb-6 text-sm sm:text-base text-justified">
|
|
202
|
+
Share your skills with other team members by publishing them to the
|
|
203
|
+
marketplace.
|
|
204
|
+
</p>
|
|
205
|
+
|
|
206
|
+
{skills.length > 0 ? (
|
|
207
|
+
<div className="space-y-3 sm:space-y-4">
|
|
208
|
+
{skills.map((skill) => {
|
|
209
|
+
const isPublished = publishedSkillIds.has(skill.id);
|
|
210
|
+
return (
|
|
211
|
+
<div
|
|
212
|
+
key={skill.id}
|
|
213
|
+
className="card flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4"
|
|
214
|
+
>
|
|
215
|
+
<div className="flex items-center gap-3 sm:gap-4 min-w-0 flex-1">
|
|
216
|
+
<div
|
|
217
|
+
className={clsx(
|
|
218
|
+
'w-10 h-10 rounded-xl flex items-center justify-center shrink-0',
|
|
219
|
+
skill.source === 'git'
|
|
220
|
+
? 'bg-primary-500/10 text-primary-400'
|
|
221
|
+
: 'bg-accent-500/10 text-accent-400'
|
|
222
|
+
)}
|
|
223
|
+
>
|
|
224
|
+
{skill.source === 'git' ? (
|
|
225
|
+
<GitBranch className="w-5 h-5" />
|
|
226
|
+
) : (
|
|
227
|
+
<Upload className="w-5 h-5" />
|
|
228
|
+
)}
|
|
229
|
+
</div>
|
|
230
|
+
<div className="min-w-0 flex-1">
|
|
231
|
+
<Tooltip content={skill.name} position="top">
|
|
232
|
+
<h3 className="font-medium text-dark-100 truncate">{skill.name}</h3>
|
|
233
|
+
</Tooltip>
|
|
234
|
+
<p className="text-xs sm:text-sm text-dark-500">
|
|
235
|
+
{skill.source === 'git' ? 'Git Repository' : 'Local Upload'}
|
|
236
|
+
</p>
|
|
237
|
+
</div>
|
|
238
|
+
</div>
|
|
239
|
+
|
|
240
|
+
<div className="self-end sm:self-auto shrink-0">
|
|
241
|
+
{isPublished ? (
|
|
242
|
+
<span className="text-sm text-green-400 flex items-center gap-1">
|
|
243
|
+
<Share2 className="w-4 h-4" />
|
|
244
|
+
Published
|
|
245
|
+
</span>
|
|
246
|
+
) : skill.source === 'git' ? (
|
|
247
|
+
<button
|
|
248
|
+
onClick={() => handlePublish(skill)}
|
|
249
|
+
disabled={publishing === skill.id}
|
|
250
|
+
className="btn-secondary"
|
|
251
|
+
>
|
|
252
|
+
{publishing === skill.id ? (
|
|
253
|
+
<Loader2 className="w-4 h-4 animate-spin" />
|
|
254
|
+
) : (
|
|
255
|
+
<>
|
|
256
|
+
<Share2 className="w-4 h-4" />
|
|
257
|
+
Publish
|
|
258
|
+
</>
|
|
259
|
+
)}
|
|
260
|
+
</button>
|
|
261
|
+
) : (
|
|
262
|
+
<span className="text-xs text-dark-500">
|
|
263
|
+
Only Git skills can be published
|
|
264
|
+
</span>
|
|
265
|
+
)}
|
|
266
|
+
</div>
|
|
267
|
+
</div>
|
|
268
|
+
);
|
|
269
|
+
})}
|
|
270
|
+
</div>
|
|
271
|
+
) : (
|
|
272
|
+
<EmptyState
|
|
273
|
+
icon={Store}
|
|
274
|
+
title="No skills to publish"
|
|
275
|
+
description="Add some skills first to share them with your team."
|
|
276
|
+
/>
|
|
277
|
+
)}
|
|
278
|
+
</div>
|
|
279
|
+
)}
|
|
280
|
+
</div>
|
|
281
|
+
);
|
|
282
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { FolderOpen, Database, Info, Save, Loader2 } from 'lucide-react';
|
|
3
|
+
import { useAppStore } from '../stores/appStore';
|
|
4
|
+
|
|
5
|
+
export default function Settings() {
|
|
6
|
+
const { showToast } = useAppStore();
|
|
7
|
+
const [skillversePath, setSkillversePath] = useState(
|
|
8
|
+
import.meta.env.VITE_SKILLVERSE_HOME || '~/.skillverse'
|
|
9
|
+
);
|
|
10
|
+
const [apiUrl, setApiUrl] = useState(
|
|
11
|
+
import.meta.env.VITE_API_URL || 'http://localhost:3001/api'
|
|
12
|
+
);
|
|
13
|
+
const [saving, setSaving] = useState(false);
|
|
14
|
+
|
|
15
|
+
const handleSave = async () => {
|
|
16
|
+
setSaving(true);
|
|
17
|
+
// In a real app, this would save to backend/localStorage
|
|
18
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
19
|
+
showToast('Settings saved successfully!', 'success');
|
|
20
|
+
setSaving(false);
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<div className="space-y-6 animate-fade-in max-w-2xl">
|
|
25
|
+
{/* Header */}
|
|
26
|
+
<div>
|
|
27
|
+
<h1 className="text-2xl sm:text-3xl font-bold text-dark-100">Settings</h1>
|
|
28
|
+
<p className="text-dark-400 mt-1 text-sm sm:text-base">
|
|
29
|
+
Configure your SkillVerse preferences
|
|
30
|
+
</p>
|
|
31
|
+
</div>
|
|
32
|
+
|
|
33
|
+
{/* Storage Settings */}
|
|
34
|
+
<div className="card">
|
|
35
|
+
<div className="flex items-center gap-3 mb-4 sm:mb-6">
|
|
36
|
+
<div className="w-10 h-10 rounded-xl bg-primary-500/10 flex items-center justify-center shrink-0">
|
|
37
|
+
<FolderOpen className="w-5 h-5 text-primary-400" />
|
|
38
|
+
</div>
|
|
39
|
+
<div className="min-w-0 flex-1">
|
|
40
|
+
<h2 className="font-semibold text-dark-100">Storage</h2>
|
|
41
|
+
<p className="text-xs sm:text-sm text-dark-500">Configure skill storage location</p>
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
<div className="space-y-4">
|
|
46
|
+
<div>
|
|
47
|
+
<label className="block text-sm font-medium text-dark-300 mb-2">
|
|
48
|
+
SkillVerse Home Directory
|
|
49
|
+
</label>
|
|
50
|
+
<input
|
|
51
|
+
type="text"
|
|
52
|
+
value={skillversePath}
|
|
53
|
+
onChange={(e) => setSkillversePath(e.target.value)}
|
|
54
|
+
className="input"
|
|
55
|
+
/>
|
|
56
|
+
<p className="mt-1 text-xs text-dark-500">
|
|
57
|
+
Where skills and configuration are stored
|
|
58
|
+
</p>
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
{/* API Settings */}
|
|
64
|
+
<div className="card">
|
|
65
|
+
<div className="flex items-center gap-3 mb-4 sm:mb-6">
|
|
66
|
+
<div className="w-10 h-10 rounded-xl bg-accent-500/10 flex items-center justify-center shrink-0">
|
|
67
|
+
<Database className="w-5 h-5 text-accent-400" />
|
|
68
|
+
</div>
|
|
69
|
+
<div className="min-w-0 flex-1">
|
|
70
|
+
<h2 className="font-semibold text-dark-100">API</h2>
|
|
71
|
+
<p className="text-xs sm:text-sm text-dark-500">Backend connection settings</p>
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
<div className="space-y-4">
|
|
76
|
+
<div>
|
|
77
|
+
<label className="block text-sm font-medium text-dark-300 mb-2">
|
|
78
|
+
API URL
|
|
79
|
+
</label>
|
|
80
|
+
<input
|
|
81
|
+
type="text"
|
|
82
|
+
value={apiUrl}
|
|
83
|
+
onChange={(e) => setApiUrl(e.target.value)}
|
|
84
|
+
className="input"
|
|
85
|
+
/>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
{/* About */}
|
|
91
|
+
<div className="card">
|
|
92
|
+
<div className="flex items-center gap-3 mb-4 sm:mb-6">
|
|
93
|
+
<div className="w-10 h-10 rounded-xl bg-dark-700 flex items-center justify-center shrink-0">
|
|
94
|
+
<Info className="w-5 h-5 text-dark-400" />
|
|
95
|
+
</div>
|
|
96
|
+
<div className="min-w-0 flex-1">
|
|
97
|
+
<h2 className="font-semibold text-dark-100">About</h2>
|
|
98
|
+
<p className="text-xs sm:text-sm text-dark-500">Application information</p>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
<div className="space-y-3 text-sm">
|
|
103
|
+
<div className="flex justify-between items-center gap-4">
|
|
104
|
+
<span className="text-dark-400">Version</span>
|
|
105
|
+
<span className="text-dark-100">0.1.0</span>
|
|
106
|
+
</div>
|
|
107
|
+
<div className="flex justify-between items-center gap-4">
|
|
108
|
+
<span className="text-dark-400">Platform</span>
|
|
109
|
+
<span className="text-dark-100 truncate max-w-[200px]">{navigator.platform}</span>
|
|
110
|
+
</div>
|
|
111
|
+
<div className="flex justify-between items-center gap-4">
|
|
112
|
+
<span className="text-dark-400">License</span>
|
|
113
|
+
<span className="text-dark-100">MIT</span>
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
{/* Save Button */}
|
|
119
|
+
<div className="flex justify-end">
|
|
120
|
+
<button onClick={handleSave} className="btn-primary w-full sm:w-auto" disabled={saving}>
|
|
121
|
+
{saving ? (
|
|
122
|
+
<>
|
|
123
|
+
<Loader2 className="w-4 h-4 animate-spin" />
|
|
124
|
+
Saving...
|
|
125
|
+
</>
|
|
126
|
+
) : (
|
|
127
|
+
<>
|
|
128
|
+
<Save className="w-4 h-4" />
|
|
129
|
+
Save Settings
|
|
130
|
+
</>
|
|
131
|
+
)}
|
|
132
|
+
</button>
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { useAppStore } from '../stores/appStore';
|
|
3
|
+
import { Package, Plus, Search, Grid, List } from 'lucide-react';
|
|
4
|
+
import { LoadingPage, EmptyState } from '../components/LoadingSpinner';
|
|
5
|
+
import SkillCard from '../components/SkillCard';
|
|
6
|
+
import AddSkillDialog from '../components/AddSkillDialog';
|
|
7
|
+
import LinkWorkspaceDialog from '../components/LinkWorkspaceDialog';
|
|
8
|
+
import { skillsApi } from '../lib/api';
|
|
9
|
+
import { clsx } from 'clsx';
|
|
10
|
+
import type { Skill } from '@skillverse/shared';
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
export default function SkillLibrary() {
|
|
14
|
+
const { skills, skillsLoading, fetchSkills, removeSkill, showToast, checkAllUpdates } = useAppStore();
|
|
15
|
+
const [showAddDialog, setShowAddDialog] = useState(false);
|
|
16
|
+
const [linkDialogSkillId, setLinkDialogSkillId] = useState<string | null>(null);
|
|
17
|
+
const [search, setSearch] = useState('');
|
|
18
|
+
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
|
19
|
+
|
|
20
|
+
const linkDialogSkill = skills.find(s => s.id === linkDialogSkillId) || null;
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
fetchSkills().then(() => {
|
|
24
|
+
// Check for updates in background after fetching
|
|
25
|
+
checkAllUpdates();
|
|
26
|
+
});
|
|
27
|
+
}, [fetchSkills, checkAllUpdates]);
|
|
28
|
+
|
|
29
|
+
const handleDelete = async (skill: Skill) => {
|
|
30
|
+
if (!confirm(`Are you sure you want to delete "${skill.name}"?`)) return;
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
await skillsApi.delete(skill.id);
|
|
34
|
+
removeSkill(skill.id);
|
|
35
|
+
showToast(`Skill "${skill.name}" deleted`, 'success');
|
|
36
|
+
} catch (err: any) {
|
|
37
|
+
showToast(err.message || 'Failed to delete skill', 'error');
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const filteredSkills = skills.filter(
|
|
42
|
+
(skill) =>
|
|
43
|
+
skill.name.toLowerCase().includes(search.toLowerCase()) ||
|
|
44
|
+
skill.description?.toLowerCase().includes(search.toLowerCase())
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
if (skillsLoading && skills.length === 0) {
|
|
48
|
+
return <LoadingPage />;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div className="space-y-6 animate-fade-in">
|
|
53
|
+
{/* Header - Responsive layout */}
|
|
54
|
+
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
|
55
|
+
<div className="min-w-0 flex-1">
|
|
56
|
+
<h1 className="text-2xl sm:text-3xl font-bold text-dark-100 truncate">Skill Library</h1>
|
|
57
|
+
<p className="text-dark-400 mt-1 text-sm sm:text-base">
|
|
58
|
+
Manage your installed skills
|
|
59
|
+
</p>
|
|
60
|
+
</div>
|
|
61
|
+
<button
|
|
62
|
+
onClick={() => setShowAddDialog(true)}
|
|
63
|
+
className="btn-primary shrink-0 w-full sm:w-auto"
|
|
64
|
+
>
|
|
65
|
+
<Plus className="w-4 h-4" />
|
|
66
|
+
Add Skill
|
|
67
|
+
</button>
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
{/* Filters - Responsive layout */}
|
|
71
|
+
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3 sm:gap-4">
|
|
72
|
+
<div className="flex-1 relative">
|
|
73
|
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-dark-400" />
|
|
74
|
+
<input
|
|
75
|
+
type="text"
|
|
76
|
+
value={search}
|
|
77
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
78
|
+
placeholder="Search skills..."
|
|
79
|
+
className="input pl-10"
|
|
80
|
+
/>
|
|
81
|
+
</div>
|
|
82
|
+
<div className="flex items-center gap-1 bg-dark-800 border border-dark-700 rounded-lg p-1 self-end sm:self-auto">
|
|
83
|
+
<button
|
|
84
|
+
onClick={() => setViewMode('grid')}
|
|
85
|
+
className={clsx(
|
|
86
|
+
'p-2 rounded-md transition-colors',
|
|
87
|
+
viewMode === 'grid'
|
|
88
|
+
? 'bg-dark-700 text-dark-100'
|
|
89
|
+
: 'text-dark-400 hover:text-dark-200'
|
|
90
|
+
)}
|
|
91
|
+
>
|
|
92
|
+
<Grid className="w-4 h-4" />
|
|
93
|
+
</button>
|
|
94
|
+
<button
|
|
95
|
+
onClick={() => setViewMode('list')}
|
|
96
|
+
className={clsx(
|
|
97
|
+
'p-2 rounded-md transition-colors',
|
|
98
|
+
viewMode === 'list'
|
|
99
|
+
? 'bg-dark-700 text-dark-100'
|
|
100
|
+
: 'text-dark-400 hover:text-dark-200'
|
|
101
|
+
)}
|
|
102
|
+
>
|
|
103
|
+
<List className="w-4 h-4" />
|
|
104
|
+
</button>
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
|
|
108
|
+
{/* Skills Grid/List - Enhanced responsive breakpoints */}
|
|
109
|
+
{filteredSkills.length > 0 ? (
|
|
110
|
+
<div
|
|
111
|
+
className={clsx(
|
|
112
|
+
viewMode === 'grid'
|
|
113
|
+
? 'grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-4 sm:gap-6'
|
|
114
|
+
: 'space-y-3 sm:space-y-4'
|
|
115
|
+
)}
|
|
116
|
+
>
|
|
117
|
+
{filteredSkills.map((skill) => (
|
|
118
|
+
<SkillCard
|
|
119
|
+
key={skill.id}
|
|
120
|
+
skill={skill}
|
|
121
|
+
onLink={() => setLinkDialogSkillId(skill.id)}
|
|
122
|
+
onDelete={() => handleDelete(skill)}
|
|
123
|
+
/>
|
|
124
|
+
))}
|
|
125
|
+
</div>
|
|
126
|
+
) : skills.length === 0 ? (
|
|
127
|
+
<EmptyState
|
|
128
|
+
icon={Package}
|
|
129
|
+
title="No skills yet"
|
|
130
|
+
description="Add your first skill from a Git repository or local file to get started."
|
|
131
|
+
action={
|
|
132
|
+
<button
|
|
133
|
+
onClick={() => setShowAddDialog(true)}
|
|
134
|
+
className="btn-primary"
|
|
135
|
+
>
|
|
136
|
+
<Plus className="w-4 h-4" />
|
|
137
|
+
Add Your First Skill
|
|
138
|
+
</button>
|
|
139
|
+
}
|
|
140
|
+
/>
|
|
141
|
+
) : (
|
|
142
|
+
<EmptyState
|
|
143
|
+
icon={Search}
|
|
144
|
+
title="No results found"
|
|
145
|
+
description={`No skills matching "${search}"`}
|
|
146
|
+
/>
|
|
147
|
+
)}
|
|
148
|
+
|
|
149
|
+
{/* Dialogs */}
|
|
150
|
+
<AddSkillDialog
|
|
151
|
+
isOpen={showAddDialog}
|
|
152
|
+
onClose={() => setShowAddDialog(false)}
|
|
153
|
+
/>
|
|
154
|
+
{linkDialogSkillId && (
|
|
155
|
+
<LinkWorkspaceDialog
|
|
156
|
+
isOpen={!!linkDialogSkillId}
|
|
157
|
+
skill={linkDialogSkill}
|
|
158
|
+
onClose={() => setLinkDialogSkillId(null)}
|
|
159
|
+
/>
|
|
160
|
+
)}
|
|
161
|
+
</div>
|
|
162
|
+
);
|
|
163
|
+
}
|