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