webspresso 0.0.78 → 0.0.80

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/README.md CHANGED
@@ -2137,7 +2137,7 @@ List query parameters: `page`, `perPage`, `sort`, `order`, `include`, `trashed`
2137
2137
 
2138
2138
  ### File upload plugin
2139
2139
 
2140
- Registers **POST** `multipart/form-data` (field name **`file`** by default) and stores the file via a pluggable provider. The framework ships with **`createLocalFileProvider({ destDir, publicBasePath })`** (writes to disk and returns a public URL path). Use **`mimeAllowlist`** / **`extensionAllowlist`** and **`maxBytes`** (default **10 MiB**) on the server; production apps should prefer an explicit MIME allowlist.
2140
+ Registers **POST** `multipart/form-data` (field name **`file`** by default) and stores the file(s) via a pluggable provider. The framework ships with **`createLocalFileProvider({ destDir, publicBasePath })`** (writes to disk and returns public URL paths). Use **`mimeAllowlist`** / **`extensionAllowlist`**, **`maxBytes`** (default **10 MiB**), **`multiple`** (default **`false`**), and **`maxFiles`** (default **10**) on the server; production apps should prefer an explicit MIME allowlist.
2141
2141
 
2142
2142
  **Setup:**
2143
2143
 
@@ -2160,6 +2160,8 @@ const { app } = createApp({
2160
2160
  extensionAllowlist: ['jpg', 'jpeg', 'png', 'pdf'],
2161
2161
  middleware: [], // optional Express handlers (e.g. session auth)
2162
2162
  fieldName: 'file',
2163
+ multiple: false, // set to true to allow multiple file uploads in a single request
2164
+ maxFiles: 10, // max number of files to accept when multiple is true
2163
2165
  }),
2164
2166
  adminPanelPlugin({
2165
2167
  db,
@@ -2169,13 +2171,14 @@ const { app } = createApp({
2169
2171
  });
2170
2172
  ```
2171
2173
 
2172
- - **ORM:** `zdb.file({ maxLength: 2048, nullable: true })` — string column for the stored public URL or path; migrations use `table.string(..., maxLength)`.
2173
- - **Admin forms:** columns with `zdb.file()` automatically render a drag-and-drop upload widget (or a manual URL text field when `uploadUrl` is not configured). Image URLs show an inline thumbnail preview; use `ui.accept: 'image/*'` for image-only fields. Optional `ui: { label, hint, accept, maxBytes }` on the column customizes the widget. For existing `zdb.string()` columns you can use `admin.customFields: { columnName: { type: 'file-upload' } }` instead of changing the schema type.
2174
- - **Admin:** the panel reads **`settings.uploadUrl`** from the registry (set automatically when `uploadPlugin` is registered **before** `adminPanelPlugin`, or pass **`adminPanelPlugin({ uploadUrl: '/api/upload' })`**). File fields (`type: 'file'` or `customFields` type `file-upload`) POST to that URL with credentials; the saved record stores the returned **`url`** / **`publicUrl`** string.
2175
- - **Response:** `{ url, publicUrl, key? }` — clients typically persist **`url`** / **`publicUrl`** in the model.
2174
+ - **ORM (Single File):** `zdb.file({ maxLength: 2048, nullable: true })` — string column for the stored public URL or path; migrations use `table.string(..., maxLength)`.
2175
+ - **ORM (Multiple Files):** `zdb.files({ nullable: true })` — array column that stores a list of file URLs/paths as a JSON field in the database; migrations use `table.json()`.
2176
+ - **Admin forms:** columns with `zdb.file()` or `zdb.files()` automatically render a drag-and-drop upload widget (or a manual URL text field when `uploadUrl` is not configured). Multiple file upload fields (`zdb.files()`) allow adding and removing multiple files concurrently and show preview grids. Image URLs show an inline thumbnail preview; use `ui.accept: 'image/*'` for image-only fields. Optional `ui: { label, hint, accept, maxBytes }` on the column customizes the widget. For existing `zdb.string()` columns you can use `admin.customFields: { columnName: { type: 'file-upload' } }` instead of changing the schema type.
2177
+ - **Admin:** the panel reads **`settings.uploadUrl`** from the registry (set automatically when `uploadPlugin` is registered **before** `adminPanelPlugin`, or pass **`adminPanelPlugin({ uploadUrl: '/api/upload' })`**). File fields (`type: 'file'`, `'files'` or `customFields` type `file-upload`) POST to that URL with credentials; the saved record stores the returned **`url`** / **`publicUrl`** string (or an array of URLs for `'files'`).
2178
+ - **Response:** Single: `{ url, publicUrl, key? }`. Multiple: `[{ url, publicUrl, key? }, ...]`.
2176
2179
  - **Custom storage:** `uploadPlugin({ provider: { async put({ buffer, originalName, mimeType, size, req }) { return { publicUrl: '...' }; } } })`.
2177
2180
 
2178
- **Admin model example** (`zdb.file()` picks up the upload widget from schema; no extra `customFields` needed):
2181
+ **Admin model example** (`zdb.file()` and `zdb.files()` pick up the upload widgets from schema; no extra `customFields` needed):
2179
2182
 
2180
2183
  ```javascript
2181
2184
  const { defineModel, zdb } = require('webspresso');
@@ -2191,6 +2194,10 @@ const Post = defineModel({
2191
2194
  nullable: true,
2192
2195
  ui: { label: 'Cover', accept: 'image/*', hint: 'JPEG or PNG' },
2193
2196
  }),
2197
+ gallery: zdb.files({
2198
+ nullable: true,
2199
+ ui: { label: 'Gallery Images', accept: 'image/*' },
2200
+ }),
2194
2201
  }),
