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,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
+ }