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,336 @@
|
|
|
1
|
+
import { useState, FormEvent } from 'react'
|
|
2
|
+
import { useNavigate } from 'react-router-dom'
|
|
3
|
+
import { Helmet } from 'react-helmet-async'
|
|
4
|
+
import { Upload, Plus, X, Rocket } from 'lucide-react'
|
|
5
|
+
import { usePluginStore } from '@client/stores/pluginStore'
|
|
6
|
+
import { LoadingSpinner } from '@client/components'
|
|
7
|
+
import type { CreatePluginInput } from '@shared/schemas'
|
|
8
|
+
|
|
9
|
+
export const PublishPage: React.FC = () => {
|
|
10
|
+
const loading = usePluginStore(state => state.loading)
|
|
11
|
+
const error = usePluginStore(state => state.error)
|
|
12
|
+
const createPlugin = usePluginStore(state => state.createPlugin)
|
|
13
|
+
const clearError = usePluginStore(state => state.clearError)
|
|
14
|
+
|
|
15
|
+
const navigate = useNavigate()
|
|
16
|
+
|
|
17
|
+
const [form, setForm] = useState<CreatePluginInput>({
|
|
18
|
+
name: '',
|
|
19
|
+
slug: '',
|
|
20
|
+
description: '',
|
|
21
|
+
repositoryUrl: undefined,
|
|
22
|
+
homepageUrl: undefined,
|
|
23
|
+
npmPackage: undefined,
|
|
24
|
+
license: undefined,
|
|
25
|
+
tags: [],
|
|
26
|
+
commands: [],
|
|
27
|
+
})
|
|
28
|
+
const [tagInput, setTagInput] = useState('')
|
|
29
|
+
const [commandName, setCommandName] = useState('')
|
|
30
|
+
const [commandDesc, setCommandDesc] = useState('')
|
|
31
|
+
|
|
32
|
+
const handleAddTag = () => {
|
|
33
|
+
if (tagInput.trim() && !(form.tags || []).includes(tagInput.trim())) {
|
|
34
|
+
setForm(prev => ({ ...prev, tags: [...(prev.tags || []), tagInput.trim()] }))
|
|
35
|
+
setTagInput('')
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const handleRemoveTag = (tag: string) => {
|
|
40
|
+
setForm(prev => ({ ...prev, tags: (prev.tags || []).filter(t => t !== tag) }))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const handleAddCommand = () => {
|
|
44
|
+
if (commandName.trim()) {
|
|
45
|
+
setForm(prev => ({
|
|
46
|
+
...prev,
|
|
47
|
+
commands: [
|
|
48
|
+
...(prev.commands || []),
|
|
49
|
+
{ name: commandName.trim(), description: commandDesc.trim() || undefined },
|
|
50
|
+
],
|
|
51
|
+
}))
|
|
52
|
+
setCommandName('')
|
|
53
|
+
setCommandDesc('')
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const handleRemoveCommand = (index: number) => {
|
|
58
|
+
setForm(prev => ({
|
|
59
|
+
...prev,
|
|
60
|
+
commands: (prev.commands || []).filter((_, i) => i !== index),
|
|
61
|
+
}))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const handleSubmit = async (e: FormEvent) => {
|
|
65
|
+
e.preventDefault()
|
|
66
|
+
clearError()
|
|
67
|
+
const slug = await createPlugin(form)
|
|
68
|
+
if (slug) {
|
|
69
|
+
navigate(`/plugins/${slug}`)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<div className="min-h-screen" data-testid="publish-page">
|
|
75
|
+
<Helmet>
|
|
76
|
+
<title>Publish Plugin - Plugin Marketplace</title>
|
|
77
|
+
<meta name="description" content="Publish a new plugin to the marketplace" />
|
|
78
|
+
</Helmet>
|
|
79
|
+
|
|
80
|
+
<div className="bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900 pt-12 pb-20 px-6">
|
|
81
|
+
<div className="max-w-2xl mx-auto">
|
|
82
|
+
<div className="flex items-center gap-3 mb-4">
|
|
83
|
+
<div className="p-2.5 bg-gradient-to-br from-violet-500 to-fuchsia-500 rounded-xl shadow-lg shadow-violet-500/25">
|
|
84
|
+
<Rocket className="w-6 h-6 text-white" />
|
|
85
|
+
</div>
|
|
86
|
+
<h1 className="text-3xl font-bold text-white tracking-tight">Publish Plugin</h1>
|
|
87
|
+
</div>
|
|
88
|
+
<p className="text-purple-200/70 text-lg">Share your creation with the community</p>
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
<div className="max-w-2xl mx-auto px-6 -mt-8 pb-16">
|
|
93
|
+
{error && (
|
|
94
|
+
<div className="mb-6 p-4 bg-red-500/10 border border-red-500/20 text-red-600 rounded-2xl">
|
|
95
|
+
{error}
|
|
96
|
+
</div>
|
|
97
|
+
)}
|
|
98
|
+
|
|
99
|
+
<form onSubmit={handleSubmit} className="space-y-5">
|
|
100
|
+
<div className="bg-white rounded-2xl shadow-xl shadow-black/5 border border-gray-100 p-7 space-y-5">
|
|
101
|
+
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
|
102
|
+
<div className="w-1.5 h-6 bg-gradient-to-b from-violet-500 to-fuchsia-500 rounded-full" />
|
|
103
|
+
Basic Information
|
|
104
|
+
</h2>
|
|
105
|
+
<div>
|
|
106
|
+
<label className="block text-sm font-semibold text-gray-700 mb-1.5">
|
|
107
|
+
Plugin Name <span className="text-red-400">*</span>
|
|
108
|
+
</label>
|
|
109
|
+
<input
|
|
110
|
+
type="text"
|
|
111
|
+
value={form.name}
|
|
112
|
+
onChange={e => setForm(prev => ({ ...prev, name: e.target.value }))}
|
|
113
|
+
placeholder="My Awesome Plugin"
|
|
114
|
+
required
|
|
115
|
+
className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl focus:ring-2 focus:ring-violet-500 focus:border-transparent outline-none text-sm transition-all"
|
|
116
|
+
/>
|
|
117
|
+
</div>
|
|
118
|
+
|
|
119
|
+
<div>
|
|
120
|
+
<label className="block text-sm font-semibold text-gray-700 mb-1.5">
|
|
121
|
+
Slug <span className="text-red-400">*</span>
|
|
122
|
+
</label>
|
|
123
|
+
<input
|
|
124
|
+
type="text"
|
|
125
|
+
value={form.slug}
|
|
126
|
+
onChange={e =>
|
|
127
|
+
setForm(prev => ({
|
|
128
|
+
...prev,
|
|
129
|
+
slug: e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '-'),
|
|
130
|
+
}))
|
|
131
|
+
}
|
|
132
|
+
placeholder="my-awesome-plugin"
|
|
133
|
+
required
|
|
134
|
+
pattern="^[a-z0-9-]+$"
|
|
135
|
+
className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl focus:ring-2 focus:ring-violet-500 focus:border-transparent outline-none text-sm font-mono transition-all"
|
|
136
|
+
/>
|
|
137
|
+
<p className="text-xs text-gray-400 mt-1.5">
|
|
138
|
+
Lowercase letters, numbers, and hyphens only
|
|
139
|
+
</p>
|
|
140
|
+
</div>
|
|
141
|
+
|
|
142
|
+
<div>
|
|
143
|
+
<label className="block text-sm font-semibold text-gray-700 mb-1.5">
|
|
144
|
+
Description <span className="text-red-400">*</span>
|
|
145
|
+
</label>
|
|
146
|
+
<textarea
|
|
147
|
+
value={form.description}
|
|
148
|
+
onChange={e => setForm(prev => ({ ...prev, description: e.target.value }))}
|
|
149
|
+
placeholder="Describe what your plugin does..."
|
|
150
|
+
required
|
|
151
|
+
rows={4}
|
|
152
|
+
className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl focus:ring-2 focus:ring-violet-500 focus:border-transparent outline-none text-sm resize-none transition-all"
|
|
153
|
+
/>
|
|
154
|
+
</div>
|
|
155
|
+
|
|
156
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
157
|
+
<div>
|
|
158
|
+
<label className="block text-sm font-semibold text-gray-700 mb-1.5">
|
|
159
|
+
Repository URL
|
|
160
|
+
</label>
|
|
161
|
+
<input
|
|
162
|
+
type="url"
|
|
163
|
+
value={form.repositoryUrl || ''}
|
|
164
|
+
onChange={e =>
|
|
165
|
+
setForm(prev => ({
|
|
166
|
+
...prev,
|
|
167
|
+
repositoryUrl: e.target.value || undefined,
|
|
168
|
+
}))
|
|
169
|
+
}
|
|
170
|
+
placeholder="https://github.com/..."
|
|
171
|
+
className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl focus:ring-2 focus:ring-violet-500 focus:border-transparent outline-none text-sm transition-all"
|
|
172
|
+
/>
|
|
173
|
+
</div>
|
|
174
|
+
<div>
|
|
175
|
+
<label className="block text-sm font-semibold text-gray-700 mb-1.5">
|
|
176
|
+
Homepage URL
|
|
177
|
+
</label>
|
|
178
|
+
<input
|
|
179
|
+
type="url"
|
|
180
|
+
value={form.homepageUrl || ''}
|
|
181
|
+
onChange={e =>
|
|
182
|
+
setForm(prev => ({
|
|
183
|
+
...prev,
|
|
184
|
+
homepageUrl: e.target.value || undefined,
|
|
185
|
+
}))
|
|
186
|
+
}
|
|
187
|
+
placeholder="https://..."
|
|
188
|
+
className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl focus:ring-2 focus:ring-violet-500 focus:border-transparent outline-none text-sm transition-all"
|
|
189
|
+
/>
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
|
|
193
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
194
|
+
<div>
|
|
195
|
+
<label className="block text-sm font-semibold text-gray-700 mb-1.5">
|
|
196
|
+
NPM Package
|
|
197
|
+
</label>
|
|
198
|
+
<input
|
|
199
|
+
type="text"
|
|
200
|
+
value={form.npmPackage || ''}
|
|
201
|
+
onChange={e =>
|
|
202
|
+
setForm(prev => ({ ...prev, npmPackage: e.target.value || undefined }))
|
|
203
|
+
}
|
|
204
|
+
placeholder="@scope/package-name"
|
|
205
|
+
className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl focus:ring-2 focus:ring-violet-500 focus:border-transparent outline-none text-sm transition-all"
|
|
206
|
+
/>
|
|
207
|
+
</div>
|
|
208
|
+
<div>
|
|
209
|
+
<label className="block text-sm font-semibold text-gray-700 mb-1.5">License</label>
|
|
210
|
+
<input
|
|
211
|
+
type="text"
|
|
212
|
+
value={form.license || ''}
|
|
213
|
+
onChange={e =>
|
|
214
|
+
setForm(prev => ({ ...prev, license: e.target.value || undefined }))
|
|
215
|
+
}
|
|
216
|
+
placeholder="MIT"
|
|
217
|
+
className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl focus:ring-2 focus:ring-violet-500 focus:border-transparent outline-none text-sm transition-all"
|
|
218
|
+
/>
|
|
219
|
+
</div>
|
|
220
|
+
</div>
|
|
221
|
+
</div>
|
|
222
|
+
|
|
223
|
+
<div className="bg-white rounded-2xl shadow-xl shadow-black/5 border border-gray-100 p-7 space-y-4">
|
|
224
|
+
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
|
225
|
+
<div className="w-1.5 h-6 bg-gradient-to-b from-violet-500 to-fuchsia-500 rounded-full" />
|
|
226
|
+
Tags
|
|
227
|
+
</h2>
|
|
228
|
+
<div className="flex items-center gap-2">
|
|
229
|
+
<input
|
|
230
|
+
type="text"
|
|
231
|
+
value={tagInput}
|
|
232
|
+
onChange={e => setTagInput(e.target.value)}
|
|
233
|
+
onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), handleAddTag())}
|
|
234
|
+
placeholder="Add a tag..."
|
|
235
|
+
className="flex-1 px-4 py-2.5 bg-gray-50 border border-gray-200 rounded-xl focus:ring-2 focus:ring-violet-500 focus:border-transparent outline-none text-sm transition-all"
|
|
236
|
+
/>
|
|
237
|
+
<button
|
|
238
|
+
type="button"
|
|
239
|
+
onClick={handleAddTag}
|
|
240
|
+
className="p-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-sm"
|
|
241
|
+
>
|
|
242
|
+
<Plus className="w-5 h-5" />
|
|
243
|
+
</button>
|
|
244
|
+
</div>
|
|
245
|
+
{form.tags && form.tags.length > 0 && (
|
|
246
|
+
<div className="flex items-center gap-2 flex-wrap">
|
|
247
|
+
{form.tags.map(tag => (
|
|
248
|
+
<span
|
|
249
|
+
key={tag}
|
|
250
|
+
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-gradient-to-r from-violet-50 to-fuchsia-50 text-violet-700 rounded-lg text-sm font-medium"
|
|
251
|
+
>
|
|
252
|
+
{tag}
|
|
253
|
+
<button
|
|
254
|
+
type="button"
|
|
255
|
+
onClick={() => handleRemoveTag(tag)}
|
|
256
|
+
className="hover:text-red-500 transition-colors"
|
|
257
|
+
>
|
|
258
|
+
<X className="w-3.5 h-3.5" />
|
|
259
|
+
</button>
|
|
260
|
+
</span>
|
|
261
|
+
))}
|
|
262
|
+
</div>
|
|
263
|
+
)}
|
|
264
|
+
</div>
|
|
265
|
+
|
|
266
|
+
<div className="bg-white rounded-2xl shadow-xl shadow-black/5 border border-gray-100 p-7 space-y-4">
|
|
267
|
+
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
|
268
|
+
<div className="w-1.5 h-6 bg-gradient-to-b from-violet-500 to-fuchsia-500 rounded-full" />
|
|
269
|
+
Commands
|
|
270
|
+
</h2>
|
|
271
|
+
<div className="flex items-center gap-2">
|
|
272
|
+
<input
|
|
273
|
+
type="text"
|
|
274
|
+
value={commandName}
|
|
275
|
+
onChange={e => setCommandName(e.target.value)}
|
|
276
|
+
placeholder="Command name"
|
|
277
|
+
className="flex-1 px-4 py-2.5 bg-gray-50 border border-gray-200 rounded-xl focus:ring-2 focus:ring-violet-500 focus:border-transparent outline-none text-sm transition-all"
|
|
278
|
+
/>
|
|
279
|
+
<input
|
|
280
|
+
type="text"
|
|
281
|
+
value={commandDesc}
|
|
282
|
+
onChange={e => setCommandDesc(e.target.value)}
|
|
283
|
+
placeholder="Description (optional)"
|
|
284
|
+
className="flex-1 px-4 py-2.5 bg-gray-50 border border-gray-200 rounded-xl focus:ring-2 focus:ring-violet-500 focus:border-transparent outline-none text-sm transition-all"
|
|
285
|
+
/>
|
|
286
|
+
<button
|
|
287
|
+
type="button"
|
|
288
|
+
onClick={handleAddCommand}
|
|
289
|
+
className="p-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-sm"
|
|
290
|
+
>
|
|
291
|
+
<Plus className="w-5 h-5" />
|
|
292
|
+
</button>
|
|
293
|
+
</div>
|
|
294
|
+
{form.commands && form.commands.length > 0 && (
|
|
295
|
+
<div className="space-y-2">
|
|
296
|
+
{form.commands.map((cmd, i) => (
|
|
297
|
+
<div
|
|
298
|
+
key={i}
|
|
299
|
+
className="flex items-center gap-3 p-3 bg-gray-50 rounded-xl border border-gray-100"
|
|
300
|
+
>
|
|
301
|
+
<code className="text-sm font-mono text-violet-600 font-semibold bg-violet-50 px-2 py-0.5 rounded">
|
|
302
|
+
{cmd.name}
|
|
303
|
+
</code>
|
|
304
|
+
{cmd.description && (
|
|
305
|
+
<span className="text-sm text-gray-500">{cmd.description}</span>
|
|
306
|
+
)}
|
|
307
|
+
<button
|
|
308
|
+
type="button"
|
|
309
|
+
onClick={() => handleRemoveCommand(i)}
|
|
310
|
+
className="ml-auto p-1 text-gray-400 hover:text-red-500 transition-colors"
|
|
311
|
+
>
|
|
312
|
+
<X className="w-4 h-4" />
|
|
313
|
+
</button>
|
|
314
|
+
</div>
|
|
315
|
+
))}
|
|
316
|
+
</div>
|
|
317
|
+
)}
|
|
318
|
+
</div>
|
|
319
|
+
|
|
320
|
+
<button
|
|
321
|
+
type="submit"
|
|
322
|
+
disabled={loading || !form.name || !form.slug || !form.description}
|
|
323
|
+
className="w-full py-4 bg-gradient-to-r from-violet-500 to-fuchsia-500 text-white rounded-2xl hover:from-violet-400 hover:to-fuchsia-400 disabled:from-gray-300 disabled:to-gray-300 disabled:cursor-not-allowed transition-all font-semibold text-base flex items-center justify-center gap-2 shadow-lg shadow-violet-500/20"
|
|
324
|
+
>
|
|
325
|
+
{loading ? (
|
|
326
|
+
<LoadingSpinner size="sm" color="text-white" />
|
|
327
|
+
) : (
|
|
328
|
+
<Upload className="w-5 h-5" />
|
|
329
|
+
)}
|
|
330
|
+
Publish Plugin
|
|
331
|
+
</button>
|
|
332
|
+
</form>
|
|
333
|
+
</div>
|
|
334
|
+
</div>
|
|
335
|
+
)
|
|
336
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { useNavigate, Link } from 'react-router-dom'
|
|
3
|
+
import { Helmet } from 'react-helmet-async'
|
|
4
|
+
import { UserPlus } from 'lucide-react'
|
|
5
|
+
import { useAuthStore } from '@client/stores/authStore'
|
|
6
|
+
|
|
7
|
+
export const RegisterPage: React.FC = () => {
|
|
8
|
+
const navigate = useNavigate()
|
|
9
|
+
const register = useAuthStore(state => state.register)
|
|
10
|
+
const loading = useAuthStore(state => state.loading)
|
|
11
|
+
const error = useAuthStore(state => state.error)
|
|
12
|
+
const clearError = useAuthStore(state => state.clearError)
|
|
13
|
+
|
|
14
|
+
const [username, setUsername] = useState('')
|
|
15
|
+
const [email, setEmail] = useState('')
|
|
16
|
+
const [password, setPassword] = useState('')
|
|
17
|
+
|
|
18
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
19
|
+
e.preventDefault()
|
|
20
|
+
await register(username, email, password)
|
|
21
|
+
const state = useAuthStore.getState()
|
|
22
|
+
if (!state.error) {
|
|
23
|
+
navigate('/login')
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<>
|
|
29
|
+
<Helmet>
|
|
30
|
+
<title>Register - Biomimic App</title>
|
|
31
|
+
</Helmet>
|
|
32
|
+
<div className="min-h-[calc(100vh-200px)] flex items-center justify-center">
|
|
33
|
+
<div className="w-full max-w-md" data-testid="register-page">
|
|
34
|
+
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-8">
|
|
35
|
+
<div className="text-center mb-6">
|
|
36
|
+
<div className="inline-flex items-center justify-center w-12 h-12 bg-green-100 rounded-full mb-3">
|
|
37
|
+
<UserPlus className="w-6 h-6 text-green-600" />
|
|
38
|
+
</div>
|
|
39
|
+
<h1 className="text-2xl font-bold text-gray-900">Create Account</h1>
|
|
40
|
+
<p className="text-sm text-gray-500 mt-1">Join Biomimic App today</p>
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
44
|
+
<div>
|
|
45
|
+
<label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-1">
|
|
46
|
+
Username
|
|
47
|
+
</label>
|
|
48
|
+
<input
|
|
49
|
+
id="username"
|
|
50
|
+
type="text"
|
|
51
|
+
value={username}
|
|
52
|
+
onChange={e => {
|
|
53
|
+
setUsername(e.target.value)
|
|
54
|
+
if (error) clearError()
|
|
55
|
+
}}
|
|
56
|
+
placeholder="Choose a username"
|
|
57
|
+
required
|
|
58
|
+
data-testid="register-username"
|
|
59
|
+
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
60
|
+
/>
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
<div>
|
|
64
|
+
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
|
|
65
|
+
Email
|
|
66
|
+
</label>
|
|
67
|
+
<input
|
|
68
|
+
id="email"
|
|
69
|
+
type="email"
|
|
70
|
+
value={email}
|
|
71
|
+
onChange={e => {
|
|
72
|
+
setEmail(e.target.value)
|
|
73
|
+
if (error) clearError()
|
|
74
|
+
}}
|
|
75
|
+
placeholder="Enter your email"
|
|
76
|
+
required
|
|
77
|
+
data-testid="register-email"
|
|
78
|
+
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
79
|
+
/>
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
<div>
|
|
83
|
+
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
|
|
84
|
+
Password
|
|
85
|
+
</label>
|
|
86
|
+
<input
|
|
87
|
+
id="password"
|
|
88
|
+
type="password"
|
|
89
|
+
value={password}
|
|
90
|
+
onChange={e => {
|
|
91
|
+
setPassword(e.target.value)
|
|
92
|
+
if (error) clearError()
|
|
93
|
+
}}
|
|
94
|
+
placeholder="Choose a password (min 6 chars)"
|
|
95
|
+
required
|
|
96
|
+
minLength={6}
|
|
97
|
+
data-testid="register-password"
|
|
98
|
+
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
99
|
+
/>
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
{error && (
|
|
103
|
+
<div
|
|
104
|
+
className="text-sm text-red-600 bg-red-50 border border-red-200 rounded-lg px-3 py-2"
|
|
105
|
+
data-testid="register-error"
|
|
106
|
+
>
|
|
107
|
+
{error}
|
|
108
|
+
</div>
|
|
109
|
+
)}
|
|
110
|
+
|
|
111
|
+
<button
|
|
112
|
+
type="submit"
|
|
113
|
+
disabled={loading}
|
|
114
|
+
data-testid="register-submit"
|
|
115
|
+
className="w-full py-2 px-4 bg-green-600 text-white rounded-lg text-sm font-medium hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
116
|
+
>
|
|
117
|
+
{loading ? 'Creating account...' : 'Create Account'}
|
|
118
|
+
</button>
|
|
119
|
+
</form>
|
|
120
|
+
|
|
121
|
+
<p className="mt-4 text-center text-sm text-gray-500">
|
|
122
|
+
Already have an account?{' '}
|
|
123
|
+
<Link
|
|
124
|
+
to="/login"
|
|
125
|
+
className="text-blue-600 hover:text-blue-700 font-medium"
|
|
126
|
+
data-testid="register-login-link"
|
|
127
|
+
>
|
|
128
|
+
Sign in
|
|
129
|
+
</Link>
|
|
130
|
+
</p>
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
</>
|
|
135
|
+
)
|
|
136
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { useState, useEffect, FormEvent } from 'react'
|
|
2
|
+
import { Link } from 'react-router-dom'
|
|
3
|
+
import { Helmet } from 'react-helmet-async'
|
|
4
|
+
import { Search, Tag, Download, ChevronLeft, ChevronRight, SlidersHorizontal } from 'lucide-react'
|
|
5
|
+
import { usePluginStore } from '@client/stores/pluginStore'
|
|
6
|
+
import { LoadingSpinner, EmptyState, StatusBadge } from '@client/components'
|
|
7
|
+
|
|
8
|
+
export const SearchPage: React.FC = () => {
|
|
9
|
+
const plugins = usePluginStore(state => state.plugins)
|
|
10
|
+
const loading = usePluginStore(state => state.loading)
|
|
11
|
+
const error = usePluginStore(state => state.error)
|
|
12
|
+
const searchQuery = usePluginStore(state => state.searchQuery)
|
|
13
|
+
const categories = usePluginStore(state => state.categories)
|
|
14
|
+
const selectedCategory = usePluginStore(state => state.selectedCategory)
|
|
15
|
+
const pagination = usePluginStore(state => state.pagination)
|
|
16
|
+
const searchPlugins = usePluginStore(state => state.searchPlugins)
|
|
17
|
+
const fetchCategories = usePluginStore(state => state.fetchCategories)
|
|
18
|
+
const setSearchQuery = usePluginStore(state => state.setSearchQuery)
|
|
19
|
+
const setSelectedCategory = usePluginStore(state => state.setSelectedCategory)
|
|
20
|
+
|
|
21
|
+
const [inputValue, setInputValue] = useState(searchQuery)
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
fetchCategories()
|
|
25
|
+
}, [fetchCategories])
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
if (searchQuery) {
|
|
29
|
+
searchPlugins(searchQuery, 1)
|
|
30
|
+
}
|
|
31
|
+
}, [selectedCategory]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
32
|
+
|
|
33
|
+
const handleSearch = (e: FormEvent) => {
|
|
34
|
+
e.preventDefault()
|
|
35
|
+
if (inputValue.trim()) {
|
|
36
|
+
setSearchQuery(inputValue.trim())
|
|
37
|
+
searchPlugins(inputValue.trim(), 1)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const totalPages = Math.ceil(pagination.total / pagination.limit)
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<div className="min-h-screen" data-testid="search-page">
|
|
45
|
+
<Helmet>
|
|
46
|
+
<title>Search Plugins - Plugin Marketplace</title>
|
|
47
|
+
<meta name="description" content="Search for plugins" />
|
|
48
|
+
</Helmet>
|
|
49
|
+
|
|
50
|
+
<div className="bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900 pt-12 pb-20 px-6">
|
|
51
|
+
<div className="max-w-3xl mx-auto">
|
|
52
|
+
<div className="flex items-center gap-3 mb-6">
|
|
53
|
+
<div className="p-2.5 bg-gradient-to-br from-violet-500 to-fuchsia-500 rounded-xl shadow-lg shadow-violet-500/25">
|
|
54
|
+
<Search className="w-6 h-6 text-white" />
|
|
55
|
+
</div>
|
|
56
|
+
<h1 className="text-3xl font-bold text-white tracking-tight">Search Plugins</h1>
|
|
57
|
+
</div>
|
|
58
|
+
<form onSubmit={handleSearch}>
|
|
59
|
+
<div className="relative">
|
|
60
|
+
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
|
61
|
+
<input
|
|
62
|
+
type="text"
|
|
63
|
+
value={inputValue}
|
|
64
|
+
onChange={e => setInputValue(e.target.value)}
|
|
65
|
+
placeholder="Search for plugins by name, tag, or keyword..."
|
|
66
|
+
className="w-full pl-12 pr-36 py-4 bg-white/10 backdrop-blur-md border border-white/20 rounded-2xl focus:ring-2 focus:ring-violet-400 focus:border-transparent outline-none text-white placeholder-purple-200/40 text-base"
|
|
67
|
+
/>
|
|
68
|
+
<button
|
|
69
|
+
type="submit"
|
|
70
|
+
disabled={!inputValue.trim() || loading}
|
|
71
|
+
className="absolute right-2 top-1/2 -translate-y-1/2 px-6 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 disabled:from-gray-600 disabled:to-gray-600 disabled:cursor-not-allowed transition-all font-semibold text-sm shadow-md"
|
|
72
|
+
>
|
|
73
|
+
{loading ? <LoadingSpinner size="sm" color="text-white" /> : 'Search'}
|
|
74
|
+
</button>
|
|
75
|
+
</div>
|
|
76
|
+
</form>
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
<div className="max-w-3xl mx-auto px-6 -mt-6 pb-16">
|
|
81
|
+
<div className="bg-white rounded-2xl shadow-xl shadow-black/5 border border-gray-100 p-4 mb-6">
|
|
82
|
+
<div className="flex items-center gap-3 flex-wrap">
|
|
83
|
+
<SlidersHorizontal className="w-4 h-4 text-gray-400 shrink-0" />
|
|
84
|
+
<button
|
|
85
|
+
onClick={() => setSelectedCategory(null)}
|
|
86
|
+
className={`px-3.5 py-1.5 text-sm rounded-lg transition-all font-medium ${
|
|
87
|
+
!selectedCategory
|
|
88
|
+
? 'bg-gradient-to-r from-violet-500 to-fuchsia-500 text-white shadow-sm'
|
|
89
|
+
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
|
90
|
+
}`}
|
|
91
|
+
>
|
|
92
|
+
All
|
|
93
|
+
</button>
|
|
94
|
+
{categories.map(cat => (
|
|
95
|
+
<button
|
|
96
|
+
key={cat.id}
|
|
97
|
+
onClick={() => setSelectedCategory(cat.slug)}
|
|
98
|
+
className={`px-3.5 py-1.5 text-sm rounded-lg transition-all font-medium ${
|
|
99
|
+
selectedCategory === cat.slug
|
|
100
|
+
? 'bg-gradient-to-r from-violet-500 to-fuchsia-500 text-white shadow-sm'
|
|
101
|
+
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
|
102
|
+
}`}
|
|
103
|
+
>
|
|
104
|
+
{cat.icon && <span className="mr-1">{cat.icon}</span>}
|
|
105
|
+
{cat.name}
|
|
106
|
+
</button>
|
|
107
|
+
))}
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
|
|
111
|
+
{error && (
|
|
112
|
+
<div className="mb-6 p-4 bg-red-500/10 border border-red-500/20 text-red-600 rounded-2xl">
|
|
113
|
+
{error}
|
|
114
|
+
</div>
|
|
115
|
+
)}
|
|
116
|
+
|
|
117
|
+
{loading && plugins.length === 0 ? (
|
|
118
|
+
<div className="flex items-center justify-center py-20">
|
|
119
|
+
<LoadingSpinner size="lg" />
|
|
120
|
+
</div>
|
|
121
|
+
) : plugins.length === 0 && searchQuery ? (
|
|
122
|
+
<EmptyState
|
|
123
|
+
icon={Search}
|
|
124
|
+
title={`No results for "${searchQuery}"`}
|
|
125
|
+
description="Try a different search term"
|
|
126
|
+
/>
|
|
127
|
+
) : plugins.length === 0 ? (
|
|
128
|
+
<EmptyState icon={Search} title="Start searching for plugins" />
|
|
129
|
+
) : (
|
|
130
|
+
<>
|
|
131
|
+
<p className="text-sm text-gray-500 mb-4">
|
|
132
|
+
Found <span className="font-semibold text-gray-700">{pagination.total}</span> results
|
|
133
|
+
</p>
|
|
134
|
+
<div className="space-y-3">
|
|
135
|
+
{plugins.map(plugin => (
|
|
136
|
+
<Link
|
|
137
|
+
key={plugin.id}
|
|
138
|
+
to={`/plugins/${plugin.slug}`}
|
|
139
|
+
className="group flex items-start gap-4 p-5 bg-white rounded-2xl border border-gray-100 hover:border-violet-200 hover:shadow-lg hover:shadow-violet-500/5 transition-all duration-300"
|
|
140
|
+
>
|
|
141
|
+
<div className="w-11 h-11 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">
|
|
142
|
+
{plugin.name.charAt(0).toUpperCase()}
|
|
143
|
+
</div>
|
|
144
|
+
<div className="flex-1 min-w-0">
|
|
145
|
+
<div className="flex items-center gap-2">
|
|
146
|
+
<h3 className="text-base font-semibold text-gray-900 group-hover:text-violet-700 transition-colors">
|
|
147
|
+
{plugin.name}
|
|
148
|
+
</h3>
|
|
149
|
+
<StatusBadge label={`v${plugin.version}`} colorScheme="purple" />
|
|
150
|
+
</div>
|
|
151
|
+
<p className="text-sm text-gray-500 mt-0.5">by {plugin.authorName}</p>
|
|
152
|
+
<p className="text-sm text-gray-600 mt-1.5 line-clamp-2 leading-relaxed">
|
|
153
|
+
{plugin.description}
|
|
154
|
+
</p>
|
|
155
|
+
<div className="mt-2.5 flex items-center gap-4 text-xs text-gray-400">
|
|
156
|
+
<span className="flex items-center gap-1">
|
|
157
|
+
<Download className="w-3.5 h-3.5" />
|
|
158
|
+
{plugin.downloadCount}
|
|
159
|
+
</span>
|
|
160
|
+
{plugin.tags &&
|
|
161
|
+
plugin.tags.slice(0, 3).map(tag => (
|
|
162
|
+
<span
|
|
163
|
+
key={tag}
|
|
164
|
+
className="inline-flex items-center px-2 py-0.5 bg-gray-50 text-gray-500 rounded-md"
|
|
165
|
+
>
|
|
166
|
+
<Tag className="w-2.5 h-2.5 mr-0.5 opacity-50" />
|
|
167
|
+
{tag}
|
|
168
|
+
</span>
|
|
169
|
+
))}
|
|
170
|
+
</div>
|
|
171
|
+
</div>
|
|
172
|
+
</Link>
|
|
173
|
+
))}
|
|
174
|
+
</div>
|
|
175
|
+
|
|
176
|
+
{totalPages > 1 && (
|
|
177
|
+
<div className="mt-10 flex items-center justify-center gap-3">
|
|
178
|
+
<button
|
|
179
|
+
onClick={() => searchPlugins(searchQuery, pagination.page - 1)}
|
|
180
|
+
disabled={pagination.page <= 1}
|
|
181
|
+
className="p-2.5 rounded-xl bg-white border border-gray-200 hover:border-violet-300 hover:shadow-md disabled:opacity-30 disabled:cursor-not-allowed transition-all"
|
|
182
|
+
>
|
|
183
|
+
<ChevronLeft className="w-5 h-5 text-gray-600" />
|
|
184
|
+
</button>
|
|
185
|
+
<div className="px-4 py-2 bg-white rounded-xl border border-gray-200 text-sm text-gray-600">
|
|
186
|
+
<span className="font-semibold text-gray-900">{pagination.page}</span>
|
|
187
|
+
<span className="mx-1.5 text-gray-300">/</span>
|
|
188
|
+
{totalPages}
|
|
189
|
+
</div>
|
|
190
|
+
<button
|
|
191
|
+
onClick={() => searchPlugins(searchQuery, pagination.page + 1)}
|
|
192
|
+
disabled={pagination.page >= totalPages}
|
|
193
|
+
className="p-2.5 rounded-xl bg-white border border-gray-200 hover:border-violet-300 hover:shadow-md disabled:opacity-30 disabled:cursor-not-allowed transition-all"
|
|
194
|
+
>
|
|
195
|
+
<ChevronRight className="w-5 h-5 text-gray-600" />
|
|
196
|
+
</button>
|
|
197
|
+
</div>
|
|
198
|
+
)}
|
|
199
|
+
</>
|
|
200
|
+
)}
|
|
201
|
+
</div>
|
|
202
|
+
</div>
|
|
203
|
+
)
|
|
204
|
+
}
|