2195
2202
  admin: { enabled: true, label: 'Posts' },
2196
2203
  });
@@ -12,7 +12,7 @@ function getJsonColumns(model) {
12
12
  const jsonCols = new Set();
13
13
  if (model.columns) {
14
14
  for (const [name, meta] of model.columns) {
15
- if (meta.type === 'json') {
15
+ if (meta.type === 'json' || meta.type === 'array' || meta.type === 'files') {
16
16
  jsonCols.add(name);
17
17
  }
18
18
  }
@@ -153,6 +153,11 @@ function generateColumnLine(columnName, meta) {
153
153
  parts.push(`table.json('${columnName}')`);
154
154
  break;
155
155
 
156
+ case 'files':
157
+ // Multiple files are stored as JSON in database
158
+ parts.push(`table.json('${columnName}')`);
159
+ break;
160
+
156
161
  case 'enum':
157
162
  const enumValues = meta.enumValues || [];
158
163
  const valuesStr = enumValues.map(v => `'${v}'`).join(', ');
@@ -439,6 +439,24 @@ function createSchemaHelpers(z) {
439
439
  }, z);
440
440
  },
441
441
 
442
+ /**
443
+ * Multiple Files / URLs column (stored as JSON array in database)
444
+ * @param {Partial<import('./types').ColumnMeta>} [options={}]
445
+ * @returns {SchemaBuilder}
446
+ */
447
+ files(options = {}) {
448
+ const { nullable = false, ...rest } = options;
449
+ let schema = z.array(z.string());
450
+ if (nullable) {
451
+ schema = schema.nullable().optional();
452
+ }
453
+ return createSchemaBuilder(schema, {
454
+ type: 'files',
455
+ nullable,
456
+ ...rest,
457
+ }, z);
458
+ },
459
+
442
460
  /**
443
461
  * Integer column
444
462
  * @param {Partial<import('./types').ColumnMeta>} [options={}]
package/core/orm/types.js CHANGED
@@ -9,7 +9,7 @@
9
9
  // ============================================================================
10
10
 
11
11
  /**
12
- * @typedef {'id'|'string'|'text'|'file'|'integer'|'bigint'|'float'|'decimal'|'boolean'|'date'|'datetime'|'timestamp'|'json'|'array'|'enum'|'uuid'|'nanoid'} ColumnType
12
+ * @typedef {'id'|'string'|'text'|'file'|'files'|'integer'|'bigint'|'float'|'decimal'|'boolean'|'date'|'datetime'|'timestamp'|'json'|'array'|'enum'|'uuid'|'nanoid'} ColumnType
13
13
  */
14
14
 
15
15
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webspresso",
3
- "version": "0.0.78",
3
+ "version": "0.0.80",
4
4
  "description": "Minimal, production-ready SSR framework for Node.js with file-based routing, Nunjucks templating, built-in i18n, and CLI tooling",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -1,4 +1,160 @@
1
1
  // Field Renderers - render appropriate input based on column type
2
+
3
+ // BelongsTo relation dropdown select component
4
+ const BelongsToField = {
5
+ oninit: (vnode) => {
6
+ const { col } = vnode.attrs;
7
+ const customField = col.customField || {};
8
+ const relationName = customField.relation || col.name.replace(/_id$/, '');
9
+ const modelName = m.route.param('model');
10
+ vnode.state.loading = true;
11
+ vnode.state.options = [];
12
+
13
+ api.get('/models/' + modelName + '/relations/' + relationName)
14
+ .then(result => {
15
+ vnode.state.options = result.data || [];
16
+ })
17
+ .catch(err => {
18
+ console.error('Failed to load relation data:', err);
19
+ })
20
+ .finally(() => {
21
+ vnode.state.loading = false;
22
+ m.redraw();
23
+ });
24
+ },
25
+ view: (vnode) => {
26
+ const { col, value, onChange, readonly } = vnode.attrs;
27
+ const customField = col.customField || {};
28
+ const label = col.ui?.label || formatColumnLabel(col.name);
29
+ const hint = col.ui?.hint || '';
30
+ const valueKey = customField.valueKey || 'id';
31
+ const displayKey = customField.displayKey ||
32
+ (vnode.state.options.length > 0 ? ['name', 'title', 'label', 'username', 'email'].find(k => k in vnode.state.options[0]) : null) ||
33
+ 'id';
34
+
35
+ if (vnode.state.loading) {
36
+ return m('.mb-4', [
37
+ m('label.block.text-sm.font-medium.text-gray-700.dark:text-slate-300.mb-1', label),
38
+ m('.text-xs.text-gray-500.dark:text-slate-400', 'Loading options...')
39
+ ]);
40
+ }
41
+
42
+ return m('.mb-4', [
43
+ m('label.block.text-sm.font-medium.text-gray-700.dark:text-slate-300.mb-1', { for: col.name }, [
44
+ label,
45
+ !col.nullable && !readonly ? m('span.text-red-500', ' *') : null
46
+ ]),
47
+ m('select.w-full.px-3.py-2.border.border-gray-300.dark:border-slate-600.rounded-md.bg-white.dark:bg-slate-900/70.text-gray-900.dark:text-slate-100.focus:outline-none.focus:ring-2.focus:ring-blue-500.dark:focus:ring-blue-400', {
48
+ id: col.name,
49
+ name: col.name,
50
+ value: value !== null && value !== undefined ? String(value) : '',
51
+ disabled: readonly,
52
+ class: readonly ? 'bg-gray-100 dark:bg-slate-800 cursor-not-allowed' : '',
53
+ onchange: (e) => {
54
+ const selectedValue = e.target.value === '' ? null : e.target.value;
55
+ const typedValue = (col.type === 'integer' || col.type === 'bigint' || col.type === 'float' || col.type === 'decimal') && selectedValue !== null
56
+ ? Number(selectedValue)
57
+ : selectedValue;
58
+ onChange(typedValue);
59
+ },
60
+ }, [
61
+ col.nullable ? m('option', { value: '' }, '-- Select --') : m('option', { value: '' }, '-- Select --'),
62
+ vnode.state.options.map(item => {
63
+ const itemValue = item[valueKey];
64
+ const itemDisplay = item[displayKey] || String(itemValue);
65
+ return m('option', {
66
+ value: String(itemValue),
67
+ selected: value !== null && value !== undefined && String(value) === String(itemValue)
68
+ }, itemDisplay);
69
+ }),
70
+ ]),
71
+ hint ? m('p.text-xs.text-gray-500.dark:text-slate-400.mt-1', hint) : null,
72
+ ]);
73
+ }
74
+ };
75
+
76
+ // HasMany relation multi-select checkbox component
77
+ const HasManyField = {
78
+ oninit: (vnode) => {
79
+ const { col } = vnode.attrs;
80
+ const customField = col.customField || {};
81
+ const relationName = customField.relation || col.name.replace(/_ids$/, '').replace(/_id$/, '');
82
+ const modelName = m.route.param('model');
83
+ vnode.state.loading = true;
84
+ vnode.state.options = [];
85
+
86
+ api.get('/models/' + modelName + '/relations/' + relationName)
87
+ .then(result => {
88
+ vnode.state.options = result.data || [];
89
+ })
90
+ .catch(err => {
91
+ console.error('Failed to load relation data:', err);
92
+ })
93
+ .finally(() => {
94
+ vnode.state.loading = false;
95
+ m.redraw();
96
+ });
97
+ },
98
+ view: (vnode) => {
99
+ const { col, value = [], onChange, readonly } = vnode.attrs;
100
+ const customField = col.customField || {};
101
+ const label = col.ui?.label || formatColumnLabel(col.name);
102
+ const hint = col.ui?.hint || '';
103
+ const valueKey = customField.valueKey || 'id';
104
+ const displayKey = customField.displayKey ||
105
+ (vnode.state.options.length > 0 ? ['name', 'title', 'label', 'username', 'email'].find(k => k in vnode.state.options[0]) : null) ||
106
+ 'id';
107
+ const selectedIds = Array.isArray(value) ? value.map(v => String(v)) : [];
108
+
109
+ if (vnode.state.loading) {
110
+ return m('.mb-4', [
111
+ m('label.block.text-sm.font-medium.text-gray-700.dark:text-slate-300.mb-1', label),
112
+ m('.text-xs.text-gray-500.dark:text-slate-400', 'Loading options...')
113
+ ]);
114
+ }
115
+
116
+ return m('.mb-4', [
117
+ m('label.block.text-sm.font-medium.text-gray-700.dark:text-slate-300.mb-1', [
118
+ label,
119
+ !col.nullable && !readonly ? m('span.text-red-500', ' *') : null
120
+ ]),
121
+ m('.border.border-gray-300.dark:border-slate-600.rounded-md.p-3.max-h-48.overflow-y-auto.bg-white.dark:bg-slate-900/70', [
122
+ vnode.state.options.map(item => {
123
+ const itemValue = String(item[valueKey]);
124
+ const itemDisplay = item[displayKey] || itemValue;
125
+ const isSelected = selectedIds.includes(itemValue);
126
+
127
+ return m('label.flex.items-center.mb-2.cursor-pointer.text-sm.text-gray-700.dark:text-slate-300', [
128
+ m('input.mr-2.rounded.border-gray-300.dark:border-slate-600.text-indigo-600.focus:ring-indigo-500', {
129
+ type: 'checkbox',
130
+ checked: isSelected,
131
+ disabled: readonly,
132
+ onchange: (e) => {
133
+ let newSelected = [...selectedIds];
134
+ if (e.target.checked) {
135
+ if (!newSelected.includes(itemValue)) {
136
+ newSelected.push(itemValue);
137
+ }
138
+ } else {
139
+ newSelected = newSelected.filter(id => id !== itemValue);
140
+ }
141
+
142
+ // Convert type if needed
143
+ const typedSelected = col.type === 'array' || typeof value[0] === 'string'
144
+ ? newSelected
145
+ : newSelected.map(Number);
146
+ onChange(typedSelected);
147
+ }
148
+ }),
149
+ m('span', itemDisplay),
150
+ ]);
151
+ }),
152
+ ]),
153
+ hint ? m('p.text-xs.text-gray-500.dark:text-slate-400.mt-1', hint) : null,
154
+ ]);
155
+ }
156
+ };
157
+
2
158
  const FieldRenderers = {
3
159
  // Text input (string)
4
160
  string: (col, value, onChange, readonly) => {
@@ -284,4 +440,14 @@ const FieldRenderers = {
284
440
  hint ? m('p.text-xs.text-gray-500 dark:text-slate-400.mt-1', hint) : null,
285
441
  ]);
286
442
  },
443
+
444
+ // belongsTo relation dropdown
445
+ belongsTo: (col, value, onChange, readonly) => {
446
+ return m(BelongsToField, { col, value, onChange, readonly });
447
+ },
448
+
449
+ // hasMany relation checklist
450
+ hasMany: (col, value, onChange, readonly) => {
451
+ return m(HasManyField, { col, value, onChange, readonly });
452
+ },
287
453
  };
@@ -372,7 +372,36 @@ function getFieldRenderer(col, modelMeta) {
372
372
  });
