Barazo default frontend barazo.forum
at main 331 lines 12 kB view raw
1/** 2 * Admin plugin management page. 3 * URL: /admin/plugins 4 * Tabbed view: "Installed" lists plugins with controls, "Browse" searches the registry. 5 * @see specs/prd-web.md Section M13 6 */ 7 8'use client' 9 10import { useState, useEffect, useCallback } from 'react' 11import { PuzzlePiece, MagnifyingGlass } from '@phosphor-icons/react' 12import { cn } from '@/lib/utils' 13import { AdminLayout } from '@/components/admin/admin-layout' 14import { ErrorAlert } from '@/components/error-alert' 15import { PluginCard } from '@/components/admin/plugins/plugin-card' 16import { RegistryPluginCard } from '@/components/admin/plugins/registry-plugin-card' 17import { PluginSettingsModal } from '@/components/admin/plugins/plugin-settings-modal' 18import { DependencyWarningDialog } from '@/components/admin/plugins/dependency-warning-dialog' 19import { usePluginManagement } from '@/hooks/admin/use-plugin-management' 20import { useRegistrySearch } from '@/hooks/admin/use-registry-search' 21import { useAuth } from '@/hooks/use-auth' 22import { installPlugin, getFeaturedPlugins } from '@/lib/api/client' 23import type { RegistryPlugin } from '@/lib/api/types' 24 25type PluginTab = 'installed' | 'browse' 26 27export 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 35 const { 36 plugins, 37 loading, 38 settingsPlugin, 39 setSettingsPlugin, 40 dependencyWarning, 41 setDependencyWarning, 42 loadError, 43 actionError, 44 setActionError, 45 fetchPlugins, 46 handleToggle, 47 confirmDisable, 48 handleSaveSettings, 49 handleUninstall, 50 settingsSaveStatus, 51 } = usePluginManagement() 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 103 return ( 104 <AdminLayout> 105 <div className="space-y-6"> 106 <h1 className="text-2xl font-bold text-foreground">Plugins</h1> 107 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> 142 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 )} 153 154 {actionError && ( 155 <ErrorAlert message={actionError} onDismiss={() => setActionError(null)} /> 156 )} 157 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 )} 210 </div> 211 )} 212 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 )} 308 </div> 309 )} 310 </div> 311 312 {settingsPlugin && ( 313 <PluginSettingsModal 314 plugin={settingsPlugin} 315 onClose={() => setSettingsPlugin(null)} 316 onSave={(settings) => void handleSaveSettings(settings)} 317 saveStatus={settingsSaveStatus} 318 /> 319 )} 320 321 {dependencyWarning && ( 322 <DependencyWarningDialog 323 pluginName={dependencyWarning.plugin.displayName} 324 dependents={dependencyWarning.dependents} 325 onConfirm={() => void confirmDisable()} 326 onCancel={() => setDependencyWarning(null)} 327 /> 328 )} 329 </AdminLayout> 330 ) 331}