create-fullstack-scaffold 0.1.1 → 0.2.0
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.
- package/dist/cli/index.js +1673 -696
- package/dist/cli/index.js.map +1 -1
- package/package.json +16 -10
- package/template/.husky/pre-commit +1 -1
- package/template/modules.config.ts +29 -2
- package/template/package.json +12 -12
- package/template/patches/{typescript+5.8.3.patch → typescript+5.9.3.patch} +2 -4
- package/template/playwright.config.ts +7 -1
- package/template/src/admin/App.tsx +2 -2
- package/template/src/admin/components/CaptchaModal.tsx +7 -9
- package/template/src/admin/layouts/Header.tsx +56 -22
- package/template/src/admin/layouts/Layout.tsx +14 -9
- package/template/src/admin/layouts/Sidebar.tsx +122 -105
- package/template/src/admin/pages/CategoryManagementPage.tsx +241 -0
- package/template/src/admin/pages/ContentPage.tsx +2 -0
- package/template/src/admin/pages/DashboardPage.tsx +2 -0
- package/template/src/admin/pages/DisputesPage.tsx +2 -0
- package/template/src/admin/pages/MediaTestPage.tsx +1 -1
- package/template/src/admin/pages/OrdersPage.tsx +2 -0
- package/template/src/admin/pages/PluginDashboardPage.tsx +297 -0
- package/template/src/admin/pages/PluginManagementPage.tsx +340 -0
- package/template/src/admin/pages/PluginReviewPage.tsx +255 -0
- package/template/src/admin/pages/SystemLogsPage.tsx +11 -2
- package/template/src/admin/pages/TicketsPage.tsx +2 -0
- package/template/src/admin/pages/UsersPage.tsx +3 -2
- package/template/src/admin/stores/adminStore.ts +5 -0
- package/template/src/cli/index.ts +17 -19
- package/template/src/cli/modules/auth/index.ts +65 -0
- package/template/src/cli/modules/config/index.ts +74 -77
- package/template/src/cli/modules/index.ts +28 -6
- package/template/src/cli/modules/notification/index.ts +95 -79
- package/template/src/cli/modules/plugin/index.ts +111 -0
- package/template/src/cli/modules/todo/index.ts +99 -51
- package/template/src/cli/utils/auto-command.ts +7 -25
- package/template/src/cli/utils/index.ts +3 -1
- package/template/src/client/App.tsx +36 -16
- package/template/src/client/Layout.tsx +67 -10
- package/template/src/client/components/AuthButton.tsx +25 -18
- package/template/src/client/components/BottomTabBar.tsx +107 -0
- package/template/src/client/components/Navigation.tsx +162 -52
- package/template/src/client/components/__tests__/App.test.tsx +48 -32
- package/template/src/client/components/__tests__/AuthButton.test.tsx +78 -77
- package/template/src/client/components/__tests__/Navigation.test.tsx +31 -20
- package/template/src/client/components/index.ts +1 -0
- package/template/src/client/contexts/PresetContext.tsx +10 -0
- package/template/src/client/main.tsx +63 -8
- package/template/src/client/pages/CartPage.tsx +244 -0
- package/template/src/client/pages/CategoriesPage.tsx +100 -0
- package/template/src/client/pages/ContentDetailPage.tsx +9 -14
- package/template/src/client/pages/ContentListPage.tsx +4 -11
- package/template/src/client/pages/DashboardPage.tsx +261 -0
- package/template/src/client/pages/DeveloperDashboardPage.tsx +211 -0
- package/template/src/client/pages/LoginPage.tsx +127 -0
- package/template/src/client/pages/OrdersPage.tsx +196 -0
- package/template/src/client/pages/PluginDetailPage.tsx +345 -0
- package/template/src/client/pages/PluginsPage.tsx +223 -0
- package/template/src/client/pages/ProfilePage.tsx +206 -0
- package/template/src/client/pages/PublishPage.tsx +336 -0
- package/template/src/client/pages/RegisterPage.tsx +136 -0
- package/template/src/client/pages/SearchPage.tsx +204 -0
- package/template/src/client/pages/SettingsPage.tsx +220 -0
- package/template/src/client/pages/TopicsPage.tsx +180 -0
- package/template/src/client/pages/__tests__/LoginPage.test.tsx +170 -0
- package/template/src/client/pages/__tests__/RegisterPage.test.tsx +168 -0
- package/template/src/client/preset-ui-config.ts +492 -0
- package/template/src/client/services/apiClient.ts +14 -4
- package/template/src/client/stores/__tests__/authStore.test.ts +293 -38
- package/template/src/client/stores/__tests__/todoStore.test.ts +7 -17
- package/template/src/client/stores/authStore.ts +58 -5
- package/template/src/client/stores/chatWSStore.ts +9 -0
- package/template/src/client/stores/notificationStore.ts +4 -0
- package/template/src/client/stores/pluginStore.ts +279 -0
- package/template/src/client/stores/todoStore.ts +2 -6
- package/template/src/server/core/__tests__/isr-cache.test.ts +117 -0
- package/template/src/server/core/__tests__/isr-invalidation.test.ts +72 -0
- package/template/src/server/core/__tests__/ssr-renderer.test.ts +89 -0
- package/template/src/server/core/isr-cache.ts +239 -0
- package/template/src/server/core/isr-invalidation.ts +45 -0
- package/template/src/server/core/module-loader.ts +14 -7
- package/template/src/server/core/ssr-renderer.ts +240 -0
- package/template/src/server/db/init.ts +257 -10
- package/template/src/server/db/schema/developers.ts +20 -0
- package/template/src/server/db/schema/index.ts +2 -0
- package/template/src/server/db/schema/plugins.ts +114 -0
- package/template/src/server/db/test-setup.ts +91 -0
- package/template/src/server/entries/cloudflare.ts +79 -7
- package/template/src/server/entries/node.ts +48 -5
- package/template/src/server/middleware/__tests__/captcha.test.ts +23 -13
- package/template/src/server/middleware/auth.ts +8 -1
- package/template/src/server/middleware/captcha.ts +14 -4
- package/template/src/server/middleware/rate-limit.ts +7 -2
- package/template/src/server/module-admin/module.ts +9 -5
- package/template/src/server/module-admin/routes/admin-notification-routes.ts +43 -14
- package/template/src/server/module-admin/routes/admin-routes.ts +0 -2
- package/template/src/server/module-admin/routes/client-auth-routes.ts +90 -0
- package/template/src/server/module-admin/routes/dashboard-routes.ts +79 -0
- package/template/src/server/module-admin/services/admin-service.ts +68 -11
- package/template/src/server/module-auth/__tests__/auth-service.test.ts +239 -0
- package/template/src/server/module-auth/index.ts +7 -0
- package/template/src/server/module-auth/module.ts +40 -0
- package/template/src/server/module-auth/routes/auth-routes.ts +94 -0
- package/template/src/server/module-auth/routes/profile-routes.ts +31 -0
- package/template/src/server/module-auth/services/auth-service.ts +100 -0
- package/template/src/server/module-content/module.ts +10 -4
- package/template/src/server/module-content/routes/public-content-routes.ts +2 -2
- package/template/src/server/module-content/routes/topics-routes.ts +205 -0
- package/template/src/server/module-content/services/content-service.ts +18 -2
- package/template/src/server/module-dispute/routes/dispute-routes.ts +2 -1
- package/template/src/server/module-dispute/services/dispute-service.ts +1 -1
- package/template/src/server/module-notifications/__tests__/sse-rpc.test.ts +14 -14
- package/template/src/server/module-notifications/routes/notification-routes.ts +34 -8
- package/template/src/server/module-order/__tests__/order-route.test.ts +22 -2
- package/template/src/server/module-order/module.ts +15 -0
- package/template/src/server/module-order/routes/cart-routes.ts +103 -0
- package/template/src/server/module-order/routes/orders-mock-routes.ts +67 -0
- package/template/src/server/module-order/services/order-service.ts +1 -1
- package/template/src/server/module-plugin/__tests__/plugin-query-service.test.ts +203 -0
- package/template/src/server/module-plugin/__tests__/plugin-service.test.ts +234 -0
- package/template/src/server/module-plugin/index.ts +2 -0
- package/template/src/server/module-plugin/module.ts +52 -0
- package/template/src/server/module-plugin/routes/plugin-admin-routes.ts +261 -0
- package/template/src/server/module-plugin/routes/plugin-routes.ts +354 -0
- package/template/src/server/module-plugin/services/admin-category-service.ts +99 -0
- package/template/src/server/module-plugin/services/admin-plugin-service.ts +170 -0
- package/template/src/server/module-plugin/services/admin-stats-service.ts +41 -0
- package/template/src/server/module-plugin/services/plugin-query-service.ts +360 -0
- package/template/src/server/module-plugin/services/plugin-review-service.ts +95 -0
- package/template/src/server/module-plugin/services/plugin-service.ts +163 -0
- package/template/src/server/module-ticket/services/ticket-service.ts +1 -1
- package/template/src/server/module-todos/routes/todos-routes.ts +0 -4
- package/template/src/server/route-registry.ts +16 -1
- package/template/src/server/test-utils/test-client.ts +1 -2
- package/template/src/server/utils/auth.ts +6 -0
- package/template/src/server/utils/json.ts +13 -0
- package/template/src/shared/core/module-manifest.ts +3 -6
- package/template/src/shared/modules/auth/index.ts +12 -0
- package/template/src/shared/modules/auth/schemas.ts +50 -0
- package/template/src/shared/modules/cart/index.ts +1 -0
- package/template/src/shared/modules/cart/schemas.ts +41 -0
- package/template/src/shared/modules/community/index.ts +1 -0
- package/template/src/shared/modules/community/schemas.ts +58 -0
- package/template/src/shared/modules/dashboard/index.ts +1 -0
- package/template/src/shared/modules/dashboard/schemas.ts +35 -0
- package/template/src/shared/modules/index.ts +41 -0
- package/template/src/shared/modules/order/schemas.ts +29 -0
- package/template/src/shared/modules/plugins/index.ts +48 -0
- package/template/src/shared/modules/plugins/schemas.ts +227 -0
- package/template/src/shared/schemas/index.ts +130 -0
- package/template/tests/e2e/todo.spec.ts +23 -18
- package/template/tests/e2e/visual-screenshots.spec.ts +1824 -0
- package/template/uploads/.gitkeep +0 -0
- package/template/vite.config.ts +2 -1
- package/template/vitest.setup.ts +11 -0
- package/template/wrangler.toml +4 -3
- package/template/package-lock.json +0 -14554
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { useEffect } from 'react'
|
|
2
|
+
import { Link } from 'react-router-dom'
|
|
3
|
+
import { Helmet } from 'react-helmet-async'
|
|
4
|
+
import { FolderOpen, Package } from 'lucide-react'
|
|
5
|
+
import { usePluginStore } from '@client/stores/pluginStore'
|
|
6
|
+
import { LoadingSpinner, EmptyState } from '@client/components'
|
|
7
|
+
|
|
8
|
+
const categoryGradients = [
|
|
9
|
+
'from-violet-500 to-purple-600',
|
|
10
|
+
'from-blue-500 to-cyan-500',
|
|
11
|
+
'from-fuchsia-500 to-pink-500',
|
|
12
|
+
'from-emerald-500 to-teal-500',
|
|
13
|
+
'from-orange-500 to-amber-500',
|
|
14
|
+
'from-rose-500 to-red-500',
|
|
15
|
+
'from-indigo-500 to-blue-600',
|
|
16
|
+
'from-teal-500 to-green-500',
|
|
17
|
+
'from-cyan-500 to-sky-500',
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
export const CategoriesPage: React.FC = () => {
|
|
21
|
+
const categories = usePluginStore(state => state.categories)
|
|
22
|
+
const loading = usePluginStore(state => state.loading)
|
|
23
|
+
const fetchCategories = usePluginStore(state => state.fetchCategories)
|
|
24
|
+
const setSelectedCategory = usePluginStore(state => state.setSelectedCategory)
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
fetchCategories()
|
|
28
|
+
}, [fetchCategories])
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<div className="min-h-screen" data-testid="categories-page">
|
|
32
|
+
<Helmet>
|
|
33
|
+
<title>Categories - Plugin Marketplace</title>
|
|
34
|
+
<meta name="description" content="Browse plugins by category" />
|
|
35
|
+
</Helmet>
|
|
36
|
+
|
|
37
|
+
<div className="bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900 pt-12 pb-16 px-6">
|
|
38
|
+
<div className="max-w-6xl mx-auto">
|
|
39
|
+
<div className="flex items-center gap-3 mb-4">
|
|
40
|
+
<div className="p-2.5 bg-gradient-to-br from-violet-500 to-fuchsia-500 rounded-xl shadow-lg shadow-violet-500/25">
|
|
41
|
+
<FolderOpen className="w-6 h-6 text-white" />
|
|
42
|
+
</div>
|
|
43
|
+
<h1 className="text-3xl font-bold text-white tracking-tight">Categories</h1>
|
|
44
|
+
</div>
|
|
45
|
+
<p className="text-purple-200/70 text-lg">
|
|
46
|
+
Browse plugins by category to find exactly what you need
|
|
47
|
+
</p>
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
|
|
51
|
+
<div className="max-w-6xl mx-auto px-6 -mt-8 pb-16">
|
|
52
|
+
{loading && categories.length === 0 ? (
|
|
53
|
+
<div className="flex items-center justify-center py-20">
|
|
54
|
+
<LoadingSpinner size="lg" />
|
|
55
|
+
</div>
|
|
56
|
+
) : categories.length === 0 ? (
|
|
57
|
+
<EmptyState icon={FolderOpen} title="No categories yet" />
|
|
58
|
+
) : (
|
|
59
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
|
60
|
+
{categories.map((category, index) => (
|
|
61
|
+
<Link
|
|
62
|
+
key={category.id}
|
|
63
|
+
to="/plugins"
|
|
64
|
+
onClick={() => setSelectedCategory(category.slug)}
|
|
65
|
+
className="group relative overflow-hidden bg-white rounded-2xl border border-gray-100 hover:border-transparent hover:shadow-2xl hover:shadow-violet-500/10 transition-all duration-300 block"
|
|
66
|
+
>
|
|
67
|
+
<div
|
|
68
|
+
className={`absolute top-0 left-0 right-0 h-1.5 bg-gradient-to-r ${
|
|
69
|
+
categoryGradients[index % categoryGradients.length]
|
|
70
|
+
} opacity-60 group-hover:opacity-100 transition-opacity`}
|
|
71
|
+
/>
|
|
72
|
+
<div className="p-6">
|
|
73
|
+
<div
|
|
74
|
+
className={`w-14 h-14 rounded-2xl bg-gradient-to-br ${
|
|
75
|
+
categoryGradients[index % categoryGradients.length]
|
|
76
|
+
} flex items-center justify-center text-2xl shadow-lg mb-4`}
|
|
77
|
+
>
|
|
78
|
+
{category.icon || '📁'}
|
|
79
|
+
</div>
|
|
80
|
+
<h3 className="text-lg font-semibold text-gray-900 group-hover:text-violet-700 transition-colors">
|
|
81
|
+
{category.name}
|
|
82
|
+
</h3>
|
|
83
|
+
{category.description && (
|
|
84
|
+
<p className="text-sm text-gray-500 mt-1.5 leading-relaxed">
|
|
85
|
+
{category.description}
|
|
86
|
+
</p>
|
|
87
|
+
)}
|
|
88
|
+
<div className="mt-4 flex items-center gap-1.5 text-xs text-violet-500 font-medium opacity-0 group-hover:opacity-100 transition-opacity">
|
|
89
|
+
<Package className="w-3.5 h-3.5" />
|
|
90
|
+
Browse plugins
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
</Link>
|
|
94
|
+
))}
|
|
95
|
+
</div>
|
|
96
|
+
)}
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
)
|
|
100
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useEffect } from 'react'
|
|
1
|
+
import { useState, useEffect, useCallback } from 'react'
|
|
2
2
|
import { useParams, Link } from 'react-router-dom'
|
|
3
3
|
import { Helmet } from 'react-helmet-async'
|
|
4
4
|
import type { Content } from '@shared/modules/content'
|
|
@@ -10,11 +10,7 @@ export const ContentDetailPage: React.FC = () => {
|
|
|
10
10
|
const [loading, setLoading] = useState(true)
|
|
11
11
|
const [error, setError] = useState<string | null>(null)
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
if (id) fetchContent(id)
|
|
15
|
-
}, [id])
|
|
16
|
-
|
|
17
|
-
const fetchContent = async (contentId: string) => {
|
|
13
|
+
const fetchContent = useCallback(async (contentId: string) => {
|
|
18
14
|
setLoading(true)
|
|
19
15
|
setError(null)
|
|
20
16
|
try {
|
|
@@ -40,7 +36,11 @@ export const ContentDetailPage: React.FC = () => {
|
|
|
40
36
|
} finally {
|
|
41
37
|
setLoading(false)
|
|
42
38
|
}
|
|
43
|
-
}
|
|
39
|
+
}, [])
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (id) fetchContent(id)
|
|
43
|
+
}, [id, fetchContent])
|
|
44
44
|
|
|
45
45
|
const formatDate = (dateStr: string | null | undefined) => {
|
|
46
46
|
if (!dateStr) return ''
|
|
@@ -63,9 +63,7 @@ export const ContentDetailPage: React.FC = () => {
|
|
|
63
63
|
return (
|
|
64
64
|
<div className="max-w-4xl mx-auto p-6">
|
|
65
65
|
<div className="text-center py-12">
|
|
66
|
-
<h1 className="text-2xl font-bold text-gray-900 mb-4">
|
|
67
|
-
{error || '内容不存在'}
|
|
68
|
-
</h1>
|
|
66
|
+
<h1 className="text-2xl font-bold text-gray-900 mb-4">{error || '内容不存在'}</h1>
|
|
69
67
|
<Link to="/content" className="text-blue-600 hover:underline">
|
|
70
68
|
← 返回内容列表
|
|
71
69
|
</Link>
|
|
@@ -107,10 +105,7 @@ export const ContentDetailPage: React.FC = () => {
|
|
|
107
105
|
{content.category}
|
|
108
106
|
</span>
|
|
109
107
|
{content.tags?.map(tag => (
|
|
110
|
-
<span
|
|
111
|
-
key={tag}
|
|
112
|
-
className="px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded"
|
|
113
|
-
>
|
|
108
|
+
<span key={tag} className="px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded">
|
|
114
109
|
{tag}
|
|
115
110
|
</span>
|
|
116
111
|
))}
|
|
@@ -33,8 +33,7 @@ export const ContentListPage: React.FC = () => {
|
|
|
33
33
|
})
|
|
34
34
|
const result = await res.json()
|
|
35
35
|
if (result.success) {
|
|
36
|
-
|
|
37
|
-
setContents(Array.isArray(data) ? data : [])
|
|
36
|
+
setContents(result.data)
|
|
38
37
|
} else {
|
|
39
38
|
setError('Failed to fetch contents')
|
|
40
39
|
}
|
|
@@ -122,9 +121,7 @@ export const ContentListPage: React.FC = () => {
|
|
|
122
121
|
</div>
|
|
123
122
|
)}
|
|
124
123
|
|
|
125
|
-
{loading &&
|
|
126
|
-
<div className="text-center py-12 text-gray-500">加载中...</div>
|
|
127
|
-
)}
|
|
124
|
+
{loading && <div className="text-center py-12 text-gray-500">加载中...</div>}
|
|
128
125
|
|
|
129
126
|
{!loading && !error && contents.length === 0 && (
|
|
130
127
|
<div className="text-center py-12 text-gray-500">暂无内容</div>
|
|
@@ -146,12 +143,8 @@ export const ContentListPage: React.FC = () => {
|
|
|
146
143
|
</span>
|
|
147
144
|
<span className="text-sm text-gray-500">{content.author}</span>
|
|
148
145
|
</div>
|
|
149
|
-
<h2 className="text-xl font-semibold text-gray-900 mb-2">
|
|
150
|
-
|
|
151
|
-
</h2>
|
|
152
|
-
<p className="text-gray-600 line-clamp-2">
|
|
153
|
-
{content.content.slice(0, 150)}...
|
|
154
|
-
</p>
|
|
146
|
+
<h2 className="text-xl font-semibold text-gray-900 mb-2">{content.title}</h2>
|
|
147
|
+
<p className="text-gray-600 line-clamp-2">{content.content.slice(0, 150)}...</p>
|
|
155
148
|
<div className="flex items-center gap-4 mt-3 text-sm text-gray-400">
|
|
156
149
|
{content.publishedAt && <span>{formatDate(content.publishedAt)}</span>}
|
|
157
150
|
<span>👁 {content.viewCount}</span>
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react'
|
|
2
|
+
import { Helmet } from 'react-helmet-async'
|
|
3
|
+
import { TrendingUp, TrendingDown, ArrowUpRight, ChevronDown } from 'lucide-react'
|
|
4
|
+
import { apiClient } from '@client/services/apiClient'
|
|
5
|
+
import { LoadingSpinner } from '@client/components'
|
|
6
|
+
import type { DashboardStat, RevenueData, ActivityStatus, Activity } from '@shared/schemas'
|
|
7
|
+
|
|
8
|
+
type StatCard = DashboardStat & { icon: React.FC<{ className?: string }> }
|
|
9
|
+
|
|
10
|
+
const statusStyles: Record<ActivityStatus, string> = {
|
|
11
|
+
Active: 'bg-green-100 text-green-700',
|
|
12
|
+
Pending: 'bg-yellow-100 text-yellow-700',
|
|
13
|
+
Inactive: 'bg-gray-100 text-gray-600',
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const timeRanges = ['Last 7 days', 'Last 30 days', 'Last 90 days', 'Last year']
|
|
17
|
+
|
|
18
|
+
export const DashboardPage: React.FC = () => {
|
|
19
|
+
const [timeRange, setTimeRange] = useState('Last 30 days')
|
|
20
|
+
const [dropdownOpen, setDropdownOpen] = useState(false)
|
|
21
|
+
const [loading, setLoading] = useState(true)
|
|
22
|
+
const [stats, setStats] = useState<StatCard[]>([])
|
|
23
|
+
const [revenueData, setRevenueData] = useState<RevenueData[]>([])
|
|
24
|
+
const [userGrowthData, setUserGrowthData] = useState<RevenueData[]>([])
|
|
25
|
+
const [recentActivity, setRecentActivity] = useState<Activity[]>([])
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
async function fetchDashboard() {
|
|
29
|
+
try {
|
|
30
|
+
const api = apiClient.api as unknown as Record<
|
|
31
|
+
string,
|
|
32
|
+
Record<string, Record<string, () => Promise<Response>>>
|
|
33
|
+
>
|
|
34
|
+
const statsEndpoint = api?.admin?.dashboard?.stats
|
|
35
|
+
if (!statsEndpoint) {
|
|
36
|
+
setLoading(false)
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
const res = await statsEndpoint()
|
|
40
|
+
const result = (await res.json()) as Record<string, unknown>
|
|
41
|
+
const data = (result.data ?? {}) as Record<string, unknown>
|
|
42
|
+
if (result.success) {
|
|
43
|
+
setStats((data.stats as StatCard[]) || [])
|
|
44
|
+
setRevenueData((data.revenue as RevenueData[]) || [])
|
|
45
|
+
setUserGrowthData((data.userGrowth as RevenueData[]) || [])
|
|
46
|
+
setRecentActivity((data.activity as Activity[]) || [])
|
|
47
|
+
}
|
|
48
|
+
} finally {
|
|
49
|
+
setLoading(false)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
fetchDashboard()
|
|
53
|
+
}, [])
|
|
54
|
+
|
|
55
|
+
if (loading) {
|
|
56
|
+
return (
|
|
57
|
+
<div
|
|
58
|
+
className="flex items-center justify-center min-h-screen"
|
|
59
|
+
data-testid="dashboard-loading"
|
|
60
|
+
>
|
|
61
|
+
<LoadingSpinner size="lg" />
|
|
62
|
+
</div>
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<div className="min-h-screen bg-gray-50 p-4 sm:p-6 lg:p-8" data-testid="dashboard-page">
|
|
68
|
+
<Helmet>
|
|
69
|
+
<title>Dashboard - SaaS Admin</title>
|
|
70
|
+
<meta name="description" content="SaaS Admin Dashboard" />
|
|
71
|
+
</Helmet>
|
|
72
|
+
|
|
73
|
+
<div className="max-w-7xl mx-auto">
|
|
74
|
+
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-8 gap-4">
|
|
75
|
+
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900">Dashboard</h1>
|
|
76
|
+
<div className="relative">
|
|
77
|
+
<button
|
|
78
|
+
onClick={() => setDropdownOpen(!dropdownOpen)}
|
|
79
|
+
data-testid="time-range-dropdown"
|
|
80
|
+
className="flex items-center gap-2 px-4 py-2 bg-white border border-gray-200 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
|
|
81
|
+
>
|
|
82
|
+
{timeRange}
|
|
83
|
+
<ChevronDown className="w-4 h-4" />
|
|
84
|
+
</button>
|
|
85
|
+
{dropdownOpen && (
|
|
86
|
+
<div className="absolute right-0 mt-1 w-44 bg-white border border-gray-200 rounded-lg shadow-lg z-10">
|
|
87
|
+
{timeRanges.map(range => (
|
|
88
|
+
<button
|
|
89
|
+
key={range}
|
|
90
|
+
onClick={() => {
|
|
91
|
+
setTimeRange(range)
|
|
92
|
+
setDropdownOpen(false)
|
|
93
|
+
}}
|
|
94
|
+
data-testid={`time-range-${range.toLowerCase().replace(/\s+/g, '-')}`}
|
|
95
|
+
className={`block w-full text-left px-4 py-2 text-sm transition-colors first:rounded-t-lg last:rounded-b-lg ${
|
|
96
|
+
range === timeRange
|
|
97
|
+
? 'bg-gray-100 text-gray-900 font-medium'
|
|
98
|
+
: 'text-gray-600 hover:bg-gray-50'
|
|
99
|
+
}`}
|
|
100
|
+
>
|
|
101
|
+
{range}
|
|
102
|
+
</button>
|
|
103
|
+
))}
|
|
104
|
+
</div>
|
|
105
|
+
)}
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
|
|
109
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 sm:gap-6 mb-8">
|
|
110
|
+
{stats.map(stat => {
|
|
111
|
+
const Icon = stat.icon
|
|
112
|
+
const isPositive = stat.trend >= 0
|
|
113
|
+
return (
|
|
114
|
+
<div
|
|
115
|
+
key={stat.label}
|
|
116
|
+
data-testid={`stat-card-${stat.label.toLowerCase().replace(/\s+/g, '-')}`}
|
|
117
|
+
className="bg-white rounded-lg border border-gray-200 p-5 hover:shadow-sm transition-shadow"
|
|
118
|
+
>
|
|
119
|
+
<div className="flex items-center justify-between mb-3">
|
|
120
|
+
<span className="text-sm font-medium text-gray-500">{stat.label}</span>
|
|
121
|
+
<div className="p-2 bg-gray-100 rounded-lg">
|
|
122
|
+
<Icon className="w-4 h-4 text-gray-600" />
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
<div className="text-2xl font-bold text-gray-900 mb-1">{stat.value}</div>
|
|
126
|
+
<div className="flex items-center gap-1">
|
|
127
|
+
{isPositive ? (
|
|
128
|
+
<TrendingUp className="w-3 h-3 text-green-500" />
|
|
129
|
+
) : (
|
|
130
|
+
<TrendingDown className="w-3 h-3 text-red-500" />
|
|
131
|
+
)}
|
|
132
|
+
<span
|
|
133
|
+
className={`text-sm font-medium ${
|
|
134
|
+
isPositive ? 'text-green-600' : 'text-red-600'
|
|
135
|
+
}`}
|
|
136
|
+
>
|
|
137
|
+
{isPositive ? '+' : ''}
|
|
138
|
+
{stat.trend}%
|
|
139
|
+
</span>
|
|
140
|
+
<span className="text-xs text-gray-400 ml-1">vs last period</span>
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
)
|
|
144
|
+
})}
|
|
145
|
+
</div>
|
|
146
|
+
|
|
147
|
+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6 mb-8">
|
|
148
|
+
<div
|
|
149
|
+
className="bg-white rounded-lg border border-gray-200 p-5 sm:p-6"
|
|
150
|
+
data-testid="revenue-chart"
|
|
151
|
+
>
|
|
152
|
+
<h2 className="text-base font-semibold text-gray-900 mb-6">Revenue Overview</h2>
|
|
153
|
+
<div className="flex items-end gap-3 h-48">
|
|
154
|
+
{revenueData.map(item => (
|
|
155
|
+
<div key={item.month} className="flex-1 flex flex-col items-center gap-2">
|
|
156
|
+
<div className="w-full flex flex-col items-center">
|
|
157
|
+
<span className="text-xs text-gray-500 mb-1">${item.value}k</span>
|
|
158
|
+
<div
|
|
159
|
+
className="w-full rounded-t-md bg-gray-800 transition-all hover:bg-gray-700"
|
|
160
|
+
style={{ height: `${(item.value / 100) * 160}px` }}
|
|
161
|
+
/>
|
|
162
|
+
</div>
|
|
163
|
+
<span className="text-xs text-gray-500">{item.month}</span>
|
|
164
|
+
</div>
|
|
165
|
+
))}
|
|
166
|
+
</div>
|
|
167
|
+
</div>
|
|
168
|
+
|
|
169
|
+
<div
|
|
170
|
+
className="bg-white rounded-lg border border-gray-200 p-5 sm:p-6"
|
|
171
|
+
data-testid="user-growth-chart"
|
|
172
|
+
>
|
|
173
|
+
<h2 className="text-base font-semibold text-gray-900 mb-6">User Growth</h2>
|
|
174
|
+
<div className="flex items-end gap-3 h-48">
|
|
175
|
+
{userGrowthData.map(item => (
|
|
176
|
+
<div key={item.month} className="flex-1 flex flex-col items-center gap-2">
|
|
177
|
+
<div className="w-full flex flex-col items-center">
|
|
178
|
+
<span className="text-xs text-gray-500 mb-1">{item.value}%</span>
|
|
179
|
+
<div
|
|
180
|
+
className="w-full rounded-t-md transition-all"
|
|
181
|
+
style={{
|
|
182
|
+
height: `${(item.value / 100) * 160}px`,
|
|
183
|
+
background: `linear-gradient(to top, #1f2937 ${item.value}%, #e5e7eb ${item.value}%)`,
|
|
184
|
+
}}
|
|
185
|
+
/>
|
|
186
|
+
</div>
|
|
187
|
+
<span className="text-xs text-gray-500">{item.month}</span>
|
|
188
|
+
</div>
|
|
189
|
+
))}
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
</div>
|
|
193
|
+
|
|
194
|
+
<div
|
|
195
|
+
className="bg-white rounded-lg border border-gray-200 overflow-hidden"
|
|
196
|
+
data-testid="recent-activity"
|
|
197
|
+
>
|
|
198
|
+
<div className="px-5 sm:px-6 py-4 border-b border-gray-100 flex items-center justify-between">
|
|
199
|
+
<h2 className="text-base font-semibold text-gray-900">Recent Activity</h2>
|
|
200
|
+
<button
|
|
201
|
+
data-testid="view-all-activity"
|
|
202
|
+
className="flex items-center gap-1 text-sm font-medium text-gray-600 hover:text-gray-900 transition-colors"
|
|
203
|
+
>
|
|
204
|
+
View all
|
|
205
|
+
<ArrowUpRight className="w-3.5 h-3.5" />
|
|
206
|
+
</button>
|
|
207
|
+
</div>
|
|
208
|
+
<div className="overflow-x-auto">
|
|
209
|
+
<table className="w-full">
|
|
210
|
+
<thead>
|
|
211
|
+
<tr className="border-b border-gray-100">
|
|
212
|
+
<th className="text-left px-5 sm:px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
213
|
+
User
|
|
214
|
+
</th>
|
|
215
|
+
<th className="text-left px-5 sm:px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
216
|
+
Action
|
|
217
|
+
</th>
|
|
218
|
+
<th className="text-left px-5 sm:px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider hidden sm:table-cell">
|
|
219
|
+
Date
|
|
220
|
+
</th>
|
|
221
|
+
<th className="text-left px-5 sm:px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
222
|
+
Status
|
|
223
|
+
</th>
|
|
224
|
+
</tr>
|
|
225
|
+
</thead>
|
|
226
|
+
<tbody>
|
|
227
|
+
{recentActivity.map(activity => (
|
|
228
|
+
<tr
|
|
229
|
+
key={activity.id}
|
|
230
|
+
data-testid={`activity-row-${activity.id}`}
|
|
231
|
+
className="border-b border-gray-50 last:border-0 hover:bg-gray-50 transition-colors"
|
|
232
|
+
>
|
|
233
|
+
<td className="px-5 sm:px-6 py-4">
|
|
234
|
+
<span className="text-sm font-medium text-gray-900">{activity.user}</span>
|
|
235
|
+
</td>
|
|
236
|
+
<td className="px-5 sm:px-6 py-4">
|
|
237
|
+
<span className="text-sm text-gray-600">{activity.action}</span>
|
|
238
|
+
</td>
|
|
239
|
+
<td className="px-5 sm:px-6 py-4 hidden sm:table-cell">
|
|
240
|
+
<span className="text-sm text-gray-500">{activity.date}</span>
|
|
241
|
+
</td>
|
|
242
|
+
<td className="px-5 sm:px-6 py-4">
|
|
243
|
+
<span
|
|
244
|
+
data-testid={`status-badge-${activity.id}`}
|
|
245
|
+
className={`inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-medium ${
|
|
246
|
+
statusStyles[activity.status]
|
|
247
|
+
}`}
|
|
248
|
+
>
|
|
249
|
+
{activity.status}
|
|
250
|
+
</span>
|
|
251
|
+
</td>
|
|
252
|
+
</tr>
|
|
253
|
+
))}
|
|
254
|
+
</tbody>
|
|
255
|
+
</table>
|
|
256
|
+
</div>
|
|
257
|
+
</div>
|
|
258
|
+
</div>
|
|
259
|
+
</div>
|
|
260
|
+
)
|
|
261
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { useEffect } from 'react'
|
|
2
|
+
import { Link } from 'react-router-dom'
|
|
3
|
+
import { Helmet } from 'react-helmet-async'
|
|
4
|
+
import {
|
|
5
|
+
Package,
|
|
6
|
+
Trash2,
|
|
7
|
+
ExternalLink,
|
|
8
|
+
Plus,
|
|
9
|
+
BarChart3,
|
|
10
|
+
CheckCircle2,
|
|
11
|
+
Clock,
|
|
12
|
+
Download,
|
|
13
|
+
} from 'lucide-react'
|
|
14
|
+
import { usePluginStore } from '@client/stores/pluginStore'
|
|
15
|
+
import { LoadingSpinner, EmptyState, StatusBadge } from '@client/components'
|
|
16
|
+
|
|
17
|
+
export const DeveloperDashboardPage: React.FC = () => {
|
|
18
|
+
const myPlugins = usePluginStore(state => state.myPlugins)
|
|
19
|
+
const loading = usePluginStore(state => state.loading)
|
|
20
|
+
const error = usePluginStore(state => state.error)
|
|
21
|
+
const fetchMyPlugins = usePluginStore(state => state.fetchMyPlugins)
|
|
22
|
+
const deletePlugin = usePluginStore(state => state.deletePlugin)
|
|
23
|
+
const fetchStats = usePluginStore(state => state.fetchStats)
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
fetchMyPlugins()
|
|
27
|
+
fetchStats()
|
|
28
|
+
}, [fetchMyPlugins, fetchStats])
|
|
29
|
+
|
|
30
|
+
const statusColors: Record<string, 'green' | 'yellow' | 'red'> = {
|
|
31
|
+
approved: 'green',
|
|
32
|
+
pending: 'yellow',
|
|
33
|
+
rejected: 'red',
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const safePlugins = myPlugins?.filter(Boolean) ?? []
|
|
37
|
+
const totalDownloads = safePlugins.reduce((sum, p) => sum + (p?.downloadCount ?? 0), 0)
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<div className="min-h-screen" data-testid="developer-dashboard-page">
|
|
41
|
+
<Helmet>
|
|
42
|
+
<title>Developer Dashboard - Plugin Marketplace</title>
|
|
43
|
+
<meta name="description" content="Manage your published plugins" />
|
|
44
|
+
</Helmet>
|
|
45
|
+
|
|
46
|
+
<div className="bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900 pt-12 pb-16 px-6">
|
|
47
|
+
<div className="max-w-5xl mx-auto">
|
|
48
|
+
<div className="flex items-center justify-between">
|
|
49
|
+
<div className="flex items-center gap-3">
|
|
50
|
+
<div className="p-2.5 bg-gradient-to-br from-violet-500 to-fuchsia-500 rounded-xl shadow-lg shadow-violet-500/25">
|
|
51
|
+
<BarChart3 className="w-6 h-6 text-white" />
|
|
52
|
+
</div>
|
|
53
|
+
<div>
|
|
54
|
+
<h1 className="text-3xl font-bold text-white tracking-tight">
|
|
55
|
+
Developer Dashboard
|
|
56
|
+
</h1>
|
|
57
|
+
<p className="text-purple-200/60 mt-0.5">Manage your published plugins</p>
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
<Link
|
|
61
|
+
to="/publish"
|
|
62
|
+
className="group flex items-center gap-2 px-5 py-2.5 bg-gradient-to-r from-violet-500 to-fuchsia-500 text-white rounded-xl hover:from-violet-400 hover:to-fuchsia-400 transition-all shadow-lg shadow-violet-500/25 hover:shadow-violet-500/40 text-sm font-semibold"
|
|
63
|
+
>
|
|
64
|
+
<Plus className="w-4 h-4 group-hover:rotate-90 transition-transform" />
|
|
65
|
+
New Plugin
|
|
66
|
+
</Link>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
|
|
71
|
+
<div className="max-w-5xl mx-auto px-6 -mt-8 pb-16">
|
|
72
|
+
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-6">
|
|
73
|
+
<div className="relative overflow-hidden bg-white rounded-2xl shadow-lg shadow-black/5 border border-gray-100 p-5">
|
|
74
|
+
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-violet-500 to-fuchsia-500" />
|
|
75
|
+
<div className="flex items-center gap-3">
|
|
76
|
+
<div className="p-2 bg-violet-50 rounded-lg">
|
|
77
|
+
<Package className="w-5 h-5 text-violet-500" />
|
|
78
|
+
</div>
|
|
79
|
+
<div>
|
|
80
|
+
<p className="text-xs text-gray-500 font-medium">My Plugins</p>
|
|
81
|
+
<p className="text-2xl font-bold text-gray-900">{safePlugins.length}</p>
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
<div className="relative overflow-hidden bg-white rounded-2xl shadow-lg shadow-black/5 border border-gray-100 p-5">
|
|
86
|
+
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-emerald-500 to-teal-500" />
|
|
87
|
+
<div className="flex items-center gap-3">
|
|
88
|
+
<div className="p-2 bg-emerald-50 rounded-lg">
|
|
89
|
+
<CheckCircle2 className="w-5 h-5 text-emerald-500" />
|
|
90
|
+
</div>
|
|
91
|
+
<div>
|
|
92
|
+
<p className="text-xs text-gray-500 font-medium">Approved</p>
|
|
93
|
+
<p className="text-2xl font-bold text-emerald-600">
|
|
94
|
+
{safePlugins.filter(p => p.status === 'approved').length}
|
|
95
|
+
</p>
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
<div className="relative overflow-hidden bg-white rounded-2xl shadow-lg shadow-black/5 border border-gray-100 p-5">
|
|
100
|
+
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-amber-500 to-orange-500" />
|
|
101
|
+
<div className="flex items-center gap-3">
|
|
102
|
+
<div className="p-2 bg-amber-50 rounded-lg">
|
|
103
|
+
<Clock className="w-5 h-5 text-amber-500" />
|
|
104
|
+
</div>
|
|
105
|
+
<div>
|
|
106
|
+
<p className="text-xs text-gray-500 font-medium">Pending</p>
|
|
107
|
+
<p className="text-2xl font-bold text-amber-600">
|
|
108
|
+
{safePlugins.filter(p => p.status === 'pending').length}
|
|
109
|
+
</p>
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
<div className="relative overflow-hidden bg-white rounded-2xl shadow-lg shadow-black/5 border border-gray-100 p-5">
|
|
114
|
+
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-blue-500 to-cyan-500" />
|
|
115
|
+
<div className="flex items-center gap-3">
|
|
116
|
+
<div className="p-2 bg-blue-50 rounded-lg">
|
|
117
|
+
<Download className="w-5 h-5 text-blue-500" />
|
|
118
|
+
</div>
|
|
119
|
+
<div>
|
|
120
|
+
<p className="text-xs text-gray-500 font-medium">Downloads</p>
|
|
121
|
+
<p className="text-2xl font-bold text-blue-600">{totalDownloads}</p>
|
|
122
|
+
</div>
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
|
|
127
|
+
{error && (
|
|
128
|
+
<div className="mb-6 p-4 bg-red-500/10 border border-red-500/20 text-red-600 rounded-2xl">
|
|
129
|
+
{error}
|
|
130
|
+
</div>
|
|
131
|
+
)}
|
|
132
|
+
|
|
133
|
+
{loading && safePlugins.length === 0 ? (
|
|
134
|
+
<div className="flex items-center justify-center py-20">
|
|
135
|
+
<LoadingSpinner size="lg" />
|
|
136
|
+
</div>
|
|
137
|
+
) : safePlugins.length === 0 ? (
|
|
138
|
+
<div className="bg-white rounded-2xl shadow-xl shadow-black/5 border border-gray-100 p-12">
|
|
139
|
+
<EmptyState
|
|
140
|
+
icon={Package}
|
|
141
|
+
title="No plugins published yet"
|
|
142
|
+
description="Publish your first plugin to get started"
|
|
143
|
+
/>
|
|
144
|
+
</div>
|
|
145
|
+
) : (
|
|
146
|
+
<div className="space-y-3">
|
|
147
|
+
{safePlugins.map(plugin => (
|
|
148
|
+
<div
|
|
149
|
+
key={plugin.id}
|
|
150
|
+
className="group bg-white rounded-2xl border border-gray-100 hover:border-violet-200 hover:shadow-lg hover:shadow-violet-500/5 transition-all duration-300 p-5"
|
|
151
|
+
>
|
|
152
|
+
<div className="flex items-start gap-4">
|
|
153
|
+
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-violet-100 to-fuchsia-100 flex items-center justify-center text-lg font-bold text-violet-600 shrink-0">
|
|
154
|
+
{plugin.name.charAt(0).toUpperCase()}
|
|
155
|
+
</div>
|
|
156
|
+
<div className="flex-1 min-w-0">
|
|
157
|
+
<div className="flex items-center gap-2">
|
|
158
|
+
<h3 className="text-base font-semibold text-gray-900 group-hover:text-violet-700 transition-colors">
|
|
159
|
+
{plugin.name}
|
|
160
|
+
</h3>
|
|
161
|
+
<StatusBadge
|
|
162
|
+
label={plugin.status}
|
|
163
|
+
colorScheme={statusColors[plugin.status] || 'gray'}
|
|
164
|
+
/>
|
|
165
|
+
</div>
|
|
166
|
+
<p className="text-xs text-gray-400 mt-0.5 font-mono">
|
|
167
|
+
{plugin.slug} · v{plugin.version}
|
|
168
|
+
</p>
|
|
169
|
+
{plugin.status === 'rejected' && plugin.rejectReason && (
|
|
170
|
+
<p className="text-sm text-red-500 mt-1">Rejected: {plugin.rejectReason}</p>
|
|
171
|
+
)}
|
|
172
|
+
<p className="text-sm text-gray-500 mt-2 line-clamp-2 leading-relaxed">
|
|
173
|
+
{plugin.description}
|
|
174
|
+
</p>
|
|
175
|
+
<div className="mt-2 flex items-center gap-4 text-xs text-gray-400">
|
|
176
|
+
<span className="flex items-center gap-1">
|
|
177
|
+
<Download className="w-3.5 h-3.5" />
|
|
178
|
+
{plugin.downloadCount} downloads
|
|
179
|
+
</span>
|
|
180
|
+
<span>{new Date(plugin.createdAt).toLocaleDateString()}</span>
|
|
181
|
+
</div>
|
|
182
|
+
</div>
|
|
183
|
+
<div className="flex items-center gap-1.5 shrink-0">
|
|
184
|
+
<Link
|
|
185
|
+
to={`/plugins/${plugin.slug}`}
|
|
186
|
+
className="p-2.5 text-gray-400 hover:text-violet-500 hover:bg-violet-50 rounded-xl transition-all"
|
|
187
|
+
title="View"
|
|
188
|
+
>
|
|
189
|
+
<ExternalLink className="w-4.5 h-4.5" />
|
|
190
|
+
</Link>
|
|
191
|
+
<button
|
|
192
|
+
onClick={() => {
|
|
193
|
+
if (confirm('Delete this plugin?')) {
|
|
194
|
+
deletePlugin(plugin.slug)
|
|
195
|
+
}
|
|
196
|
+
}}
|
|
197
|
+
className="p-2.5 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-xl transition-all"
|
|
198
|
+
title="Delete"
|
|
199
|
+
>
|
|
200
|
+
<Trash2 className="w-4.5 h-4.5" />
|
|
201
|
+
</button>
|
|
202
|
+
</div>
|
|
203
|
+
</div>
|
|
204
|
+
</div>
|
|
205
|
+
))}
|
|
206
|
+
</div>
|
|
207
|
+
)}
|
|
208
|
+
</div>
|
|
209
|
+
</div>
|
|
210
|
+
)
|
|
211
|
+
}
|