373
373
  };
374
374
  }
375
- // Add other custom field types here if needed
375
+ if (col.customField.type === 'belongsTo') {
376
+ return FieldRenderers.belongsTo;
377
+ }
378
+ if (col.customField.type === 'hasMany') {
379
+ return FieldRenderers.hasMany;
380
+ }
381
+
382
+ // Check registered custom field renderers in browser
383
+ const custom = window.__customFieldRenderers && window.__customFieldRenderers[col.customField.type];
384
+ if (custom) {
385
+ return (col, value, onChange, readonly) => {
386
+ if (custom.edit && typeof custom.edit === 'object' && custom.edit.view) {
387
+ return m(custom.edit, { col, value, onChange, readonly });
388
+ } else if (typeof custom.edit === 'function') {
389
+ return custom.edit(value, onChange, col);
390
+ }
391
+ };
392
+ }
393
+ }
394
+
395
+ // Check schema type custom field renderers
396
+ if (col.type && window.__customFieldRenderers && window.__customFieldRenderers[col.type]) {
397
+ const custom = window.__customFieldRenderers[col.type];
398
+ return (col, value, onChange, readonly) => {
399
+ if (custom.edit && typeof custom.edit === 'object' && custom.edit.view) {
400
+ return m(custom.edit, { col, value, onChange, readonly });
401
+ } else if (typeof custom.edit === 'function') {
402
+ return custom.edit(value, onChange, col);
403
+ }
404
+ };
376
405
  }
