webspresso 0.0.13 → 0.0.14

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 (41) hide show
  1. package/README.md +4 -7
  2. package/bin/commands/add-tailwind.js +151 -0
  3. package/bin/commands/api.js +70 -0
  4. package/bin/commands/db-make.js +76 -0
  5. package/bin/commands/db-migrate.js +43 -0
  6. package/bin/commands/db-rollback.js +48 -0
  7. package/bin/commands/db-status.js +53 -0
  8. package/bin/commands/dev.js +73 -0
  9. package/bin/commands/new.js +634 -0
  10. package/bin/commands/page.js +134 -0
  11. package/bin/commands/seed.js +154 -0
  12. package/bin/commands/start.js +30 -0
  13. package/bin/utils/db.js +54 -0
  14. package/bin/utils/migration.js +36 -0
  15. package/bin/utils/project.js +97 -0
  16. package/bin/utils/seed.js +112 -0
  17. package/bin/webspresso.js +24 -1696
  18. package/core/orm/index.js +14 -1
  19. package/core/orm/migrations/scaffold.js +5 -0
  20. package/core/orm/model.js +8 -0
  21. package/core/orm/schema-helpers.js +39 -1
  22. package/core/orm/seeder.js +56 -3
  23. package/core/orm/types.js +28 -1
  24. package/index.js +2 -1
  25. package/package.json +1 -1
  26. package/plugins/admin-panel/admin-user-model.js +42 -0
  27. package/plugins/admin-panel/api.js +436 -0
  28. package/plugins/admin-panel/app.js +68 -0
  29. package/plugins/admin-panel/auth.js +157 -0
  30. package/plugins/admin-panel/components.js +359 -0
  31. package/plugins/admin-panel/field-renderers/array.js +57 -0
  32. package/plugins/admin-panel/field-renderers/basic.js +205 -0
  33. package/plugins/admin-panel/field-renderers/file-upload.js +124 -0
  34. package/plugins/admin-panel/field-renderers/index.js +93 -0
  35. package/plugins/admin-panel/field-renderers/json.js +52 -0
  36. package/plugins/admin-panel/field-renderers/relations.js +96 -0
  37. package/plugins/admin-panel/field-renderers/rich-text.js +83 -0
  38. package/plugins/admin-panel/index.js +187 -0
  39. package/plugins/admin-panel/migration-template.js +39 -0
  40. package/plugins/admin-panel/styles.js +9 -0
  41. package/plugins/index.js +2 -0
