groove-dev 0.13.1 → 0.14.1

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.
@@ -1,7 +1,7 @@
1
- // GROOVE GUI — Skills Marketplace
1
+ // GROOVE GUI — Skills Marketplace (App Store)
2
2
  // FSL-1.1-Apache-2.0 — see LICENSE
3
3
 
4
- import React, { useState, useEffect } from 'react';
4
+ import React, { useState, useEffect, useCallback } from 'react';
5
5
 
6
6
  const CATEGORY_LABELS = {
7
7
  all: 'All Skills',
@@ -13,22 +13,361 @@ const CATEGORY_LABELS = {
13
13
  specialized: 'Specialized',
14
14
  };
15
15
 
16
+ const CATEGORY_ICONS = {
17
+ design: '\u2728',
18
+ quality: '\u2714',
19
+ devtools: '\u2699',
20
+ workflow: '\u21BB',
21
+ security: '\u26E8',
22
+ specialized: '\u2606',
23
+ };
24
+
25
+ const SORT_OPTIONS = [
26
+ { id: 'popular', label: 'Popular' },
27
+ { id: 'rating', label: 'Top Rated' },
28
+ { id: 'newest', label: 'Newest' },
29
+ { id: 'name', label: 'A\u2013Z' },
30
+ ];
31
+
32
+ // Trust tiers for verification badges
33
+ function getVerification(skill) {
34
+ if (skill.source === 'claude-official') return { label: 'Anthropic', color: '#d4a574', bg: 'rgba(212, 165, 116, 0.12)' };
35
+ if (skill.source === 'groove-official') return { label: 'Groove', color: 'var(--accent)', bg: 'rgba(51, 175, 188, 0.12)' };
36
+ if (skill.verified) return { label: 'Verified', color: 'var(--green)', bg: 'rgba(74, 225, 104, 0.10)' };
37
+ return null;
38
+ }
39
+
40
+ function VerifiedBadge({ skill, size = 'small' }) {
41
+ const v = getVerification(skill);
42
+ if (!v) return null;
43
+ const isSmall = size === 'small';
44
+ return (
45
+ <span
46
+ title={`${v.label} — Verified publisher`}
47
+ style={{
48
+ display: 'inline-flex', alignItems: 'center', gap: 3,
49
+ fontSize: isSmall ? 9 : 10, fontWeight: 600,
50
+ color: v.color, background: v.bg,
51
+ padding: isSmall ? '1px 6px' : '2px 8px',
52
+ borderRadius: 3, letterSpacing: 0.3,
53
+ flexShrink: 0, cursor: 'default',
54
+ }}
55
+ >
56
+ <span style={{ fontSize: isSmall ? 8 : 10, lineHeight: 1 }}>{'\u2713'}</span>
57
+ {v.label}
58
+ </span>
59
+ );
60
+ }
61
+
62
+ function formatDownloads(n) {
63
+ if (!n) return '0';
64
+ if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'k';
65
+ return String(n);
66
+ }
67
+
68
+ function renderStars(rating) {
69
+ if (!rating) return null;
70
+ const full = Math.floor(rating);
71
+ const half = rating - full >= 0.3;
72
+ const stars = [];
73
+ for (let i = 0; i < 5; i++) {
74
+ if (i < full) stars.push('\u2605');
75
+ else if (i === full && half) stars.push('\u2606');
76
+ else stars.push('\u2606');
77
+ }
78
+ return stars.join('');
79
+ }
80
+
81
+ function sortSkills(skills, sortBy) {
82
+ const sorted = [...skills];
83
+ switch (sortBy) {
84
+ case 'popular': return sorted.sort((a, b) => (b.downloads || 0) - (a.downloads || 0));
85
+ case 'rating': return sorted.sort((a, b) => (b.rating || 0) - (a.rating || 0));
86
+ case 'name': return sorted.sort((a, b) => a.name.localeCompare(b.name));
87
+ case 'newest': return sorted.reverse();
88
+ default: return sorted;
89
+ }
90
+ }
91
+
92
+ // ── Skill Detail Modal ──────────────────────────────────────────────
93
+ function SkillDetailModal({ skill, content, installing, onInstall, onUninstall, onClose }) {
94
+ if (!skill) return null;
95
+
96
+ return (
97
+ <div style={modal.overlay} onClick={onClose}>
98
+ <div style={modal.container} onClick={(e) => e.stopPropagation()}>
99
+ {/* Top bar with close */}
100
+ <div style={modal.topBar}>
101
+ <VerifiedBadge skill={skill} size="large" />
102
+ <button onClick={onClose} style={modal.closeBtn}>&times;</button>
103
+ </div>
104
+
105
+ {/* Header */}
106
+ <div style={modal.header}>
107
+ <div style={{
108
+ ...modal.icon,
109
+ background: skill.installed
110
+ ? 'var(--green)'
111
+ : skill.price > 0 ? 'var(--purple)' : 'var(--accent)',
112
+ }}>
113
+ {skill.icon || skill.name.charAt(0)}
114
+ </div>
115
+ <div style={modal.headerInfo}>
116
+ <div style={modal.name}>{skill.name}</div>
117
+ <div style={modal.author}>
118
+ by {skill.author}
119
+ </div>
120
+ <div style={modal.meta}>
121
+ {skill.rating > 0 && (
122
+ <span style={modal.metaItem}>
123
+ <span style={{ color: 'var(--amber)' }}>{renderStars(skill.rating)}</span>
124
+ {' '}{skill.rating} ({skill.ratingCount || 0})
125
+ </span>
126
+ )}
127
+ {skill.downloads > 0 && (
128
+ <span style={modal.metaItem}>
129
+ {'\u2913'} {formatDownloads(skill.downloads)}
130
+ </span>
131
+ )}
132
+ <span style={modal.metaItem}>
133
+ {CATEGORY_ICONS[skill.category] || ''} {CATEGORY_LABELS[skill.category] || skill.category}
134
+ </span>
135
+ </div>
136
+ </div>
137
+ </div>
138
+
139
+ {/* Action bar — install/uninstall below header, full width */}
140
+ <div style={modal.actionBar}>
141
+ {skill.installed ? (
142
+ <button
143
+ onClick={() => onUninstall(skill.id)}
144
+ disabled={installing === skill.id}
145
+ style={modal.uninstallBtn}
146
+ >
147
+ {installing === skill.id ? 'Removing...' : 'Uninstall'}
148
+ </button>
149
+ ) : (
150
+ <button
151
+ onClick={() => onInstall(skill.id)}
152
+ disabled={installing === skill.id}
153
+ style={skill.price > 0 ? modal.buyBtn : modal.installBtn}
154
+ >
155
+ {installing === skill.id
156
+ ? 'Installing...'
157
+ : skill.price > 0
158
+ ? `$${skill.price.toFixed(2)}`
159
+ : 'Install'}
160
+ </button>
161
+ )}
162
+ {skill.price === 0 && !skill.installed && <span style={modal.freeLabel}>Free</span>}
163
+ </div>
164
+
165
+ {/* Description */}
166
+ <div style={modal.section}>
167
+ <div style={modal.sectionTitle}>About</div>
168
+ <div style={modal.description}>{skill.description}</div>
169
+ </div>
170
+
171
+ {/* Tags */}
172
+ <div style={modal.section}>
173
+ <div style={modal.sectionTitle}>Tags</div>
174
+ <div style={modal.tagRow}>
175
+ {(skill.tags || []).map((t) => (
176
+ <span key={t} style={modal.tag}>{t}</span>
177
+ ))}
178
+ {(skill.roles || []).map((r) => (
179
+ <span key={r} style={modal.roleTag}>{r}</span>
180
+ ))}
181
+ </div>
182
+ </div>
183
+
184
+ {/* Author Profile */}
185
+ {skill.authorProfile && (
186
+ <div style={modal.section}>
187
+ <div style={modal.sectionTitle}>Developer</div>
188
+ <div style={modal.authorCard}>
189
+ <div style={modal.authorAvatar}>
190
+ {skill.authorProfile.avatar
191
+ ? <img src={skill.authorProfile.avatar} alt="" style={{ width: '100%', height: '100%', borderRadius: 6 }} />
192
+ : skill.author.charAt(0)
193
+ }
194
+ </div>
195
+ <div style={modal.authorDetails}>
196
+ <div style={modal.authorName}>{skill.author}</div>
197
+ <div style={modal.authorLinks}>
198
+ {skill.authorProfile.website && (
199
+ <a href={skill.authorProfile.website} target="_blank" rel="noopener noreferrer" style={modal.authorLink}>
200
+ Website
201
+ </a>
202
+ )}
203
+ {skill.authorProfile.github && (
204
+ <a href={`https://github.com/${skill.authorProfile.github}`} target="_blank" rel="noopener noreferrer" style={modal.authorLink}>
205
+ GitHub
206
+ </a>
207
+ )}
208
+ {skill.authorProfile.twitter && (
209
+ <a href={`https://x.com/${skill.authorProfile.twitter}`} target="_blank" rel="noopener noreferrer" style={modal.authorLink}>
210
+ @{skill.authorProfile.twitter}
211
+ </a>
212
+ )}
213
+ </div>
214
+ </div>
215
+ </div>
216
+ </div>
217
+ )}
218
+
219
+ {/* Content Preview */}
220
+ {content && (
221
+ <div style={modal.section}>
222
+ <div style={modal.sectionTitle}>Skill Instructions</div>
223
+ <pre style={modal.contentPre}>
224
+ {content.replace(/^---[\s\S]*?---\n/, '').trim().slice(0, 2000)}
225
+ {content.length > 2000 ? '\n...' : ''}
226
+ </pre>
227
+ </div>
228
+ )}
229
+ </div>
230
+ </div>
231
+ );
232
+ }
233
+
234
+ // ── Featured Banner ─────────────────────────────────────────────────
235
+ function FeaturedBanner({ skills, onSelect }) {
236
+ const featured = skills.filter((s) => s.featured).slice(0, 3);
237
+ if (featured.length === 0) return null;
238
+
239
+ return (
240
+ <div style={styles.featuredSection}>
241
+ <div style={styles.featuredLabel}>Featured</div>
242
+ <div style={styles.featuredRow}>
243
+ {featured.map((skill) => (
244
+ <div
245
+ key={skill.id}
246
+ onClick={() => onSelect(skill)}
247
+ style={styles.featuredCard}
248
+ onMouseEnter={(e) => { e.currentTarget.style.borderColor = 'var(--accent)'; e.currentTarget.style.transform = 'translateY(-1px)'; }}
249
+ onMouseLeave={(e) => { e.currentTarget.style.borderColor = 'var(--border)'; e.currentTarget.style.transform = 'none'; }}
250
+ >
251
+ <div style={styles.featuredGradient}>
252
+ <div style={{
253
+ ...styles.featuredIcon,
254
+ background: skill.installed ? 'var(--green)' : 'var(--accent)',
255
+ }}>
256
+ {skill.icon || skill.name.charAt(0)}
257
+ </div>
258
+ </div>
259
+ <div style={styles.featuredInfo}>
260
+ <div style={styles.featuredName}>{skill.name}</div>
261
+ <div style={styles.featuredAuthorRow}>
262
+ <span style={styles.featuredAuthor}>{skill.author}</span>
263
+ <VerifiedBadge skill={skill} />
264
+ </div>
265
+ <div style={styles.featuredDesc}>{skill.description}</div>
266
+ <div style={styles.featuredMeta}>
267
+ {skill.rating > 0 && (
268
+ <span style={{ color: 'var(--amber)', fontSize: 10 }}>
269
+ {renderStars(skill.rating)} {skill.rating}
270
+ </span>
271
+ )}
272
+ <span style={{ color: 'var(--text-muted)', fontSize: 10 }}>
273
+ {'\u2913'} {formatDownloads(skill.downloads)}
274
+ </span>
275
+ </div>
276
+ </div>
277
+ {skill.installed && (
278
+ <div style={styles.featuredBadge}>installed</div>
279
+ )}
280
+ </div>
281
+ ))}
282
+ </div>
283
+ </div>
284
+ );
285
+ }
286
+
287
+ // ── Skill Card ──────────────────────────────────────────────────────
288
+ function SkillCard({ skill, onSelect, hovered, onHover }) {
289
+ return (
290
+ <div
291
+ onClick={() => onSelect(skill)}
292
+ onMouseEnter={() => onHover(skill.id)}
293
+ onMouseLeave={() => onHover(null)}
294
+ style={{
295
+ ...styles.card,
296
+ borderColor: skill.installed
297
+ ? 'var(--green)'
298
+ : hovered ? 'var(--accent)' : 'var(--border)',
299
+ background: hovered ? 'var(--bg-hover)' : 'var(--bg-surface)',
300
+ transform: hovered ? 'translateY(-1px)' : 'none',
301
+ }}
302
+ >
303
+ {/* Top row: icon + info + badges */}
304
+ <div style={styles.cardTop}>
305
+ <div style={{
306
+ ...styles.cardIcon,
307
+ background: skill.installed
308
+ ? 'var(--green)'
309
+ : skill.price > 0 ? 'var(--purple)' : 'var(--accent)',
310
+ }}>
311
+ {skill.icon || skill.name.charAt(0)}
312
+ </div>
313
+ <div style={styles.cardInfo}>
314
+ <div style={styles.cardName}>{skill.name}</div>
315
+ <div style={styles.cardAuthorRow}>
316
+ <span style={styles.cardAuthor}>{skill.author}</span>
317
+ <VerifiedBadge skill={skill} />
318
+ </div>
319
+ </div>
320
+ {skill.installed && (
321
+ <div style={styles.installedBadge}>installed</div>
322
+ )}
323
+ {skill.price > 0 && !skill.installed && (
324
+ <div style={styles.priceBadge}>${skill.price.toFixed(2)}</div>
325
+ )}
326
+ {skill.price === 0 && !skill.installed && (
327
+ <div style={styles.freeBadge}>Free</div>
328
+ )}
329
+ </div>
330
+
331
+ {/* Description */}
332
+ <div style={styles.cardDesc}>{skill.description}</div>
333
+
334
+ {/* Bottom: rating + downloads + category */}
335
+ <div style={styles.cardBottom}>
336
+ <div style={styles.cardStats}>
337
+ {skill.rating > 0 && (
338
+ <span style={styles.cardRating}>
339
+ <span style={{ color: 'var(--amber)' }}>{'\u2605'}</span> {skill.rating}
340
+ </span>
341
+ )}
342
+ {skill.downloads > 0 && (
343
+ <span style={styles.cardDownloads}>
344
+ {'\u2913'} {formatDownloads(skill.downloads)}
345
+ </span>
346
+ )}
347
+ </div>
348
+ <span style={styles.catTag}>
349
+ {CATEGORY_ICONS[skill.category] || ''} {CATEGORY_LABELS[skill.category] || skill.category}
350
+ </span>
351
+ </div>
352
+ </div>
353
+ );
354
+ }
355
+
356
+ // ── Main Marketplace ────────────────────────────────────────────────
16
357
  export default function SkillsMarketplace() {
17
358
  const [skills, setSkills] = useState([]);
18
359
  const [categories, setCategories] = useState([]);
19
360
  const [search, setSearch] = useState('');
20
361
  const [category, setCategory] = useState('all');
362
+ const [sortBy, setSortBy] = useState('popular');
363
+ const [tab, setTab] = useState('browse'); // browse | installed
21
364
  const [loading, setLoading] = useState(true);
22
365
  const [installing, setInstalling] = useState(null);
23
- const [expandedSkill, setExpandedSkill] = useState(null);
366
+ const [selectedSkill, setSelectedSkill] = useState(null);
24
367
  const [skillContent, setSkillContent] = useState(null);
25
368
  const [hovered, setHovered] = useState(null);
26
369
 
27
- useEffect(() => {
28
- fetchSkills();
29
- }, [search, category]);
30
-
31
- async function fetchSkills() {
370
+ const fetchSkills = useCallback(async () => {
32
371
  setLoading(true);
33
372
  try {
34
373
  const params = new URLSearchParams();
@@ -40,13 +379,23 @@ export default function SkillsMarketplace() {
40
379
  setCategories(data.categories || []);
41
380
  } catch { /* ignore */ }
42
381
  setLoading(false);
43
- }
382
+ }, [search, category]);
383
+
384
+ useEffect(() => { fetchSkills(); }, [fetchSkills]);
44
385
 
45
386
  async function handleInstall(id) {
46
387
  setInstalling(id);
47
388
  try {
48
389
  await fetch(`/api/skills/${id}/install`, { method: 'POST' });
49
390
  await fetchSkills();
391
+ // Update selected skill state
392
+ setSkills((prev) => {
393
+ const updated = prev.find((s) => s.id === id);
394
+ if (updated && selectedSkill?.id === id) {
395
+ setSelectedSkill({ ...updated, installed: true });
396
+ }
397
+ return prev;
398
+ });
50
399
  } catch { /* ignore */ }
51
400
  setInstalling(null);
52
401
  }
@@ -55,20 +404,17 @@ export default function SkillsMarketplace() {
55
404
  setInstalling(id);
56
405
  try {
57
406
  await fetch(`/api/skills/${id}`, { method: 'DELETE' });
58
- setExpandedSkill(null);
59
- setSkillContent(null);
60
407
  await fetchSkills();
408
+ if (selectedSkill?.id === id) {
409
+ setSelectedSkill((prev) => prev ? { ...prev, installed: false } : null);
410
+ setSkillContent(null);
411
+ }
61
412
  } catch { /* ignore */ }
62
413
  setInstalling(null);
63
414
  }
64
415
 
65
- async function handleExpand(skill) {
66
- if (expandedSkill === skill.id) {
67
- setExpandedSkill(null);
68
- setSkillContent(null);
69
- return;
70
- }
71
- setExpandedSkill(skill.id);
416
+ async function handleSelect(skill) {
417
+ setSelectedSkill(skill);
72
418
  setSkillContent(null);
73
419
  if (skill.installed) {
74
420
  try {
@@ -79,176 +425,224 @@ export default function SkillsMarketplace() {
79
425
  }
80
426
  }
81
427
 
82
- const installedCount = skills.filter((s) => s.installed).length;
428
+ const installedSkills = skills.filter((s) => s.installed);
429
+ const displaySkills = tab === 'installed'
430
+ ? sortSkills(installedSkills, sortBy)
431
+ : sortSkills(skills, sortBy);
83
432
 
84
433
  return (
85
434
  <div style={styles.root}>
86
- {/* Header */}
87
- <div style={styles.header}>
88
- <div>
89
- <div style={styles.title}>Skills Marketplace</div>
90
- <div style={styles.subtitle}>
91
- {skills.length} skills available, {installedCount} installed
435
+ {/* Header bar */}
436
+ <div style={styles.headerBar}>
437
+ <div style={styles.headerLeft}>
438
+ <div style={styles.title}>Skills Store</div>
439
+ <div style={styles.headerTabs}>
440
+ <button
441
+ onClick={() => setTab('browse')}
442
+ style={{
443
+ ...styles.headerTab,
444
+ color: tab === 'browse' ? 'var(--text-bright)' : 'var(--text-dim)',
445
+ borderBottom: tab === 'browse' ? '2px solid var(--accent)' : '2px solid transparent',
446
+ }}
447
+ >
448
+ Browse
449
+ </button>
450
+ <button
451
+ onClick={() => setTab('installed')}
452
+ style={{
453
+ ...styles.headerTab,
454
+ color: tab === 'installed' ? 'var(--text-bright)' : 'var(--text-dim)',
455
+ borderBottom: tab === 'installed' ? '2px solid var(--green)' : '2px solid transparent',
456
+ }}
457
+ >
458
+ My Skills ({installedSkills.length})
459
+ </button>
92
460
  </div>
93
461
  </div>
462
+ <div style={styles.headerRight}>
463
+ <span style={styles.headerCount}>
464
+ {skills.length} skills
465
+ </span>
466
+ </div>
94
467
  </div>
95
468
 
96
- {/* Search + Categories */}
469
+ {/* Search + Filters */}
97
470
  <div style={styles.toolbar}>
98
- <input
99
- style={styles.search}
100
- placeholder="Search skills..."
101
- value={search}
102
- onChange={(e) => setSearch(e.target.value)}
103
- />
104
- <div style={styles.catRow}>
105
- <button
106
- onClick={() => setCategory('all')}
107
- style={{
108
- ...styles.catBtn,
109
- ...(category === 'all' ? styles.catBtnActive : {}),
110
- }}
111
- >
112
- All
113
- </button>
114
- {categories.map((cat) => (
471
+ <div style={styles.searchRow}>
472
+ <div style={styles.searchWrap}>
473
+ <span style={styles.searchIcon}>{'\u2315'}</span>
474
+ <input
475
+ style={styles.search}
476
+ placeholder="Search skills..."
477
+ value={search}
478
+ onChange={(e) => setSearch(e.target.value)}
479
+ />
480
+ </div>
481
+ <div style={styles.sortWrap}>
482
+ {SORT_OPTIONS.map((opt) => (
483
+ <button
484
+ key={opt.id}
485
+ onClick={() => setSortBy(opt.id)}
486
+ style={{
487
+ ...styles.sortBtn,
488
+ color: sortBy === opt.id ? 'var(--text-bright)' : 'var(--text-muted)',
489
+ background: sortBy === opt.id ? 'var(--bg-active)' : 'transparent',
490
+ }}
491
+ >
492
+ {opt.label}
493
+ </button>
494
+ ))}
495
+ </div>
496
+ </div>
497
+ {tab === 'browse' && (
498
+ <div style={styles.catRow}>
115
499
  <button
116
- key={cat.id}
117
- onClick={() => setCategory(cat.id)}
500
+ onClick={() => setCategory('all')}
118
501
  style={{
119
502
  ...styles.catBtn,
120
- ...(category === cat.id ? styles.catBtnActive : {}),
503
+ ...(category === 'all' ? styles.catBtnActive : {}),
121
504
  }}
122
505
  >
123
- {CATEGORY_LABELS[cat.id] || cat.id} ({cat.count})
506
+ All
124
507
  </button>
125
- ))}
126
- </div>
508
+ {categories.map((cat) => (
509
+ <button
510
+ key={cat.id}
511
+ onClick={() => setCategory(cat.id)}
512
+ style={{
513
+ ...styles.catBtn,
514
+ ...(category === cat.id ? styles.catBtnActive : {}),
515
+ }}
516
+ >
517
+ {CATEGORY_ICONS[cat.id] || ''} {CATEGORY_LABELS[cat.id] || cat.id} ({cat.count})
518
+ </button>
519
+ ))}
520
+ </div>
521
+ )}
127
522
  </div>
128
523
 
129
- {/* Skills Grid */}
130
- <div style={styles.grid}>
524
+ {/* Scrollable content */}
525
+ <div style={styles.scrollArea}>
526
+ {/* Featured banner — only on browse tab, no search, all category */}
527
+ {tab === 'browse' && !search && category === 'all' && (
528
+ <FeaturedBanner skills={skills} onSelect={handleSelect} />
529
+ )}
530
+
531
+ {/* Grid */}
131
532
  {loading && skills.length === 0 && (
132
533
  <div style={styles.empty}>Loading skills...</div>
133
534
  )}
134
535
 
135
- {!loading && skills.length === 0 && (
136
- <div style={styles.empty}>No skills match your search.</div>
536
+ {!loading && displaySkills.length === 0 && (
537
+ <div style={styles.empty}>
538
+ {tab === 'installed'
539
+ ? 'No skills installed yet. Browse the store to find skills.'
540
+ : 'No skills match your search.'}
541
+ </div>
137
542
  )}
138
543
 
139
- {skills.map((skill) => (
140
- <div key={skill.id}>
141
- <div
142
- onClick={() => handleExpand(skill)}
143
- onMouseEnter={() => setHovered(skill.id)}
144
- onMouseLeave={() => setHovered(null)}
145
- style={{
146
- ...styles.card,
147
- borderColor: skill.installed ? 'var(--green)' : (hovered === skill.id ? 'var(--accent)' : 'var(--border)'),
148
- background: hovered === skill.id ? 'var(--bg-hover)' : 'var(--bg-surface)',
149
- }}
150
- >
151
- {/* Card header */}
152
- <div style={styles.cardTop}>
153
- <div style={{
154
- ...styles.cardIcon,
155
- background: skill.installed ? 'var(--green)' : 'var(--accent)',
156
- }}>
157
- {skill.icon || skill.name.charAt(0)}
158
- </div>
159
- <div style={styles.cardInfo}>
160
- <div style={styles.cardName}>{skill.name}</div>
161
- <div style={styles.cardAuthor}>{skill.author}</div>
162
- </div>
163
- {skill.installed && (
164
- <div style={styles.installedBadge}>installed</div>
165
- )}
166
- </div>
167
-
168
- {/* Description */}
169
- <div style={styles.cardDesc}>{skill.description}</div>
170
-
171
- {/* Tags */}
172
- <div style={styles.tagRow}>
173
- <span style={styles.catTag}>{CATEGORY_LABELS[skill.category] || skill.category}</span>
174
- {skill.roles.slice(0, 3).map((r) => (
175
- <span key={r} style={styles.roleTag}>{r}</span>
176
- ))}
177
- </div>
178
- </div>
179
-
180
- {/* Expanded detail */}
181
- {expandedSkill === skill.id && (
182
- <div style={styles.detail}>
183
- {skillContent && (
184
- <div style={styles.contentPreview}>
185
- <div style={styles.contentLabel}>SKILL INSTRUCTIONS</div>
186
- <pre style={styles.contentPre}>
187
- {skillContent.replace(/^---[\s\S]*?---\n/, '').trim().slice(0, 800)}
188
- {skillContent.length > 800 ? '\n...' : ''}
189
- </pre>
190
- </div>
191
- )}
192
- <div style={styles.detailActions}>
193
- {skill.installed ? (
194
- <button
195
- onClick={(e) => { e.stopPropagation(); handleUninstall(skill.id); }}
196
- disabled={installing === skill.id}
197
- style={styles.uninstallBtn}
198
- >
199
- {installing === skill.id ? 'Removing...' : 'Uninstall'}
200
- </button>
201
- ) : (
202
- <button
203
- onClick={(e) => { e.stopPropagation(); handleInstall(skill.id); }}
204
- disabled={installing === skill.id}
205
- style={styles.installBtn}
206
- >
207
- {installing === skill.id ? 'Installing...' : 'Install'}
208
- </button>
209
- )}
210
- </div>
211
- </div>
212
- )}
213
- </div>
214
- ))}
544
+ <div style={styles.grid}>
545
+ {displaySkills.map((skill) => (
546
+ <SkillCard
547
+ key={skill.id}
548
+ skill={skill}
549
+ onSelect={handleSelect}
550
+ hovered={hovered === skill.id}
551
+ onHover={setHovered}
552
+ />
553
+ ))}
554
+ </div>
215
555
  </div>
556
+
557
+ {/* Detail Modal */}
558
+ <SkillDetailModal
559
+ skill={selectedSkill}
560
+ content={skillContent}
561
+ installing={installing}
562
+ onInstall={handleInstall}
563
+ onUninstall={handleUninstall}
564
+ onClose={() => { setSelectedSkill(null); setSkillContent(null); }}
565
+ />
216
566
  </div>
217
567
  );
218
568
  }
219
569
 
570
+ // ── Styles ──────────────────────────────────────────────────────────
571
+
220
572
  const styles = {
221
573
  root: {
222
574
  height: '100%', display: 'flex', flexDirection: 'column',
223
575
  overflow: 'hidden',
224
576
  },
225
- header: {
226
- padding: '16px 20px 0',
577
+
578
+ // Header bar
579
+ headerBar: {
580
+ padding: '12px 20px 0',
581
+ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
227
582
  flexShrink: 0,
228
583
  },
584
+ headerLeft: {
585
+ display: 'flex', alignItems: 'center', gap: 20,
586
+ },
229
587
  title: {
230
- fontSize: 14, fontWeight: 700, color: 'var(--text-bright)',
588
+ fontSize: 15, fontWeight: 700, color: 'var(--text-bright)',
589
+ letterSpacing: 0.3,
231
590
  },
232
- subtitle: {
233
- fontSize: 11, color: 'var(--text-dim)', marginTop: 2,
591
+ headerTabs: {
592
+ display: 'flex', gap: 0,
593
+ },
594
+ headerTab: {
595
+ padding: '6px 14px',
596
+ background: 'none', border: 'none',
597
+ fontSize: 12, fontWeight: 500,
598
+ fontFamily: 'var(--font)', cursor: 'pointer',
599
+ transition: 'color 0.1s',
600
+ },
601
+ headerRight: {
602
+ display: 'flex', alignItems: 'center', gap: 10,
234
603
  },
604
+ headerCount: {
605
+ fontSize: 11, color: 'var(--text-muted)',
606
+ },
607
+
608
+ // Toolbar
235
609
  toolbar: {
236
- padding: '12px 20px',
610
+ padding: '10px 20px',
237
611
  flexShrink: 0,
238
612
  },
613
+ searchRow: {
614
+ display: 'flex', gap: 10, alignItems: 'center',
615
+ },
616
+ searchWrap: {
617
+ flex: 1, position: 'relative',
618
+ },
619
+ searchIcon: {
620
+ position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)',
621
+ color: 'var(--text-muted)', fontSize: 13, pointerEvents: 'none',
622
+ },
239
623
  search: {
240
- width: '100%', padding: '8px 12px',
624
+ width: '100%', padding: '7px 12px 7px 30px',
241
625
  background: 'var(--bg-surface)', border: '1px solid var(--border)',
242
- borderRadius: 4, color: 'var(--text-primary)', fontSize: 12,
626
+ borderRadius: 6, color: 'var(--text-primary)', fontSize: 12,
243
627
  fontFamily: 'var(--font)', outline: 'none',
244
628
  },
629
+ sortWrap: {
630
+ display: 'flex', gap: 2,
631
+ },
632
+ sortBtn: {
633
+ padding: '5px 10px',
634
+ background: 'transparent', border: '1px solid transparent',
635
+ borderRadius: 4, fontSize: 10, fontWeight: 500,
636
+ fontFamily: 'var(--font)', cursor: 'pointer',
637
+ transition: 'all 0.1s',
638
+ },
245
639
  catRow: {
246
640
  display: 'flex', gap: 4, marginTop: 8, flexWrap: 'wrap',
247
641
  },
248
642
  catBtn: {
249
- padding: '3px 10px',
643
+ padding: '4px 12px',
250
644
  background: 'transparent', border: '1px solid var(--border)',
251
- borderRadius: 12, color: 'var(--text-dim)', fontSize: 10,
645
+ borderRadius: 14, color: 'var(--text-dim)', fontSize: 10,
252
646
  fontFamily: 'var(--font)', cursor: 'pointer',
253
647
  transition: 'all 0.1s',
254
648
  },
@@ -256,27 +650,90 @@ const styles = {
256
650
  borderColor: 'var(--accent)', color: 'var(--accent)',
257
651
  background: 'rgba(51, 175, 188, 0.08)',
258
652
  },
259
- grid: {
653
+
654
+ // Scroll area
655
+ scrollArea: {
260
656
  flex: 1, overflowY: 'auto', padding: '0 20px 20px',
261
- display: 'flex', flexDirection: 'column', gap: 6,
657
+ },
658
+
659
+ // Featured
660
+ featuredSection: {
661
+ marginBottom: 16,
662
+ },
663
+ featuredLabel: {
664
+ fontSize: 11, fontWeight: 700, color: 'var(--text-dim)',
665
+ textTransform: 'uppercase', letterSpacing: 1, marginBottom: 8,
666
+ },
667
+ featuredRow: {
668
+ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 8,
669
+ },
670
+ featuredCard: {
671
+ padding: '14px',
672
+ background: 'var(--bg-surface)', border: '1px solid var(--border)',
673
+ borderRadius: 8, cursor: 'pointer',
674
+ transition: 'border-color 0.15s, transform 0.15s',
675
+ position: 'relative', overflow: 'hidden',
676
+ },
677
+ featuredGradient: {
678
+ display: 'flex', alignItems: 'center', marginBottom: 10,
679
+ },
680
+ featuredIcon: {
681
+ width: 40, height: 40, borderRadius: 10,
682
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
683
+ fontSize: 16, fontWeight: 700, color: 'var(--bg-base)',
684
+ },
685
+ featuredInfo: {
686
+ flex: 1,
687
+ },
688
+ featuredName: {
689
+ fontSize: 13, fontWeight: 700, color: 'var(--text-bright)',
690
+ },
691
+ featuredAuthorRow: {
692
+ display: 'flex', alignItems: 'center', gap: 6, marginTop: 2,
693
+ },
694
+ featuredAuthor: {
695
+ fontSize: 10, color: 'var(--text-muted)',
696
+ },
697
+ featuredDesc: {
698
+ fontSize: 10, color: 'var(--text-dim)', marginTop: 6,
699
+ lineHeight: 1.45, display: '-webkit-box',
700
+ WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden',
701
+ },
702
+ featuredMeta: {
703
+ display: 'flex', gap: 10, marginTop: 8,
704
+ },
705
+ featuredBadge: {
706
+ position: 'absolute', top: 8, right: 8,
707
+ fontSize: 8, fontWeight: 600, color: 'var(--green)',
708
+ border: '1px solid var(--green)', borderRadius: 3,
709
+ padding: '1px 5px', textTransform: 'uppercase', letterSpacing: 0.5,
710
+ },
711
+
712
+ // 3-wide grid
713
+ grid: {
714
+ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 8,
262
715
  },
263
716
  empty: {
264
- padding: '40px 0', textAlign: 'center',
717
+ padding: '60px 0', textAlign: 'center',
265
718
  color: 'var(--text-dim)', fontSize: 12,
719
+ gridColumn: '1 / -1',
266
720
  },
721
+
722
+ // Skill Card
267
723
  card: {
268
- padding: '12px 14px',
724
+ padding: '14px',
269
725
  background: 'var(--bg-surface)', border: '1px solid var(--border)',
270
- borderRadius: 6, cursor: 'pointer',
271
- transition: 'border-color 0.1s, background 0.1s',
726
+ borderRadius: 8, cursor: 'pointer',
727
+ transition: 'border-color 0.12s, background 0.12s, transform 0.12s',
728
+ display: 'flex', flexDirection: 'column',
272
729
  },
273
730
  cardTop: {
274
731
  display: 'flex', alignItems: 'center', gap: 10,
275
732
  },
276
733
  cardIcon: {
277
- width: 32, height: 32, borderRadius: 6,
734
+ width: 36, height: 36, borderRadius: 8,
278
735
  display: 'flex', alignItems: 'center', justifyContent: 'center',
279
- fontSize: 13, fontWeight: 700, color: 'var(--bg-base)',
736
+ fontSize: 14, fontWeight: 700, color: 'var(--bg-base)',
280
737
  flexShrink: 0,
281
738
  },
282
739
  cardInfo: {
@@ -285,66 +742,188 @@ const styles = {
285
742
  cardName: {
286
743
  fontSize: 12, fontWeight: 600, color: 'var(--text-bright)',
287
744
  },
745
+ cardAuthorRow: {
746
+ display: 'flex', alignItems: 'center', gap: 6, marginTop: 2,
747
+ },
288
748
  cardAuthor: {
289
749
  fontSize: 10, color: 'var(--text-muted)',
290
750
  },
291
751
  installedBadge: {
292
- fontSize: 9, fontWeight: 600, color: 'var(--green)',
752
+ fontSize: 8, fontWeight: 600, color: 'var(--green)',
293
753
  border: '1px solid var(--green)', borderRadius: 3,
294
754
  padding: '1px 6px', textTransform: 'uppercase', letterSpacing: 0.5,
295
755
  flexShrink: 0,
296
756
  },
757
+ priceBadge: {
758
+ fontSize: 10, fontWeight: 700, color: 'var(--purple)',
759
+ border: '1px solid var(--purple)', borderRadius: 4,
760
+ padding: '2px 8px', flexShrink: 0,
761
+ },
762
+ freeBadge: {
763
+ fontSize: 9, fontWeight: 500, color: 'var(--text-muted)',
764
+ flexShrink: 0,
765
+ },
297
766
  cardDesc: {
298
- fontSize: 11, color: 'var(--text-dim)', marginTop: 8,
299
- lineHeight: 1.45,
767
+ fontSize: 11, color: 'var(--text-dim)', marginTop: 10,
768
+ lineHeight: 1.5, flex: 1,
300
769
  display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical',
301
770
  overflow: 'hidden',
302
771
  },
303
- tagRow: {
304
- display: 'flex', gap: 4, marginTop: 8, flexWrap: 'wrap',
772
+ cardBottom: {
773
+ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
774
+ marginTop: 10, paddingTop: 8,
775
+ borderTop: '1px solid var(--border)',
776
+ },
777
+ cardStats: {
778
+ display: 'flex', gap: 10,
779
+ },
780
+ cardRating: {
781
+ fontSize: 10, color: 'var(--text-dim)',
782
+ },
783
+ cardDownloads: {
784
+ fontSize: 10, color: 'var(--text-muted)',
305
785
  },
306
786
  catTag: {
307
- fontSize: 9, padding: '1px 6px', borderRadius: 3,
787
+ fontSize: 9, padding: '2px 8px', borderRadius: 4,
308
788
  background: 'var(--bg-active)', color: 'var(--text-dim)',
309
- fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5,
789
+ fontWeight: 500,
310
790
  },
311
- roleTag: {
312
- fontSize: 9, padding: '1px 6px', borderRadius: 3,
313
- background: 'rgba(51, 175, 188, 0.1)', color: 'var(--accent)',
791
+ };
792
+
793
+ // ── Modal styles ────────────────────────────────────────────────────
794
+ const modal = {
795
+ overlay: {
796
+ position: 'fixed', inset: 0,
797
+ background: 'rgba(0, 0, 0, 0.6)',
798
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
799
+ zIndex: 1000,
800
+ backdropFilter: 'blur(2px)',
314
801
  },
315
- detail: {
316
- margin: '0 0 4px',
317
- padding: '10px 14px',
318
- background: 'var(--bg-base)', border: '1px solid var(--border)',
319
- borderTop: 'none', borderRadius: '0 0 6px 6px',
802
+ container: {
803
+ width: '90%', maxWidth: 560, maxHeight: '85vh',
804
+ background: 'var(--bg-chrome)', border: '1px solid var(--border)',
805
+ borderRadius: 10, overflowY: 'auto',
806
+ padding: '0 24px 24px', position: 'relative',
320
807
  },
321
- contentPreview: {
322
- marginBottom: 10,
808
+ topBar: {
809
+ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
810
+ padding: '14px 0 10px',
811
+ position: 'sticky', top: 0, zIndex: 1,
812
+ background: 'var(--bg-chrome)',
323
813
  },
324
- contentLabel: {
325
- fontSize: 9, fontWeight: 600, color: 'var(--text-muted)',
326
- textTransform: 'uppercase', letterSpacing: 1, marginBottom: 6,
814
+ closeBtn: {
815
+ background: 'none', border: '1px solid var(--border)',
816
+ borderRadius: 4,
817
+ color: 'var(--text-dim)', fontSize: 16,
818
+ cursor: 'pointer', fontFamily: 'var(--font)',
819
+ lineHeight: 1, padding: '2px 8px',
820
+ transition: 'border-color 0.1s',
327
821
  },
328
- contentPre: {
329
- fontSize: 10, color: 'var(--text-dim)', lineHeight: 1.5,
330
- fontFamily: 'var(--font)', whiteSpace: 'pre-wrap', wordBreak: 'break-word',
331
- maxHeight: 200, overflowY: 'auto',
332
- padding: '8px 10px', background: 'var(--bg-surface)',
333
- border: '1px solid var(--border)', borderRadius: 4,
822
+ header: {
823
+ display: 'flex', gap: 14, alignItems: 'flex-start',
824
+ marginBottom: 14,
334
825
  },
335
- detailActions: {
336
- display: 'flex', justifyContent: 'flex-end', gap: 8,
826
+ actionBar: {
827
+ display: 'flex', alignItems: 'center', gap: 10,
828
+ marginBottom: 18, paddingBottom: 16,
829
+ borderBottom: '1px solid var(--border)',
830
+ },
831
+ icon: {
832
+ width: 52, height: 52, borderRadius: 12,
833
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
834
+ fontSize: 20, fontWeight: 700, color: 'var(--bg-base)',
835
+ flexShrink: 0,
836
+ },
837
+ headerInfo: {
838
+ flex: 1, minWidth: 0,
839
+ },
840
+ name: {
841
+ fontSize: 16, fontWeight: 700, color: 'var(--text-bright)',
842
+ },
843
+ author: {
844
+ fontSize: 11, color: 'var(--text-dim)', marginTop: 2,
845
+ },
846
+ meta: {
847
+ display: 'flex', gap: 12, marginTop: 6,
848
+ fontSize: 11, color: 'var(--text-dim)',
849
+ },
850
+ metaItem: {
851
+ display: 'flex', alignItems: 'center', gap: 3,
337
852
  },
338
853
  installBtn: {
339
- padding: '6px 20px',
340
- background: 'var(--accent)', border: '1px solid var(--accent)',
341
- borderRadius: 4, color: 'var(--bg-base)', fontSize: 11, fontWeight: 700,
854
+ padding: '8px 28px',
855
+ background: 'var(--accent)', border: 'none',
856
+ borderRadius: 6, color: 'var(--bg-base)', fontSize: 12, fontWeight: 700,
857
+ fontFamily: 'var(--font)', cursor: 'pointer',
858
+ transition: 'opacity 0.1s',
859
+ },
860
+ buyBtn: {
861
+ padding: '8px 28px',
862
+ background: 'var(--purple)', border: 'none',
863
+ borderRadius: 6, color: '#fff', fontSize: 12, fontWeight: 700,
342
864
  fontFamily: 'var(--font)', cursor: 'pointer',
865
+ transition: 'opacity 0.1s',
343
866
  },
344
867
  uninstallBtn: {
345
- padding: '6px 16px',
868
+ padding: '8px 20px',
346
869
  background: 'transparent', border: '1px solid var(--red)',
347
- borderRadius: 4, color: 'var(--red)', fontSize: 11, fontWeight: 600,
870
+ borderRadius: 6, color: 'var(--red)', fontSize: 12, fontWeight: 600,
348
871
  fontFamily: 'var(--font)', cursor: 'pointer',
349
872
  },
873
+ freeLabel: {
874
+ fontSize: 9, color: 'var(--text-muted)', fontWeight: 500,
875
+ },
876
+ section: {
877
+ marginBottom: 16,
878
+ },
879
+ sectionTitle: {
880
+ fontSize: 10, fontWeight: 600, color: 'var(--text-muted)',
881
+ textTransform: 'uppercase', letterSpacing: 0.8, marginBottom: 8,
882
+ },
883
+ description: {
884
+ fontSize: 12, color: 'var(--text-primary)', lineHeight: 1.6,
885
+ },
886
+ tagRow: {
887
+ display: 'flex', gap: 5, flexWrap: 'wrap',
888
+ },
889
+ tag: {
890
+ fontSize: 10, padding: '2px 8px', borderRadius: 4,
891
+ background: 'var(--bg-active)', color: 'var(--text-dim)',
892
+ },
893
+ roleTag: {
894
+ fontSize: 10, padding: '2px 8px', borderRadius: 4,
895
+ background: 'rgba(51, 175, 188, 0.1)', color: 'var(--accent)',
896
+ },
897
+ authorCard: {
898
+ display: 'flex', gap: 12, alignItems: 'center',
899
+ padding: '10px 12px',
900
+ background: 'var(--bg-surface)', border: '1px solid var(--border)',
901
+ borderRadius: 6,
902
+ },
903
+ authorAvatar: {
904
+ width: 36, height: 36, borderRadius: 6,
905
+ background: 'var(--accent)',
906
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
907
+ fontSize: 14, fontWeight: 700, color: 'var(--bg-base)',
908
+ flexShrink: 0,
909
+ },
910
+ authorDetails: {
911
+ flex: 1,
912
+ },
913
+ authorName: {
914
+ fontSize: 12, fontWeight: 600, color: 'var(--text-bright)',
915
+ },
916
+ authorLinks: {
917
+ display: 'flex', gap: 10, marginTop: 4,
918
+ },
919
+ authorLink: {
920
+ fontSize: 10, color: 'var(--accent)', textDecoration: 'none',
921
+ },
922
+ contentPre: {
923
+ fontSize: 10, color: 'var(--text-dim)', lineHeight: 1.6,
924
+ fontFamily: 'var(--font)', whiteSpace: 'pre-wrap', wordBreak: 'break-word',
925
+ maxHeight: 300, overflowY: 'auto',
926
+ padding: '10px 12px', background: 'var(--bg-surface)',
927
+ border: '1px solid var(--border)', borderRadius: 6,
928
+ },
350
929
  };