quiver-skill-manager 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +87 -0
- package/bin/quiver.js +2 -0
- package/package.json +45 -0
- package/src/cli.js +307 -0
- package/src/core/add.js +33 -0
- package/src/core/config.js +48 -0
- package/src/core/export.js +48 -0
- package/src/core/import.js +57 -0
- package/src/core/inventory.js +234 -0
- package/src/core/paths.js +17 -0
- package/src/core/registry.js +488 -0
- package/src/core/remove.js +24 -0
- package/src/core/sync/git.js +212 -0
- package/src/core/sync/index.js +1 -0
- package/src/core/sync/snapshot.js +148 -0
- package/src/routes.js +270 -0
- package/src/server.js +58 -0
- package/ui/app.js +922 -0
- package/ui/index.html +14 -0
- package/ui/styles.css +870 -0
- package/ui/vendor/htm.mjs +4 -0
- package/ui/vendor/preact-hooks.mjs +3 -0
- package/ui/vendor/preact.mjs +3 -0
package/ui/app.js
ADDED
|
@@ -0,0 +1,922 @@
|
|
|
1
|
+
import { h, render } from './vendor/preact.mjs';
|
|
2
|
+
import { useState, useEffect, useCallback } from './vendor/preact-hooks.mjs';
|
|
3
|
+
import htm from './vendor/htm.mjs';
|
|
4
|
+
|
|
5
|
+
const html = htm.bind(h);
|
|
6
|
+
|
|
7
|
+
// --- API helpers ---
|
|
8
|
+
const api = {
|
|
9
|
+
async list() {
|
|
10
|
+
const res = await fetch('/api/skills');
|
|
11
|
+
return res.json();
|
|
12
|
+
},
|
|
13
|
+
async detail(name) {
|
|
14
|
+
const res = await fetch(`/api/skills/${encodeURIComponent(name)}`);
|
|
15
|
+
return res.json();
|
|
16
|
+
},
|
|
17
|
+
async remove(name) {
|
|
18
|
+
const res = await fetch(`/api/skills/${encodeURIComponent(name)}`, { method: 'DELETE' });
|
|
19
|
+
return res.json();
|
|
20
|
+
},
|
|
21
|
+
async importZip(file) {
|
|
22
|
+
const form = new FormData();
|
|
23
|
+
form.append('file', file);
|
|
24
|
+
const res = await fetch('/api/skills/import', { method: 'POST', body: form });
|
|
25
|
+
return res.json();
|
|
26
|
+
},
|
|
27
|
+
async reveal(path) {
|
|
28
|
+
const res = await fetch('/api/reveal', {
|
|
29
|
+
method: 'POST',
|
|
30
|
+
headers: { 'Content-Type': 'application/json' },
|
|
31
|
+
body: JSON.stringify({ path })
|
|
32
|
+
});
|
|
33
|
+
return res.json();
|
|
34
|
+
},
|
|
35
|
+
async getStartup() {
|
|
36
|
+
const res = await fetch('/api/startup');
|
|
37
|
+
return res.json();
|
|
38
|
+
},
|
|
39
|
+
async setStartup(enabled) {
|
|
40
|
+
const res = await fetch('/api/startup', {
|
|
41
|
+
method: 'POST',
|
|
42
|
+
headers: { 'Content-Type': 'application/json' },
|
|
43
|
+
body: JSON.stringify({ enabled })
|
|
44
|
+
});
|
|
45
|
+
return res.json();
|
|
46
|
+
},
|
|
47
|
+
async save(name, raw) {
|
|
48
|
+
const res = await fetch(`/api/skills/${encodeURIComponent(name)}`, {
|
|
49
|
+
method: 'PUT',
|
|
50
|
+
headers: { 'Content-Type': 'application/json' },
|
|
51
|
+
body: JSON.stringify({ raw })
|
|
52
|
+
});
|
|
53
|
+
return res.json();
|
|
54
|
+
},
|
|
55
|
+
exportUrl(name) {
|
|
56
|
+
return `/api/skills/${encodeURIComponent(name)}/export`;
|
|
57
|
+
},
|
|
58
|
+
async registryPlugins(params = {}) {
|
|
59
|
+
const qs = new URLSearchParams(params).toString();
|
|
60
|
+
const res = await fetch(`/api/registry/plugins${qs ? '?' + qs : ''}`);
|
|
61
|
+
return res.json();
|
|
62
|
+
},
|
|
63
|
+
async registryInstall(name, marketplace) {
|
|
64
|
+
const res = await fetch('/api/registry/install', {
|
|
65
|
+
method: 'POST',
|
|
66
|
+
headers: { 'Content-Type': 'application/json' },
|
|
67
|
+
body: JSON.stringify({ name, marketplace })
|
|
68
|
+
});
|
|
69
|
+
return res.json();
|
|
70
|
+
},
|
|
71
|
+
async registrySources() {
|
|
72
|
+
const res = await fetch('/api/registry/sources');
|
|
73
|
+
return res.json();
|
|
74
|
+
},
|
|
75
|
+
async setSourceEnabled(id, enabled) {
|
|
76
|
+
const res = await fetch('/api/registry/sources', {
|
|
77
|
+
method: 'POST',
|
|
78
|
+
headers: { 'Content-Type': 'application/json' },
|
|
79
|
+
body: JSON.stringify({ id, enabled })
|
|
80
|
+
});
|
|
81
|
+
return res.json();
|
|
82
|
+
},
|
|
83
|
+
async registryCategories() {
|
|
84
|
+
const res = await fetch('/api/registry/categories');
|
|
85
|
+
return res.json();
|
|
86
|
+
},
|
|
87
|
+
async syncStatus() {
|
|
88
|
+
const res = await fetch('/api/sync/status');
|
|
89
|
+
return res.json();
|
|
90
|
+
},
|
|
91
|
+
async syncInit() {
|
|
92
|
+
const res = await fetch('/api/sync/init', { method: 'POST' });
|
|
93
|
+
return res.json();
|
|
94
|
+
},
|
|
95
|
+
async syncRemote(url) {
|
|
96
|
+
const res = await fetch('/api/sync/remote', {
|
|
97
|
+
method: 'POST',
|
|
98
|
+
headers: { 'Content-Type': 'application/json' },
|
|
99
|
+
body: JSON.stringify({ url })
|
|
100
|
+
});
|
|
101
|
+
return res.json();
|
|
102
|
+
},
|
|
103
|
+
async syncPush() {
|
|
104
|
+
const res = await fetch('/api/sync/push', { method: 'POST' });
|
|
105
|
+
return res.json();
|
|
106
|
+
},
|
|
107
|
+
async syncPull() {
|
|
108
|
+
const res = await fetch('/api/sync/pull', { method: 'POST' });
|
|
109
|
+
return res.json();
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// --- Toast ---
|
|
114
|
+
function Toast({ message, onDone }) {
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
const t = setTimeout(onDone, 3000);
|
|
117
|
+
return () => clearTimeout(t);
|
|
118
|
+
}, []);
|
|
119
|
+
if (!message) return null;
|
|
120
|
+
return html`<div class="toast">${message}</div>`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// --- Drop Zone ---
|
|
124
|
+
function ImportZone({ onImport }) {
|
|
125
|
+
const [active, setActive] = useState(false);
|
|
126
|
+
const fileRef = useCallback((el) => {
|
|
127
|
+
if (el) el.addEventListener('change', (e) => {
|
|
128
|
+
if (e.target.files[0]) onImport(e.target.files[0]);
|
|
129
|
+
});
|
|
130
|
+
}, []);
|
|
131
|
+
|
|
132
|
+
function handleDrop(e) {
|
|
133
|
+
e.preventDefault();
|
|
134
|
+
setActive(false);
|
|
135
|
+
const file = e.dataTransfer?.files[0];
|
|
136
|
+
if (file) onImport(file);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return html`
|
|
140
|
+
<div
|
|
141
|
+
class="drop-zone ${active ? 'active' : ''}"
|
|
142
|
+
onDragOver=${(e) => { e.preventDefault(); setActive(true); }}
|
|
143
|
+
onDragLeave=${() => setActive(false)}
|
|
144
|
+
onDrop=${handleDrop}
|
|
145
|
+
onClick=${() => document.getElementById('zip-input').click()}
|
|
146
|
+
>
|
|
147
|
+
<input id="zip-input" type="file" accept=".zip" ref=${fileRef} />
|
|
148
|
+
<strong>Drop a .skill.zip here</strong> or click to browse
|
|
149
|
+
</div>
|
|
150
|
+
`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// --- Skill Card ---
|
|
154
|
+
function SkillCard({ skill, onClick }) {
|
|
155
|
+
const sourceClass = skill.source === 'local' ? 'source-local' : 'source-plugin';
|
|
156
|
+
const badgeClass = skill.source === 'local' ? 'local' : 'plugin';
|
|
157
|
+
const badgeLabel = skill.source === 'local' ? 'Local' : skill.pluginName || 'Plugin';
|
|
158
|
+
|
|
159
|
+
return html`
|
|
160
|
+
<div class="skill-card ${sourceClass}" onClick=${() => onClick(skill)}>
|
|
161
|
+
<div class="skill-card-header">
|
|
162
|
+
<h3>${skill.name}</h3>
|
|
163
|
+
<span class="source-badge ${badgeClass}">${badgeLabel}</span>
|
|
164
|
+
</div>
|
|
165
|
+
<p>${skill.description || 'No description'}</p>
|
|
166
|
+
<div class="skill-card-meta">
|
|
167
|
+
${(skill.tags || []).map(t => html`<span class="tag" key=${t}>${t}</span>`)}
|
|
168
|
+
${skill.isSymlink && html`<span class="tag">symlink</span>`}
|
|
169
|
+
<span class="file-count">${skill.fileCount} file${skill.fileCount !== 1 ? 's' : ''}</span>
|
|
170
|
+
</div>
|
|
171
|
+
</div>
|
|
172
|
+
`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// --- Skill Detail Panel ---
|
|
176
|
+
function SkillDetail({ skill, onClose, onRemove, onExport, onToast, onRefresh }) {
|
|
177
|
+
const [detail, setDetail] = useState(null);
|
|
178
|
+
const [editing, setEditing] = useState(false);
|
|
179
|
+
const [editContent, setEditContent] = useState('');
|
|
180
|
+
const [saving, setSaving] = useState(false);
|
|
181
|
+
const badgeClass = skill.source === 'local' ? 'local' : 'plugin';
|
|
182
|
+
const badgeLabel = skill.source === 'local' ? 'Local Skill' : skill.pluginName || 'Plugin';
|
|
183
|
+
const isLocal = skill.source === 'local';
|
|
184
|
+
|
|
185
|
+
useEffect(() => {
|
|
186
|
+
setDetail(null);
|
|
187
|
+
setEditing(false);
|
|
188
|
+
api.detail(skill.name).then(setDetail);
|
|
189
|
+
}, [skill.name]);
|
|
190
|
+
|
|
191
|
+
async function copyPath() {
|
|
192
|
+
try {
|
|
193
|
+
await navigator.clipboard.writeText(skill.path);
|
|
194
|
+
onToast('Path copied to clipboard');
|
|
195
|
+
} catch {
|
|
196
|
+
onToast('Failed to copy');
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function revealInFinder() {
|
|
201
|
+
try {
|
|
202
|
+
await api.reveal(skill.path);
|
|
203
|
+
} catch {
|
|
204
|
+
onToast('Failed to open Finder');
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function startEditing() {
|
|
209
|
+
if (!detail) return;
|
|
210
|
+
// Reconstruct the raw file content (frontmatter + content)
|
|
211
|
+
const fm = detail.frontmatter || {};
|
|
212
|
+
const hasFrontmatter = Object.keys(fm).length > 0;
|
|
213
|
+
let raw = '';
|
|
214
|
+
if (hasFrontmatter) {
|
|
215
|
+
raw = '---\n';
|
|
216
|
+
for (const [k, v] of Object.entries(fm)) {
|
|
217
|
+
if (Array.isArray(v)) {
|
|
218
|
+
raw += `${k}:\n${v.map(i => ` - ${i}`).join('\n')}\n`;
|
|
219
|
+
} else {
|
|
220
|
+
raw += `${k}: ${v}\n`;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
raw += '---\n';
|
|
224
|
+
}
|
|
225
|
+
raw += detail.content || '';
|
|
226
|
+
setEditContent(raw);
|
|
227
|
+
setEditing(true);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function handleSave() {
|
|
231
|
+
setSaving(true);
|
|
232
|
+
try {
|
|
233
|
+
const result = await api.save(skill.dirName, editContent);
|
|
234
|
+
if (result.ok) {
|
|
235
|
+
onToast('Saved');
|
|
236
|
+
setEditing(false);
|
|
237
|
+
const updated = await api.detail(skill.name);
|
|
238
|
+
setDetail(updated);
|
|
239
|
+
onRefresh();
|
|
240
|
+
} else {
|
|
241
|
+
onToast(result.error || 'Save failed');
|
|
242
|
+
}
|
|
243
|
+
} catch {
|
|
244
|
+
onToast('Save failed');
|
|
245
|
+
}
|
|
246
|
+
setSaving(false);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return html`
|
|
250
|
+
<div class="detail-overlay" onClick=${(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
|
251
|
+
<div class="detail-panel">
|
|
252
|
+
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 4px;">
|
|
253
|
+
<h2 style="margin: 0;">${skill.name}</h2>
|
|
254
|
+
<span class="source-badge ${badgeClass}">${badgeLabel}</span>
|
|
255
|
+
</div>
|
|
256
|
+
<p class="description">${skill.description || 'No description'}</p>
|
|
257
|
+
|
|
258
|
+
<div class="detail-section">
|
|
259
|
+
<h4>Location</h4>
|
|
260
|
+
<div class="detail-path">
|
|
261
|
+
<span class="detail-path-text">${skill.path}</span>
|
|
262
|
+
<div class="detail-path-actions">
|
|
263
|
+
<button class="btn btn-sm" onClick=${copyPath}>Copy</button>
|
|
264
|
+
<button class="btn btn-sm" onClick=${revealInFinder}>Reveal</button>
|
|
265
|
+
</div>
|
|
266
|
+
</div>
|
|
267
|
+
</div>
|
|
268
|
+
|
|
269
|
+
<div class="detail-section">
|
|
270
|
+
<h4>Metadata</h4>
|
|
271
|
+
<dl class="detail-meta">
|
|
272
|
+
<dt>Source</dt><dd>${skill.source === 'local' ? 'Local skill' : 'Plugin: ' + (skill.pluginName || 'unknown')}</dd>
|
|
273
|
+
<dt>Files</dt><dd>${skill.fileCount}</dd>
|
|
274
|
+
${skill.isSymlink && html`<dt>Symlink</dt><dd>Yes</dd>`}
|
|
275
|
+
<dt>Modified</dt><dd>${new Date(skill.modified).toLocaleDateString()}</dd>
|
|
276
|
+
${skill.version && html`<dt>Version</dt><dd>${skill.version}</dd>`}
|
|
277
|
+
${skill.author && html`<dt>Author</dt><dd>${skill.author}</dd>`}
|
|
278
|
+
</dl>
|
|
279
|
+
</div>
|
|
280
|
+
|
|
281
|
+
${editing ? html`
|
|
282
|
+
<div class="detail-section">
|
|
283
|
+
<h4>Editing SKILL.md</h4>
|
|
284
|
+
<textarea
|
|
285
|
+
class="edit-textarea"
|
|
286
|
+
value=${editContent}
|
|
287
|
+
onInput=${(e) => setEditContent(e.target.value)}
|
|
288
|
+
/>
|
|
289
|
+
</div>
|
|
290
|
+
` : html`
|
|
291
|
+
${detail?.content && html`
|
|
292
|
+
<div class="detail-section">
|
|
293
|
+
<h4>Content</h4>
|
|
294
|
+
<div class="detail-content">${detail.content.trim()}</div>
|
|
295
|
+
</div>
|
|
296
|
+
`}
|
|
297
|
+
|
|
298
|
+
${skill.files?.length > 0 && html`
|
|
299
|
+
<div class="detail-section">
|
|
300
|
+
<h4>Files</h4>
|
|
301
|
+
<div class="detail-content">${skill.files.join('\n')}</div>
|
|
302
|
+
</div>
|
|
303
|
+
`}
|
|
304
|
+
`}
|
|
305
|
+
|
|
306
|
+
<div class="detail-actions">
|
|
307
|
+
${editing ? html`
|
|
308
|
+
<button class="btn btn-primary" onClick=${handleSave} disabled=${saving}>
|
|
309
|
+
${saving ? 'Saving...' : 'Save'}
|
|
310
|
+
</button>
|
|
311
|
+
<button class="btn" onClick=${() => setEditing(false)}>Cancel</button>
|
|
312
|
+
` : html`
|
|
313
|
+
${isLocal && html`
|
|
314
|
+
<button class="btn btn-primary" onClick=${startEditing}>Edit</button>
|
|
315
|
+
`}
|
|
316
|
+
<button class="btn" onClick=${() => onExport(skill)}>Export .zip</button>
|
|
317
|
+
${isLocal && html`
|
|
318
|
+
<button class="btn btn-danger" onClick=${() => onRemove(skill)}>Remove</button>
|
|
319
|
+
`}
|
|
320
|
+
`}
|
|
321
|
+
<button class="btn" onClick=${onClose} style="margin-left: auto;">Close</button>
|
|
322
|
+
</div>
|
|
323
|
+
</div>
|
|
324
|
+
</div>
|
|
325
|
+
`;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// --- Sources Panel ---
|
|
329
|
+
function SourcesPanel({ onClose, onChanged }) {
|
|
330
|
+
const [sources, setSources] = useState([]);
|
|
331
|
+
const [busy, setBusy] = useState(null);
|
|
332
|
+
|
|
333
|
+
useEffect(() => {
|
|
334
|
+
api.registrySources().then(d => setSources(d.sources || []));
|
|
335
|
+
}, []);
|
|
336
|
+
|
|
337
|
+
async function toggle(id, enabled) {
|
|
338
|
+
setBusy(id);
|
|
339
|
+
await api.setSourceEnabled(id, enabled);
|
|
340
|
+
const updated = await api.registrySources();
|
|
341
|
+
setSources(updated.sources || []);
|
|
342
|
+
setBusy(null);
|
|
343
|
+
onChanged();
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return html`
|
|
347
|
+
<div class="detail-overlay" onClick=${(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
|
348
|
+
<div class="detail-panel">
|
|
349
|
+
<h2 style="margin: 0 0 4px 0;">Marketplace Sources</h2>
|
|
350
|
+
<p class="description">Enable community marketplaces to browse more plugins. Enabled sources are fetched from GitHub.</p>
|
|
351
|
+
|
|
352
|
+
<div class="sources-list">
|
|
353
|
+
${sources.map(s => html`
|
|
354
|
+
<div class="source-item" key=${s.id}>
|
|
355
|
+
<div class="source-info">
|
|
356
|
+
<div style="display: flex; align-items: center; gap: 8px;">
|
|
357
|
+
<strong>${s.name}</strong>
|
|
358
|
+
${s.local && html`<span class="source-badge plugin" style="font-size: 9px;">LOCAL</span>`}
|
|
359
|
+
</div>
|
|
360
|
+
<a class="source-repo" href="https://github.com/${s.repo}" target="_blank" rel="noopener">${s.repo}</a>
|
|
361
|
+
<span style="font-size: 12px; color: var(--text-secondary)">${s.description}</span>
|
|
362
|
+
</div>
|
|
363
|
+
<label class="startup-toggle">
|
|
364
|
+
<input type="checkbox" checked=${s.enabled}
|
|
365
|
+
disabled=${busy === s.id}
|
|
366
|
+
onChange=${() => toggle(s.id, !s.enabled)} />
|
|
367
|
+
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
|
368
|
+
</label>
|
|
369
|
+
</div>
|
|
370
|
+
`)}
|
|
371
|
+
</div>
|
|
372
|
+
|
|
373
|
+
<div class="detail-actions">
|
|
374
|
+
<button class="btn" onClick=${onClose} style="margin-left: auto;">Close</button>
|
|
375
|
+
</div>
|
|
376
|
+
</div>
|
|
377
|
+
</div>
|
|
378
|
+
`;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// --- Browse Card ---
|
|
382
|
+
function BrowseCard({ plugin, onClick }) {
|
|
383
|
+
return html`
|
|
384
|
+
<div class="browse-card ${plugin.installed ? 'installed' : ''}" onClick=${() => onClick(plugin)}>
|
|
385
|
+
<div class="browse-card-header">
|
|
386
|
+
<h3>${plugin.name}</h3>
|
|
387
|
+
<span class="status-badge ${plugin.installed ? 'installed' : 'available'}">
|
|
388
|
+
${plugin.installed ? 'Installed' : 'Available'}
|
|
389
|
+
</span>
|
|
390
|
+
</div>
|
|
391
|
+
<p>${plugin.description || 'No description'}</p>
|
|
392
|
+
<div class="browse-card-meta">
|
|
393
|
+
${plugin.category && html`<span class="source-badge registry">${plugin.category}</span>`}
|
|
394
|
+
${plugin.author && html`<span class="tag">${plugin.author}</span>`}
|
|
395
|
+
</div>
|
|
396
|
+
</div>
|
|
397
|
+
`;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// --- Browse Detail Panel ---
|
|
401
|
+
function BrowseDetail({ plugin, onClose, onToast, onRefresh }) {
|
|
402
|
+
const [installing, setInstalling] = useState(false);
|
|
403
|
+
const installCmd = `claude /plugin install ${plugin.name}`;
|
|
404
|
+
|
|
405
|
+
async function handleInstall() {
|
|
406
|
+
setInstalling(true);
|
|
407
|
+
const result = await api.registryInstall(plugin.name, plugin.marketplace);
|
|
408
|
+
if (result.ok) {
|
|
409
|
+
onToast(`Installed: ${plugin.name}`);
|
|
410
|
+
onRefresh();
|
|
411
|
+
onClose();
|
|
412
|
+
} else {
|
|
413
|
+
onToast(result.error || 'Install failed');
|
|
414
|
+
}
|
|
415
|
+
setInstalling(false);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async function copyCommand() {
|
|
419
|
+
try {
|
|
420
|
+
await navigator.clipboard.writeText(installCmd);
|
|
421
|
+
onToast('Command copied to clipboard');
|
|
422
|
+
} catch {
|
|
423
|
+
onToast('Failed to copy');
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
return html`
|
|
428
|
+
<div class="detail-overlay" onClick=${(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
|
429
|
+
<div class="detail-panel">
|
|
430
|
+
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 4px;">
|
|
431
|
+
<h2 style="margin: 0;">${plugin.name}</h2>
|
|
432
|
+
<span class="status-badge ${plugin.installed ? 'installed' : 'available'}">
|
|
433
|
+
${plugin.installed ? 'Installed' : 'Available'}
|
|
434
|
+
</span>
|
|
435
|
+
</div>
|
|
436
|
+
<p class="description">${plugin.description || 'No description'}</p>
|
|
437
|
+
|
|
438
|
+
<div class="detail-section">
|
|
439
|
+
<h4>Details</h4>
|
|
440
|
+
<dl class="detail-meta">
|
|
441
|
+
${plugin.category && html`<dt>Category</dt><dd>${plugin.category}</dd>`}
|
|
442
|
+
${plugin.author && html`<dt>Author</dt><dd>${plugin.author}</dd>`}
|
|
443
|
+
<dt>Source</dt><dd>${plugin.sourceType}</dd>
|
|
444
|
+
<dt>Marketplace</dt><dd>${plugin.marketplace}</dd>
|
|
445
|
+
</dl>
|
|
446
|
+
</div>
|
|
447
|
+
|
|
448
|
+
${plugin.homepage && html`
|
|
449
|
+
<div class="detail-section">
|
|
450
|
+
<h4>Homepage</h4>
|
|
451
|
+
<a href=${plugin.homepage} target="_blank" rel="noopener" style="color: var(--accent); font-size: 13px; word-break: break-all;">
|
|
452
|
+
${plugin.homepage}
|
|
453
|
+
</a>
|
|
454
|
+
</div>
|
|
455
|
+
`}
|
|
456
|
+
|
|
457
|
+
${!plugin.installed && html`
|
|
458
|
+
<div class="detail-section">
|
|
459
|
+
<h4>Install</h4>
|
|
460
|
+
<div style="display: flex; gap: 10px; align-items: center; margin-bottom: 10px;">
|
|
461
|
+
<button class="btn btn-primary" onClick=${handleInstall} disabled=${installing}>
|
|
462
|
+
${installing ? 'Installing...' : 'Install Plugin'}
|
|
463
|
+
</button>
|
|
464
|
+
</div>
|
|
465
|
+
<div class="install-command">
|
|
466
|
+
<span>${installCmd}</span>
|
|
467
|
+
<button class="btn btn-sm" onClick=${copyCommand}>Copy</button>
|
|
468
|
+
</div>
|
|
469
|
+
</div>
|
|
470
|
+
`}
|
|
471
|
+
|
|
472
|
+
${plugin.installed && html`
|
|
473
|
+
<div class="detail-section">
|
|
474
|
+
<p style="font-size: 13px; color: var(--source-plugin);">
|
|
475
|
+
This plugin is already installed. Its skills appear in the Marketplace tab.
|
|
476
|
+
</p>
|
|
477
|
+
</div>
|
|
478
|
+
`}
|
|
479
|
+
|
|
480
|
+
<div class="detail-actions">
|
|
481
|
+
<button class="btn" onClick=${onClose} style="margin-left: auto;">Close</button>
|
|
482
|
+
</div>
|
|
483
|
+
</div>
|
|
484
|
+
</div>
|
|
485
|
+
`;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// --- Sync Panel ---
|
|
489
|
+
function SyncPanel({ onClose, onToast, onRefresh }) {
|
|
490
|
+
const [status, setStatus] = useState(null);
|
|
491
|
+
const [remoteUrl, setRemoteUrl] = useState('');
|
|
492
|
+
const [busy, setBusy] = useState(false);
|
|
493
|
+
|
|
494
|
+
async function loadStatus() {
|
|
495
|
+
const s = await api.syncStatus();
|
|
496
|
+
setStatus(s);
|
|
497
|
+
if (s.remote) setRemoteUrl(s.remote);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
useEffect(() => { loadStatus(); }, []);
|
|
501
|
+
|
|
502
|
+
async function handleInit() {
|
|
503
|
+
setBusy(true);
|
|
504
|
+
const result = await api.syncInit();
|
|
505
|
+
onToast(result.message || result.error);
|
|
506
|
+
await loadStatus();
|
|
507
|
+
setBusy(false);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
async function handleSetRemote() {
|
|
511
|
+
if (!remoteUrl.trim()) return;
|
|
512
|
+
setBusy(true);
|
|
513
|
+
const result = await api.syncRemote(remoteUrl.trim());
|
|
514
|
+
onToast(result.ok ? result.message : result.error);
|
|
515
|
+
await loadStatus();
|
|
516
|
+
setBusy(false);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
async function handlePush() {
|
|
520
|
+
setBusy(true);
|
|
521
|
+
const result = await api.syncPush();
|
|
522
|
+
onToast(result.ok ? result.message : result.error);
|
|
523
|
+
await loadStatus();
|
|
524
|
+
onRefresh();
|
|
525
|
+
setBusy(false);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
async function handlePull() {
|
|
529
|
+
setBusy(true);
|
|
530
|
+
const result = await api.syncPull();
|
|
531
|
+
onToast(result.ok ? result.message : result.error);
|
|
532
|
+
await loadStatus();
|
|
533
|
+
onRefresh();
|
|
534
|
+
setBusy(false);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
if (!status) return html`
|
|
538
|
+
<div class="detail-overlay" onClick=${(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
|
539
|
+
<div class="detail-panel"><p style="color: var(--text-secondary)">Loading...</p></div>
|
|
540
|
+
</div>
|
|
541
|
+
`;
|
|
542
|
+
|
|
543
|
+
const { added = [], modified = [], removed = [] } = status.localChanges || {};
|
|
544
|
+
const localTotal = added.length + modified.length + removed.length;
|
|
545
|
+
|
|
546
|
+
return html`
|
|
547
|
+
<div class="detail-overlay" onClick=${(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
|
548
|
+
<div class="detail-panel">
|
|
549
|
+
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 4px;">
|
|
550
|
+
<h2 style="margin: 0;">Sync</h2>
|
|
551
|
+
<span class="source-badge ${status.initialized ? 'local' : ''}" style="${!status.initialized ? 'opacity:0.5' : ''}">
|
|
552
|
+
${status.initialized ? 'Git' : 'Not configured'}
|
|
553
|
+
</span>
|
|
554
|
+
</div>
|
|
555
|
+
<p class="description">Keep your skills in sync across machines using a git remote.</p>
|
|
556
|
+
|
|
557
|
+
${!status.initialized && html`
|
|
558
|
+
<div class="detail-section">
|
|
559
|
+
<button class="btn btn-primary" onClick=${handleInit} disabled=${busy}>
|
|
560
|
+
${busy ? 'Initializing...' : 'Initialize Sync'}
|
|
561
|
+
</button>
|
|
562
|
+
</div>
|
|
563
|
+
`}
|
|
564
|
+
|
|
565
|
+
${status.initialized && html`
|
|
566
|
+
<div class="detail-section">
|
|
567
|
+
<h4>Remote</h4>
|
|
568
|
+
<div class="sync-remote-row">
|
|
569
|
+
<input
|
|
570
|
+
class="search-input sync-remote-input"
|
|
571
|
+
type="text"
|
|
572
|
+
placeholder="git@github.com:user/skills.git"
|
|
573
|
+
value=${remoteUrl}
|
|
574
|
+
onInput=${(e) => setRemoteUrl(e.target.value)}
|
|
575
|
+
/>
|
|
576
|
+
<button class="btn btn-sm" onClick=${handleSetRemote} disabled=${busy}>Set</button>
|
|
577
|
+
</div>
|
|
578
|
+
</div>
|
|
579
|
+
|
|
580
|
+
<div class="detail-section">
|
|
581
|
+
<h4>Actions</h4>
|
|
582
|
+
<div class="sync-actions-row">
|
|
583
|
+
<button class="btn btn-primary" onClick=${handlePush} disabled=${busy || !status.remote}>
|
|
584
|
+
${busy ? 'Pushing...' : 'Push'}
|
|
585
|
+
${localTotal > 0 ? html` <span class="sync-badge">${localTotal}</span>` : ''}
|
|
586
|
+
</button>
|
|
587
|
+
<button class="btn" onClick=${handlePull} disabled=${busy || !status.remote}>
|
|
588
|
+
${busy ? 'Pulling...' : 'Pull'}
|
|
589
|
+
${status.remoteChanges > 0 ? html` <span class="sync-badge">${status.remoteChanges}</span>` : ''}
|
|
590
|
+
</button>
|
|
591
|
+
</div>
|
|
592
|
+
${!status.remote && html`
|
|
593
|
+
<p class="sync-hint">Set a remote URL above to enable push and pull.</p>
|
|
594
|
+
`}
|
|
595
|
+
</div>
|
|
596
|
+
|
|
597
|
+
${(localTotal > 0 || status.remoteChanges > 0) && html`
|
|
598
|
+
<div class="detail-section">
|
|
599
|
+
<h4>Changes</h4>
|
|
600
|
+
<div class="sync-diff-list">
|
|
601
|
+
${added.map(s => html`<div class="sync-diff-item added">+ ${s}</div>`)}
|
|
602
|
+
${modified.map(s => html`<div class="sync-diff-item modified">~ ${s}</div>`)}
|
|
603
|
+
${removed.map(s => html`<div class="sync-diff-item removed">- ${s}</div>`)}
|
|
604
|
+
${status.remoteChanges > 0 && html`
|
|
605
|
+
<div class="sync-diff-item" style="color: var(--text-secondary); margin-top: 8px;">
|
|
606
|
+
${status.remoteChanges} remote commit${status.remoteChanges !== 1 ? 's' : ''} to pull
|
|
607
|
+
</div>
|
|
608
|
+
`}
|
|
609
|
+
</div>
|
|
610
|
+
</div>
|
|
611
|
+
`}
|
|
612
|
+
|
|
613
|
+
${localTotal === 0 && status.remoteChanges === 0 && html`
|
|
614
|
+
<div class="detail-section">
|
|
615
|
+
<p style="color: var(--text-secondary); font-size: 13px;">Everything up to date.</p>
|
|
616
|
+
</div>
|
|
617
|
+
`}
|
|
618
|
+
|
|
619
|
+
${status.lastSync && html`
|
|
620
|
+
<div class="detail-section">
|
|
621
|
+
<h4>Last Sync</h4>
|
|
622
|
+
<p style="font-size: 13px; color: var(--text-secondary);">
|
|
623
|
+
${new Date(status.lastSync).toLocaleString()}
|
|
624
|
+
</p>
|
|
625
|
+
</div>
|
|
626
|
+
`}
|
|
627
|
+
`}
|
|
628
|
+
|
|
629
|
+
<div class="detail-actions">
|
|
630
|
+
<button class="btn" onClick=${onClose} style="margin-left: auto;">Close</button>
|
|
631
|
+
</div>
|
|
632
|
+
</div>
|
|
633
|
+
</div>
|
|
634
|
+
`;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// --- App ---
|
|
638
|
+
function App() {
|
|
639
|
+
const [skills, setSkills] = useState([]);
|
|
640
|
+
const [search, setSearch] = useState('');
|
|
641
|
+
const [tab, setTab] = useState('all');
|
|
642
|
+
const [selected, setSelected] = useState(null);
|
|
643
|
+
const [toast, setToast] = useState('');
|
|
644
|
+
const [loading, setLoading] = useState(true);
|
|
645
|
+
const [startupEnabled, setStartupEnabled] = useState(false);
|
|
646
|
+
const [showSync, setShowSync] = useState(false);
|
|
647
|
+
const [syncInfo, setSyncInfo] = useState(null);
|
|
648
|
+
const [browsePlugins, setBrowsePlugins] = useState([]);
|
|
649
|
+
const [browseCategories, setBrowseCategories] = useState([]);
|
|
650
|
+
const [browseCategory, setBrowseCategory] = useState(null);
|
|
651
|
+
const [browseSelected, setBrowseSelected] = useState(null);
|
|
652
|
+
const [browseLoaded, setBrowseLoaded] = useState(false);
|
|
653
|
+
const [showSources, setShowSources] = useState(false);
|
|
654
|
+
|
|
655
|
+
const refresh = useCallback(async () => {
|
|
656
|
+
setLoading(true);
|
|
657
|
+
const data = await api.list();
|
|
658
|
+
setSkills(data);
|
|
659
|
+
setLoading(false);
|
|
660
|
+
}, []);
|
|
661
|
+
|
|
662
|
+
const loadBrowse = useCallback(async () => {
|
|
663
|
+
const [pluginData, catData] = await Promise.all([
|
|
664
|
+
api.registryPlugins(),
|
|
665
|
+
api.registryCategories()
|
|
666
|
+
]);
|
|
667
|
+
setBrowsePlugins(pluginData.plugins || []);
|
|
668
|
+
setBrowseCategories(catData.categories || []);
|
|
669
|
+
setBrowseLoaded(true);
|
|
670
|
+
}, []);
|
|
671
|
+
|
|
672
|
+
const refreshSync = useCallback(async () => {
|
|
673
|
+
try {
|
|
674
|
+
const s = await api.syncStatus();
|
|
675
|
+
setSyncInfo(s);
|
|
676
|
+
} catch {}
|
|
677
|
+
}, []);
|
|
678
|
+
|
|
679
|
+
useEffect(() => {
|
|
680
|
+
refresh();
|
|
681
|
+
refreshSync();
|
|
682
|
+
api.getStartup().then(d => setStartupEnabled(d.enabled)).catch(() => {});
|
|
683
|
+
}, []);
|
|
684
|
+
|
|
685
|
+
useEffect(() => {
|
|
686
|
+
const onScroll = () => {
|
|
687
|
+
const header = document.querySelector('.header');
|
|
688
|
+
if (header) header.classList.toggle('scrolled', window.scrollY > 20);
|
|
689
|
+
};
|
|
690
|
+
window.addEventListener('scroll', onScroll, { passive: true });
|
|
691
|
+
return () => window.removeEventListener('scroll', onScroll);
|
|
692
|
+
}, []);
|
|
693
|
+
|
|
694
|
+
async function toggleStartup() {
|
|
695
|
+
const next = !startupEnabled;
|
|
696
|
+
const result = await api.setStartup(next);
|
|
697
|
+
setStartupEnabled(result.enabled);
|
|
698
|
+
setToast(result.message);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
useEffect(() => {
|
|
702
|
+
if (tab === 'browse' && !browseLoaded) loadBrowse();
|
|
703
|
+
}, [tab]);
|
|
704
|
+
|
|
705
|
+
const browseCategoryFiltered = browseCategory
|
|
706
|
+
? browsePlugins.filter(p => p.category === browseCategory)
|
|
707
|
+
: browsePlugins;
|
|
708
|
+
|
|
709
|
+
const browseFiltered = search
|
|
710
|
+
? browseCategoryFiltered.filter(p => {
|
|
711
|
+
const q = search.toLowerCase();
|
|
712
|
+
return p.name.toLowerCase().includes(q)
|
|
713
|
+
|| p.description.toLowerCase().includes(q)
|
|
714
|
+
|| (p.category || '').toLowerCase().includes(q)
|
|
715
|
+
|| (p.author || '').toLowerCase().includes(q);
|
|
716
|
+
})
|
|
717
|
+
: browseCategoryFiltered;
|
|
718
|
+
|
|
719
|
+
const localCount = skills.filter(s => s.source === 'local').length;
|
|
720
|
+
const pluginCount = skills.filter(s => s.source === 'plugin').length;
|
|
721
|
+
|
|
722
|
+
const filtered = skills.filter(s => {
|
|
723
|
+
// Tab filter
|
|
724
|
+
if (tab === 'local' && s.source !== 'local') return false;
|
|
725
|
+
if (tab === 'plugin' && s.source !== 'plugin') return false;
|
|
726
|
+
|
|
727
|
+
// Search filter
|
|
728
|
+
const q = search.toLowerCase();
|
|
729
|
+
if (!q) return true;
|
|
730
|
+
return s.name.toLowerCase().includes(q)
|
|
731
|
+
|| s.description.toLowerCase().includes(q)
|
|
732
|
+
|| (s.tags || []).some(t => t.toLowerCase().includes(q))
|
|
733
|
+
|| s.source.toLowerCase().includes(q)
|
|
734
|
+
|| (s.pluginName || '').toLowerCase().includes(q);
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
async function handleImport(file) {
|
|
738
|
+
try {
|
|
739
|
+
const result = await api.importZip(file);
|
|
740
|
+
setToast(result.message || `Imported: ${result.name}`);
|
|
741
|
+
refresh();
|
|
742
|
+
} catch {
|
|
743
|
+
setToast('Import failed');
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
async function handleRemove(skill) {
|
|
748
|
+
if (!confirm(`Remove "${skill.name}"? ${skill.isSymlink ? 'This will remove the symlink only.' : 'This will delete the skill directory.'}`)) return;
|
|
749
|
+
try {
|
|
750
|
+
await api.remove(skill.dirName);
|
|
751
|
+
setToast(`Removed: ${skill.name}`);
|
|
752
|
+
setSelected(null);
|
|
753
|
+
refresh();
|
|
754
|
+
} catch {
|
|
755
|
+
setToast('Remove failed');
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
function handleExport(skill) {
|
|
760
|
+
window.open(api.exportUrl(skill.dirName), '_blank');
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
return html`
|
|
764
|
+
<div class="header">
|
|
765
|
+
<h1>Quiver</h1>
|
|
766
|
+
<div class="header-actions">
|
|
767
|
+
<input
|
|
768
|
+
class="search-input"
|
|
769
|
+
type="text"
|
|
770
|
+
placeholder="Search skills..."
|
|
771
|
+
value=${search}
|
|
772
|
+
onInput=${(e) => setSearch(e.target.value)}
|
|
773
|
+
/>
|
|
774
|
+
<button class="btn" onClick=${refresh}>Refresh</button>
|
|
775
|
+
<button class="btn sync-btn" onClick=${() => setShowSync(true)}>
|
|
776
|
+
<span class="sync-dot ${syncInfo?.initialized ? (
|
|
777
|
+
((syncInfo.localChanges?.added?.length || 0) + (syncInfo.localChanges?.modified?.length || 0) + (syncInfo.localChanges?.removed?.length || 0) + (syncInfo.remoteChanges || 0)) > 0
|
|
778
|
+
? 'pending' : 'ok'
|
|
779
|
+
) : 'off'}"></span>
|
|
780
|
+
Sync
|
|
781
|
+
</button>
|
|
782
|
+
<label class="startup-toggle" title="Launch Quiver automatically when you log in">
|
|
783
|
+
<input type="checkbox" checked=${startupEnabled} onChange=${toggleStartup} />
|
|
784
|
+
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
|
785
|
+
<span class="toggle-label">Launch on startup</span>
|
|
786
|
+
</label>
|
|
787
|
+
</div>
|
|
788
|
+
</div>
|
|
789
|
+
|
|
790
|
+
<div class="tab-bar">
|
|
791
|
+
<button class="tab ${tab === 'all' ? 'active' : ''}" onClick=${() => setTab('all')}>
|
|
792
|
+
<span class="tooltip">All skills from every source</span>
|
|
793
|
+
All <span class="tab-count">${skills.length}</span>
|
|
794
|
+
</button>
|
|
795
|
+
<button class="tab ${tab === 'local' ? 'active' : ''}" onClick=${() => setTab('local')}>
|
|
796
|
+
<span class="tooltip">Skills you created locally in ~/.claude/skills/</span>
|
|
797
|
+
Local <span class="tab-count">${localCount}</span>
|
|
798
|
+
</button>
|
|
799
|
+
<button class="tab ${tab === 'plugin' ? 'active' : ''}" onClick=${() => setTab('plugin')}>
|
|
800
|
+
<span class="tooltip">Skills installed from the Claude marketplace</span>
|
|
801
|
+
Marketplace <span class="tab-count">${pluginCount}</span>
|
|
802
|
+
</button>
|
|
803
|
+
<button class="tab ${tab === 'browse' ? 'active' : ''}" onClick=${() => setTab('browse')}>
|
|
804
|
+
<span class="tooltip">Browse the full plugin marketplace catalog</span>
|
|
805
|
+
Browse ${browseLoaded ? html`<span class="tab-count">${browsePlugins.length}</span>` : ''}
|
|
806
|
+
</button>
|
|
807
|
+
</div>
|
|
808
|
+
|
|
809
|
+
${tab === 'browse' && html`
|
|
810
|
+
<div class="category-bar">
|
|
811
|
+
<button class="category-pill sources-btn" onClick=${() => setShowSources(true)}>
|
|
812
|
+
Sources
|
|
813
|
+
</button>
|
|
814
|
+
<span class="category-divider"></span>
|
|
815
|
+
<button class="category-pill ${!browseCategory ? 'active' : ''}" onClick=${() => setBrowseCategory(null)}>
|
|
816
|
+
All
|
|
817
|
+
</button>
|
|
818
|
+
${browseCategories.map(c => html`
|
|
819
|
+
<button key=${c.name} class="category-pill ${browseCategory === c.name ? 'active' : ''}"
|
|
820
|
+
onClick=${() => setBrowseCategory(browseCategory === c.name ? null : c.name)}>
|
|
821
|
+
${c.name} <span style="opacity: 0.7">${c.count}</span>
|
|
822
|
+
</button>
|
|
823
|
+
`)}
|
|
824
|
+
</div>
|
|
825
|
+
`}
|
|
826
|
+
|
|
827
|
+
<div class="main">
|
|
828
|
+
${tab !== 'browse' && html`<${ImportZone} onImport=${handleImport} />`}
|
|
829
|
+
|
|
830
|
+
${tab !== 'browse' && loading && skills.length === 0 && html`
|
|
831
|
+
<div class="empty-state"><p>Loading...</p></div>
|
|
832
|
+
`}
|
|
833
|
+
|
|
834
|
+
${tab !== 'browse' && !loading && skills.length === 0 && html`
|
|
835
|
+
<div class="empty-state">
|
|
836
|
+
<h3>No skills found</h3>
|
|
837
|
+
<p>Drop a .skill.zip above or use the CLI to add skills.</p>
|
|
838
|
+
</div>
|
|
839
|
+
`}
|
|
840
|
+
|
|
841
|
+
${tab !== 'browse' && !loading && skills.length > 0 && filtered.length === 0 && html`
|
|
842
|
+
<div class="empty-state">
|
|
843
|
+
<p>No skills match "${search}"</p>
|
|
844
|
+
</div>
|
|
845
|
+
`}
|
|
846
|
+
|
|
847
|
+
${tab !== 'browse' && html`
|
|
848
|
+
<div class="skill-grid">
|
|
849
|
+
${filtered.map(s => html`
|
|
850
|
+
<${SkillCard} key=${s.path} skill=${s} onClick=${setSelected} />
|
|
851
|
+
`)}
|
|
852
|
+
</div>
|
|
853
|
+
`}
|
|
854
|
+
|
|
855
|
+
${tab === 'browse' && html`
|
|
856
|
+
${!browseLoaded && html`
|
|
857
|
+
<div class="empty-state"><p>Loading marketplace catalog...</p></div>
|
|
858
|
+
`}
|
|
859
|
+
|
|
860
|
+
${browseLoaded && browsePlugins.length === 0 && html`
|
|
861
|
+
<div class="empty-state">
|
|
862
|
+
<h3>No sources enabled</h3>
|
|
863
|
+
<p>Click <strong>Sources</strong> above to enable marketplace sources.</p>
|
|
864
|
+
</div>
|
|
865
|
+
`}
|
|
866
|
+
|
|
867
|
+
${browseLoaded && browsePlugins.length > 0 && browseFiltered.length === 0 && html`
|
|
868
|
+
<div class="empty-state">
|
|
869
|
+
<p>No plugins match your search${browseCategory ? ` in "${browseCategory}"` : ''}</p>
|
|
870
|
+
</div>
|
|
871
|
+
`}
|
|
872
|
+
|
|
873
|
+
${browseLoaded && html`
|
|
874
|
+
<div class="skill-grid">
|
|
875
|
+
${browseFiltered.map(p => html`
|
|
876
|
+
<${BrowseCard} key=${p.name} plugin=${p} onClick=${setBrowseSelected} />
|
|
877
|
+
`)}
|
|
878
|
+
</div>
|
|
879
|
+
`}
|
|
880
|
+
`}
|
|
881
|
+
</div>
|
|
882
|
+
|
|
883
|
+
${showSources && html`
|
|
884
|
+
<${SourcesPanel}
|
|
885
|
+
onClose=${() => setShowSources(false)}
|
|
886
|
+
onChanged=${() => { setBrowseLoaded(false); loadBrowse(); }}
|
|
887
|
+
/>
|
|
888
|
+
`}
|
|
889
|
+
|
|
890
|
+
${browseSelected && html`
|
|
891
|
+
<${BrowseDetail}
|
|
892
|
+
plugin=${browseSelected}
|
|
893
|
+
onClose=${() => setBrowseSelected(null)}
|
|
894
|
+
onToast=${setToast}
|
|
895
|
+
onRefresh=${() => { setBrowseLoaded(false); loadBrowse(); refresh(); }}
|
|
896
|
+
/>
|
|
897
|
+
`}
|
|
898
|
+
|
|
899
|
+
${showSync && html`
|
|
900
|
+
<${SyncPanel}
|
|
901
|
+
onClose=${() => { setShowSync(false); refreshSync(); }}
|
|
902
|
+
onToast=${setToast}
|
|
903
|
+
onRefresh=${refresh}
|
|
904
|
+
/>
|
|
905
|
+
`}
|
|
906
|
+
|
|
907
|
+
${selected && html`
|
|
908
|
+
<${SkillDetail}
|
|
909
|
+
skill=${selected}
|
|
910
|
+
onClose=${() => setSelected(null)}
|
|
911
|
+
onRemove=${handleRemove}
|
|
912
|
+
onExport=${handleExport}
|
|
913
|
+
onToast=${setToast}
|
|
914
|
+
onRefresh=${refresh}
|
|
915
|
+
/>
|
|
916
|
+
`}
|
|
917
|
+
|
|
918
|
+
${toast && html`<${Toast} message=${toast} onDone=${() => setToast('')} />`}
|
|
919
|
+
`;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
render(html`<${App} />`, document.getElementById('app'));
|