377
406
 
378
407
  if (col.type === 'file') {
@@ -403,6 +432,8 @@ function getFieldRenderer(col, modelMeta) {
403
432
  array: 'array',
404
433
  uuid: 'string',
405
434
  nanoid: 'string',
435
+ belongsTo: 'belongsTo',
436
+ hasMany: 'hasMany',
406
437
  };
407
438
  return FieldRenderers[typeMap[col.type] || 'string'];
408
439
  }
@@ -65,10 +65,20 @@ const ModelList = {
65
65
  };
66
66
 
67
67
  // Format cell value based on column type
68
- function formatCellValue(value, col) {
68
+ function formatCellValue(value, col, record = null) {
69
69
  if (value === null || value === undefined) {
70
70
  return m('span.text-gray-400 dark:text-slate-500.italic', 'null');
71
71
  }
72
+
73
+ const customRendererType = (col && col.customField && col.customField.type) || (col && col.type);
74
+ const custom = window.__customFieldRenderers && window.__customFieldRenderers[customRendererType];
75
+ if (custom && custom.display) {
76
+ if (typeof custom.display === 'object' && custom.display.view) {
77
+ return m(custom.display, { value, col, record });
78
+ } else if (typeof custom.display === 'function') {
79
+ return custom.display(value, record);
80
+ }
81
+ }
72
82
 
73
83
  switch (col?.type) {
74
84
  case 'boolean':
@@ -118,6 +128,24 @@ function formatCellValue(value, col) {
118
128
  }
119
129
  return short || m('span.text-gray-400', '—');
120
130
  }
131
+
132
+ case 'files': {
133
+ if (Array.isArray(value)) {
134
+ return value.length > 0
135
+ ? m('div.flex.gap-1.items-center', [
136
+ m('span.text-xs.bg-gray-100.dark:bg-slate-800.px-2.py-1.rounded.mr-1', `${value.length} files`),
137
+ ...value.slice(0, 2).map(s => {
138
+ const isImg = /\.(jpe?g|png|gif|webp|svg|avif|bmp|ico)(\?|#|$)/i.test(s);
139
+ if (isImg) {
140
+ return m('img.h-6.w-6.rounded.object-cover.border.border-gray-200.dark:border-slate-700.bg-white', { src: s });
141
+ }
142
+ return m('a.text-xs.text-indigo-600.dark:text-indigo-400.hover:underline', { href: s, target: '_blank' }, '📄');
143
+ })
144
+ ])
145
+ : m('span.text-gray-400.dark:text-slate-500.italic', 'empty');
146
+ }
147
+ return String(value);
148
+ }
121
149
 
122
150
  default: {
123
151
  const str = String(value);
@@ -478,7 +478,7 @@ const RecordList = {
478
478
  ...displayColumns.map((col, i) =>
479
479
  m('td.px-4.py-3.text-sm.whitespace-nowrap.text-gray-700 dark:text-slate-300.bg-white dark:bg-slate-800',
480
480
  i === 0 ? { style: 'position: sticky; left: 40px; z-index: 5; box-shadow: 4px 0 8px -4px rgba(0,0,0,0.08);' } : {},
481
- formatCellValue(record[col.name], col)
481
+ formatCellValue(record[col.name], col, record)
482
482
  )
483
483
  ),
484
484
  // Sticky actions cell (sticky right, box-shadow on left)
@@ -219,6 +219,166 @@ module.exports = {
219
219
  ]);
220
220
  },
221
221
  },
222
+
223
+ FilesUploadField: {
224
+ oninit: (vnode) => {
225
+ vnode.state.uploading = false;
226
+ },
227
+ oncreate: (vnode) => {
228
+ const { name, meta = {} } = vnode.attrs;
229
+ if (!getUploadUrlFromAdminConfig()) return;
230
+
231
+ const dropZoneId = 'drop-zone-multiple-' + name;
232
+ const dropZone = document.getElementById(dropZoneId);
233
+ if (!dropZone) return;
234
+
235
+ ['dragenter', 'dragover', 'dragleave', 'drop'].forEach((eventName) => {
236
+ dropZone.addEventListener(eventName, (e) => {
237
+ e.preventDefault();
238
+ e.stopPropagation();
239
+ });
240
+ });
241
+
242
+ ['dragenter', 'dragover'].forEach((eventName) => {
243
+ dropZone.addEventListener(eventName, () => {
244
+ dropZone.classList.add('border-blue-500', 'bg-blue-50', 'dark:bg-slate-800');
245
+ });
246
+ });
247
+
248
+ ['dragleave', 'drop'].forEach((eventName) => {
249
+ dropZone.addEventListener(eventName, () => {
250
+ dropZone.classList.remove('border-blue-500', 'bg-blue-50', 'dark:bg-slate-800');
251
+ });
252
+ });
253
+
254
+ dropZone.addEventListener('drop', (e) => {
255
+ const files = e.dataTransfer.files;
256
+ if (files.length > 0) {
257
+ handleMultipleFilesUpload(files, vnode, meta);
258
+ }
259
+ });
260
+
261
+ const fileInput = dropZone.querySelector('input[type=file]');
262
+ if (fileInput) {
263
+ fileInput.addEventListener('change', (e) => {
264
+ if (e.target.files.length > 0) {
265
+ handleMultipleFilesUpload(e.target.files, vnode, meta);
266
+ }
267
+ });
268
+ }
269
+ },
270
+ view: (vnode) => {
271
+ const { name, value = [], meta = {}, required = false, readonly = false } = vnode.attrs;
272
+ const state = vnode.state;
273
+ const dropZoneId = 'drop-zone-multiple-' + name;
274
+ const maxSize = meta.maxSize || meta.maxBytes || 10 * 1024 * 1024;
275
+ const accept = meta.accept || '*/*';
276
+ const uploadUrl = getUploadUrlFromAdminConfig();
277
+ const label = meta.label || name;
278
+
279
+ const urls = Array.isArray(value) ? value : (value ? [value] : []);
280
+
281
+ if (!uploadUrl) {
282
+ return m('.mb-4', [
283
+ m(
284
+ 'label.block.text-sm.font-medium.mb-2',
285
+ label,
286
+ required ? m('span.text-red-500', ' *') : null
287
+ ),
288
+ m('p.text-xs.text-amber-700.dark:text-amber-400.mb-2', 'Upload URL is not configured.'),
289
+ ]);
290
+ }
291
+
292
+ return m('.mb-4', [
293
+ m(
294
+ 'label.block.text-sm.font-medium.mb-2',
295
+ label,
296
+ required ? m('span.text-red-500', ' *') : null
297
+ ),
298
+
299
+ urls.length > 0
300
+ ? m('.grid.grid-cols-2.sm:grid-cols-3.md:grid-cols-4.gap-4.mb-3', urls.map((url, idx) => {
301
+ return m('.relative.group.border.border-gray-200.dark:border-slate-700.rounded-lg.p-2.bg-gray-50.dark:bg-slate-900', [
302
+ shouldShowImagePreview(url, accept)
303
+ ? m('a.block', { href: url, target: '_blank', rel: 'noopener noreferrer' },
304
+ m('img.h-32.w-full.rounded.object-contain.bg-white.dark:bg-slate-950', {
305
+ src: url,
306
+ alt: `${label} ${idx + 1}`,
307
+ loading: 'lazy',
308
+ })
309
+ )
310
+ : m('a.flex.items-center.justify-center.h-32.w-full.text-sm.text-indigo-600.dark:text-indigo-400.break-all.p-2', {
311
+ href: url,
312
+ target: '_blank',
313
+ rel: 'noopener noreferrer',
314
+ }, url.substring(url.lastIndexOf('/') + 1) || url),
315
+ !readonly
316
+ ? m('button.absolute.top-1.right-1.bg-red-500.text-white.rounded-full.p-1.shadow-md.opacity-0.group-hover:opacity-100.hover:bg-red-700.transition-opacity', {
317
+ type: 'button',
318
+ onclick: () => {
319
+ const newUrls = urls.filter((_, i) => i !== idx);
320
+ if (vnode.attrs.onchange) vnode.attrs.onchange(newUrls);
321
+ },
322
+ title: 'Remove file',
323
+ }, [
324
+ m('svg.w-4.h-4', { fill: 'none', stroke: 'currentColor', viewBox: '0 0 24 24' },
325
+ m('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M6 18L18 6M6 6l12 12' })
326
+ )
327
+ ])
328
+ : null,
329
+ ]);
330
+ }))
331
+ : null,
332
+
333
+ m(
334
+ 'div.border-2.border-dashed.border-gray-300.dark:border-slate-600.rounded.p-6.text-center',
335
+ {
336
+ id: dropZoneId,
337
+ style: readonly || state.uploading ? undefined : 'cursor: pointer;',
338
+ },
339
+ [
340
+ m('input[type=file]', {
341
+ class: 'hidden',
342
+ id: 'file-input-multiple-' + name,
343
+ accept,
344
+ multiple: true,
345
+ disabled: readonly || state.uploading,
346
+ onchange: (e) => {
347
+ if (e.target.files.length > 0) {
348
+ handleMultipleFilesUpload(e.target.files, vnode, meta);
349
+ }
350
+ },
351
+ }),
352
+ readonly
353
+ ? null
354
+ : m('div', [
355
+ state.uploading
356
+ ? m('p.text-blue-600.dark:text-blue-400.animate-pulse', 'Uploading files...')
357
+ : m('div', [
358
+ m(
359
+ 'p.text-gray-600.dark:text-slate-400.mb-2',
360
+ 'Drag and drop files here, or'
361
+ ),
362
+ m(
363
+ 'label.text-blue-600.hover:text-blue-800.cursor-pointer',
364
+ { for: 'file-input-multiple-' + name },
365
+ 'browse'
366
+ ),
367
+ ]),
368
+ ]),
369
+ ]
370
+ ),
371
+ m('input[type=hidden]', {
372
+ name,
373
+ value: JSON.stringify(urls),
374
+ }),
375
+ m(
376
+ 'p.text-xs.text-gray-500.mt-1',
377
+ 'Max ' + Math.round(maxSize / 1024 / 1024) + ' MB per file (server enforces limits)'
378
+ ),
379
+ ]);
380
+ },
381
+ },
222
382
  };
