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.
Files changed (155) hide show
  1. package/dist/cli/index.js +1673 -696
  2. package/dist/cli/index.js.map +1 -1
  3. package/package.json +16 -10
  4. package/template/.husky/pre-commit +1 -1
  5. package/template/modules.config.ts +29 -2
  6. package/template/package.json +12 -12
  7. package/template/patches/{typescript+5.8.3.patch → typescript+5.9.3.patch} +2 -4
  8. package/template/playwright.config.ts +7 -1
  9. package/template/src/admin/App.tsx +2 -2
  10. package/template/src/admin/components/CaptchaModal.tsx +7 -9
  11. package/template/src/admin/layouts/Header.tsx +56 -22
  12. package/template/src/admin/layouts/Layout.tsx +14 -9
  13. package/template/src/admin/layouts/Sidebar.tsx +122 -105
  14. package/template/src/admin/pages/CategoryManagementPage.tsx +241 -0
  15. package/template/src/admin/pages/ContentPage.tsx +2 -0
  16. package/template/src/admin/pages/DashboardPage.tsx +2 -0
  17. package/template/src/admin/pages/DisputesPage.tsx +2 -0
  18. package/template/src/admin/pages/MediaTestPage.tsx +1 -1
  19. package/template/src/admin/pages/OrdersPage.tsx +2 -0
  20. package/template/src/admin/pages/PluginDashboardPage.tsx +297 -0
  21. package/template/src/admin/pages/PluginManagementPage.tsx +340 -0
  22. package/template/src/admin/pages/PluginReviewPage.tsx +255 -0
  23. package/template/src/admin/pages/SystemLogsPage.tsx +11 -2
  24. package/template/src/admin/pages/TicketsPage.tsx +2 -0
  25. package/template/src/admin/pages/UsersPage.tsx +3 -2
  26. package/template/src/admin/stores/adminStore.ts +5 -0
  27. package/template/src/cli/index.ts +17 -19
  28. package/template/src/cli/modules/auth/index.ts +65 -0
  29. package/template/src/cli/modules/config/index.ts +74 -77
  30. package/template/src/cli/modules/index.ts +28 -6
  31. package/template/src/cli/modules/notification/index.ts +95 -79
  32. package/template/src/cli/modules/plugin/index.ts +111 -0
  33. package/template/src/cli/modules/todo/index.ts +99 -51
  34. package/template/src/cli/utils/auto-command.ts +7 -25
  35. package/template/src/cli/utils/index.ts +3 -1
  36. package/template/src/client/App.tsx +36 -16
  37. package/template/src/client/Layout.tsx +67 -10
  38. package/template/src/client/components/AuthButton.tsx +25 -18
  39. package/template/src/client/components/BottomTabBar.tsx +107 -0
  40. package/template/src/client/components/Navigation.tsx +162 -52
  41. package/template/src/client/components/__tests__/App.test.tsx +48 -32
  42. package/template/src/client/components/__tests__/AuthButton.test.tsx +78 -77
  43. package/template/src/client/components/__tests__/Navigation.test.tsx +31 -20
  44. package/template/src/client/components/index.ts +1 -0
  45. package/template/src/client/contexts/PresetContext.tsx +10 -0
  46. package/template/src/client/main.tsx +63 -8
  47. package/template/src/client/pages/CartPage.tsx +244 -0
  48. package/template/src/client/pages/CategoriesPage.tsx +100 -0
  49. package/template/src/client/pages/ContentDetailPage.tsx +9 -14
  50. package/template/src/client/pages/ContentListPage.tsx +4 -11
  51. package/template/src/client/pages/DashboardPage.tsx +261 -0
  52. package/template/src/client/pages/DeveloperDashboardPage.tsx +211 -0
  53. package/template/src/client/pages/LoginPage.tsx +127 -0
  54. package/template/src/client/pages/OrdersPage.tsx +196 -0
  55. package/template/src/client/pages/PluginDetailPage.tsx +345 -0
  56. package/template/src/client/pages/PluginsPage.tsx +223 -0
  57. package/template/src/client/pages/ProfilePage.tsx +206 -0
  58. package/template/src/client/pages/PublishPage.tsx +336 -0
  59. package/template/src/client/pages/RegisterPage.tsx +136 -0
  60. package/template/src/client/pages/SearchPage.tsx +204 -0
  61. package/template/src/client/pages/SettingsPage.tsx +220 -0
  62. package/template/src/client/pages/TopicsPage.tsx +180 -0
  63. package/template/src/client/pages/__tests__/LoginPage.test.tsx +170 -0
  64. package/template/src/client/pages/__tests__/RegisterPage.test.tsx +168 -0
  65. package/template/src/client/preset-ui-config.ts +492 -0
  66. package/template/src/client/services/apiClient.ts +14 -4
  67. package/template/src/client/stores/__tests__/authStore.test.ts +293 -38
  68. package/template/src/client/stores/__tests__/todoStore.test.ts +7 -17
  69. package/template/src/client/stores/authStore.ts +58 -5
  70. package/template/src/client/stores/chatWSStore.ts +9 -0
  71. package/template/src/client/stores/notificationStore.ts +4 -0
  72. package/template/src/client/stores/pluginStore.ts +279 -0
  73. package/template/src/client/stores/todoStore.ts +2 -6
  74. package/template/src/server/core/__tests__/isr-cache.test.ts +117 -0
  75. package/template/src/server/core/__tests__/isr-invalidation.test.ts +72 -0
  76. package/template/src/server/core/__tests__/ssr-renderer.test.ts +89 -0
  77. package/template/src/server/core/isr-cache.ts +239 -0
  78. package/template/src/server/core/isr-invalidation.ts +45 -0
  79. package/template/src/server/core/module-loader.ts +14 -7
  80. package/template/src/server/core/ssr-renderer.ts +240 -0
  81. package/template/src/server/db/init.ts +257 -10
  82. package/template/src/server/db/schema/developers.ts +20 -0
  83. package/template/src/server/db/schema/index.ts +2 -0
  84. package/template/src/server/db/schema/plugins.ts +114 -0
  85. package/template/src/server/db/test-setup.ts +91 -0
  86. package/template/src/server/entries/cloudflare.ts +79 -7
  87. package/template/src/server/entries/node.ts +48 -5
  88. package/template/src/server/middleware/__tests__/captcha.test.ts +23 -13
  89. package/template/src/server/middleware/auth.ts +8 -1
  90. package/template/src/server/middleware/captcha.ts +14 -4
  91. package/template/src/server/middleware/rate-limit.ts +7 -2
  92. package/template/src/server/module-admin/module.ts +9 -5
  93. package/template/src/server/module-admin/routes/admin-notification-routes.ts +43 -14
  94. package/template/src/server/module-admin/routes/admin-routes.ts +0 -2
  95. package/template/src/server/module-admin/routes/client-auth-routes.ts +90 -0
  96. package/template/src/server/module-admin/routes/dashboard-routes.ts +79 -0
  97. package/template/src/server/module-admin/services/admin-service.ts +68 -11
  98. package/template/src/server/module-auth/__tests__/auth-service.test.ts +239 -0
  99. package/template/src/server/module-auth/index.ts +7 -0
  100. package/template/src/server/module-auth/module.ts +40 -0
  101. package/template/src/server/module-auth/routes/auth-routes.ts +94 -0
  102. package/template/src/server/module-auth/routes/profile-routes.ts +31 -0
  103. package/template/src/server/module-auth/services/auth-service.ts +100 -0
  104. package/template/src/server/module-content/module.ts +10 -4
  105. package/template/src/server/module-content/routes/public-content-routes.ts +2 -2
  106. package/template/src/server/module-content/routes/topics-routes.ts +205 -0
  107. package/template/src/server/module-content/services/content-service.ts +18 -2
  108. package/template/src/server/module-dispute/routes/dispute-routes.ts +2 -1
  109. package/template/src/server/module-dispute/services/dispute-service.ts +1 -1
  110. package/template/src/server/module-notifications/__tests__/sse-rpc.test.ts +14 -14
  111. package/template/src/server/module-notifications/routes/notification-routes.ts +34 -8
  112. package/template/src/server/module-order/__tests__/order-route.test.ts +22 -2
  113. package/template/src/server/module-order/module.ts +15 -0
  114. package/template/src/server/module-order/routes/cart-routes.ts +103 -0
  115. package/template/src/server/module-order/routes/orders-mock-routes.ts +67 -0
  116. package/template/src/server/module-order/services/order-service.ts +1 -1
  117. package/template/src/server/module-plugin/__tests__/plugin-query-service.test.ts +203 -0
  118. package/template/src/server/module-plugin/__tests__/plugin-service.test.ts +234 -0
  119. package/template/src/server/module-plugin/index.ts +2 -0
  120. package/template/src/server/module-plugin/module.ts +52 -0
  121. package/template/src/server/module-plugin/routes/plugin-admin-routes.ts +261 -0
  122. package/template/src/server/module-plugin/routes/plugin-routes.ts +354 -0
  123. package/template/src/server/module-plugin/services/admin-category-service.ts +99 -0
  124. package/template/src/server/module-plugin/services/admin-plugin-service.ts +170 -0
  125. package/template/src/server/module-plugin/services/admin-stats-service.ts +41 -0
  126. package/template/src/server/module-plugin/services/plugin-query-service.ts +360 -0
  127. package/template/src/server/module-plugin/services/plugin-review-service.ts +95 -0
  128. package/template/src/server/module-plugin/services/plugin-service.ts +163 -0
  129. package/template/src/server/module-ticket/services/ticket-service.ts +1 -1
  130. package/template/src/server/module-todos/routes/todos-routes.ts +0 -4
  131. package/template/src/server/route-registry.ts +16 -1
  132. package/template/src/server/test-utils/test-client.ts +1 -2
  133. package/template/src/server/utils/auth.ts +6 -0
  134. package/template/src/server/utils/json.ts +13 -0
  135. package/template/src/shared/core/module-manifest.ts +3 -6
  136. package/template/src/shared/modules/auth/index.ts +12 -0
  137. package/template/src/shared/modules/auth/schemas.ts +50 -0
  138. package/template/src/shared/modules/cart/index.ts +1 -0
  139. package/template/src/shared/modules/cart/schemas.ts +41 -0
  140. package/template/src/shared/modules/community/index.ts +1 -0
  141. package/template/src/shared/modules/community/schemas.ts +58 -0
  142. package/template/src/shared/modules/dashboard/index.ts +1 -0
  143. package/template/src/shared/modules/dashboard/schemas.ts +35 -0
  144. package/template/src/shared/modules/index.ts +41 -0
  145. package/template/src/shared/modules/order/schemas.ts +29 -0
  146. package/template/src/shared/modules/plugins/index.ts +48 -0
  147. package/template/src/shared/modules/plugins/schemas.ts +227 -0
  148. package/template/src/shared/schemas/index.ts +130 -0
  149. package/template/tests/e2e/todo.spec.ts +23 -18
  150. package/template/tests/e2e/visual-screenshots.spec.ts +1824 -0
  151. package/template/uploads/.gitkeep +0 -0
  152. package/template/vite.config.ts +2 -1
  153. package/template/vitest.setup.ts +11 -0
  154. package/template/wrangler.toml +4 -3
  155. 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
+ }