webspresso 0.0.79 → 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.79",
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",
@@ -128,6 +128,24 @@ function formatCellValue(value, col, record = null) {
128
128
  }
129
129
  return short || m('span.text-gray-400', '—');
130
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
+ }
131
149
 
132
150
  default: {
133
151
  const str = String(value);
@@ -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);
@@ -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 });