223
383
 
224
384
  /**
@@ -272,3 +432,66 @@ async function handleFileUpload(file, vnode, meta) {
272
432
  alert(err.message || 'Upload failed');
273
433
  }
274
434
  }
435
+
436
+ /**
437
+ * @param {FileList} files
438
+ * @param {import('mithril').Vnode} vnode
439
+ * @param {Object} meta
440
+ */
441
+ async function handleMultipleFilesUpload(files, vnode, meta) {
442
+ const maxSize = meta.maxSize || meta.maxBytes || 10 * 1024 * 1024;
443
+ const uploadUrl = getUploadUrlFromAdminConfig();
444
+ if (!uploadUrl) {
445
+ alert('Upload URL is not configured.');
446
+ return;
447
+ }
448
+
449
+ const currentUrls = Array.isArray(vnode.attrs.value)
450
+ ? [...vnode.attrs.value]
451
+ : (vnode.attrs.value ? [vnode.attrs.value] : []);
452
+
453
+ vnode.state.uploading = true;
454
+ if (typeof m !== 'undefined' && m.redraw) m.redraw();
455
+
456
+ const uploadPromises = Array.from(files).map(async (file) => {
457
+ if (file.size > maxSize) {
458
+ alert(`File ${file.name} is too large (max ${Math.round(maxSize / 1024 / 1024)} MB) and was skipped.`);
459
+ return null;
460
+ }
461
+
462
+ const fd = new FormData();
463
+ fd.append('file', file);
464
+
465
+ try {
466
+ const res = await fetch(uploadUrl, { method: 'POST', body: fd, credentials: 'include' });
467
+ let data = {};
468
+ try {
469
+ data = await res.json();
470
+ } catch {
471
+ data = {};
472
+ }
473
+ if (!res.ok) {
474
+ alert(`Upload failed for ${file.name}: ${data.message || data.error || res.status}`);
475
+ return null;
476
+ }
477
+ return data.url || data.publicUrl || '';
478
+ } catch (err) {
479
+ alert(`Upload failed for ${file.name}: ${err.message}`);
480
+ return null;
481
+ }
482
+ });
483
+
484
+ try {
485
+ const results = await Promise.all(uploadPromises);
486
+ const newUrls = results.filter(Boolean);
487
+ if (newUrls.length > 0) {
488
+ const updatedUrls = [...currentUrls, ...newUrls];
489
+ if (vnode.attrs.onchange) {
490
+ vnode.attrs.onchange(updatedUrls);
491
+ }
492
+ }
493
+ } finally {
494
+ vnode.state.uploading = false;
495
+ if (typeof m !== 'undefined' && m.redraw) m.redraw();
496
+ }
497
+ }
@@ -79,6 +79,7 @@ function initializeDefaultRenderers() {
79
79
  registerFieldRenderer('rich-text', richTextRenderer.RichTextField);
80
80
  registerFieldRenderer('file-upload', fileUploadRenderer.FileUploadField);
81
81
  registerFieldRenderer('file', fileUploadRenderer.FileUploadField);
82
+ registerFieldRenderer('files', fileUploadRenderer.FilesUploadField);
82
83
 
83
84
  // Relation types
84
85
  registerFieldRenderer('belongsTo', relationRenderers.BelongsToField);
@@ -329,6 +329,25 @@ function adminPanelPlugin(options = {}) {
329
329
  };
330
330
  }
