extension-list.tsx
1 'use client' 2 3 import { useEffect, useState, useCallback, useMemo } from 'react' 4 import { useAppStore } from '@/stores/use-app-store' 5 import { useNavigate } from '@/lib/app/navigation' 6 import { api } from '@/lib/app/api-client' 7 import { getExtensionSourceLabel } from '@/lib/extension-sources' 8 import { toast } from 'sonner' 9 import { useMountedRef } from '@/hooks/use-mounted-ref' 10 import type { Agent, MarketplaceExtension, ExtensionMeta } from '@/types' 11 import { AgentAvatar } from '@/components/agents/agent-avatar' 12 import { ConfirmDialog } from '@/components/shared/confirm-dialog' 13 import { dedup } from '@/lib/shared-utils' 14 15 type TopTab = 'extensions' | 'marketplace' 16 17 export function ExtensionList({ inSidebar }: { inSidebar?: boolean }) { 18 const extensions = useAppStore((s) => s.extensions) 19 const loadExtensions = useAppStore((s) => s.loadExtensions) 20 const setExtensionSheetOpen = useAppStore((s) => s.setExtensionSheetOpen) 21 const setEditingExtensionFilename = useAppStore((s) => s.setEditingExtensionFilename) 22 const agents = useAppStore((s) => s.agents) 23 const setCurrentAgent = useAppStore((s) => s.setCurrentAgent) 24 const navigateTo = useNavigate() 25 26 const navigateToAgentChat = useCallback((agentId: string) => { 27 void setCurrentAgent(agentId) 28 navigateTo('agents') 29 // eslint-disable-next-line react-hooks/exhaustive-deps 30 }, []) 31 32 const [tab, setTab] = useState<TopTab>('extensions') 33 const [marketplace, setMarketplace] = useState<MarketplaceExtension[]>([]) 34 const [mpLoading, setMpLoading] = useState(false) 35 const [installing, setInstalling] = useState<string | null>(null) 36 const [deleting, setDeleting] = useState(false) 37 const [confirmDelete, setConfirmDelete] = useState<{ filename: string; name: string } | null>(null) 38 const [search, setSearch] = useState('') 39 const [activeTag, setActiveTag] = useState<string | null>(null) 40 const [sort, setSort] = useState<'name' | 'downloads'>('downloads') 41 const mountedRef = useMountedRef() 42 43 useEffect(() => { 44 void loadExtensions() 45 }, [loadExtensions]) 46 47 const loadMarketplace = useCallback(async () => { 48 if (!mountedRef.current) return 49 setMpLoading(true) 50 try { 51 const data = await api<MarketplaceExtension[]>('GET', '/extensions/marketplace') 52 if (mountedRef.current && Array.isArray(data)) setMarketplace(data) 53 } catch { /* ignore */ } 54 if (mountedRef.current) setMpLoading(false) 55 }, [mountedRef]) 56 57 useEffect(() => { 58 if (inSidebar || tab !== 'marketplace') return 59 const timer = setTimeout(() => { void loadMarketplace() }, 0) 60 return () => clearTimeout(timer) 61 }, [tab, inSidebar, loadMarketplace]) 62 63 const extensionList = Object.values(extensions) 64 const filteredExtensionList = useMemo(() => extensionList, [extensionList]) 65 66 // Search filtering for installed extensions 67 const filterInstalled = useCallback((list: ExtensionMeta[]) => { 68 if (!search.trim()) return list 69 const q = search.toLowerCase() 70 return list.filter((p) => 71 p.name.toLowerCase().includes(q) || 72 (p.description || '').toLowerCase().includes(q) || 73 p.filename.toLowerCase().includes(q) 74 ) 75 }, [search]) 76 77 const filteredExtensions = useMemo(() => filterInstalled(filteredExtensionList), [filterInstalled, filteredExtensionList]) 78 79 const handleEdit = (filename: string) => { 80 setEditingExtensionFilename(filename) 81 setExtensionSheetOpen(true) 82 } 83 84 const handleToggle = async (e: React.MouseEvent, filename: string, enabled: boolean) => { 85 e.stopPropagation() 86 try { 87 await api('POST', '/extensions', { filename, enabled: !enabled }) 88 toast.success(!enabled ? 'Extension enabled' : 'Extension disabled') 89 loadExtensions() 90 } catch (err: unknown) { 91 toast.error(err instanceof Error ? err.message : 'Failed to toggle extension') 92 } 93 } 94 95 const handleDeleteClick = (e: React.MouseEvent, filename: string, name: string) => { 96 e.stopPropagation() 97 setConfirmDelete({ filename, name }) 98 } 99 100 const handleDeleteConfirm = async () => { 101 if (!confirmDelete) return 102 setDeleting(true) 103 try { 104 await api('DELETE', `/extensions?filename=${encodeURIComponent(confirmDelete.filename)}`) 105 toast.success('Extension deleted') 106 await loadExtensions() 107 } catch (err: unknown) { 108 toast.error(err instanceof Error ? err.message : 'Delete failed') 109 } finally { 110 setDeleting(false) 111 setConfirmDelete(null) 112 } 113 } 114 115 const installFromMarketplace = async (p: MarketplaceExtension) => { 116 setInstalling(p.id) 117 const toastId = toast.loading(`Installing ${p.name}...`) 118 try { 119 const safeFilename = `${p.id.replace(/[^a-zA-Z0-9.-]/g, '_')}.js` 120 await api('POST', '/extensions/install', { 121 url: p.url, 122 filename: safeFilename, 123 installMethod: 'marketplace', 124 sourceLabel: p.source, 125 installSource: p.catalogSource || p.source, 126 }) 127 await loadExtensions() 128 toast.success(`Installed ${p.name}`, { id: toastId }) 129 } catch (err: unknown) { 130 toast.error(err instanceof Error ? err.message : 'Install failed', { id: toastId }) 131 } 132 setInstalling(null) 133 } 134 135 const installedFilenames = new Set(Object.keys(extensions)) 136 137 // --- Sidebar mode --- 138 if (inSidebar) { 139 return ( 140 <div className="px-3 pb-4 flex-1 overflow-y-auto"> 141 <div className="space-y-2"> 142 {extensionList.map((ext) => ( 143 <SidebarExtensionCard key={ext.filename} ext={ext} onEdit={handleEdit} /> 144 ))} 145 </div> 146 </div> 147 ) 148 } 149 150 // --- Full page mode --- 151 const enabledCount = extensionList.filter((p) => p.enabled).length 152 const totalTools = extensionList.reduce((acc, p) => acc + (p.toolCount ?? 0), 0) 153 const totalHooks = extensionList.reduce((acc, p) => acc + (p.hookCount ?? 0), 0) 154 155 return ( 156 <div className="flex-1 overflow-y-auto px-5 pb-6"> 157 {/* Stats bar */} 158 <div className="flex items-center gap-3 mb-4"> 159 <Stat label="Installed" value={extensionList.length} /> 160 <Stat label="Enabled" value={enabledCount} accent /> 161 <Stat label="Tools" value={totalTools} /> 162 <Stat label="Hooks" value={totalHooks} /> 163 <div className="flex-1" /> 164 {/* Search */} 165 <div className="relative w-[260px]"> 166 <svg className="absolute left-2.5 top-1/2 -translate-y-1/2 text-text-3/40" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"> 167 <circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" /> 168 </svg> 169 <input 170 value={search} 171 onChange={(e) => setSearch(e.target.value)} 172 placeholder="Search extensions..." 173 className="w-full pl-8 pr-3 py-2 rounded-[10px] bg-surface border border-white/[0.06] text-[12px] text-text placeholder:text-text-3/40 outline-none focus:border-accent-bright/30 transition-colors" 174 style={{ fontFamily: 'inherit' }} 175 /> 176 </div> 177 </div> 178 179 {/* Tabs */} 180 <div className="flex items-center gap-1 mb-5 border-b border-white/[0.06] pb-px"> 181 <TabButton active={tab === 'extensions'} onClick={() => setTab('extensions')} count={extensionList.length}> 182 Extensions 183 </TabButton> 184 <TabButton active={tab === 'marketplace'} onClick={() => setTab('marketplace')}> 185 Marketplace 186 </TabButton> 187 </div> 188 189 {/* Tab content */} 190 {tab === 'extensions' && ( 191 <InstalledGrid 192 extensions={filteredExtensions} 193 allowDelete 194 search={search} 195 agents={agents} 196 onEdit={handleEdit} 197 onToggle={handleToggle} 198 onDelete={handleDeleteClick} 199 onNavigateToAgent={navigateToAgentChat} 200 emptyMessage={search ? 'No extensions match your search' : 'No extensions installed'} 201 emptyAction={!search ? ( 202 <button 203 onClick={() => setTab('marketplace')} 204 className="mt-3 px-4 py-2 rounded-[10px] bg-transparent text-accent-bright text-[12px] font-600 cursor-pointer border border-accent-bright/20 hover:bg-accent-soft transition-all" 205 style={{ fontFamily: 'inherit' }} 206 > 207 Browse Marketplace 208 </button> 209 ) : undefined} 210 /> 211 )} 212 213 {tab === 'marketplace' && ( 214 <MarketplaceTab 215 marketplace={marketplace} 216 loading={mpLoading} 217 installing={installing} 218 installedFilenames={installedFilenames} 219 search={search} 220 activeTag={activeTag} 221 setActiveTag={setActiveTag} 222 sort={sort} 223 setSort={setSort} 224 onInstall={installFromMarketplace} 225 /> 226 )} 227 228 <ConfirmDialog 229 open={!!confirmDelete} 230 title="Delete Extension" 231 message={confirmDelete ? `Delete "${confirmDelete.name}"? This cannot be undone.` : ''} 232 confirmLabel={deleting ? 'Deleting...' : 'Delete'} 233 danger 234 onConfirm={() => { void handleDeleteConfirm() }} 235 onCancel={() => { if (!deleting) setConfirmDelete(null) }} 236 /> 237 </div> 238 ) 239 } 240 241 // --- Sub-components --- 242 243 function Stat({ label, value, accent }: { label: string; value: number; accent?: boolean }) { 244 return ( 245 <div className="flex items-center gap-1.5"> 246 <span className={`text-[18px] font-700 tabular-nums ${accent ? 'text-accent-bright' : 'text-text'}`}> 247 {value} 248 </span> 249 <span className="text-[11px] text-text-3/60 font-500">{label}</span> 250 </div> 251 ) 252 } 253 254 function TabButton({ active, onClick, count, children }: { 255 active: boolean; onClick: () => void; count?: number; children: React.ReactNode 256 }) { 257 return ( 258 <button 259 onClick={onClick} 260 className={`relative px-3 py-2 text-[12px] font-600 cursor-pointer transition-all border-none bg-transparent 261 ${active ? 'text-accent-bright' : 'text-text-3/60 hover:text-text-2'}`} 262 style={{ fontFamily: 'inherit' }} 263 > 264 <span className="flex items-center gap-1.5"> 265 {children} 266 {count !== undefined && ( 267 <span className={`text-[10px] tabular-nums px-1.5 py-px rounded-full ${ 268 active ? 'bg-accent-soft text-accent-bright' : 'bg-white/[0.04] text-text-3/50' 269 }`}> 270 {count} 271 </span> 272 )} 273 </span> 274 {active && <div className="absolute bottom-0 left-2 right-2 h-[2px] rounded-full bg-accent-bright" />} 275 </button> 276 ) 277 } 278 279 function extensionDescription(ext: ExtensionMeta): string { 280 const raw = (ext.description || '').trim() 281 if (raw) return raw 282 const sourceLabel = ext.isBuiltin ? 'built-in tool integration' : 'installed extension' 283 return `No description provided. Click to view metadata and controls for this ${sourceLabel}.` 284 } 285 286 function extensionCapabilityBadges(ext: ExtensionMeta): string[] { 287 const badges: string[] = [] 288 if (ext.toolCount && ext.toolCount > 0) badges.push(`${ext.toolCount} tool${ext.toolCount === 1 ? '' : 's'}`) 289 if (ext.hookCount && ext.hookCount > 0) badges.push(`${ext.hookCount} hook${ext.hookCount === 1 ? '' : 's'}`) 290 if (ext.hasUI) badges.push('UI') 291 if (ext.providerCount && ext.providerCount > 0) badges.push(`${ext.providerCount} provider${ext.providerCount === 1 ? '' : 's'}`) 292 if (ext.connectorCount && ext.connectorCount > 0) badges.push(`${ext.connectorCount} connector${ext.connectorCount === 1 ? '' : 's'}`) 293 if (ext.hasDependencyManifest) badges.push(`${ext.dependencyCount ?? 0} dep${ext.dependencyCount === 1 ? '' : 's'}`) 294 return badges 295 } 296 297 // --- Installed extensions grid --- 298 299 function InstalledGrid({ extensions, allowDelete, search, agents, onEdit, onToggle, onDelete, onNavigateToAgent, emptyMessage, emptyAction }: { 300 extensions: ExtensionMeta[] 301 allowDelete: boolean 302 search: string 303 agents: Record<string, Agent> 304 onEdit: (filename: string) => void 305 onToggle: (e: React.MouseEvent, filename: string, enabled: boolean) => void 306 onDelete: (e: React.MouseEvent, filename: string, name: string) => void 307 onNavigateToAgent: (agentId: string) => void 308 emptyMessage: string 309 emptyAction?: React.ReactNode 310 }) { 311 if (extensions.length === 0) { 312 return ( 313 <div className="text-center py-16"> 314 <div className="inline-flex items-center justify-center w-12 h-12 rounded-full bg-white/[0.03] mb-3"> 315 <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-text-3/30"> 316 <path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" strokeLinecap="round" strokeLinejoin="round" /> 317 </svg> 318 </div> 319 <p className="text-[13px] text-text-3/50">{emptyMessage}</p> 320 {emptyAction} 321 </div> 322 ) 323 } 324 325 // Group enabled first, then disabled 326 const enabled = extensions.filter((p) => p.enabled) 327 const disabled = extensions.filter((p) => !p.enabled) 328 const sorted = [...enabled, ...disabled] 329 330 return ( 331 <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3"> 332 {sorted.map((ext) => ( 333 <ExtensionCard 334 key={ext.filename} 335 ext={ext} 336 allowDelete={allowDelete} 337 agents={agents} 338 onEdit={onEdit} 339 onToggle={onToggle} 340 onDelete={onDelete} 341 onNavigateToAgent={onNavigateToAgent} 342 highlight={search} 343 /> 344 ))} 345 </div> 346 ) 347 } 348 349 // --- Extension card --- 350 351 function ExtensionCard({ ext, allowDelete, agents, onEdit, onToggle, onDelete, onNavigateToAgent, highlight }: { 352 ext: ExtensionMeta 353 allowDelete: boolean 354 agents: Record<string, Agent> 355 onEdit: (filename: string) => void 356 onToggle: (e: React.MouseEvent, filename: string, enabled: boolean) => void 357 onDelete: (e: React.MouseEvent, filename: string, name: string) => void 358 onNavigateToAgent: (agentId: string) => void 359 highlight: string 360 }) { 361 const badges = extensionCapabilityBadges(ext) 362 const agent = ext.createdByAgentId ? agents[ext.createdByAgentId] : null 363 364 return ( 365 <div 366 role="button" 367 tabIndex={0} 368 onClick={() => onEdit(ext.filename)} 369 onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onEdit(ext.filename) } }} 370 className={`group relative text-left p-4 rounded-[14px] border transition-all cursor-pointer 371 ${ext.enabled 372 ? 'border-white/[0.06] bg-surface hover:bg-surface-2 hover:border-white/[0.1]' 373 : 'border-white/[0.03] bg-surface/50 hover:bg-surface hover:border-white/[0.06] opacity-70 hover:opacity-100' 374 }`} 375 > 376 {/* Top row: name + toggle */} 377 <div className="flex items-center justify-between gap-2 mb-1.5"> 378 <div className="flex items-center gap-2 min-w-0"> 379 {agent && ( 380 <button 381 type="button" 382 title={`Created by ${agent.name}`} 383 onClick={(e) => { e.stopPropagation(); onNavigateToAgent(ext.createdByAgentId!) }} 384 className="shrink-0 rounded-full hover:ring-2 hover:ring-accent-bright/40 transition-all cursor-pointer bg-transparent border-none p-0" 385 > 386 <AgentAvatar 387 seed={agent.avatarSeed || null} 388 avatarUrl={agent.avatarUrl} 389 name={agent.name || 'Agent'} 390 size={20} 391 /> 392 </button> 393 )} 394 <span className="font-display text-[14px] font-600 text-text truncate"> 395 <HighlightText text={ext.name} highlight={highlight} /> 396 </span> 397 {ext.version && ( 398 <span className="text-[10px] font-mono text-text-3/40 shrink-0">v{ext.version}</span> 399 )} 400 </div> 401 <div className="flex items-center gap-2 shrink-0"> 402 <div 403 onClick={(e) => onToggle(e, ext.filename, ext.enabled)} 404 className={`w-9 h-5 rounded-full transition-all relative cursor-pointer shrink-0 405 ${ext.enabled ? 'bg-accent-bright' : 'bg-white/[0.08]'}`} 406 > 407 <div className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-all 408 ${ext.enabled ? 'left-[18px]' : 'left-0.5'}`} /> 409 </div> 410 {allowDelete && ( 411 <button 412 onClick={(e) => onDelete(e, ext.filename, ext.name)} 413 className="text-text-3/30 hover:text-red-400 transition-colors p-0.5 opacity-0 group-hover:opacity-100" 414 title="Delete" 415 > 416 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"> 417 <path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /> 418 </svg> 419 </button> 420 )} 421 </div> 422 </div> 423 424 {/* Description */} 425 <p className="text-[12px] text-text-3/60 leading-relaxed line-clamp-2 mb-2.5"> 426 {extensionDescription(ext)} 427 </p> 428 429 {/* Badges */} 430 <div className="flex items-center gap-1.5 flex-wrap"> 431 {badges.map((badge) => ( 432 <span key={badge} className="text-[10px] font-600 px-1.5 py-0.5 rounded-full text-text-3/70 bg-white/[0.04]"> 433 {badge} 434 </span> 435 ))} 436 {ext.sourceLabel && ( 437 <SourceChip label={getExtensionSourceLabel(ext.sourceLabel)} tone="publisher" /> 438 )} 439 {ext.installSource && ext.installSource !== ext.sourceLabel && ( 440 <SourceChip label={`via ${getExtensionSourceLabel(ext.installSource)}`} tone="catalog" /> 441 )} 442 {ext.hasDependencyManifest && ( 443 <span className={`text-[10px] font-700 px-1.5 py-0.5 rounded-full ${ 444 ext.dependencyInstallStatus === 'installed' 445 ? 'text-emerald-400 bg-emerald-500/10' 446 : ext.dependencyInstallStatus === 'error' 447 ? 'text-red-400 bg-red-500/10' 448 : 'text-amber-400 bg-amber-500/10' 449 }`}> 450 deps {ext.dependencyInstallStatus || 'ready'} 451 </span> 452 )} 453 {ext.author && ( 454 <span className="text-[10px] text-text-3/40 ml-auto"> 455 {ext.author} 456 </span> 457 )} 458 </div> 459 460 {/* Failure warning */} 461 {ext.autoDisabled && ( 462 <p className="mt-2 text-[11px] text-amber-400/90 line-clamp-2"> 463 Auto-disabled after {ext.failureCount ?? 0} failures 464 {ext.lastFailureStage ? ` (${ext.lastFailureStage})` : ''}. 465 {ext.lastFailureError ? ` ${ext.lastFailureError}` : ''} 466 </p> 467 )} 468 </div> 469 ) 470 } 471 472 // --- Sidebar card (compact) --- 473 474 function SidebarExtensionCard({ ext, onEdit }: { ext: ExtensionMeta; onEdit: (filename: string) => void }) { 475 return ( 476 <div 477 role="button" 478 tabIndex={0} 479 onClick={() => onEdit(ext.filename)} 480 onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onEdit(ext.filename) } }} 481 className="w-full text-left p-3 rounded-[12px] border border-white/[0.06] bg-surface hover:bg-surface-2 transition-all cursor-pointer" 482 > 483 <div className="flex items-center justify-between mb-0.5"> 484 <span className="font-display text-[13px] font-600 text-text truncate">{ext.name}</span> 485 <span className={`text-[10px] font-600 px-1.5 py-0.5 rounded-full ${ 486 ext.enabled ? 'text-emerald-400 bg-emerald-400/10' : 'text-text-3/50 bg-white/[0.04]' 487 }`}> 488 {ext.enabled ? 'On' : 'Off'} 489 </span> 490 </div> 491 <p className="text-[11px] text-text-3/50 line-clamp-1">{extensionDescription(ext)}</p> 492 </div> 493 ) 494 } 495 496 // --- Highlight text helper --- 497 498 function HighlightText({ text, highlight }: { text: string; highlight: string }) { 499 if (!highlight.trim()) return <>{text}</> 500 const idx = text.toLowerCase().indexOf(highlight.toLowerCase()) 501 if (idx === -1) return <>{text}</> 502 return ( 503 <> 504 {text.slice(0, idx)} 505 <span className="text-accent-bright">{text.slice(idx, idx + highlight.length)}</span> 506 {text.slice(idx + highlight.length)} 507 </> 508 ) 509 } 510 511 // --- Marketplace tab --- 512 513 function MarketplaceTab({ marketplace, loading, installing, installedFilenames, search, activeTag, setActiveTag, sort, setSort, onInstall }: { 514 marketplace: MarketplaceExtension[] 515 loading: boolean 516 installing: string | null 517 installedFilenames: Set<string> 518 search: string 519 activeTag: string | null 520 setActiveTag: (v: string | null) => void 521 sort: 'name' | 'downloads' 522 setSort: (v: 'name' | 'downloads') => void 523 onInstall: (p: MarketplaceExtension) => void 524 }) { 525 if (loading) return <p className="text-[12px] text-text-3/70 py-8 text-center">Loading marketplace...</p> 526 527 if (marketplace.length === 0) { 528 return ( 529 <div className="text-center py-16"> 530 <div className="inline-flex items-center justify-center w-12 h-12 rounded-full bg-white/[0.03] mb-3"> 531 <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-text-3/30"> 532 <path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" strokeLinecap="round" strokeLinejoin="round" /> 533 <polyline points="9,22 9,12 15,12 15,22" strokeLinecap="round" strokeLinejoin="round" /> 534 </svg> 535 </div> 536 <p className="text-[13px] text-text-3/50">No extensions available in the marketplace</p> 537 </div> 538 ) 539 } 540 541 const allTags = dedup(marketplace.flatMap((p) => p.tags ?? [])).sort() 542 const q = search.toLowerCase() 543 const filtered = marketplace 544 .filter((p) => { 545 const sourceTerms = [getExtensionSourceLabel(p.source).toLowerCase(), getExtensionSourceLabel(p.catalogSource).toLowerCase()] 546 if ( 547 q 548 && !p.name.toLowerCase().includes(q) 549 && !p.description.toLowerCase().includes(q) 550 && !(p.tags ?? []).some((t) => t.toLowerCase().includes(q)) 551 && !sourceTerms.some((term) => term.includes(q)) 552 ) return false 553 if (activeTag && !(p.tags ?? []).includes(activeTag)) return false 554 return true 555 }) 556 .sort((a, b) => sort === 'downloads' ? (b.downloads ?? 0) - (a.downloads ?? 0) : a.name.localeCompare(b.name)) 557 558 return ( 559 <div className="space-y-3"> 560 {/* Tags + Sort */} 561 <div className="flex items-center gap-1.5 flex-wrap"> 562 <button 563 onClick={() => setActiveTag(null)} 564 className={`px-2 py-1 rounded-[6px] text-[10px] font-600 cursor-pointer transition-all border-none ${ 565 !activeTag ? 'bg-accent-soft text-accent-bright' : 'bg-white/[0.03] text-text-3/60 hover:text-text-3' 566 }`} 567 > 568 All 569 </button> 570 {allTags.map((t) => ( 571 <button 572 key={t} 573 onClick={() => setActiveTag(activeTag === t ? null : t)} 574 className={`px-2 py-1 rounded-[6px] text-[10px] font-600 cursor-pointer transition-all border-none ${ 575 activeTag === t ? 'bg-accent-soft text-accent-bright' : 'bg-white/[0.03] text-text-3/60 hover:text-text-3' 576 }`} 577 > 578 {t} 579 </button> 580 ))} 581 <div className="flex-1" /> 582 <select 583 value={sort} 584 onChange={(e) => setSort(e.target.value as 'name' | 'downloads')} 585 className="px-2 py-1 rounded-[6px] bg-surface border border-white/[0.06] text-[10px] text-text-3 outline-none cursor-pointer appearance-none" 586 style={{ fontFamily: 'inherit' }} 587 > 588 <option value="downloads">Popular</option> 589 <option value="name">A-Z</option> 590 </select> 591 </div> 592 593 {filtered.length === 0 ? ( 594 <p className="text-[12px] text-text-3/50 text-center py-4">No extensions match your search</p> 595 ) : ( 596 <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3"> 597 {filtered.map((p) => { 598 const isInstalled = installedFilenames.has(`${p.id}.js`) 599 return ( 600 <div key={p.id} className="py-3.5 px-4 rounded-[14px] bg-surface border border-white/[0.06]"> 601 <div className="flex items-start gap-3"> 602 <div className="flex-1 min-w-0"> 603 <div className="flex items-center gap-2"> 604 <span className="text-[14px] font-600 text-text">{p.name}</span> 605 <span className="text-[10px] font-mono text-text-3/70">v{p.version}</span> 606 {p.openclaw && <span className="text-[9px] font-600 text-emerald-400 bg-emerald-400/10 px-1.5 py-0.5 rounded-full">OpenClaw</span>} 607 </div> 608 <div className="flex items-center gap-1.5 mt-2 flex-wrap"> 609 {p.source && <SourceChip label={getExtensionSourceLabel(p.source)} tone="publisher" />} 610 {p.catalogSource && p.catalogSource !== p.source && ( 611 <SourceChip label={`via ${getExtensionSourceLabel(p.catalogSource)}`} tone="catalog" /> 612 )} 613 </div> 614 <div className="text-[11px] text-text-3/60 mt-1 line-clamp-2">{p.description}</div> 615 <div className="flex items-center gap-2 mt-2"> 616 <span className="text-[10px] text-text-3/70">by {p.author}</span> 617 <span className="text-[10px] text-text-3/50">·</span> 618 {(p.tags ?? []).slice(0, 3).map((t) => ( 619 <button 620 key={t} 621 onClick={() => setActiveTag(activeTag === t ? null : t)} 622 className={`text-[9px] font-600 px-1.5 py-0.5 rounded-full cursor-pointer transition-all border-none ${ 623 activeTag === t ? 'text-accent-bright bg-accent-soft' : 'text-text-3/50 bg-white/[0.04] hover:text-text-3' 624 }`} 625 > 626 {t} 627 </button> 628 ))} 629 </div> 630 </div> 631 <button 632 onClick={() => !isInstalled && onInstall(p)} 633 disabled={isInstalled || installing === p.id} 634 className={`shrink-0 py-2 px-4 rounded-[10px] text-[12px] font-600 transition-all cursor-pointer 635 ${isInstalled 636 ? 'bg-white/[0.04] text-text-3/70 cursor-default' 637 : installing === p.id 638 ? 'bg-accent-soft text-accent-bright animate-pulse' 639 : 'bg-accent-soft text-accent-bright hover:bg-accent-soft/80 border border-accent-bright/20'}`} 640 style={{ fontFamily: 'inherit' }} 641 > 642 {isInstalled ? 'Installed' : installing === p.id ? 'Installing...' : 'Install'} 643 </button> 644 </div> 645 </div> 646 ) 647 })} 648 </div> 649 )} 650 </div> 651 ) 652 } 653 654 function SourceChip({ label, tone }: { label: string; tone: 'publisher' | 'catalog' }) { 655 return ( 656 <span className={tone === 'publisher' 657 ? 'text-[10px] font-700 px-1.5 py-0.5 rounded-full bg-sky-500/10 text-sky-300' 658 : 'text-[10px] font-700 px-1.5 py-0.5 rounded-full bg-white/[0.05] text-text-3/75'}> 659 {label} 660 </span> 661 ) 662 }