@cryptiklemur/lattice 1.25.0 → 1.26.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.
@@ -0,0 +1,801 @@
1
+ import { useState, useEffect, useCallback, useRef } from "react";
2
+ import {
3
+ Blocks, Trash2, RefreshCw, Loader2, X, Download, Search,
4
+ ChevronRight, Package, Webhook, ScrollText, Puzzle, ExternalLink,
5
+ AlertTriangle,
6
+ } from "lucide-react";
7
+ import { useWebSocket } from "../../hooks/useWebSocket";
8
+ import type {
9
+ ServerMessage,
10
+ PluginInfo,
11
+ PluginDetails,
12
+ PluginError,
13
+ PluginMarketplaceInfo,
14
+ MarketplacePluginEntry,
15
+ } from "@lattice/shared";
16
+
17
+ export function GlobalPlugins() {
18
+ var { send, subscribe, unsubscribe } = useWebSocket();
19
+ var [plugins, setPlugins] = useState<PluginInfo[]>([]);
20
+ var [marketplaces, setMarketplaces] = useState<PluginMarketplaceInfo[]>([]);
21
+ var [loaded, setLoaded] = useState(false);
22
+ var [detailPlugin, setDetailPlugin] = useState<PluginDetails | null>(null);
23
+ var [searchQuery, setSearchQuery] = useState("");
24
+ var [searchResults, setSearchResults] = useState<MarketplacePluginEntry[]>([]);
25
+ var [searching, setSearching] = useState(false);
26
+ var [hasSearched, setHasSearched] = useState(false);
27
+ var [installingKey, setInstallingKey] = useState<string | null>(null);
28
+ var [uninstallingKey, setUninstallingKey] = useState<string | null>(null);
29
+ var [updatingKey, setUpdatingKey] = useState<string | null>(null);
30
+ var [confirmUninstall, setConfirmUninstall] = useState<string | null>(null);
31
+ var [actionMessage, setActionMessage] = useState<{ text: string; success: boolean } | null>(null);
32
+ var [pluginErrors, setPluginErrors] = useState<PluginError[]>([]);
33
+ var [discoverPlugins, setDiscoverPlugins] = useState<MarketplacePluginEntry[]>([]);
34
+ var [discoverLoaded, setDiscoverLoaded] = useState(false);
35
+ var searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
36
+
37
+ useEffect(function () {
38
+ function handleListResult(msg: ServerMessage) {
39
+ if (msg.type !== "plugin:list_result") return;
40
+ var data = msg as { type: "plugin:list_result"; plugins: PluginInfo[] };
41
+ setPlugins(data.plugins);
42
+ setLoaded(true);
43
+ }
44
+
45
+ function handleMarketplacesResult(msg: ServerMessage) {
46
+ if (msg.type !== "plugin:marketplaces_result") return;
47
+ var data = msg as { type: "plugin:marketplaces_result"; marketplaces: PluginMarketplaceInfo[] };
48
+ setMarketplaces(data.marketplaces);
49
+ }
50
+
51
+ function handleSearchResult(msg: ServerMessage) {
52
+ if (msg.type !== "plugin:search_result") return;
53
+ var data = msg as { type: "plugin:search_result"; query: string; plugins: MarketplacePluginEntry[]; count: number };
54
+ setSearchResults(data.plugins);
55
+ setSearching(false);
56
+ setHasSearched(true);
57
+ }
58
+
59
+ function handleInstallResult(msg: ServerMessage) {
60
+ if (msg.type !== "plugin:install_result") return;
61
+ var data = msg as { type: "plugin:install_result"; success: boolean; message?: string };
62
+ setInstallingKey(null);
63
+ showAction(data.message ?? (data.success ? "Installed" : "Install failed"), data.success);
64
+ }
65
+
66
+ function handleUninstallResult(msg: ServerMessage) {
67
+ if (msg.type !== "plugin:uninstall_result") return;
68
+ var data = msg as { type: "plugin:uninstall_result"; success: boolean; message?: string };
69
+ setUninstallingKey(null);
70
+ showAction(data.message ?? (data.success ? "Uninstalled" : "Uninstall failed"), data.success);
71
+ }
72
+
73
+ function handleUpdateResult(msg: ServerMessage) {
74
+ if (msg.type !== "plugin:update_result") return;
75
+ var data = msg as { type: "plugin:update_result"; success: boolean; message?: string };
76
+ setUpdatingKey(null);
77
+ showAction(data.message ?? (data.success ? "Updated" : "Update failed"), data.success);
78
+ }
79
+
80
+ function handleDetailsResult(msg: ServerMessage) {
81
+ if (msg.type !== "plugin:details_result") return;
82
+ var data = msg as { type: "plugin:details_result"; plugin: PluginDetails | null };
83
+ setDetailPlugin(data.plugin);
84
+ }
85
+
86
+ function handleErrorsResult(msg: ServerMessage) {
87
+ if (msg.type !== "plugin:errors_result") return;
88
+ var data = msg as { type: "plugin:errors_result"; errors: PluginError[] };
89
+ setPluginErrors(data.errors);
90
+ }
91
+
92
+ function handleDiscoverResult(msg: ServerMessage) {
93
+ if (msg.type !== "plugin:discover_result") return;
94
+ var data = msg as { type: "plugin:discover_result"; plugins: MarketplacePluginEntry[] };
95
+ setDiscoverPlugins(data.plugins);
96
+ setDiscoverLoaded(true);
97
+ }
98
+
99
+ subscribe("plugin:list_result", handleListResult);
100
+ subscribe("plugin:marketplaces_result", handleMarketplacesResult);
101
+ subscribe("plugin:search_result", handleSearchResult);
102
+ subscribe("plugin:install_result", handleInstallResult);
103
+ subscribe("plugin:uninstall_result", handleUninstallResult);
104
+ subscribe("plugin:update_result", handleUpdateResult);
105
+ subscribe("plugin:details_result", handleDetailsResult);
106
+ subscribe("plugin:errors_result", handleErrorsResult);
107
+ subscribe("plugin:discover_result", handleDiscoverResult);
108
+
109
+ send({ type: "plugin:list" } as any);
110
+ send({ type: "plugin:marketplaces" } as any);
111
+ send({ type: "plugin:errors" } as any);
112
+ send({ type: "plugin:discover" } as any);
113
+
114
+ return function () {
115
+ unsubscribe("plugin:list_result", handleListResult);
116
+ unsubscribe("plugin:marketplaces_result", handleMarketplacesResult);
117
+ unsubscribe("plugin:search_result", handleSearchResult);
118
+ unsubscribe("plugin:install_result", handleInstallResult);
119
+ unsubscribe("plugin:uninstall_result", handleUninstallResult);
120
+ unsubscribe("plugin:update_result", handleUpdateResult);
121
+ unsubscribe("plugin:details_result", handleDetailsResult);
122
+ unsubscribe("plugin:errors_result", handleErrorsResult);
123
+ unsubscribe("plugin:discover_result", handleDiscoverResult);
124
+ };
125
+ }, []);
126
+
127
+ function showAction(text: string, success: boolean) {
128
+ setActionMessage({ text, success });
129
+ setTimeout(function () { setActionMessage(null); }, 3000);
130
+ }
131
+
132
+ function handleViewDetails(plugin: PluginInfo) {
133
+ send({ type: "plugin:details", name: plugin.name, marketplace: plugin.marketplace } as any);
134
+ }
135
+
136
+ function handleInstall(name: string, marketplace: string) {
137
+ var key = name + "@" + marketplace;
138
+ setInstallingKey(key);
139
+ send({ type: "plugin:install", name: name, marketplace: marketplace } as any);
140
+ }
141
+
142
+ function handleUninstall(plugin: PluginInfo) {
143
+ setUninstallingKey(plugin.key);
144
+ setConfirmUninstall(null);
145
+ send({ type: "plugin:uninstall", name: plugin.name, marketplace: plugin.marketplace } as any);
146
+ }
147
+
148
+ function handleUpdate(plugin: PluginInfo) {
149
+ setUpdatingKey(plugin.key);
150
+ send({ type: "plugin:update", name: plugin.name, marketplace: plugin.marketplace } as any);
151
+ }
152
+
153
+ var handleSearchInput = useCallback(function (value: string) {
154
+ setSearchQuery(value);
155
+ if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
156
+ if (!value.trim()) {
157
+ setSearchResults([]);
158
+ setHasSearched(false);
159
+ setSearching(false);
160
+ return;
161
+ }
162
+ setSearching(true);
163
+ searchTimerRef.current = setTimeout(function () {
164
+ send({ type: "plugin:search", query: value.trim() } as any);
165
+ }, 300);
166
+ }, [send]);
167
+
168
+ if (!loaded) {
169
+ return <div className="text-[13px] text-base-content/40 py-4">Loading...</div>;
170
+ }
171
+
172
+ return (
173
+ <div className="py-2 space-y-8">
174
+ {actionMessage && (
175
+ <div className={
176
+ "text-[12px] px-3 py-2 rounded-lg border " +
177
+ (actionMessage.success
178
+ ? "bg-success/10 border-success/20 text-success"
179
+ : "bg-error/10 border-error/20 text-error")
180
+ }>
181
+ {actionMessage.text}
182
+ </div>
183
+ )}
184
+
185
+ {pluginErrors.length > 0 && (
186
+ <PluginErrorsSection errors={pluginErrors} />
187
+ )}
188
+
189
+ <InstalledPlugins
190
+ plugins={plugins}
191
+ updatingKey={updatingKey}
192
+ uninstallingKey={uninstallingKey}
193
+ confirmUninstall={confirmUninstall}
194
+ onViewDetails={handleViewDetails}
195
+ onUpdate={handleUpdate}
196
+ onUninstall={handleUninstall}
197
+ onConfirmUninstall={setConfirmUninstall}
198
+ />
199
+
200
+ <MarketplaceSearch
201
+ query={searchQuery}
202
+ results={searchResults}
203
+ searching={searching}
204
+ hasSearched={hasSearched}
205
+ installingKey={installingKey}
206
+ installedKeys={new Set(plugins.map(function (p) { return p.key; }))}
207
+ onSearch={handleSearchInput}
208
+ onInstall={handleInstall}
209
+ />
210
+
211
+ <DiscoverSection
212
+ plugins={discoverPlugins}
213
+ loaded={discoverLoaded}
214
+ installingKey={installingKey}
215
+ installedKeys={new Set(plugins.map(function (p) { return p.key; }))}
216
+ onInstall={handleInstall}
217
+ />
218
+
219
+ <MarketplaceList marketplaces={marketplaces} />
220
+
221
+ {detailPlugin && (
222
+ <PluginDetailModal
223
+ plugin={detailPlugin}
224
+ onClose={function () { setDetailPlugin(null); }}
225
+ />
226
+ )}
227
+ </div>
228
+ );
229
+ }
230
+
231
+ function InstalledPlugins({
232
+ plugins,
233
+ updatingKey,
234
+ uninstallingKey,
235
+ confirmUninstall,
236
+ onViewDetails,
237
+ onUpdate,
238
+ onUninstall,
239
+ onConfirmUninstall,
240
+ }: {
241
+ plugins: PluginInfo[];
242
+ updatingKey: string | null;
243
+ uninstallingKey: string | null;
244
+ confirmUninstall: string | null;
245
+ onViewDetails: (p: PluginInfo) => void;
246
+ onUpdate: (p: PluginInfo) => void;
247
+ onUninstall: (p: PluginInfo) => void;
248
+ onConfirmUninstall: (key: string | null) => void;
249
+ }) {
250
+ return (
251
+ <div>
252
+ <div className="text-[12px] font-semibold text-base-content/40 mb-2">Installed Plugins</div>
253
+ {plugins.length === 0 ? (
254
+ <div className="py-6 text-center text-[13px] text-base-content/30">
255
+ No plugins installed. Search the marketplace below to get started.
256
+ </div>
257
+ ) : (
258
+ <div className="space-y-2">
259
+ {plugins.map(function (plugin) {
260
+ return (
261
+ <PluginCard
262
+ key={plugin.key}
263
+ plugin={plugin}
264
+ isUpdating={updatingKey === plugin.key}
265
+ isUninstalling={uninstallingKey === plugin.key}
266
+ isConfirmingUninstall={confirmUninstall === plugin.key}
267
+ onViewDetails={function () { onViewDetails(plugin); }}
268
+ onUpdate={function () { onUpdate(plugin); }}
269
+ onUninstall={function () { onUninstall(plugin); }}
270
+ onConfirmUninstall={function () { onConfirmUninstall(plugin.key); }}
271
+ onCancelUninstall={function () { onConfirmUninstall(null); }}
272
+ />
273
+ );
274
+ })}
275
+ </div>
276
+ )}
277
+ </div>
278
+ );
279
+ }
280
+
281
+ function PluginCard({
282
+ plugin,
283
+ isUpdating,
284
+ isUninstalling,
285
+ isConfirmingUninstall,
286
+ onViewDetails,
287
+ onUpdate,
288
+ onUninstall,
289
+ onConfirmUninstall,
290
+ onCancelUninstall,
291
+ }: {
292
+ plugin: PluginInfo;
293
+ isUpdating: boolean;
294
+ isUninstalling: boolean;
295
+ isConfirmingUninstall: boolean;
296
+ onViewDetails: () => void;
297
+ onUpdate: () => void;
298
+ onUninstall: () => void;
299
+ onConfirmUninstall: () => void;
300
+ onCancelUninstall: () => void;
301
+ }) {
302
+ return (
303
+ <div
304
+ onClick={onViewDetails}
305
+ className="flex items-start gap-3 px-3 py-2.5 bg-base-300 border border-base-content/15 rounded-xl cursor-pointer hover:border-base-content/30 hover:bg-base-300/80 transition-colors duration-[120ms]"
306
+ role="button"
307
+ tabIndex={0}
308
+ onKeyDown={function (e) { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onViewDetails(); } }}
309
+ >
310
+ <Blocks size={14} className="text-base-content/25 mt-0.5 flex-shrink-0" />
311
+ <div className="flex-1 min-w-0">
312
+ <div className="flex items-center gap-2">
313
+ <span className="text-[13px] font-bold text-base-content truncate">{plugin.name}</span>
314
+ <span className="shrink-0 text-[10px] font-mono px-1.5 py-0.5 rounded-md bg-base-content/8 text-base-content/40">
315
+ v{plugin.version}
316
+ </span>
317
+ </div>
318
+ <div className="flex items-center gap-3 mt-0.5">
319
+ <span className="text-[11px] text-base-content/30">{plugin.marketplace}</span>
320
+ <span className="text-[11px] text-base-content/25">
321
+ {plugin.skillCount} skill{plugin.skillCount !== 1 ? "s" : ""}
322
+ {plugin.hookCount > 0 ? ", " + plugin.hookCount + " hook" + (plugin.hookCount !== 1 ? "s" : "") : ""}
323
+ {plugin.ruleCount > 0 ? ", " + plugin.ruleCount + " rule" + (plugin.ruleCount !== 1 ? "s" : "") : ""}
324
+ </span>
325
+ </div>
326
+ {plugin.description && (
327
+ <div className="text-[12px] text-base-content/40 mt-0.5 line-clamp-1">{plugin.description}</div>
328
+ )}
329
+ </div>
330
+ <div className="flex gap-1 flex-shrink-0 mt-0.5" onClick={function (e) { e.stopPropagation(); }}>
331
+ {isUpdating ? (
332
+ <Loader2 size={12} className="text-primary animate-spin mt-1 mx-1" />
333
+ ) : (
334
+ <button
335
+ onClick={onUpdate}
336
+ aria-label={"Update " + plugin.name}
337
+ className="btn btn-ghost btn-xs btn-square text-base-content/30 hover:text-info focus-visible:ring-2 focus-visible:ring-primary"
338
+ >
339
+ <RefreshCw size={12} />
340
+ </button>
341
+ )}
342
+ {isConfirmingUninstall ? (
343
+ <div className="flex gap-1">
344
+ <button
345
+ onClick={onUninstall}
346
+ className="btn btn-error btn-xs"
347
+ disabled={isUninstalling}
348
+ >
349
+ {isUninstalling ? <Loader2 size={10} className="animate-spin" /> : "Remove"}
350
+ </button>
351
+ <button onClick={onCancelUninstall} className="btn btn-ghost btn-xs">
352
+ Cancel
353
+ </button>
354
+ </div>
355
+ ) : (
356
+ <button
357
+ onClick={onConfirmUninstall}
358
+ aria-label={"Remove " + plugin.name}
359
+ className="btn btn-ghost btn-xs btn-square text-base-content/30 hover:text-error focus-visible:ring-2 focus-visible:ring-primary"
360
+ >
361
+ <Trash2 size={12} />
362
+ </button>
363
+ )}
364
+ </div>
365
+ </div>
366
+ );
367
+ }
368
+
369
+ function MarketplaceSearch({
370
+ query,
371
+ results,
372
+ searching,
373
+ hasSearched,
374
+ installingKey,
375
+ installedKeys,
376
+ onSearch,
377
+ onInstall,
378
+ }: {
379
+ query: string;
380
+ results: MarketplacePluginEntry[];
381
+ searching: boolean;
382
+ hasSearched: boolean;
383
+ installingKey: string | null;
384
+ installedKeys: Set<string>;
385
+ onSearch: (q: string) => void;
386
+ onInstall: (name: string, marketplace: string) => void;
387
+ }) {
388
+ return (
389
+ <div>
390
+ <div className="text-[12px] font-semibold text-base-content/40 mb-2">Browse Marketplace</div>
391
+ <div className="relative mb-3">
392
+ <Search size={13} className="absolute left-3 top-1/2 -translate-y-1/2 text-base-content/30 pointer-events-none" />
393
+ <input
394
+ type="text"
395
+ value={query}
396
+ onChange={function (e) { onSearch(e.target.value); }}
397
+ placeholder="Search plugins..."
398
+ className="w-full bg-base-300 border border-base-content/15 rounded-xl pl-8 pr-3 py-2.5 text-[13px] text-base-content placeholder:text-base-content/30 focus:outline-none focus:border-primary transition-colors"
399
+ />
400
+ {searching && (
401
+ <Loader2 size={13} className="absolute right-3 top-1/2 -translate-y-1/2 text-primary animate-spin" />
402
+ )}
403
+ </div>
404
+
405
+ {hasSearched && results.length === 0 && !searching && (
406
+ <div className="py-4 text-center text-[13px] text-base-content/30">
407
+ No plugins found for "{query}"
408
+ </div>
409
+ )}
410
+
411
+ {results.length > 0 && (
412
+ <div className="space-y-2">
413
+ {results.map(function (entry) {
414
+ var key = entry.name + "@" + entry.marketplace;
415
+ var isInstalled = installedKeys.has(key);
416
+ var isInstalling = installingKey === key;
417
+ return (
418
+ <div
419
+ key={key}
420
+ className="flex items-start gap-3 px-3 py-2.5 bg-base-300/50 border border-base-content/10 rounded-xl"
421
+ >
422
+ <Package size={14} className="text-base-content/20 mt-0.5 flex-shrink-0" />
423
+ <div className="flex-1 min-w-0">
424
+ <div className="flex items-center gap-2">
425
+ <span className="text-[13px] font-bold text-base-content truncate">{entry.name}</span>
426
+ <span className="text-[10px] font-mono text-base-content/30">{entry.marketplace}</span>
427
+ </div>
428
+ {entry.description && (
429
+ <div className="text-[12px] text-base-content/40 mt-0.5 line-clamp-2">{entry.description}</div>
430
+ )}
431
+ <div className="flex items-center gap-3 mt-1">
432
+ {entry.author && (
433
+ <span className="text-[10px] text-base-content/25">{entry.author.name}</span>
434
+ )}
435
+ {entry.installs != null && entry.installs > 0 && (
436
+ <span className="text-[10px] text-base-content/25 flex items-center gap-0.5">
437
+ <Download size={9} />
438
+ {formatInstalls(entry.installs)}
439
+ </span>
440
+ )}
441
+ </div>
442
+ </div>
443
+ <div className="flex-shrink-0 mt-0.5" onClick={function (e) { e.stopPropagation(); }}>
444
+ {isInstalled ? (
445
+ <span className="text-[10px] font-mono px-2 py-1 rounded-md bg-success/10 text-success/70">
446
+ Installed
447
+ </span>
448
+ ) : isInstalling ? (
449
+ <Loader2 size={14} className="text-primary animate-spin mx-2" />
450
+ ) : (
451
+ <button
452
+ onClick={function () { onInstall(entry.name, entry.marketplace); }}
453
+ className="btn btn-primary btn-xs"
454
+ >
455
+ Install
456
+ </button>
457
+ )}
458
+ </div>
459
+ </div>
460
+ );
461
+ })}
462
+ </div>
463
+ )}
464
+ </div>
465
+ );
466
+ }
467
+
468
+ function MarketplaceList({ marketplaces }: { marketplaces: PluginMarketplaceInfo[] }) {
469
+ if (marketplaces.length === 0) return null;
470
+ return (
471
+ <div>
472
+ <div className="text-[12px] font-semibold text-base-content/40 mb-2">Registered Marketplaces</div>
473
+ <div className="space-y-1.5">
474
+ {marketplaces.map(function (m) {
475
+ return (
476
+ <div key={m.name} className="flex items-center gap-2.5 px-3 py-2 bg-base-300/30 border border-base-content/10 rounded-lg">
477
+ <Package size={12} className="text-base-content/20 flex-shrink-0" />
478
+ <div className="flex-1 min-w-0">
479
+ <span className="text-[12px] font-bold text-base-content/60">{m.name}</span>
480
+ <span className="text-[11px] text-base-content/25 ml-2">{m.source.repo}</span>
481
+ </div>
482
+ </div>
483
+ );
484
+ })}
485
+ </div>
486
+ </div>
487
+ );
488
+ }
489
+
490
+ function PluginDetailModal({ plugin, onClose }: { plugin: PluginDetails; onClose: () => void }) {
491
+ useEffect(function () {
492
+ function handleKeyDown(e: KeyboardEvent) {
493
+ if (e.key === "Escape") onClose();
494
+ }
495
+ document.addEventListener("keydown", handleKeyDown);
496
+ return function () { document.removeEventListener("keydown", handleKeyDown); };
497
+ }, [onClose]);
498
+
499
+ return (
500
+ <div className="fixed inset-0 z-50 flex items-center justify-center" role="dialog" aria-modal="true" aria-label={"Plugin: " + plugin.name}>
501
+ <div className="absolute inset-0 bg-black/50" onClick={onClose} />
502
+ <div className="relative bg-base-200 border border-base-content/15 rounded-2xl shadow-2xl w-full max-w-2xl mx-4 max-h-[80vh] flex flex-col overflow-hidden">
503
+ <div className="flex items-center justify-between px-5 py-4 border-b border-base-content/15 flex-shrink-0">
504
+ <div className="flex items-center gap-2.5 min-w-0">
505
+ <Blocks size={16} className="text-primary flex-shrink-0" />
506
+ <div className="min-w-0">
507
+ <h2 className="text-[15px] font-mono font-bold text-base-content truncate">{plugin.name}</h2>
508
+ <div className="flex items-center gap-2 mt-0.5">
509
+ <span className="text-[10px] font-mono text-base-content/40">v{plugin.version}</span>
510
+ <span className="text-[10px] text-base-content/30">{plugin.marketplace}</span>
511
+ </div>
512
+ </div>
513
+ </div>
514
+ <button
515
+ onClick={onClose}
516
+ aria-label="Close"
517
+ className="btn btn-ghost btn-xs btn-square text-base-content/40 hover:text-base-content"
518
+ >
519
+ <X size={16} />
520
+ </button>
521
+ </div>
522
+
523
+ <div className="overflow-y-auto flex-1">
524
+ <div className="px-5 py-3 border-b border-base-content/10 bg-base-300/30">
525
+ <div className="flex flex-wrap gap-x-6 gap-y-1.5">
526
+ {plugin.author && (
527
+ <MetaItem label="Author" value={plugin.author.name} />
528
+ )}
529
+ {plugin.license && (
530
+ <MetaItem label="License" value={plugin.license} />
531
+ )}
532
+ <MetaItem label="Installed" value={formatDate(plugin.installedAt)} />
533
+ <MetaItem label="Updated" value={formatDate(plugin.lastUpdated)} />
534
+ {plugin.homepage && (
535
+ <a
536
+ href={plugin.homepage}
537
+ target="_blank"
538
+ rel="noopener noreferrer"
539
+ className="flex items-center gap-1 text-[11px] text-primary/60 hover:text-primary transition-colors"
540
+ >
541
+ <ExternalLink size={10} />
542
+ Homepage
543
+ </a>
544
+ )}
545
+ </div>
546
+ </div>
547
+
548
+ {plugin.description && (
549
+ <div className="px-5 py-3 border-b border-base-content/10">
550
+ <p className="text-[13px] text-base-content/60">{plugin.description}</p>
551
+ </div>
552
+ )}
553
+
554
+ {plugin.keywords && plugin.keywords.length > 0 && (
555
+ <div className="px-5 py-3 border-b border-base-content/10">
556
+ <div className="flex flex-wrap gap-1.5">
557
+ {plugin.keywords.map(function (kw) {
558
+ return (
559
+ <span key={kw} className="text-[10px] font-mono px-1.5 py-0.5 rounded-md bg-base-content/8 text-base-content/40">
560
+ {kw}
561
+ </span>
562
+ );
563
+ })}
564
+ </div>
565
+ </div>
566
+ )}
567
+
568
+ {plugin.skills.length > 0 && (
569
+ <div className="px-5 py-3 border-b border-base-content/10">
570
+ <div className="flex items-center gap-1.5 mb-2">
571
+ <Puzzle size={12} className="text-base-content/30" />
572
+ <span className="text-[11px] font-bold text-base-content/40 uppercase tracking-wider">
573
+ Skills ({plugin.skills.length})
574
+ </span>
575
+ </div>
576
+ <div className="space-y-1.5">
577
+ {plugin.skills.map(function (skill) {
578
+ return (
579
+ <div key={skill.name} className="flex items-start gap-2 py-1">
580
+ <ChevronRight size={10} className="text-base-content/20 mt-0.5 flex-shrink-0" />
581
+ <div className="min-w-0">
582
+ <span className="text-[12px] font-bold text-base-content/70">{skill.name}</span>
583
+ {skill.description && (
584
+ <div className="text-[11px] text-base-content/35 line-clamp-1">{skill.description}</div>
585
+ )}
586
+ </div>
587
+ </div>
588
+ );
589
+ })}
590
+ </div>
591
+ </div>
592
+ )}
593
+
594
+ {Object.keys(plugin.hooks).length > 0 && (
595
+ <div className="px-5 py-3 border-b border-base-content/10">
596
+ <div className="flex items-center gap-1.5 mb-2">
597
+ <Webhook size={12} className="text-base-content/30" />
598
+ <span className="text-[11px] font-bold text-base-content/40 uppercase tracking-wider">
599
+ Hooks ({Object.keys(plugin.hooks).length})
600
+ </span>
601
+ </div>
602
+ <div className="space-y-1">
603
+ {Object.keys(plugin.hooks).map(function (hookName) {
604
+ return (
605
+ <div key={hookName} className="flex items-center gap-2 py-0.5">
606
+ <ChevronRight size={10} className="text-base-content/20 flex-shrink-0" />
607
+ <span className="text-[12px] font-mono text-base-content/50">{hookName}</span>
608
+ </div>
609
+ );
610
+ })}
611
+ </div>
612
+ </div>
613
+ )}
614
+
615
+ {plugin.rules.length > 0 && (
616
+ <div className="px-5 py-3">
617
+ <div className="flex items-center gap-1.5 mb-2">
618
+ <ScrollText size={12} className="text-base-content/30" />
619
+ <span className="text-[11px] font-bold text-base-content/40 uppercase tracking-wider">
620
+ Rules ({plugin.rules.length})
621
+ </span>
622
+ </div>
623
+ <div className="space-y-1">
624
+ {plugin.rules.map(function (rule) {
625
+ return (
626
+ <div key={rule} className="flex items-center gap-2 py-0.5">
627
+ <ChevronRight size={10} className="text-base-content/20 flex-shrink-0" />
628
+ <span className="text-[12px] font-mono text-base-content/50">{rule}</span>
629
+ </div>
630
+ );
631
+ })}
632
+ </div>
633
+ </div>
634
+ )}
635
+ </div>
636
+
637
+ <div className="px-5 py-3 border-t border-base-content/15 flex-shrink-0">
638
+ <div className="text-[11px] font-mono text-base-content/30 truncate">
639
+ {plugin.gitCommitSha.slice(0, 8)} &middot; {plugin.installPath}
640
+ </div>
641
+ </div>
642
+ </div>
643
+ </div>
644
+ );
645
+ }
646
+
647
+ function PluginErrorsSection({ errors }: { errors: PluginError[] }) {
648
+ return (
649
+ <div>
650
+ <div className="flex items-center gap-1.5 mb-2">
651
+ <AlertTriangle size={12} className="text-warning" />
652
+ <span className="text-[12px] font-semibold text-warning/80">
653
+ Errors ({errors.length})
654
+ </span>
655
+ </div>
656
+ <div className="space-y-2">
657
+ {errors.map(function (err) {
658
+ return (
659
+ <div key={err.key} className="px-3 py-2.5 bg-warning/5 border border-warning/20 rounded-xl">
660
+ <div className="flex items-center gap-2 mb-1">
661
+ <span className="text-[13px] font-bold text-base-content">{err.name}</span>
662
+ <span className="text-[10px] font-mono text-base-content/30">{err.marketplace}</span>
663
+ </div>
664
+ {err.errors.map(function (e, i) {
665
+ return (
666
+ <div key={i} className="flex items-start gap-1.5 mt-0.5">
667
+ <AlertTriangle size={10} className="text-warning/60 mt-0.5 flex-shrink-0" />
668
+ <span className="text-[12px] text-warning/70">{e}</span>
669
+ </div>
670
+ );
671
+ })}
672
+ </div>
673
+ );
674
+ })}
675
+ </div>
676
+ </div>
677
+ );
678
+ }
679
+
680
+ function DiscoverSection({
681
+ plugins,
682
+ loaded,
683
+ installingKey,
684
+ installedKeys,
685
+ onInstall,
686
+ }: {
687
+ plugins: MarketplacePluginEntry[];
688
+ loaded: boolean;
689
+ installingKey: string | null;
690
+ installedKeys: Set<string>;
691
+ onInstall: (name: string, marketplace: string) => void;
692
+ }) {
693
+ var [showAll, setShowAll] = useState(false);
694
+ var INITIAL_COUNT = 12;
695
+
696
+ if (!loaded) return null;
697
+ if (plugins.length === 0) return null;
698
+
699
+ var visible = showAll ? plugins : plugins.slice(0, INITIAL_COUNT);
700
+
701
+ return (
702
+ <div>
703
+ <div className="flex items-center justify-between mb-2">
704
+ <div className="text-[12px] font-semibold text-base-content/40">
705
+ Discover ({plugins.length} available)
706
+ </div>
707
+ </div>
708
+ <div className="space-y-2">
709
+ {visible.map(function (entry) {
710
+ var key = entry.name + "@" + entry.marketplace;
711
+ var isInstalled = installedKeys.has(key);
712
+ var isInstalling = installingKey === key;
713
+ return (
714
+ <div
715
+ key={key}
716
+ className="flex items-start gap-3 px-3 py-2.5 bg-base-300/50 border border-base-content/10 rounded-xl"
717
+ >
718
+ <Package size={14} className="text-base-content/20 mt-0.5 flex-shrink-0" />
719
+ <div className="flex-1 min-w-0">
720
+ <div className="flex items-center gap-2">
721
+ <span className="text-[13px] font-bold text-base-content truncate">{entry.name}</span>
722
+ <span className="text-[10px] font-mono text-base-content/30">{entry.marketplace}</span>
723
+ </div>
724
+ {entry.description && (
725
+ <div className="text-[12px] text-base-content/40 mt-0.5 line-clamp-2">{entry.description}</div>
726
+ )}
727
+ <div className="flex items-center gap-3 mt-1">
728
+ {entry.author && (
729
+ <span className="text-[10px] text-base-content/25">{entry.author.name}</span>
730
+ )}
731
+ {entry.installs != null && entry.installs > 0 && (
732
+ <span className="text-[10px] text-base-content/25 flex items-center gap-0.5">
733
+ <Download size={9} />
734
+ {formatInstalls(entry.installs)}
735
+ </span>
736
+ )}
737
+ </div>
738
+ </div>
739
+ <div className="flex-shrink-0 mt-0.5" onClick={function (e) { e.stopPropagation(); }}>
740
+ {isInstalled ? (
741
+ <span className="text-[10px] font-mono px-2 py-1 rounded-md bg-success/10 text-success/70">
742
+ Installed
743
+ </span>
744
+ ) : isInstalling ? (
745
+ <Loader2 size={14} className="text-primary animate-spin mx-2" />
746
+ ) : (
747
+ <button
748
+ onClick={function () { onInstall(entry.name, entry.marketplace); }}
749
+ className="btn btn-primary btn-xs"
750
+ >
751
+ Install
752
+ </button>
753
+ )}
754
+ </div>
755
+ </div>
756
+ );
757
+ })}
758
+ </div>
759
+ {!showAll && plugins.length > INITIAL_COUNT && (
760
+ <button
761
+ onClick={function () { setShowAll(true); }}
762
+ className="mt-3 text-[12px] text-primary/60 hover:text-primary transition-colors"
763
+ >
764
+ Show all {plugins.length} plugins
765
+ </button>
766
+ )}
767
+ {showAll && plugins.length > INITIAL_COUNT && (
768
+ <button
769
+ onClick={function () { setShowAll(false); }}
770
+ className="mt-3 text-[12px] text-primary/60 hover:text-primary transition-colors"
771
+ >
772
+ Show less
773
+ </button>
774
+ )}
775
+ </div>
776
+ );
777
+ }
778
+
779
+ function MetaItem({ label, value }: { label: string; value: string }) {
780
+ return (
781
+ <div className="flex items-baseline gap-1.5">
782
+ <span className="text-[11px] font-mono text-base-content/35 uppercase tracking-wider">{label}</span>
783
+ <span className="text-[12px] text-base-content/70">{value}</span>
784
+ </div>
785
+ );
786
+ }
787
+
788
+ function formatInstalls(count: number): string {
789
+ if (count >= 1000000) return (count / 1000000).toFixed(1) + "M";
790
+ if (count >= 1000) return (count / 1000).toFixed(1) + "k";
791
+ return String(count);
792
+ }
793
+
794
+ function formatDate(iso: string): string {
795
+ try {
796
+ var d = new Date(iso);
797
+ return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
798
+ } catch {
799
+ return iso;
800
+ }
801
+ }