webspresso 0.0.76 → 0.0.77

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
@@ -2165,7 +2165,7 @@ const { app } = createApp({
2165
2165
  ```
2166
2166
 
2167
2167
  - **ORM:** `zdb.file({ maxLength: 2048, nullable: true })` — string column for the stored public URL or path; migrations use `table.string(..., maxLength)`.
2168
- - **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). 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.
2168
+ - **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.
2169
2169
  - **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.
2170
2170
  - **Response:** `{ url, publicUrl, key? }` — clients typically persist **`url`** / **`publicUrl`** in the model.
2171
2171
  - **Custom storage:** `uploadPlugin({ provider: { async put({ buffer, originalName, mimeType, size, req }) { return { publicUrl: '...' }; } } })`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webspresso",
3
- "version": "0.0.76",
3
+ "version": "0.0.77",
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",
@@ -114,8 +114,76 @@ const RichTextField = {
114
114
  }
115
115
  };
116
116
 
117
+ function isImageAccept(accept) {
118
+ return accept && accept !== '*/*' && String(accept).indexOf('image') !== -1;
119
+ }
120
+
121
+ function isImageUrl(url) {
122
+ if (!url || typeof url !== 'string') return false;
123
+ if (url.indexOf('blob:') === 0) return true;
124
+ return /\.(jpe?g|png|gif|webp|svg|avif|bmp|ico)(\?|#|$)/i.test(url.trim());
125
+ }
126
+
127
+ function shouldShowImagePreview(url, accept) {
128
+ return isImageAccept(accept) || isImageUrl(url);
129
+ }
130
+
131
+ function renderUploadedFilePreview(url, options) {
132
+ var accept = options.accept;
133
+ var readonly = options.readonly;
134
+ var onRemove = options.onRemove;
135
+ var label = options.label;
136
+ if (!url) return null;
137
+
138
+ if (shouldShowImagePreview(url, accept)) {
139
+ return m('.mt-3.flex.flex-col.items-start.gap-2', [
140
+ m('a.block', { href: url, target: '_blank', rel: 'noopener noreferrer' },
141
+ m('img.max-h-48.max-w-full.rounded-lg.border.border-gray-200.dark:border-slate-600.object-contain.bg-white.dark:bg-slate-900.shadow-sm', {
142
+ src: url,
143
+ alt: label || 'Preview',
144
+ loading: 'lazy',
145
+ })
146
+ ),
147
+ m('p.text-xs.text-gray-500.dark:text-slate-400.break-all', url),
148
+ !readonly && onRemove
149
+ ? m('button.text-red-600.dark:text-red-400.hover:text-red-800.dark:hover:text-red-300.text-sm', {
150
+ type: 'button',
151
+ onclick: onRemove,
152
+ }, 'Remove')
153
+ : null,
154
+ ]);
155
+ }
156
+
157
+ return m('.mt-3.flex.flex-col.items-start.gap-2', [
158
+ m('a.text-sm.text-indigo-600.dark:text-indigo-400.break-all', {
159
+ href: url,
160
+ target: '_blank',
161
+ rel: 'noopener noreferrer',
162
+ }, url),
163
+ !readonly && onRemove
164
+ ? m('button.text-red-600.dark:text-red-400.hover:text-red-800.dark:hover:text-red-300.text-sm', {
165
+ type: 'button',
166
+ onclick: onRemove,
167
+ }, 'Remove')
168
+ : null,
169
+ ]);
170
+ }
171
+
172
+ function revokeLocalPreview(state) {
173
+ if (state && state.localPreview) {
174
+ try { URL.revokeObjectURL(state.localPreview); } catch (e) {}
175
+ state.localPreview = null;
176
+ }
177
+ }
178
+
117
179
  // File upload field (multipart POST to settings.uploadUrl; field name "file")
118
180
  const FileUploadField = {
181
+ oninit: function (vnode) {
182
+ vnode.state.localPreview = null;
183
+ },
184
+ onremove: function (vnode) {
185
+ revokeLocalPreview(vnode.state);
186
+ },
119
187
  oncreate: (vnode) => {
120
188
  const col = vnode.attrs.col;
121
189
  const readonly = vnode.attrs.readonly;
@@ -128,6 +196,7 @@ const FileUploadField = {
128
196
  if (!dropZone) return;
129
197
  const meta = col.ui || {};
130
198
  const onChange = vnode.attrs.onChange;
199
+ const state = vnode.state;
131
200
  ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(function (eventName) {
132
201
  dropZone.addEventListener(eventName, function (e) {
133
202
  e.preventDefault();
@@ -147,14 +216,14 @@ const FileUploadField = {
147
216
  dropZone.addEventListener('drop', function (e) {
148
217
  var files = e.dataTransfer.files;
149
218
  if (files.length > 0) {
150
- handleAdminFileUpload(files[0], onChange, meta);
219
+ handleAdminFileUpload(files[0], onChange, meta, state);
151
220
  }
152
221
  });
153
222
  var fileInput = dropZone.querySelector('input[type=file]');
154
223
  if (fileInput) {
155
224
  fileInput.addEventListener('change', function (e) {
156
225
  if (e.target.files.length > 0) {
157
- handleAdminFileUpload(e.target.files[0], onChange, meta);
226
+ handleAdminFileUpload(e.target.files[0], onChange, meta, state);
158
227
  }
159
228
  });
160
229
  }
@@ -164,6 +233,7 @@ const FileUploadField = {
164
233
  const value = vnode.attrs.value || '';
165
234
  const onChange = vnode.attrs.onChange;
166
235
  const readonly = vnode.attrs.readonly || false;
236
+ const state = vnode.state;
167
237
  const meta = col.ui || {};
168
238
  const label = meta.label || formatColumnLabel(col.name);
169
239
  const hint = meta.hint || '';
@@ -176,10 +246,17 @@ const FileUploadField = {
176
246
  var maxSize = meta.maxSize || meta.maxBytes || (10 * 1024 * 1024);
177
247
  var accept = meta.accept || '*/*';
178
248
  var dropZoneId = 'drop-zone-' + col.name;
249
+ var displayUrl = state.localPreview || value;
250
+ var clearValue = function () {
251
+ revokeLocalPreview(state);
252
+ if (onChange) onChange('');
253
+ };
179
254
  if (readonly) {
180
255
  return m('.mb-4', [
181
256
  m('label.block.text-sm.font-medium.text-gray-700.dark:text-slate-300.mb-1', label, required ? m('span.text-red-500', ' *') : null),
182
- value ? m('a.text-indigo-600.dark:text-indigo-400.break-all', { href: value, target: '_blank', rel: 'noopener noreferrer' }, value) : m('span.text-gray-400.dark:text-slate-500', '—'),
257
+ value
258
+ ? renderUploadedFilePreview(value, { accept: accept, readonly: true, label: label })
259
+ : m('span.text-gray-400.dark:text-slate-500', '—'),
183
260
  hint ? m('p.text-xs.text-gray-500.dark:text-slate-400.mt-1', hint) : null,
184
261
  ]);
185
262
  }
@@ -196,32 +273,29 @@ const FileUploadField = {
196
273
  required: required,
197
274
  oninput: function (e) { if (onChange) onChange(e.target.value); },
198
275
  }),
276
+ value ? renderUploadedFilePreview(value, { accept: accept, readonly: false, onRemove: clearValue, label: label }) : null,
199
277
  hint ? m('p.text-xs.text-gray-500.dark:text-slate-400.mt-1', hint) : null,
200
278
  ]);
201
279
  }
202
280
  return m('.mb-4', [
203
281
  m('label.block.text-sm.font-medium.text-gray-700.dark:text-slate-300.mb-1', label, required ? m('span.text-red-500', ' *') : null),
204
- m('div#' + dropZoneId + '.border-2.border-dashed.border-gray-300.dark:border-slate-600.rounded-lg.p-8.text-center.bg-gray-50.dark:bg-slate-900/40', { style: 'cursor: pointer;' }, [
282
+ displayUrl
283
+ ? renderUploadedFilePreview(displayUrl, { accept: accept, readonly: false, onRemove: clearValue, label: label })
284
+ : null,
285
+ m('div#' + dropZoneId + '.border-2.border-dashed.border-gray-300.dark:border-slate-600.rounded-lg.p-6.text-center.bg-gray-50.dark:bg-slate-900/40', { style: 'cursor: pointer;' }, [
205
286
  m('input[type=file].hidden', {
206
287
  id: 'file-input-' + col.name,
207
288
  accept: accept,
208
289
  onchange: function (e) {
209
290
  if (e.target.files.length > 0) {
210
- handleAdminFileUpload(e.target.files[0], onChange, meta);
291
+ handleAdminFileUpload(e.target.files[0], onChange, meta, state);
211
292
  }
212
293
  },
213
294
  }),
214
295
  m('div', [
215
- m('p.text-gray-600.dark:text-slate-400.mb-2', 'Drag and drop a file here, or'),
216
- m('label.text-blue-600.hover:text-blue-800.dark:text-blue-400.cursor-pointer', { for: 'file-input-' + col.name }, 'browse'),
296
+ m('p.text-gray-600.dark:text-slate-400.mb-2', displayUrl ? 'Drag and drop to replace, or' : 'Drag and drop a file here, or'),
297
+ m('label.text-blue-600.hover:text-blue-800.dark:text-blue-400.cursor-pointer', { for: 'file-input-' + col.name }, displayUrl ? 'choose another file' : 'browse'),
217
298
  ]),
218
- value ? m('.mt-4.text-left', [
219
- m('p.text-sm.text-gray-600.dark:text-slate-400.break-all', 'Current: ' + value),
220
- m('button.text-red-600.dark:text-red-400.hover:text-red-800.dark:hover:text-red-300.text-sm.mt-2', {
221
- type: 'button',
222
- onclick: function () { if (onChange) onChange(''); },
223
- }, 'Remove'),
224
- ]) : null,
225
299
  ]),
226
300
  m('input[type=hidden]', { name: col.name, value: typeof value === 'string' ? value : '' }),
227
301
  m('p.text-xs.text-gray-500.dark:text-slate-400.mt-1', 'Max ' + Math.round(maxSize / 1024 / 1024) + ' MB (server enforces limits)'),
@@ -230,7 +304,7 @@ const FileUploadField = {
230
304
  },
231
305
  };
232
306
 
233
- async function handleAdminFileUpload(file, onChange, meta) {
307
+ async function handleAdminFileUpload(file, onChange, meta, state) {
234
308
  var uploadUrl = '';
235
309
  try {
236
310
  var cfg = window.__ADMIN_CONFIG__;
@@ -245,6 +319,11 @@ async function handleAdminFileUpload(file, onChange, meta) {
245
319
  alert('File too large (max ' + Math.round(maxSize / 1024 / 1024) + ' MB).');
246
320
  return;
247
321
  }
322
+ if (state && file.type && file.type.indexOf('image/') === 0) {
323
+ revokeLocalPreview(state);
324
+ state.localPreview = URL.createObjectURL(file);
325
+ m.redraw();
326
+ }
248
327
  var fd = new FormData();
249
328
  fd.append('file', file);
250
329
  try {
@@ -252,13 +331,18 @@ async function handleAdminFileUpload(file, onChange, meta) {
252
331
  var data = {};
253
332
  try { data = await res.json(); } catch (e2) { data = {}; }
254
333
  if (!res.ok) {
334
+ revokeLocalPreview(state);
335
+ m.redraw();
255
336
  alert(data.message || data.error || ('Upload failed (' + res.status + ')'));
256
337
  return;
257
338
  }
339
+ revokeLocalPreview(state);
258
340
  var url = data.url || data.publicUrl || '';
259
341
  if (onChange) onChange(url);
260
342
  m.redraw();
261
343
  } catch (err) {
344
+ revokeLocalPreview(state);
345
+ m.redraw();
262
346
  alert(err.message || 'Upload failed');
263
347
  }
264
348
  }
@@ -13,8 +13,76 @@ function getUploadUrlFromAdminConfig() {
13
13
  }
14
14
  }
15
15
 
16
+ function isImageAccept(accept) {
17
+ return accept && accept !== '*/*' && String(accept).includes('image');
18
+ }
19
+
20
+ function isImageUrl(url) {
21
+ if (!url || typeof url !== 'string') return false;
22
+ if (url.startsWith('blob:')) return true;
23
+ return /\.(jpe?g|png|gif|webp|svg|avif|bmp|ico)(\?|#|$)/i.test(url.trim());
24
+ }
25
+
26
+ function shouldShowImagePreview(url, accept) {
27
+ return isImageAccept(accept) || isImageUrl(url);
28
+ }
29
+
30
+ function renderUploadedFilePreview(url, { accept, readonly, onRemove, label }) {
31
+ if (!url) return null;
32
+
33
+ if (shouldShowImagePreview(url, accept)) {
34
+ return m('.mt-3.flex.flex-col.items-start.gap-2', [
35
+ m('a.block', { href: url, target: '_blank', rel: 'noopener noreferrer' },
36
+ m('img.max-h-48.max-w-full.rounded-lg.border.border-gray-200.dark:border-slate-600.object-contain.bg-white.dark:bg-slate-900.shadow-sm', {
37
+ src: url,
38
+ alt: label || 'Preview',
39
+ loading: 'lazy',
40
+ })
41
+ ),
42
+ m('p.text-xs.text-gray-500.dark:text-slate-400.break-all', url),
43
+ !readonly && onRemove
44
+ ? m('button.text-red-600.dark:text-red-400.hover:text-red-800.dark:hover:text-red-300.text-sm', {
45
+ type: 'button',
46
+ onclick: onRemove,
47
+ }, 'Remove')
48
+ : null,
49
+ ]);
50
+ }
51
+
52
+ return m('.mt-3.flex.flex-col.items-start.gap-2', [
53
+ m('a.text-sm.text-indigo-600.dark:text-indigo-400.break-all', {
54
+ href: url,
55
+ target: '_blank',
56
+ rel: 'noopener noreferrer',
57
+ }, url),
58
+ !readonly && onRemove
59
+ ? m('button.text-red-600.dark:text-red-400.hover:text-red-800.dark:hover:text-red-300.text-sm', {
60
+ type: 'button',
61
+ onclick: onRemove,
62
+ }, 'Remove')
63
+ : null,
64
+ ]);
65
+ }
66
+
67
+ function revokeLocalPreview(state) {
68
+ if (state?.localPreview) {
69
+ try {
70
+ URL.revokeObjectURL(state.localPreview);
71
+ } catch {
72
+ // ignore
73
+ }
74
+ state.localPreview = null;
75
+ }
76
+ }
77
+
16
78
  module.exports = {
17
79
  FileUploadField: {
80
+ oninit: (vnode) => {
81
+ vnode.state.localPreview = null;
82
+ },
83
+ onremove: (vnode) => {
84
+ revokeLocalPreview(vnode.state);
85
+ },
18
86
  oncreate: (vnode) => {
19
87
  const { name, meta = {} } = vnode.attrs;
20
88
  if (!getUploadUrlFromAdminConfig()) return;
@@ -60,17 +128,24 @@ module.exports = {
60
128
  },
61
129
 
62
130
  view: (vnode) => {
63
- const { name, value = '', meta = {}, required = false } = vnode.attrs;
131
+ const { name, value = '', meta = {}, required = false, readonly = false } = vnode.attrs;
132
+ const state = vnode.state;
64
133
  const dropZoneId = 'drop-zone-' + name;
65
134
  const maxSize = meta.maxSize || meta.maxBytes || 10 * 1024 * 1024;
66
135
  const accept = meta.accept || '*/*';
67
136
  const uploadUrl = getUploadUrlFromAdminConfig();
137
+ const displayUrl = state.localPreview || value;
138
+ const label = meta.label || name;
139
+ const clearValue = () => {
140
+ revokeLocalPreview(state);
141
+ if (vnode.attrs.onchange) vnode.attrs.onchange('');
142
+ };
68
143
 
69
144
  if (!uploadUrl) {
70
145
  return m('.mb-4', [
71
146
  m(
72
147
  'label.block.text-sm.font-medium.mb-2',
73
- meta.label || name,
148
+ label,
74
149
  required ? m('span.text-red-500', ' *') : null
75
150
  ),
76
151
  m('p.text-xs.text-amber-700.dark:text-amber-400.mb-2', 'Upload URL is not configured.'),
@@ -80,62 +155,57 @@ module.exports = {
80
155
  value: typeof value === 'string' ? value : '',
81
156
  placeholder: 'https://… or /uploads/…',
82
157
  required,
158
+ readonly,
83
159
  oninput: (e) => {
84
160
  if (vnode.attrs.onchange) vnode.attrs.onchange(e.target.value);
85
161
  },
86
162
  }),
163
+ value
164
+ ? renderUploadedFilePreview(value, { accept, readonly, onRemove: clearValue, label })
165
+ : null,
87
166
  ]);
88
167
  }
89
168
 
90
169
  return m('.mb-4', [
91
170
  m(
92
171
  'label.block.text-sm.font-medium.mb-2',
93
- meta.label || name,
172
+ label,
94
173
  required ? m('span.text-red-500', ' *') : null
95
174
  ),
175
+ displayUrl
176
+ ? renderUploadedFilePreview(displayUrl, { accept, readonly, onRemove: clearValue, label })
177
+ : null,
96
178
  m(
97
- 'div.border-2.border-dashed.border-gray-300.dark:border-slate-600.rounded.p-8.text-center',
179
+ 'div.border-2.border-dashed.border-gray-300.dark:border-slate-600.rounded.p-6.text-center',
98
180
  {
99
181
  id: dropZoneId,
100
- style: 'cursor: pointer;',
182
+ style: readonly ? undefined : 'cursor: pointer;',
101
183
  },
102
184
  [
103
185
  m('input[type=file]', {
104
186
  class: 'hidden',
105
187
  id: 'file-input-' + name,
106
188
  accept,
189
+ disabled: readonly,
107
190
  onchange: (e) => {
108
191
  if (e.target.files.length > 0) {
109
192
  handleFileUpload(e.target.files[0], vnode, meta);
110
193
  }
111
194
  },
112
195
  }),
113
- m('div', [
114
- m('p.text-gray-600.dark:text-slate-400.mb-2', 'Drag and drop a file here, or'),
115
- m(
116
- 'label.text-blue-600.hover:text-blue-800.cursor-pointer',
117
- { for: 'file-input-' + name },
118
- 'browse'
119
- ),
120
- ]),
121
- value
122
- ? m('.mt-4', [
196
+ readonly
197
+ ? null
198
+ : m('div', [
123
199
  m(
124
- 'p.text-sm.text-gray-600.break-all',
125
- 'Current: ' + (typeof value === 'string' ? value : value.name || 'uploaded')
200
+ 'p.text-gray-600.dark:text-slate-400.mb-2',
201
+ displayUrl ? 'Drag and drop to replace, or' : 'Drag and drop a file here, or'
126
202
  ),
127
203
  m(
128
- 'button.text-red-600.hover:text-red-800.text-sm.mt-2',
129
- {
130
- type: 'button',
131
- onclick: () => {
132
- if (vnode.attrs.onchange) vnode.attrs.onchange('');
133
- },
134
- },
135
- 'Remove'
204
+ 'label.text-blue-600.hover:text-blue-800.cursor-pointer',
205
+ { for: 'file-input-' + name },
206
+ displayUrl ? 'choose another file' : 'browse'
136
207
  ),
137
- ])
138
- : null,
208
+ ]),
139
209
  ]
140
210
  ),
141
211
  m('input[type=hidden]', {
@@ -169,6 +239,12 @@ async function handleFileUpload(file, vnode, meta) {
169
239
  return;
170
240
  }
171
241
 
242
+ if (file.type?.startsWith('image/')) {
243
+ revokeLocalPreview(vnode.state);
244
+ vnode.state.localPreview = URL.createObjectURL(file);
245
+ if (typeof m !== 'undefined' && m.redraw) m.redraw();
246
+ }
247
+
172
248
  const fd = new FormData();
173
249
  fd.append('file', file);
174
250
 
@@ -181,13 +257,18 @@ async function handleFileUpload(file, vnode, meta) {
181
257
  data = {};
182
258
  }
183
259
  if (!res.ok) {
260
+ revokeLocalPreview(vnode.state);
261
+ if (typeof m !== 'undefined' && m.redraw) m.redraw();
184
262
  alert(data.message || data.error || `Upload failed (${res.status})`);
185
263
  return;
186
264
  }
265
+ revokeLocalPreview(vnode.state);
187
266
  const url = data.url || data.publicUrl || '';
188
267
  if (vnode.attrs.onchange) vnode.attrs.onchange(url);
189
268
  if (typeof m !== 'undefined' && m.redraw) m.redraw();
190
269
  } catch (err) {
270
+ revokeLocalPreview(vnode.state);
271
+ if (typeof m !== 'undefined' && m.redraw) m.redraw();
191
272
  alert(err.message || 'Upload failed');
192
273
  }
193
274
  }