Barazo default frontend
barazo.forum
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}