spine-framework-portal 0.1.1

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,289 @@
1
+ import { useState } from 'react'
2
+ import { usePortalItems, useCreatePortalItem } from '../hooks/usePortalData'
3
+ import { UnifiedItemCard } from '../components/UnifiedItemCard'
4
+
5
+ // Simple UI components to avoid import issues
6
+ function Button({ children, variant = 'primary', onClick, disabled, loading, size = 'md' }: {
7
+ children: React.ReactNode;
8
+ variant?: 'primary' | 'outline' | 'ghost';
9
+ onClick?: () => void;
10
+ disabled?: boolean;
11
+ loading?: boolean;
12
+ size?: 'sm' | 'md' | 'lg';
13
+ }) {
14
+ const baseClasses = "px-4 py-2 rounded-md font-medium transition-colors"
15
+ const sizeClasses = {
16
+ sm: "px-3 py-1 text-sm",
17
+ md: "px-4 py-2",
18
+ lg: "px-6 py-3 text-lg"
19
+ }
20
+ const variantClasses = {
21
+ primary: "bg-blue-600 text-white hover:bg-blue-700 disabled:bg-blue-400",
22
+ outline: "border border-gray-300 bg-white text-gray-700 hover:bg-gray-50",
23
+ ghost: "text-gray-600 hover:text-gray-900 hover:bg-gray-100"
24
+ }
25
+
26
+ return (
27
+ <button
28
+ className={`${baseClasses} ${sizeClasses[size]} ${variantClasses[variant]}`}
29
+ onClick={onClick}
30
+ disabled={disabled || loading}
31
+ >
32
+ {loading ? 'Loading...' : children}
33
+ </button>
34
+ )
35
+ }
36
+
37
+ function Card({ children, className = '' }: { children: React.ReactNode; className?: string }) {
38
+ return (
39
+ <div className={`bg-white rounded-lg shadow border border-gray-200 ${className}`}>
40
+ {children}
41
+ </div>
42
+ )
43
+ }
44
+
45
+ Card.Header = function({ children }: { children: React.ReactNode }) {
46
+ return (
47
+ <div className="px-6 py-4 border-b border-gray-200">
48
+ {children}
49
+ </div>
50
+ )
51
+ }
52
+
53
+ Card.Content = function({ children, className = '' }: { children: React.ReactNode; className?: string }) {
54
+ return (
55
+ <div className={`px-6 py-4 ${className}`}>
56
+ {children}
57
+ </div>
58
+ )
59
+ }
60
+
61
+ function Badge({ children, variant = 'default' }: { children: React.ReactNode; variant?: string }) {
62
+ const variantClasses = {
63
+ default: "bg-gray-100 text-gray-800",
64
+ success: "bg-green-100 text-green-800",
65
+ error: "bg-red-100 text-red-800",
66
+ warning: "bg-yellow-100 text-yellow-800",
67
+ info: "bg-blue-100 text-blue-800"
68
+ }
69
+
70
+ return (
71
+ <span className={`px-2 py-1 rounded-full text-xs font-medium ${variantClasses[variant as keyof typeof variantClasses] || variantClasses.default}`}>
72
+ {children}
73
+ </span>
74
+ )
75
+ }
76
+
77
+ function LoadingSpinner() {
78
+ return (
79
+ <div className="flex justify-center items-center py-8">
80
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
81
+ </div>
82
+ )
83
+ }
84
+
85
+ /**
86
+ * Integrity Page - Placeholder for Spine install integrity checking
87
+ *
88
+ * This is a placeholder UI for the integrity checking functionality.
89
+ * The actual integrity checking will be implemented as a separate project.
90
+ */
91
+ export function IntegrityPage() {
92
+ const [isChecking, setIsChecking] = useState(false)
93
+ const [lastCheck, setLastCheck] = useState<{
94
+ status: 'pass' | 'fail' | 'warning'
95
+ timestamp: string
96
+ issues: string[]
97
+ } | null>(null)
98
+
99
+ const handleIntegrityCheck = async () => {
100
+ setIsChecking(true)
101
+
102
+ // Simulate integrity checking process
103
+ setTimeout(() => {
104
+ setLastCheck({
105
+ status: Math.random() > 0.3 ? 'pass' : Math.random() > 0.5 ? 'warning' : 'fail',
106
+ timestamp: new Date().toISOString(),
107
+ issues: [
108
+ 'Custom migration detected in local database',
109
+ 'Modified core files found',
110
+ 'Missing security patches'
111
+ ].slice(0, Math.floor(Math.random() * 3) + 1)
112
+ })
113
+ setIsChecking(false)
114
+ }, 3000)
115
+ }
116
+
117
+ const getStatusColor = (status: string) => {
118
+ switch (status) {
119
+ case 'pass': return 'success'
120
+ case 'warning': return 'warning'
121
+ case 'fail': return 'error'
122
+ default: return 'default'
123
+ }
124
+ }
125
+
126
+ const getStatusIcon = (status: string) => {
127
+ switch (status) {
128
+ case 'pass': return 'âś…'
129
+ case 'warning': return '⚠️'
130
+ case 'fail': return '❌'
131
+ default: return 'âť“'
132
+ }
133
+ }
134
+
135
+ return (
136
+ <div className="max-w-4xl mx-auto space-y-6">
137
+ {/* Header */}
138
+ <div>
139
+ <h1 className="text-2xl font-bold text-slate-900">Integrity Checker</h1>
140
+ <p className="text-slate-600">
141
+ Verify your Spine installation integrity and compliance
142
+ </p>
143
+ </div>
144
+
145
+ {/* Main Check Card */}
146
+ <Card>
147
+ <Card.Header>
148
+ <div className="flex items-center justify-between">
149
+ <h3 className="font-medium">System Integrity</h3>
150
+ {lastCheck && (
151
+ <Badge variant={getStatusColor(lastCheck.status) as any}>
152
+ {getStatusIcon(lastCheck.status)} {lastCheck.status.toUpperCase()}
153
+ </Badge>
154
+ )}
155
+ </div>
156
+ </Card.Header>
157
+
158
+ <Card.Content className="space-y-4">
159
+ <div className="text-center py-8">
160
+ <div className="w-16 h-16 bg-slate-100 rounded-full flex items-center justify-center mx-auto mb-4">
161
+ <span className="text-2xl">🛡️</span>
162
+ </div>
163
+
164
+ <h3 className="text-lg font-medium text-slate-900 mb-2">
165
+ Integrity Validation
166
+ </h3>
167
+
168
+ <p className="text-slate-600 mb-6 max-w-md mx-auto">
169
+ Run a comprehensive check to validate your Spine installation against
170
+ security standards and best practices.
171
+ </p>
172
+
173
+ <Button
174
+ onClick={handleIntegrityCheck}
175
+ disabled={isChecking}
176
+ loading={isChecking}
177
+ size="lg"
178
+ >
179
+ {isChecking ? 'Checking Integrity...' : 'Run Integrity Check'}
180
+ </Button>
181
+
182
+ {lastCheck && (
183
+ <div className="mt-4 text-sm text-slate-500">
184
+ Last check: {new Date(lastCheck.timestamp).toLocaleString()}
185
+ </div>
186
+ )}
187
+ </div>
188
+
189
+ {/* Results */}
190
+ {lastCheck && (
191
+ <div className="border-t pt-6">
192
+ <h4 className="font-medium mb-4">Check Results</h4>
193
+
194
+ {lastCheck.issues.length > 0 ? (
195
+ <div className="space-y-2">
196
+ {lastCheck.issues.map((issue, index) => (
197
+ <div
198
+ key={index}
199
+ className="flex items-center gap-3 p-3 bg-slate-50 rounded-lg"
200
+ >
201
+ <span className="text-lg">
202
+ {lastCheck.status === 'fail' ? '❌' : '⚠️'}
203
+ </span>
204
+ <span className="text-sm text-slate-700">{issue}</span>
205
+ </div>
206
+ ))}
207
+ </div>
208
+ ) : (
209
+ <div className="flex items-center gap-3 p-3 bg-green-50 rounded-lg">
210
+ <span className="text-lg">âś…</span>
211
+ <span className="text-sm text-green-700">
212
+ No integrity issues found. Your installation is compliant.
213
+ </span>
214
+ </div>
215
+ )}
216
+ </div>
217
+ )}
218
+ </Card.Content>
219
+ </Card>
220
+
221
+ {/* Information Cards */}
222
+ <div className="grid md:grid-cols-2 gap-6">
223
+ <Card>
224
+ <Card.Header>
225
+ <h3 className="font-medium">What We Check</h3>
226
+ </Card.Header>
227
+ <Card.Content>
228
+ <ul className="space-y-2 text-sm text-slate-600">
229
+ <li>• Core file integrity and modifications</li>
230
+ <li>• Security patch compliance</li>
231
+ <li>• Database schema consistency</li>
232
+ <li>• Configuration security</li>
233
+ <li>• Custom migration safety</li>
234
+ <li>• API endpoint compliance</li>
235
+ </ul>
236
+ </Card.Content>
237
+ </Card>
238
+
239
+ <Card>
240
+ <Card.Header>
241
+ <h3 className="font-medium">Future Enhancements</h3>
242
+ </Card.Header>
243
+ <Card.Content>
244
+ <div className="space-y-3">
245
+ <p className="text-sm text-slate-600">
246
+ This integrity checker is a placeholder for a comprehensive
247
+ validation system that will include:
248
+ </p>
249
+ <div className="space-y-2 text-sm text-slate-600">
250
+ <div className="flex items-center gap-2">
251
+ <span className="text-green-500">🔄</span>
252
+ <span>Automated continuous monitoring</span>
253
+ </div>
254
+ <div className="flex items-center gap-2">
255
+ <span className="text-blue-500">📊</span>
256
+ <span>Compliance reporting and analytics</span>
257
+ </div>
258
+ <div className="flex items-center gap-2">
259
+ <span className="text-purple-500">đź”§</span>
260
+ <span>Automated fix suggestions</span>
261
+ </div>
262
+ <div className="flex items-center gap-2">
263
+ <span className="text-orange-500">🚨</span>
264
+ <span>Real-time threat detection</span>
265
+ </div>
266
+ </div>
267
+ </div>
268
+ </Card.Content>
269
+ </Card>
270
+ </div>
271
+
272
+ {/* Call to Action */}
273
+ <Card className="bg-blue-50 border-blue-200">
274
+ <Card.Content className="text-center py-6">
275
+ <h3 className="font-medium text-blue-900 mb-2">
276
+ Enterprise Integrity Monitoring
277
+ </h3>
278
+ <p className="text-blue-700 mb-4 text-sm">
279
+ Contact us for advanced integrity monitoring with automated remediation,
280
+ compliance reporting, and 24/7 security monitoring.
281
+ </p>
282
+ <Button variant="outline">
283
+ Contact Sales
284
+ </Button>
285
+ </Card.Content>
286
+ </Card>
287
+ </div>
288
+ )
289
+ }
@@ -0,0 +1,124 @@
1
+ import { useState } from 'react'
2
+ import { BookOpen } from 'lucide-react'
3
+ import { useKBArticles, useKBArticle } from '../hooks/useKBArticles'
4
+ import { usePortalSignal } from '../hooks/usePortalSignal'
5
+ import { SearchFilterBar } from '../components/SearchFilterBar'
6
+ import { Skeleton } from '@core/components/ui/skeleton'
7
+ import { ScrollArea } from '@core/components/ui/scroll-area'
8
+
9
+ export function KnowledgePage() {
10
+ const [search, setSearch] = useState('')
11
+ const [debouncedSearch, setDebouncedSearch] = useState('')
12
+ const [selectedId, setSelectedId] = useState<string | null>(null)
13
+
14
+ const { articles, loading: listLoading, error } = useKBArticles(debouncedSearch)
15
+ const { article, loading: detailLoading } = useKBArticle(selectedId)
16
+ const { sendSignal } = usePortalSignal()
17
+
18
+ const handleSelectArticle = (id: string) => {
19
+ setSelectedId(id)
20
+ sendSignal('kb_article_read', 'Read KB article')
21
+ }
22
+
23
+ const handleSearchChange = (val: string) => {
24
+ setSearch(val)
25
+ clearTimeout((handleSearchChange as any)._t)
26
+ ;(handleSearchChange as any)._t = setTimeout(() => {
27
+ setDebouncedSearch(val)
28
+ if (val.trim().length > 1) sendSignal('kb_search', `Searched KB: ${val.trim()}`)
29
+ }, 300)
30
+ }
31
+
32
+ return (
33
+ <div className="flex flex-col h-full">
34
+ <div className="flex items-center gap-3 px-4 py-3 border-b border-border bg-background shrink-0">
35
+ <SearchFilterBar placeholder="Search articles…" value={search} onChange={handleSearchChange} />
36
+ </div>
37
+
38
+ {error && <div className="px-4 py-2 text-sm text-destructive border-b border-border shrink-0">{error}</div>}
39
+
40
+ <div className="flex flex-1 min-h-0">
41
+ {/* Col 1 — article list */}
42
+ <div className="w-64 shrink-0 border-r border-border flex flex-col min-h-0">
43
+ <div className="flex-1 overflow-y-auto">
44
+ {listLoading ? (
45
+ <div className="p-4 space-y-2">{Array.from({ length: 6 }).map((_, i) => <Skeleton key={i} className="h-12 w-full" />)}</div>
46
+ ) : articles.length === 0 ? (
47
+ <div className="p-6 text-sm text-muted-foreground text-center">No articles found.</div>
48
+ ) : (
49
+ articles.map((a) => (
50
+ <button key={a.id} onClick={() => handleSelectArticle(a.id)}
51
+ className={`w-full text-left px-4 py-3 border-b border-border hover:bg-accent/50 transition-colors ${
52
+ selectedId === a.id ? 'bg-accent border-l-2 border-l-primary' : ''
53
+ }`}
54
+ >
55
+ <p className={`text-sm font-medium truncate ${selectedId === a.id ? 'text-primary' : ''}`}>{a.title}</p>
56
+ {a.data?.kb_type && (
57
+ <span className="text-xs bg-muted text-muted-foreground px-1.5 py-0.5 rounded mt-0.5 inline-block">
58
+ {a.data.kb_type.replace(/_/g, ' ')}
59
+ </span>
60
+ )}
61
+ </button>
62
+ ))
63
+ )}
64
+ </div>
65
+ </div>
66
+
67
+ {/* Col 2 — article content */}
68
+ <div className="flex-1 min-h-0 flex flex-col">
69
+ {!selectedId ? (
70
+ <div className="flex-1 flex flex-col items-center justify-center gap-3 text-muted-foreground">
71
+ <BookOpen size={32} className="opacity-30" />
72
+ <p className="text-sm">Select an article to read</p>
73
+ </div>
74
+ ) : detailLoading ? (
75
+ <div className="p-6 space-y-4 max-w-2xl">
76
+ <Skeleton className="h-7 w-2/3" /><Skeleton className="h-4 w-full" /><Skeleton className="h-4 w-5/6" />
77
+ </div>
78
+ ) : !article ? (
79
+ <div className="p-6 text-sm text-muted-foreground">Article not found.</div>
80
+ ) : (
81
+ <>
82
+ <div className="px-6 py-3 border-b border-border shrink-0">
83
+ <h1 className="text-base font-semibold">{article.title}</h1>
84
+ </div>
85
+ <ScrollArea className="flex-1">
86
+ <div className="px-6 py-5 max-w-2xl">
87
+ {article.description ? (
88
+ <div
89
+ className="prose prose-sm dark:prose-invert max-w-none text-sm leading-relaxed prose-pre:bg-muted prose-pre:border prose-pre:border-border prose-pre:rounded prose-pre:text-xs prose-code:bg-muted prose-code:px-1 prose-code:rounded prose-code:text-xs prose-code:font-mono"
90
+ dangerouslySetInnerHTML={{ __html: article.description }}
91
+ />
92
+ ) : (
93
+ <p className="text-sm text-muted-foreground italic">No content available.</p>
94
+ )}
95
+ </div>
96
+ </ScrollArea>
97
+ </>
98
+ )}
99
+ </div>
100
+
101
+ {/* Col 3 — related articles */}
102
+ {selectedId && article && (
103
+ <div className="w-72 shrink-0 border-l border-border flex flex-col min-h-0">
104
+ <div className="flex items-center px-4 py-2 h-9 border-b border-border shrink-0">
105
+ <p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Related</p>
106
+ </div>
107
+ <div className="flex-1 overflow-y-auto">
108
+ {articles.filter((a) => a.id !== selectedId).slice(0, 6).map((a) => (
109
+ <button key={a.id} onClick={() => handleSelectArticle(a.id)}
110
+ className="w-full text-left px-4 py-3 border-b border-border hover:bg-accent/50 transition-colors">
111
+ <p className="text-sm font-medium truncate">{a.title}</p>
112
+ {a.description && <p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{a.description}</p>}
113
+ </button>
114
+ ))}
115
+ {articles.filter((a) => a.id !== selectedId).length === 0 && (
116
+ <p className="text-sm text-muted-foreground text-center py-6">No other articles.</p>
117
+ )}
118
+ </div>
119
+ </div>
120
+ )}
121
+ </div>
122
+ </div>
123
+ )
124
+ }
@@ -0,0 +1,250 @@
1
+ import { useState } from 'react'
2
+ import { Search, Zap, BarChart3, Plug, Brain, MessageSquare, FileText, X } from 'lucide-react'
3
+ import { Input } from '@core/components/ui/input'
4
+ import { Badge } from '@core/components/ui/badge'
5
+ import { Button } from '@core/components/ui/button'
6
+ import { Card, CardContent, CardHeader } from '@core/components/ui/card'
7
+ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@core/components/ui/dialog'
8
+ import { Separator } from '@core/components/ui/separator'
9
+
10
+ interface Plugin {
11
+ id: string
12
+ name: string
13
+ tagline: string
14
+ description: string
15
+ category: string
16
+ version: string
17
+ author: string
18
+ icon: React.ElementType
19
+ tags: string[]
20
+ }
21
+
22
+ const MOCK_PLUGINS: Plugin[] = [
23
+ {
24
+ id: '1',
25
+ name: 'AI Answer Engine',
26
+ tagline: 'Automatically answer tickets with AI',
27
+ description: 'Uses your knowledge base to draft answers for incoming tickets, reducing first-response time by up to 70%. Integrates directly with the Tickets module.',
28
+ category: 'AI',
29
+ version: '2.1.0',
30
+ author: 'Spine Labs',
31
+ icon: Brain,
32
+ tags: ['tickets', 'automation', 'ai'],
33
+ },
34
+ {
35
+ id: '2',
36
+ name: 'Analytics Dashboard',
37
+ tagline: 'Insights into support and engagement',
38
+ description: 'Track ticket volume, resolution times, community activity, and course completion rates in a single dashboard with exportable reports.',
39
+ category: 'Analytics',
40
+ version: '1.4.2',
41
+ author: 'Spine Labs',
42
+ icon: BarChart3,
43
+ tags: ['analytics', 'reporting'],
44
+ },
45
+ {
46
+ id: '3',
47
+ name: 'Slack Integration',
48
+ tagline: 'Get ticket and community alerts in Slack',
49
+ description: 'Receive real-time notifications for new tickets, replies, and community posts directly in your Slack workspace. Supports custom channel routing.',
50
+ category: 'Integrations',
51
+ version: '3.0.1',
52
+ author: 'Community',
53
+ icon: MessageSquare,
54
+ tags: ['slack', 'notifications', 'integrations'],
55
+ },
56
+ {
57
+ id: '4',
58
+ name: 'Smart KB Generator',
59
+ tagline: 'Turn resolved tickets into KB articles',
60
+ description: 'Automatically drafts knowledge base articles from resolved support tickets using AI. Review, edit, and publish with one click.',
61
+ category: 'AI',
62
+ version: '1.2.0',
63
+ author: 'Spine Labs',
64
+ icon: FileText,
65
+ tags: ['kb', 'ai', 'automation'],
66
+ },
67
+ {
68
+ id: '5',
69
+ name: 'Zapier Connector',
70
+ tagline: 'Connect to 5000+ apps via Zapier',
71
+ description: 'Trigger Zaps from ticket events, community posts, and course completions. Send data to CRMs, project management tools, and more.',
72
+ category: 'Integrations',
73
+ version: '2.0.0',
74
+ author: 'Community',
75
+ icon: Zap,
76
+ tags: ['zapier', 'integrations', 'automation'],
77
+ },
78
+ {
79
+ id: '6',
80
+ name: 'Custom Webhooks',
81
+ tagline: 'Push events to any endpoint',
82
+ description: 'Configure webhooks for any portal event — new tickets, status changes, new community posts, course completions — and push to any HTTP endpoint.',
83
+ category: 'Productivity',
84
+ version: '1.0.3',
85
+ author: 'Spine Labs',
86
+ icon: Plug,
87
+ tags: ['webhooks', 'integrations', 'developer'],
88
+ },
89
+ ]
90
+
91
+ const CATEGORIES = ['All', 'AI', 'Analytics', 'Integrations', 'Productivity']
92
+
93
+ const CATEGORY_VARIANT: Record<string, 'default' | 'secondary' | 'outline'> = {
94
+ AI: 'default',
95
+ Analytics: 'secondary',
96
+ Integrations: 'outline',
97
+ Productivity: 'outline',
98
+ }
99
+
100
+ export function MarketplacePage() {
101
+ const [search, setSearch] = useState('')
102
+ const [activeCategory, setActiveCategory] = useState('All')
103
+ const [selected, setSelected] = useState<Plugin | null>(null)
104
+
105
+ const filtered = MOCK_PLUGINS.filter((p) => {
106
+ const matchSearch = p.name.toLowerCase().includes(search.toLowerCase()) ||
107
+ p.tagline.toLowerCase().includes(search.toLowerCase()) ||
108
+ p.tags.some((t) => t.includes(search.toLowerCase()))
109
+ const matchCategory = activeCategory === 'All' || p.category === activeCategory
110
+ return matchSearch && matchCategory
111
+ })
112
+
113
+ return (
114
+ <div className="flex flex-col min-h-full">
115
+ <div className="max-w-4xl mx-auto w-full px-6 py-8 space-y-6">
116
+ {/* Header */}
117
+ <div>
118
+ <h1 className="text-xl font-semibold tracking-tight">Marketplace</h1>
119
+ <p className="text-sm text-muted-foreground mt-1">
120
+ Browse plugins and integrations to extend your portal experience.
121
+ </p>
122
+ </div>
123
+
124
+ {/* Search + filters */}
125
+ <div className="flex flex-col sm:flex-row gap-3">
126
+ <div className="relative flex-1">
127
+ <Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
128
+ <Input
129
+ placeholder="Search plugins…"
130
+ value={search}
131
+ onChange={(e) => setSearch(e.target.value)}
132
+ className="pl-9"
133
+ />
134
+ </div>
135
+ <div className="flex items-center gap-1.5 flex-wrap">
136
+ {CATEGORIES.map((cat) => (
137
+ <Button
138
+ key={cat}
139
+ variant={activeCategory === cat ? 'default' : 'outline'}
140
+ size="sm"
141
+ onClick={() => setActiveCategory(cat)}
142
+ className="h-8"
143
+ >
144
+ {cat}
145
+ </Button>
146
+ ))}
147
+ </div>
148
+ </div>
149
+
150
+ {/* Results count */}
151
+ <p className="text-xs text-muted-foreground">{filtered.length} plugin{filtered.length !== 1 ? 's' : ''}</p>
152
+
153
+ {/* Plugin grid */}
154
+ {filtered.length === 0 ? (
155
+ <div className="text-center py-16 text-muted-foreground">
156
+ <Search size={32} className="mx-auto mb-3 opacity-30" />
157
+ <p className="text-sm">No plugins match your search.</p>
158
+ </div>
159
+ ) : (
160
+ <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
161
+ {filtered.map((plugin) => {
162
+ const Icon = plugin.icon
163
+ return (
164
+ <Card
165
+ key={plugin.id}
166
+ className="cursor-pointer hover:border-primary/40 hover:shadow-sm transition-all group"
167
+ onClick={() => setSelected(plugin)}
168
+ >
169
+ <CardHeader className="pb-2">
170
+ <div className="flex items-start gap-3">
171
+ <div className="p-2 rounded-md bg-primary/10 text-primary shrink-0">
172
+ <Icon size={18} />
173
+ </div>
174
+ <div className="flex-1 min-w-0">
175
+ <p className="font-semibold text-sm truncate">{plugin.name}</p>
176
+ <Badge
177
+ variant={CATEGORY_VARIANT[plugin.category] ?? 'secondary'}
178
+ className="text-xs mt-0.5"
179
+ >
180
+ {plugin.category}
181
+ </Badge>
182
+ </div>
183
+ </div>
184
+ </CardHeader>
185
+ <CardContent>
186
+ <p className="text-sm text-muted-foreground line-clamp-2">{plugin.tagline}</p>
187
+ </CardContent>
188
+ </Card>
189
+ )
190
+ })}
191
+ </div>
192
+ )}
193
+ </div>
194
+
195
+ {/* Plugin detail Dialog */}
196
+ <Dialog open={!!selected} onOpenChange={(v) => !v && setSelected(null)}>
197
+ <DialogContent className="sm:max-w-lg">
198
+ {selected && (
199
+ <>
200
+ <DialogHeader>
201
+ <div className="flex items-center gap-3">
202
+ <div className="p-2.5 rounded-md bg-primary/10 text-primary shrink-0">
203
+ <selected.icon size={22} />
204
+ </div>
205
+ <div>
206
+ <DialogTitle className="text-lg">{selected.name}</DialogTitle>
207
+ <Badge
208
+ variant={CATEGORY_VARIANT[selected.category] ?? 'secondary'}
209
+ className="text-xs mt-1"
210
+ >
211
+ {selected.category}
212
+ </Badge>
213
+ </div>
214
+ </div>
215
+ </DialogHeader>
216
+
217
+ <div className="space-y-4 py-2">
218
+ <p className="text-sm leading-relaxed">{selected.description}</p>
219
+
220
+ <Separator />
221
+
222
+ <div className="grid grid-cols-2 gap-3 text-sm">
223
+ <div>
224
+ <p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-0.5">Version</p>
225
+ <p className="font-mono">{selected.version}</p>
226
+ </div>
227
+ <div>
228
+ <p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-0.5">Author</p>
229
+ <p>{selected.author}</p>
230
+ </div>
231
+ </div>
232
+
233
+ <div className="flex flex-wrap gap-1.5">
234
+ {selected.tags.map((tag) => (
235
+ <Badge key={tag} variant="outline" className="text-xs">{tag}</Badge>
236
+ ))}
237
+ </div>
238
+ </div>
239
+
240
+ <DialogFooter className="gap-2">
241
+ <Button variant="outline" onClick={() => setSelected(null)}>Close</Button>
242
+ <Button>Install Plugin</Button>
243
+ </DialogFooter>
244
+ </>
245
+ )}
246
+ </DialogContent>
247
+ </Dialog>
248
+ </div>
249
+ )
250
+ }