@@ -0,0 +1,359 @@
1
+ /**
2
+ * Admin Panel Components
3
+ * Mithril.js components for admin panel UI
4
+ */
5
+
6
+ module.exports = `
7
+ // API helper
8
+ const api = {
9
+ async request(path, options = {}) {
10
+ const adminPath = window.__ADMIN_PATH__ || '/_admin';
11
+ const url = adminPath + '/api' + path;
12
+ const response = await fetch(url, {
13
+ ...options,
14
+ headers: {
15
+ 'Content-Type': 'application/json',
16
+ ...options.headers,
17
+ },
18
+ credentials: 'include',
19
+ });
20
+
21
+ if (!response.ok) {
22
+ const error = await response.json().catch(() => ({ error: 'Request failed' }));
23
+ throw new Error(error.error || 'Request failed');
24
+ }
25
+
26
+ return response.json();
27
+ },
28
+
29
+ get(path) { return this.request(path, { method: 'GET' }); },
30
+ post(path, data) { return this.request(path, { method: 'POST', body: JSON.stringify(data) }); },
31
+ put(path, data) { return this.request(path, { method: 'PUT', body: JSON.stringify(data) }); },
32
+ delete(path) { return this.request(path, { method: 'DELETE' }); },
33
+ };
34
+
35
+ // State
36
+ const state = {
37
+ user: null,
38
+ needsSetup: false,
39
+ loading: false,
40
+ error: null,
41
+ models: [],
42
+ currentModel: null,
43
+ records: [],
44
+ pagination: null,
45
+ currentRecord: null,
46
+ editing: false,
47
+ };
48
+
49
+ // Login Form Component
50
+ const LoginForm = {
51
+ view: () => m('.max-w-md.mx-auto.mt-16', [
52
+ m('h1.text-3xl.font-bold.mb-6', 'Admin Login'),
53
+ m('form', {
54
+ onsubmit: async (e) => {
55
+ e.preventDefault();
56
+ state.loading = true;
57
+ state.error = null;
58
+ try {
59
+ const data = new FormData(e.target);
60
+ const result = await api.post('/auth/login', {
61
+ email: data.get('email'),
62
+ password: data.get('password'),
63
+ });
64
+ state.user = result.user;
65
+ m.route.set('/');
66
+ } catch (err) {
67
+ state.error = err.message;
68
+ } finally {
69
+ state.loading = false;
70
+ }
71
+ }
72
+ }, [
73
+ state.error ? m('.bg-red-100.border.border-red-400.text-red-700.px-4.py-3.rounded.mb-4', state.error) : null,
74
+ m('.mb-4', [
75
+ m('label.block.text-sm.font-medium.mb-2', { for: 'email' }, 'Email'),
76
+ m('input#email.w-full.px-3.py-2.border.border-gray-300.rounded', {
77
+ type: 'email',
78
+ name: 'email',
79
+ required: true,
80
+ }),
81
+ ]),
82
+ m('.mb-4', [
83
+ m('label.block.text-sm.font-medium.mb-2', { for: 'password' }, 'Password'),
84
+ m('input#password.w-full.px-3.py-2.border.border-gray-300.rounded', {
85
+ type: 'password',
86
+ name: 'password',
87
+ required: true,
88
+ }),
89
+ ]),
90
+ m('button.w-full.bg-blue-600.text-white.py-2.px-4.rounded.hover:bg-blue-700', {
91
+ type: 'submit',
92
+ disabled: state.loading,
93
+ }, state.loading ? 'Logging in...' : 'Login'),
94
+ ]),
95
+ ]),
96
+ };
97
+
98
+ // Setup Form Component
99
+ const SetupForm = {
100
+ view: () => m('.max-w-md.mx-auto.mt-16', [
101
+ m('h1.text-3xl.font-bold.mb-6', 'Setup Admin Account'),
102
+ m('p.text-gray-600.mb-6', 'Create the first admin user account.'),
103
+ m('form', {
104
+ onsubmit: async (e) => {
105
+ e.preventDefault();
106
+ state.loading = true;
107
+ state.error = null;
108
+ try {
109
+ const data = new FormData(e.target);
110
+ const result = await api.post('/auth/setup', {
111
+ email: data.get('email'),
112
+ password: data.get('password'),
113
+ name: data.get('name'),
114
+ });
115
+ state.user = result.user;
116
+ state.needsSetup = false;
117
+ m.route.set('/');
118
+ } catch (err) {
119
+ state.error = err.message;
120
+ } finally {
121
+ state.loading = false;
122
+ }
123
+ }
124
+ }, [
125
+ state.error ? m('.bg-red-100.border.border-red-400.text-red-700.px-4.py-3.rounded.mb-4', state.error) : null,
126
+ m('.mb-4', [
127
+ m('label.block.text-sm.font-medium.mb-2', { for: 'name' }, 'Name'),
128
+ m('input#name.w-full.px-3.py-2.border.border-gray-300.rounded', {
129
+ type: 'text',
130
+ name: 'name',
131
+ required: true,
132
+ }),
133
+ ]),
134
+ m('.mb-4', [
135
+ m('label.block.text-sm.font-medium.mb-2', { for: 'email' }, 'Email'),
136
+ m('input#email.w-full.px-3.py-2.border.border-gray-300.rounded', {
137
+ type: 'email',
138
+ name: 'email',
139
+ required: true,
140
+ }),
141
+ ]),
142
+ m('.mb-4', [
143
+ m('label.block.text-sm.font-medium.mb-2', { for: 'password' }, 'Password'),
144
+ m('input#password.w-full.px-3.py-2.border.border-gray-300.rounded', {
145
+ type: 'password',
146
+ name: 'password',
147
+ required: true,
148
+ }),
149
+ ]),
150
+ m('button.w-full.bg-blue-600.text-white.py-2.px-4.rounded.hover:bg-blue-700', {
151
+ type: 'submit',
152
+ disabled: state.loading,
153
+ }, state.loading ? 'Creating...' : 'Create Admin Account'),
154
+ ]),
155
+ ]),
156
+ };
157
+
158
+ // Layout Component
159
+ const Layout = {
160
+ view: (vnode) => m('.min-h-screen.bg-gray-100', [
161
+ m('.bg-white.shadow', [
162
+ m('.max-w-7xl.mx-auto.px-4.py-4', [
163
+ m('.flex.items-center.justify-between', [
164
+ m('h1.text-xl.font-bold', 'Admin Panel'),
165
+ state.user ? m('.flex.items-center.gap-4', [
166
+ m('span.text-sm.text-gray-600', state.user.name || state.user.email),
167
+ m('button.text-sm.text-red-600.hover:text-red-800', {
168
+ onclick: async () => {
169
+ await api.post('/auth/logout');
170
+ state.user = null;
171
+ m.route.set('/login');
172
+ }
173
+ }, 'Logout'),
174
+ ]) : null,
175
+ ]),
176
+ ]),
177
+ ]),
178
+ m('.max-w-7xl.mx-auto.px-4.py-6', vnode.children),
179
+ ]),
180
+ };
181
+
182
+ // Model List Component
183
+ const ModelList = {
184
+ oninit: async () => {
185
+ try {
186
+ const result = await api.get('/models');
187
+ state.models = result.models || [];
188
+ } catch (err) {
189
+ state.error = err.message;
190
+ }
191
+ },
192
+ view: () => m(Layout, [
193
+ m('h2.text-2xl.font-bold.mb-6', 'Models'),
194
+ state.models.length === 0
195
+ ? m('p.text-gray-600', 'No models enabled in admin panel.')
196
+ : m('.grid.grid-cols-1.md:grid-cols-2.lg:grid-cols-3.gap-4',
197
+ state.models.map(model =>
198
+ m('a.bg-white.p-6.rounded.shadow.hover:shadow-lg.transition', {
199
+ href: '/models/' + model.name,
200
+ onclick: (e) => {
201
+ e.preventDefault();
202
+ m.route.set('/models/' + model.name);
203
+ }
204
+ }, [
205
+ model.icon ? m('span.text-2xl.mb-2.block', model.icon) : null,
206
+ m('h3.font-semibold.text-lg', model.label || model.name),
207
+ m('p.text-sm.text-gray-600.mt-2', model.table),
208
+ ])
209
+ )
210
+ ),
211
+ ]),
212
+ };
213
+
214
+ // Record List Component (placeholder - will be enhanced with field renderers)
215
+ const RecordList = {
216
+ oninit: async (vnode) => {
217
+ const modelName = vnode.attrs.model;
218
+ try {
219
+ const result = await api.get('/models/' + modelName + '/records');
220
+ state.records = result.data || [];
221
+ state.pagination = result.pagination || null;
222
+ state.currentModel = state.models.find(m => m.name === modelName);
223
+ } catch (err) {
224
+ state.error = err.message;
225
+ }
226
+ },
227
+ view: (vnode) => {
228
+ const modelName = vnode.attrs.model;
229
+ return m(Layout, [
230
+ m('.flex.items-center.justify-between.mb-6', [
231
+ m('h2.text-2xl.font-bold', state.currentModel?.label || modelName),
232
+ m('button.bg-blue-600.text-white.px-4.py-2.rounded.hover:bg-blue-700', {
233
+ onclick: () => {
234
+ state.currentRecord = null;
235
+ state.editing = true;
236
+ m.route.set('/models/' + modelName + '/new');
237
+ }
238
+ }, 'New Record'),
239
+ ]),
240
+ state.records.length === 0
241
+ ? m('p.text-gray-600', 'No records found.')
242
+ : m('.bg-white.rounded.shadow.overflow-hidden', [
243
+ m('table.w-full', [
244
+ m('thead.bg-gray-50', [
245
+ m('tr', [
246
+ m('th.px-6.py-3.text-left.text-xs.font-medium.text-gray-500.uppercase', 'ID'),
247
+ m('th.px-6.py-3.text-left.text-xs.font-medium.text-gray-500.uppercase', 'Data'),
248
+ m('th.px-6.py-3.text-left.text-xs.font-medium.text-gray-500.uppercase', 'Actions'),
249
+ ]),
250
+ ]),
251
+ m('tbody', state.records.map(record =>
252
+ m('tr.border-t', [
253
+ m('td.px-6.py-4.text-sm', record.id || record[state.currentModel?.primaryKey || 'id']),
254
+ m('td.px-6.py-4.text-sm.text-gray-600', JSON.stringify(record).substring(0, 100) + '...'),
255
+ m('td.px-6.py-4.text-sm', [
256
+ m('button.text-blue-600.hover:text-blue-800.mr-4', {
257
+ onclick: () => {
258
+ state.currentRecord = record;
259
+ state.editing = true;
260
+ m.route.set('/models/' + modelName + '/edit/' + (record.id || record[state.currentModel?.primaryKey || 'id']));
261
+ }
262
+ }, 'Edit'),
263
+ m('button.text-red-600.hover:text-red-800', {
264
+ onclick: async () => {
265
+ if (confirm('Are you sure you want to delete this record?')) {
266
+ try {
267
+ await api.delete('/models/' + modelName + '/records/' + (record.id || record[state.currentModel?.primaryKey || 'id']));
268
+ m.route.set('/models/' + modelName);
269
+ } catch (err) {
270
+ alert('Error: ' + err.message);
271
+ }
272
+ }
273
+ }
274
+ }, 'Delete'),
275
+ ]),
276
+ ])
277
+ )),
278
+ ]),
279
+ ]),
280
+ ]);
281
+ },
282
+ };
283
+
284
+ // Record Form Component (placeholder - will be enhanced with field renderers)
285
+ const RecordForm = {
286
+ oninit: async (vnode) => {
287
+ const { model: modelName, id } = vnode.attrs;
288
+ if (id && id !== 'new') {
289
+ try {
290
+ const result = await api.get('/models/' + modelName + '/records/' + id);
291
+ state.currentRecord = result.data;
292
+ } catch (err) {
293
+ state.error = err.message;
294
+ }
295
+ } else {
296
+ state.currentRecord = {};
297
+ }
298
+ },
299
+ view: (vnode) => {
300
+ const { model: modelName, id } = vnode.attrs;
301
+ const isNew = !id || id === 'new';
302
+ return m(Layout, [
303
+ m('h2.text-2xl.font-bold.mb-6', isNew ? 'New Record' : 'Edit Record'),
304
+ m('form.bg-white.p-6.rounded.shadow', {
305
+ onsubmit: async (e) => {
306
+ e.preventDefault();
307
+ state.loading = true;
308
+ state.error = null;
309
+ try {
310
+ const data = new FormData(e.target);
311
+ const record = {};
312
+ for (const [key, value] of data.entries()) {
313
+ record[key] = value;
314
+ }
315
+ if (isNew) {
316
+ await api.post('/models/' + modelName + '/records', record);
317
+ } else {
318
+ await api.put('/models/' + modelName + '/records/' + id, record);
319
+ }
320
+ m.route.set('/models/' + modelName);
321
+ } catch (err) {
322
+ state.error = err.message;
323
+ } finally {
324
+ state.loading = false;
325
+ }
326
+ }
327
+ }, [
328
+ state.error ? m('.bg-red-100.border.border-red-400.text-red-700.px-4.py-3.rounded.mb-4', state.error) : null,
329
+ m('p.text-gray-600.mb-4', 'Form fields will be rendered here based on model schema.'),
330
+ m('.flex.gap-4', [
331
+ m('button.bg-blue-600.text-white.px-6.py-2.rounded.hover:bg-blue-700', {
332
+ type: 'submit',
333
+ disabled: state.loading,
334
+ }, state.loading ? 'Saving...' : 'Save'),
335
+ m('a.bg-gray-200.text-gray-800.px-6.py-2.rounded.hover:bg-gray-300', {
336
+ href: '/models/' + modelName,
337
+ onclick: (e) => {
338
+ e.preventDefault();
339
+ m.route.set('/models/' + modelName);
340
+ }
341
+ }, 'Cancel'),
342
+ ]),
343
+ ]),
344
+ ]);
345
+ },
346
+ };
347
+
348
+ // Export components
349
+ window.__ADMIN_COMPONENTS__ = {
350
+ LoginForm,
351
+ SetupForm,
352
+ Layout,
353
+ ModelList,
354
+ RecordList,
355
+ RecordForm,
356
+ api,
357
+ state,
358
+ };
359
+ `;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Array Field Renderer
3
+ * Component for editing array fields
4
+ */
5
+
6
+ module.exports = {
7
+ ArrayField: {
8
+ view: (vnode) => {
9
+ const { name, value = [], meta = {}, required = false } = vnode.attrs;
10
+ const items = Array.isArray(value) ? value : [];
11
+
12
+ return m('.mb-4', [
13
+ m('label.block.text-sm.font-medium.mb-2',
14
+ meta.label || name,
15
+ required ? m('span.text-red-500', ' *') : null
16
+ ),
17
+ m('.border.border-gray-300.rounded.p-4', [
18
+ items.map((item, index) =>
19
+ m('.flex.items-center.gap-2.mb-2', [
20
+ m('input.flex-1.px-3.py-2.border.border-gray-300.rounded', {
21
+ type: 'text',
22
+ value: String(item),
23
+ oninput: (e) => {
24
+ const newItems = [...items];
25
+ newItems[index] = e.target.value;
26
+ if (vnode.attrs.onchange) {
27
+ vnode.attrs.onchange(newItems);
28
+ }
29
+ }
30
+ }),
31
+ m('button.text-red-600.hover:text-red-800', {
32
+ onclick: () => {
33
+ const newItems = items.filter((_, i) => i !== index);
34
+ if (vnode.attrs.onchange) {
35
+ vnode.attrs.onchange(newItems);
36
+ }
37
+ }
38
+ }, 'Remove'),
39
+ ])
40
+ ),
41
+ m('button.text-blue-600.hover:text-blue-800.text-sm', {
42
+ onclick: () => {
43
+ const newItems = [...items, ''];
44
+ if (vnode.attrs.onchange) {
45
+ vnode.attrs.onchange(newItems);
46
+ }
47
+ }
48
+ }, '+ Add Item'),
49
+ ]),
50
+ m('input[type=hidden]', {
51
+ name,
52
+ value: JSON.stringify(items),
53
+ }),
54
+ ]);
55
+ }
56
+ },
57
+ };
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Basic Field Renderers
3
+ * Standard input components for basic field types
4
+ */
5
+
6
+ module.exports = {
7
+ /**
8
+ * Text input field
9
+ */
10
+ TextField: {
11
+ view: (vnode) => {
12
+ const { name, value = '', meta = {}, required = false } = vnode.attrs;
13
+ return m('.mb-4', [
14
+ m('label.block.text-sm.font-medium.mb-2', { for: name },
15
+ meta.label || name,
16
+ required ? m('span.text-red-500', ' *') : null
17
+ ),
18
+ m('input.w-full.px-3.py-2.border.border-gray-300.rounded', {
19
+ id: name,
20
+ name,
21
+ type: 'text',
22
+ value: String(value || ''),
23
+ maxLength: meta.maxLength,
24
+ required,
25
+ oninput: (e) => {
26
+ if (vnode.attrs.onchange) {
27
+ vnode.attrs.onchange(e.target.value);
28
+ }
29
+ }
30
+ }),
31
+ ]);
32
+ }
33
+ },
34
+
35
+ /**
36
+ * Textarea field
37
+ */
38
+ TextAreaField: {
39
+ view: (vnode) => {
40
+ const { name, value = '', meta = {}, required = false } = vnode.attrs;
41
+ return m('.mb-4', [
42
+ m('label.block.text-sm.font-medium.mb-2', { for: name },
43
+ meta.label || name,
44
+ required ? m('span.text-red-500', ' *') : null
45
+ ),
46
+ m('textarea.w-full.px-3.py-2.border.border-gray-300.rounded', {
47
+ id: name,
48
+ name,
49
+ rows: meta.rows || 5,
50
+ required,
51
+ oninput: (e) => {
52
+ if (vnode.attrs.onchange) {
53
+ vnode.attrs.onchange(e.target.value);
54
+ }
55
+ }
56
+ }, value || ''),
57
+ ]);
58
+ }
59
+ },
60
+
61
+ /**
62
+ * Number input field
63
+ */
64
+ NumberField: {
65
+ view: (vnode) => {
66
+ const { name, value = '', meta = {}, required = false } = vnode.attrs;
67
+ return m('.mb-4', [
68
+ m('label.block.text-sm.font-medium.mb-2', { for: name },
69
+ meta.label || name,
70
+ required ? m('span.text-red-500', ' *') : null
71
+ ),
72
+ m('input.w-full.px-3.py-2.border.border-gray-300.rounded', {
73
+ id: name,
74
+ name,
75
+ type: 'number',
76
+ value: value !== null && value !== undefined ? String(value) : '',
77
+ step: meta.type === 'float' || meta.type === 'decimal' ? '0.01' : '1',
78
+ required,
79
+ oninput: (e) => {
80
+ const numValue = e.target.value === '' ? null : Number(e.target.value);
81
+ if (vnode.attrs.onchange) {
82
+ vnode.attrs.onchange(numValue);
83
+ }
84
+ }
85
+ }),
86
+ ]);
87
+ }
88
+ },
89
+
90
+ /**
91
+ * Boolean checkbox field
92
+ */
93
+ BooleanField: {
94
+ view: (vnode) => {
95
+ const { name, value = false, meta = {}, required = false } = vnode.attrs;
96
+ return m('.mb-4', [
97
+ m('label.flex.items-center', [
98
+ m('input.mr-2', {
99
+ type: 'checkbox',
100
+ name,
101
+ checked: Boolean(value),
102
+ required,
103
+ onchange: (e) => {
104
+ if (vnode.attrs.onchange) {
105
+ vnode.attrs.onchange(e.target.checked);
106
+ }
107
+ }
108
+ }),
109
+ m('span.text-sm.font-medium',
110
+ meta.label || name,
111
+ required ? m('span.text-red-500', ' *') : null
112
+ ),
113
+ ]),
114
+ ]);
115
+ }
116
+ },
117
+
118
+ /**
119
+ * Date input field
120
+ */
121
+ DateField: {
122
+ view: (vnode) => {
123
+ const { name, value = '', meta = {}, required = false } = vnode.attrs;
124
+ const dateValue = value ? new Date(value).toISOString().split('T')[0] : '';
125
+ return m('.mb-4', [
126
+ m('label.block.text-sm.font-medium.mb-2', { for: name },
127
+ meta.label || name,
128
+ required ? m('span.text-red-500', ' *') : null
129
+ ),
130
+ m('input.w-full.px-3.py-2.border.border-gray-300.rounded', {
131
+ id: name,
132
+ name,
133
+ type: 'date',
134
+ value: dateValue,
135
+ required,
136
+ oninput: (e) => {
137
+ if (vnode.attrs.onchange) {
138
+ vnode.attrs.onchange(e.target.value);
139
+ }
140
+ }
141
+ }),
142
+ ]);
143
+ }
144
+ },
145
+
146
+ /**
147
+ * DateTime input field
148
+ */
149
+ DateTimeField: {
150
+ view: (vnode) => {
151
+ const { name, value = '', meta = {}, required = false } = vnode.attrs;
152
+ const dateTimeValue = value ? new Date(value).toISOString().slice(0, 16) : '';
153
+ return m('.mb-4', [
154
+ m('label.block.text-sm.font-medium.mb-2', { for: name },
155
+ meta.label || name,
156
+ required ? m('span.text-red-500', ' *') : null
157
+ ),
158
+ m('input.w-full.px-3.py-2.border.border-gray-300.rounded', {
159
+ id: name,
160
+ name,
161
+ type: 'datetime-local',
162
+ value: dateTimeValue,
163
+ required,
164
+ oninput: (e) => {
165
+ if (vnode.attrs.onchange) {
166
+ vnode.attrs.onchange(e.target.value);
167
+ }
168
+ }
169
+ }),
170
+ ]);
171
+ }
172
+ },
173
+
174
+ /**
175
+ * Select dropdown field (for enum)
176
+ */
177
+ SelectField: {
178
+ view: (vnode) => {
179
+ const { name, value = '', meta = {}, required = false } = vnode.attrs;
180
+ const options = meta.enumValues || [];
181
+ return m('.mb-4', [
182
+ m('label.block.text-sm.font-medium.mb-2', { for: name },
183
+ meta.label || name,
184
+ required ? m('span.text-red-500', ' *') : null
185
+ ),
186
+ m('select.w-full.px-3.py-2.border.border-gray-300.rounded', {
187
+ id: name,
188
+ name,
189
+ value: String(value || ''),
190
+ required,
191
+ onchange: (e) => {
192
+ if (vnode.attrs.onchange) {
193
+ vnode.attrs.onchange(e.target.value);
194
+ }
195
+ }
196
+ }, [
197
+ !required ? m('option', { value: '' }, '-- Select --') : null,
198
+ ...options.map(opt =>
199
+ m('option', { value: String(opt), selected: String(value) === String(opt) }, String(opt))
200
+ ),
201
+ ]),
202
+ ]);
203
+ }
204
+ },
205
+ };