Barazo default frontend barazo.forum

feat(plugins): add marketplace UI with registry browsing and install flow (#191)

* feat(plugins): add marketplace browse tab and version comparison (P2.12 M3)

Add tabbed UI to admin plugins page with Installed and Browse tabs.
Browse tab searches the plugin registry and allows installing plugins.
Installed tab shows "Update available" badge when registry has a newer version.

* style: format marketplace components with prettier

authored by

Guido X Jansen and committed by
GitHub
0d53a92b ee9923d1

+408 -36
+262 -36
src/app/admin/plugins/page.tsx
··· 1 1 /** 2 2 * Admin plugin management page. 3 3 * URL: /admin/plugins 4 - * Lists installed plugins with enable/disable, settings, and uninstall controls. 4 + * Tabbed view: "Installed" lists plugins with controls, "Browse" searches the registry. 5 5 * @see specs/prd-web.md Section M13 6 6 */ 7 7 8 8 'use client' 9 9 10 - import { PuzzlePiece } from '@phosphor-icons/react' 10 + import { useState, useEffect, useCallback } from 'react' 11 + import { PuzzlePiece, MagnifyingGlass } from '@phosphor-icons/react' 12 + import { cn } from '@/lib/utils' 11 13 import { AdminLayout } from '@/components/admin/admin-layout' 12 14 import { ErrorAlert } from '@/components/error-alert' 13 15 import { PluginCard } from '@/components/admin/plugins/plugin-card' 16 + import { RegistryPluginCard } from '@/components/admin/plugins/registry-plugin-card' 14 17 import { PluginSettingsModal } from '@/components/admin/plugins/plugin-settings-modal' 15 18 import { DependencyWarningDialog } from '@/components/admin/plugins/dependency-warning-dialog' 16 19 import { usePluginManagement } from '@/hooks/admin/use-plugin-management' 20 + import { useRegistrySearch } from '@/hooks/admin/use-registry-search' 21 + import { useAuth } from '@/hooks/use-auth' 22 + import { installPlugin, getFeaturedPlugins } from '@/lib/api/client' 23 + import type { RegistryPlugin } from '@/lib/api/types' 24 + 25 + type PluginTab = 'installed' | 'browse' 17 26 18 27 export default function AdminPluginsPage() { 28 + const { getAccessToken } = useAuth() 29 + const [tab, setTab] = useState<PluginTab>('installed') 30 + const [searchQuery, setSearchQuery] = useState('') 31 + const [installingName, setInstallingName] = useState<string | null>(null) 32 + const [installError, setInstallError] = useState<string | null>(null) 33 + const [registryVersions, setRegistryVersions] = useState<Map<string, string>>(new Map()) 34 + 19 35 const { 20 36 plugins, 21 37 loading, ··· 34 50 settingsSaveStatus, 35 51 } = usePluginManagement() 36 52 53 + const registry = useRegistrySearch() 54 + 55 + // Fetch featured plugins on mount to get registry versions for update comparison 56 + useEffect(() => { 57 + async function loadRegistryVersions() { 58 + try { 59 + const response = await getFeaturedPlugins() 60 + const versions = new Map<string, string>() 61 + for (const plugin of response.plugins) { 62 + versions.set(plugin.name, plugin.version) 63 + } 64 + setRegistryVersions(versions) 65 + } catch { 66 + // Non-critical: version comparison is a nice-to-have 67 + } 68 + } 69 + void loadRegistryVersions() 70 + }, []) 71 + 72 + const installedNames = new Set(plugins.map((p) => p.name)) 73 + 74 + const handleSearch = useCallback(() => { 75 + void registry.search({ q: searchQuery || undefined }) 76 + }, [registry, searchQuery]) 77 + 78 + const handleInstall = useCallback( 79 + async (plugin: RegistryPlugin) => { 80 + setInstallingName(plugin.name) 81 + setInstallError(null) 82 + try { 83 + await installPlugin(plugin.name, plugin.version, getAccessToken() ?? '') 84 + await fetchPlugins() 85 + } catch { 86 + setInstallError(`Failed to install ${plugin.displayName}. Please try again.`) 87 + } finally { 88 + setInstallingName(null) 89 + } 90 + }, 91 + [getAccessToken, fetchPlugins] 92 + ) 93 + 94 + const hasUpdate = useCallback( 95 + (pluginName: string, installedVersion: string): boolean => { 96 + const registryVersion = registryVersions.get(pluginName) 97 + if (!registryVersion) return false 98 + return registryVersion !== installedVersion 99 + }, 100 + [registryVersions] 101 + ) 102 + 37 103 return ( 38 104 <AdminLayout> 39 105 <div className="space-y-6"> 40 106 <h1 className="text-2xl font-bold text-foreground">Plugins</h1> 41 107 42 - {loadError && ( 43 - <ErrorAlert message={loadError} variant="page" onRetry={() => void fetchPlugins()} /> 44 - )} 108 + <div role="tablist" aria-label="Plugin tabs" className="flex gap-1 border-b border-border"> 109 + <button 110 + type="button" 111 + onClick={() => setTab('installed')} 112 + className={cn( 113 + 'px-4 py-2 text-sm font-medium transition-colors', 114 + tab === 'installed' 115 + ? 'border-b-2 border-primary text-foreground' 116 + : 'text-muted-foreground hover:text-foreground' 117 + )} 118 + aria-selected={tab === 'installed'} 119 + role="tab" 120 + id="tab-installed" 121 + aria-controls="tabpanel-installed" 122 + > 123 + Installed 124 + </button> 125 + <button 126 + type="button" 127 + onClick={() => setTab('browse')} 128 + className={cn( 129 + 'px-4 py-2 text-sm font-medium transition-colors', 130 + tab === 'browse' 131 + ? 'border-b-2 border-primary text-foreground' 132 + : 'text-muted-foreground hover:text-foreground' 133 + )} 134 + aria-selected={tab === 'browse'} 135 + role="tab" 136 + id="tab-browse" 137 + aria-controls="tabpanel-browse" 138 + > 139 + Browse 140 + </button> 141 + </div> 45 142 46 - {actionError && <ErrorAlert message={actionError} onDismiss={() => setActionError(null)} />} 143 + {tab === 'installed' && ( 144 + <div 145 + role="tabpanel" 146 + id="tabpanel-installed" 147 + aria-labelledby="tab-installed" 148 + className="space-y-6" 149 + > 150 + {loadError && ( 151 + <ErrorAlert message={loadError} variant="page" onRetry={() => void fetchPlugins()} /> 152 + )} 47 153 48 - {loading && ( 49 - <div className="space-y-3" aria-busy="true" aria-label="Loading plugins"> 50 - {[1, 2, 3].map((i) => ( 51 - <div 52 - key={i} 53 - className="h-20 animate-pulse rounded-lg border border-border bg-muted/50" 54 - /> 55 - ))} 56 - </div> 57 - )} 154 + {actionError && ( 155 + <ErrorAlert message={actionError} onDismiss={() => setActionError(null)} /> 156 + )} 58 157 59 - {!loading && !loadError && plugins.length === 0 && ( 60 - <div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-border py-16 text-center"> 61 - <PuzzlePiece className="mb-4 h-12 w-12 text-muted-foreground/50" aria-hidden="true" /> 62 - <h2 className="text-lg font-semibold text-foreground">No plugins installed</h2> 63 - <p className="mt-1 max-w-sm text-sm text-muted-foreground"> 64 - Plugins extend your community with additional features. Install plugins to see them 65 - listed here. 66 - </p> 158 + {loading && ( 159 + <div className="space-y-3" aria-busy="true" aria-label="Loading plugins"> 160 + {[1, 2, 3].map((i) => ( 161 + <div 162 + key={i} 163 + className="h-20 animate-pulse rounded-lg border border-border bg-muted/50" 164 + /> 165 + ))} 166 + </div> 167 + )} 168 + 169 + {!loading && !loadError && plugins.length === 0 && ( 170 + <div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-border py-16 text-center"> 171 + <PuzzlePiece 172 + className="mb-4 h-12 w-12 text-muted-foreground/50" 173 + aria-hidden="true" 174 + /> 175 + <h2 className="text-lg font-semibold text-foreground">No plugins installed</h2> 176 + <p className="mt-1 max-w-sm text-sm text-muted-foreground"> 177 + Plugins extend your community with additional features. Browse the registry to 178 + find and install plugins. 179 + </p> 180 + <button 181 + type="button" 182 + onClick={() => setTab('browse')} 183 + className="mt-4 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90" 184 + > 185 + Browse Plugins 186 + </button> 187 + </div> 188 + )} 189 + 190 + {!loading && plugins.length > 0 && ( 191 + <div className="space-y-3"> 192 + {plugins.map((plugin) => ( 193 + <div key={plugin.id} className="relative"> 194 + <PluginCard 195 + plugin={plugin} 196 + allPlugins={plugins} 197 + onOpenSettings={(p) => setSettingsPlugin(p)} 198 + onToggle={(p) => void handleToggle(p)} 199 + onUninstall={(p) => void handleUninstall(p)} 200 + /> 201 + {hasUpdate(plugin.name, plugin.version) && ( 202 + <span className="absolute right-14 top-4 rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-800 dark:bg-blue-900/30 dark:text-blue-400"> 203 + Update available 204 + </span> 205 + )} 206 + </div> 207 + ))} 208 + </div> 209 + )} 67 210 </div> 68 211 )} 69 212 70 - {!loading && plugins.length > 0 && ( 71 - <div className="space-y-3"> 72 - {plugins.map((plugin) => ( 73 - <PluginCard 74 - key={plugin.id} 75 - plugin={plugin} 76 - allPlugins={plugins} 77 - onOpenSettings={(p) => setSettingsPlugin(p)} 78 - onToggle={(p) => void handleToggle(p)} 79 - onUninstall={(p) => void handleUninstall(p)} 80 - /> 81 - ))} 213 + {tab === 'browse' && ( 214 + <div 215 + role="tabpanel" 216 + id="tabpanel-browse" 217 + aria-labelledby="tab-browse" 218 + className="space-y-6" 219 + > 220 + <div className="flex gap-2"> 221 + <div className="relative flex-1"> 222 + <MagnifyingGlass 223 + className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" 224 + aria-hidden="true" 225 + /> 226 + <input 227 + type="search" 228 + placeholder="Search plugins..." 229 + value={searchQuery} 230 + onChange={(e) => setSearchQuery(e.target.value)} 231 + onKeyDown={(e) => { 232 + if (e.key === 'Enter') handleSearch() 233 + }} 234 + className="w-full rounded-md border border-border bg-background py-2 pl-9 pr-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary" 235 + aria-label="Search plugins" 236 + /> 237 + </div> 238 + <button 239 + type="button" 240 + onClick={handleSearch} 241 + disabled={registry.loading} 242 + className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50" 243 + > 244 + Search 245 + </button> 246 + </div> 247 + 248 + {installError && ( 249 + <ErrorAlert message={installError} onDismiss={() => setInstallError(null)} /> 250 + )} 251 + {registry.error && <ErrorAlert message={registry.error} />} 252 + 253 + {registry.loading && ( 254 + <div className="space-y-3" aria-busy="true" aria-label="Searching plugins"> 255 + {[1, 2, 3].map((i) => ( 256 + <div 257 + key={i} 258 + className="h-20 animate-pulse rounded-lg border border-border bg-muted/50" 259 + /> 260 + ))} 261 + </div> 262 + )} 263 + 264 + {!registry.loading && 265 + registry.hasSearched && 266 + registry.results.length === 0 && 267 + !registry.error && ( 268 + <div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-border py-16 text-center"> 269 + <MagnifyingGlass 270 + className="mb-4 h-12 w-12 text-muted-foreground/50" 271 + aria-hidden="true" 272 + /> 273 + <h2 className="text-lg font-semibold text-foreground">No plugins found</h2> 274 + <p className="mt-1 max-w-sm text-sm text-muted-foreground"> 275 + Try a different search term or browse all available plugins. 276 + </p> 277 + </div> 278 + )} 279 + 280 + {!registry.loading && !registry.hasSearched && ( 281 + <div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-border py-16 text-center"> 282 + <PuzzlePiece 283 + className="mb-4 h-12 w-12 text-muted-foreground/50" 284 + aria-hidden="true" 285 + /> 286 + <h2 className="text-lg font-semibold text-foreground"> 287 + Browse the plugin registry 288 + </h2> 289 + <p className="mt-1 max-w-sm text-sm text-muted-foreground"> 290 + Search for plugins by name, category, or keyword to extend your community. 291 + </p> 292 + </div> 293 + )} 294 + 295 + {!registry.loading && registry.results.length > 0 && ( 296 + <div className="space-y-3"> 297 + {registry.results.map((plugin) => ( 298 + <RegistryPluginCard 299 + key={plugin.name} 300 + plugin={plugin} 301 + isInstalled={installedNames.has(plugin.name)} 302 + onInstall={(p) => void handleInstall(p)} 303 + installing={installingName === plugin.name} 304 + /> 305 + ))} 306 + </div> 307 + )} 82 308 </div> 83 309 )} 84 310 </div>
+77
src/components/admin/plugins/registry-plugin-card.tsx
··· 1 + /** 2 + * RegistryPluginCard - Card display for a plugin from the registry (browse tab). 3 + * @see specs/prd-web.md Section M13 4 + */ 5 + 6 + import { cn } from '@/lib/utils' 7 + import type { RegistryPlugin } from '@/lib/api/types' 8 + 9 + const SOURCE_STYLES: Record<string, string> = { 10 + core: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400', 11 + official: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400', 12 + community: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400', 13 + experimental: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400', 14 + } 15 + 16 + interface RegistryPluginCardProps { 17 + plugin: RegistryPlugin 18 + isInstalled: boolean 19 + onInstall: (plugin: RegistryPlugin) => void 20 + installing: boolean 21 + } 22 + 23 + export function RegistryPluginCard({ 24 + plugin, 25 + isInstalled, 26 + onInstall, 27 + installing, 28 + }: RegistryPluginCardProps) { 29 + return ( 30 + <article className="rounded-lg border border-border bg-card p-4"> 31 + <div className="flex items-start justify-between gap-4"> 32 + <div className="min-w-0 flex-1"> 33 + <div className="flex items-center gap-2"> 34 + <h3 className="text-sm font-semibold text-foreground">{plugin.displayName}</h3> 35 + <span className="text-xs text-muted-foreground">v{plugin.version}</span> 36 + <span 37 + className={cn( 38 + 'rounded-full px-2 py-0.5 text-xs font-medium', 39 + SOURCE_STYLES[plugin.source] ?? SOURCE_STYLES.community 40 + )} 41 + > 42 + {plugin.source} 43 + </span> 44 + {plugin.approved && ( 45 + <span 46 + className="text-xs text-green-600 dark:text-green-400" 47 + title="Approved by Barazo" 48 + > 49 + verified 50 + </span> 51 + )} 52 + </div> 53 + <p className="mt-1 text-xs text-muted-foreground">{plugin.description}</p> 54 + <p className="mt-1 text-xs text-muted-foreground"> 55 + By {plugin.author.name} · {plugin.license} · {plugin.category} 56 + </p> 57 + </div> 58 + <div className="flex shrink-0 items-center"> 59 + {isInstalled ? ( 60 + <span className="rounded-md border border-border px-3 py-1.5 text-xs text-muted-foreground"> 61 + Installed 62 + </span> 63 + ) : ( 64 + <button 65 + type="button" 66 + onClick={() => onInstall(plugin)} 67 + disabled={installing} 68 + className="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50" 69 + > 70 + {installing ? 'Installing...' : 'Install'} 71 + </button> 72 + )} 73 + </div> 74 + </div> 75 + </article> 76 + ) 77 + }
+34
src/hooks/admin/use-registry-search.ts
··· 1 + /** 2 + * Hook for searching the plugin registry. 3 + * @see specs/prd-web.md Section M13 4 + */ 5 + 6 + 'use client' 7 + 8 + import { useState, useCallback } from 'react' 9 + import { searchPluginRegistry } from '@/lib/api/client' 10 + import type { RegistryPlugin } from '@/lib/api/types' 11 + 12 + export function useRegistrySearch() { 13 + const [results, setResults] = useState<RegistryPlugin[]>([]) 14 + const [loading, setLoading] = useState(false) 15 + const [error, setError] = useState<string | null>(null) 16 + const [hasSearched, setHasSearched] = useState(false) 17 + 18 + const search = useCallback(async (params: { q?: string; category?: string; source?: string }) => { 19 + setLoading(true) 20 + setError(null) 21 + setHasSearched(true) 22 + try { 23 + const response = await searchPluginRegistry(params) 24 + setResults(response.plugins) 25 + } catch { 26 + setError('Failed to search the plugin registry.') 27 + setResults([]) 28 + } finally { 29 + setLoading(false) 30 + } 31 + }, []) 32 + 33 + return { results, loading, error, hasSearched, search } 34 + }
+35
src/mocks/handlers.ts
··· 572 572 return new HttpResponse(null, { status: 204 }) 573 573 }), 574 574 575 + // GET /api/plugins/registry/search 576 + http.get(`${API_URL}/api/plugins/registry/search`, () => { 577 + return HttpResponse.json({ plugins: [] }) 578 + }), 579 + 580 + // GET /api/plugins/registry/featured 581 + http.get(`${API_URL}/api/plugins/registry/featured`, () => { 582 + return HttpResponse.json({ plugins: [] }) 583 + }), 584 + 585 + // POST /api/plugins/install 586 + http.post(`${API_URL}/api/plugins/install`, ({ request }) => { 587 + const auth = request.headers.get('Authorization') 588 + if (!auth?.startsWith('Bearer ')) { 589 + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 590 + } 591 + return HttpResponse.json({ 592 + plugin: { 593 + id: 'installed-1', 594 + name: '@barazo/plugin-new', 595 + displayName: 'New Plugin', 596 + version: '1.0.0', 597 + description: 'A newly installed plugin', 598 + source: 'community', 599 + enabled: false, 600 + category: 'general', 601 + dependencies: [], 602 + dependents: [], 603 + settingsSchema: {}, 604 + settings: {}, 605 + installedAt: new Date().toISOString(), 606 + }, 607 + }) 608 + }), 609 + 575 610 // GET /api/users/resolve-handles 576 611 http.get(`${API_URL}/api/users/resolve-handles`, ({ request }) => { 577 612 const auth = request.headers.get('Authorization')