331
331
 
332
+ /**
333
+ * Helper to serialize arbitrary JS structures, including functions
334
+ */
335
+ function serializeJs(obj) {
336
+ if (typeof obj === 'function') {
337
+ return obj.toString();
338
+ }
339
+ if (Array.isArray(obj)) {
340
+ return '[' + obj.map(serializeJs).join(', ') + ']';
341
+ }
342
+ if (obj && typeof obj === 'object') {
343
+ const entries = Object.entries(obj).map(([key, val]) => {
344
+ return `${JSON.stringify(key)}: ${serializeJs(val)}`;
345
+ });
346
+ return '{' + entries.join(',\n') + '}';
347
+ }
348
+ return JSON.stringify(obj);
349
+ }
350
+
332
351
  /**
333
352
  * Generate admin panel HTML
334
353
  * @param {string} adminPath - Admin panel path
@@ -343,6 +362,12 @@ function generateAdminPanelHtml(adminPath, registry) {
343
362
  const customPageComponent = generateCustomPageComponent();
344
363
 
345
364
  const settings = registry.settings;
365
+ const fieldRenderersCode = Array.from(registry.fieldRenderers.entries()).map(([type, r]) => {
366
+ return `window.__customFieldRenderers['${type}'] = {
367
+ display: ${r.display ? serializeJs(r.display) : 'null'},
368
+ edit: ${r.edit ? serializeJs(r.edit) : 'null'}
369
+ };`;
370
+ }).join('\n');
346
371
 
347
372
  return `<!DOCTYPE html>
348
373
  <html lang="en">
@@ -418,6 +443,10 @@ function generateAdminPanelHtml(adminPath, registry) {
418
443
  // Custom page components container
419
444
  window.__customPages = window.__customPages || {};
420
445
  ${registry.getClientComponents()}
446
+
447
+ // Custom field renderers container
448
+ window.__customFieldRenderers = window.__customFieldRenderers || {};
449
+ ${fieldRenderersCode}
421
450
  </script>
422
451
  <script>${appScript}</script>
423
452
  </body>
@@ -54,12 +54,12 @@ function extensionFromName(name) {
54
54
  * @param {string} fieldName
55
55
  * @returns {import('express').RequestHandler}
56
56
  */
57
- function createMulterSingleMiddleware(maxBytes, fieldName) {
57
+ function createMulterMiddleware(maxBytes, fieldName, multiple, maxFiles) {
58
58
  const upload = multer({
59
59
  storage: multer.memoryStorage(),
60
- limits: { fileSize: maxBytes, files: 1 },
60
+ limits: { fileSize: maxBytes, files: multiple ? maxFiles : 1 },
61
61
  });
62
- return upload.single(fieldName);
62
+ return multiple ? upload.array(fieldName, maxFiles) : upload.single(fieldName);
63
63
  }
64
64
 
65
65
  /**
@@ -91,6 +91,8 @@ function multerErrorResponse(err) {
91
91
  * @param {string[]|null} [options.extensionAllowlist] — Optional extra guard on original extension
92
92
  * @param {import('express').RequestHandler|import('express').RequestHandler[]} [options.middleware]
93
93
  * @param {string} [options.fieldName='file'] — Multipart field name
94
+ * @param {boolean} [options.multiple=false] — Allow multiple file uploads
95
+ * @param {number} [options.maxFiles=10] — Max files to allow when multiple is true
94
96
  * @returns {Object} Webspresso plugin
95
97
  */
96
98
  function uploadPlugin(options = {}) {
@@ -103,6 +105,8 @@ function uploadPlugin(options = {}) {
103
105
  extensionAllowlist = null,
104
106
  middleware = [],
105
107
  fieldName = 'file',
108
+ multiple = false,
109
+ maxFiles = 10,
106
110
  } = options;
107
111
 
108
112
  const normalizedPath = normalizeRoutePath(routePath);
@@ -115,7 +119,7 @@ function uploadPlugin(options = {}) {
115
119
  });
116
120
 
117
121
  const mw = (Array.isArray(middleware) ? middleware : [middleware]).filter(Boolean);
118
- const parseMultipart = createMulterSingleMiddleware(maxBytes, fieldName);
122
+ const parseMultipart = createMulterMiddleware(maxBytes, fieldName, multiple, maxFiles);
119
123
 
120
124
  return {
121
125
  name: 'upload',
@@ -133,39 +137,58 @@ function uploadPlugin(options = {}) {
133
137
  return res.status(status).json({ error: message, message });
134
138
  }
135
139
 
136
- const file = req.file;
137
- if (!file || !file.buffer) {
140
+ const files = multiple ? (req.files || []) : (req.file ? [req.file] : []);
141
+ if (files.length === 0) {
138
142
  return res.status(400).json({ error: 'No file uploaded', message: 'No file uploaded' });
139
143
  }
140
144
 
141
- const mime = file.mimetype || 'application/octet-stream';
142
- const originalName = file.originalname || 'upload';
143
- const ext = extensionFromName(originalName);
144
-
145
- if (mimeAllowlist && mimeAllowlist.length && !mimeAllowlist.includes(mime)) {
146
- return res.status(415).json({ error: 'MIME type not allowed', message: 'MIME type not allowed' });
147
- }
148
- if (
149
- extensionAllowlist &&
150
- extensionAllowlist.length &&
151
- (!ext || !extensionAllowlist.includes(ext))
152
- ) {
153
- return res.status(400).json({ error: 'File extension not allowed', message: 'File extension not allowed' });
145
+ // Validate all files first
146
+ for (const file of files) {
147
+ if (!file || !file.buffer) {
148
+ return res.status(400).json({ error: 'No file uploaded', message: 'No file uploaded' });
149
+ }
150
+
151
+ const mime = file.mimetype || 'application/octet-stream';
152
+ const originalName = file.originalname || 'upload';
153
+ const ext = extensionFromName(originalName);
154
+
155
+ if (mimeAllowlist && mimeAllowlist.length && !mimeAllowlist.includes(mime)) {
156
+ return res.status(415).json({ error: 'MIME type not allowed', message: 'MIME type not allowed' });
157
+ }
158
+ if (
159
+ extensionAllowlist &&
160
+ extensionAllowlist.length &&
161
+ (!ext || !extensionAllowlist.includes(ext))
162
+ ) {
163
+ return res.status(400).json({ error: 'File extension not allowed', message: 'File extension not allowed' });
164
+ }
154
165
  }
155
166
 
156
167
  try {
157
- const result = await provider.put({
158
- buffer: file.buffer,
159
- originalName,
160
- mimeType: mime,
161
- size: file.size,
162
- req,
163
- });
164
- res.json({
165
- url: result.publicUrl,
166
- publicUrl: result.publicUrl,
167
- key: result.key,
168
+ const uploadPromises = files.map(async (file) => {
169
+ const mime = file.mimetype || 'application/octet-stream';
170
+ const originalName = file.originalname || 'upload';
171
+ const result = await provider.put({
172
+ buffer: file.buffer,
173
+ originalName,
174
+ mimeType: mime,
175
+ size: file.size,
176
+ req,
177
+ });
178
+ return {
179
+ url: result.publicUrl,
180
+ publicUrl: result.publicUrl,
181
+ key: result.key,
182
+ };
168
183
  });
184
+
185
+ const results = await Promise.all(uploadPromises);
186
+
187
+ if (multiple) {
188
+ res.json(results);
189
+ } else {
190
+ res.json(results[0]);
191
+ }
169
192
  } catch (e) {
170
193
  const message = e instanceof Error ? e.message : String(e);
171
194
  res.status(500).json({ error: message, message });