webspresso 0.0.75 → 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,10 +2165,34 @@ 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:** 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.
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
+ - **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.
2169
2170
  - **Response:** `{ url, publicUrl, key? }` — clients typically persist **`url`** / **`publicUrl`** in the model.
2170
2171
  - **Custom storage:** `uploadPlugin({ provider: { async put({ buffer, originalName, mimeType, size, req }) { return { publicUrl: '...' }; } } })`.
2171
2172
 
2173
+ **Admin model example** (`zdb.file()` picks up the upload widget from schema; no extra `customFields` needed):
2174
+
2175
+ ```javascript
2176
+ const { defineModel, zdb } = require('webspresso');
2177
+
2178
+ const Post = defineModel({
2179
+ name: 'Post',
2180
+ table: 'posts',
2181
+ schema: zdb.schema({
2182
+ id: zdb.id(),
2183
+ title: zdb.string(),
2184
+ cover_image: zdb.file({
2185
+ maxLength: 2048,
2186
+ nullable: true,
2187
+ ui: { label: 'Cover', accept: 'image/*', hint: 'JPEG or PNG' },
2188
+ }),
2189
+ }),
2190
+ admin: { enabled: true, label: 'Posts' },
2191
+ });
2192
+ ```
2193
+
2194
+ For a plain string column, use `admin.customFields: { attachment: { type: 'file-upload' } }` instead of `zdb.file()`.
2195
+
2172
2196
  ### Health check plugin
2173
2197
 
2174
2198
  Exposes a lightweight **GET** endpoint for load balancers and orchestrators (Kubernetes, Docker healthcheck, etc.). **Enabled by default** in all environments; set `enabled: false` to turn it off.
@@ -3,7 +3,8 @@
3
3
  * Reset admin user password via CLI
4
4
  */
5
5
 
6
- const readline = require('readline');
6
+ const inquirer = require('inquirer');
7
+ const { hash } = require('../../core/auth/hash');
7
8
  const { loadDbConfig, createDbInstance } = require('../utils/db');
8
9
 
9
10
  function registerCommand(program) {
@@ -32,17 +33,14 @@ function registerCommand(program) {
32
33
  // Get email (interactive if not provided)
33
34
  let email = options.email;
34
35
  if (!email) {
35
- const rl = readline.createInterface({
36
- input: process.stdin,
37
- output: process.stdout,
38
- });
39
-
40
- email = await new Promise((resolve) => {
41
- rl.question('Enter admin email: ', (answer) => {
42
- rl.close();
43
- resolve(answer.trim());
44
- });
45
- });
36
+ const answers = await inquirer.prompt([
37
+ {
38
+ type: 'input',
39
+ name: 'email',
40
+ message: 'Enter admin email:',
41
+ },
42
+ ]);
43
+ email = answers.email.trim();
46
44
  }
47
45
 
48
46
  if (!email) {
@@ -70,49 +68,15 @@ function registerCommand(program) {
70
68
  // Get new password (interactive if not provided)
71
69
  let password = options.password;
72
70
  if (!password) {
73
- const rl = readline.createInterface({
74
- input: process.stdin,
75
- output: process.stdout,
76
- });
77
-
78
- // Disable echo for password input
79
- if (process.stdin.isTTY) {
80
- process.stdout.write('Enter new password: ');
81
- password = await new Promise((resolve) => {
82
- let pwd = '';
83
- process.stdin.setRawMode(true);
84
- process.stdin.resume();
85
- process.stdin.on('data', (char) => {
86
- char = char.toString();
87
- if (char === '\n' || char === '\r') {
88
- process.stdin.setRawMode(false);
89
- process.stdin.pause();
90
- console.log(); // New line after password
91
- resolve(pwd);
92
- } else if (char === '\u0003') {
93
- // Ctrl+C
94
- process.exit();
95
- } else if (char === '\u007F') {
96
- // Backspace
97
- if (pwd.length > 0) {
98
- pwd = pwd.slice(0, -1);
99
- process.stdout.write('\b \b');
100
- }
101
- } else {
102
- pwd += char;
103
- process.stdout.write('*');
104
- }
105
- });
106
- });
107
- rl.close();
108
- } else {
109
- password = await new Promise((resolve) => {
110
- rl.question('Enter new password: ', (answer) => {
111
- rl.close();
112
- resolve(answer);
113
- });
114
- });
115
- }
71
+ const answers = await inquirer.prompt([
72
+ {
73
+ type: 'password',
74
+ name: 'password',
75
+ message: 'Enter new password:',
76
+ mask: '*',
77
+ },
78
+ ]);
79
+ password = answers.password;
116
80
  }
117
81
 
118
82
  if (!password || password.length < 6) {
@@ -121,8 +85,8 @@ function registerCommand(program) {
121
85
  process.exit(1);
122
86
  }
123
87
 
124
- // Hash the password
125
- const hashedPassword = await bcrypt.hash(password, 10);
88
+ // Hash the password (same rounds as admin panel setup)
89
+ const hashedPassword = await hash(password, 10);
126
90
 
127
91
  // Update the password
128
92
  await db('admin_users')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webspresso",
3
- "version": "0.0.75",
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
  }
@@ -169,6 +169,7 @@ Analytics plugin adds `fsy.analyticsHead`, `fsy.verificationTags`, etc., when co
169
169
  - **Relations:** `belongsTo`, `hasMany`, `hasOne` with `model: () => OtherModel`.
170
170
  - **Scopes:** `softDelete`, `timestamps`, optional `tenant` column.
171
171
  - **`hidden`:** columns never exposed in admin/API (e.g. `password_hash`).
172
+ - **`zdb.file()`:** varchar column for uploaded asset URL/path; admin forms render a file upload widget when `uploadPlugin` is registered (or `adminPanelPlugin({ uploadUrl })`). Optional `ui: { label, accept, maxBytes }`. For `zdb.string()` columns use `admin.customFields: { col: { type: 'file-upload' } }`.
172
173
  - **Nanoid PK:** `zdb.nanoid()` / `zdb.nanoid({ maxLength: 12 })` — string primary key; migrations use `string(length)`. On **`create()`**, omitting the PK auto-fills a URL-safe id (built-in generator, same alphabet as `nanoid`). Use **`zdb.foreignNanoid('table', { maxLength })`** when the parent uses nanoid PKs; **`generateNanoid`** is exported from `webspresso` for manual ids. In API **`schema`**, use **`z.nanoid()`** / **`z.nanoid(12)`** / **`z.nanoid({ maxLength })`** (the `z` from `schema: ({ z })` is extended by Webspresso). **`zodNanoid`** / **`extendZ`** are also exported for non-route use.
173
174
 
174
175
  **Database:** `createDatabase({ client, connection, models: './models' })` — auto-loads `models/*.js` (ignore `_prefix`).
@@ -197,7 +198,7 @@ Pass **`db`** into **`createApp({ db })`** so **`ctx.db`** works in pages and pl
197
198
  | `adminPanelPlugin` | SPA admin CRUD — needs **`db`**; optional **`uploadUrl`** (or infer from **`uploadPlugin`**); optional **`userManagement: { enabled, model, fields }`** + **`auth`** (same **`AuthManager`** as **`createApp({ auth })`**) for site-user CRUD + remember-me session UI — see **Session authentication** above |
198
199
  | `dataExchangePlugin` | Admin-only **Excel export** + **CSV/XLSX import** under `${adminPath}/api/data-exchange/*`; register **after** `adminPanelPlugin` with same `db` / `adminPath`; optional `maxRows`, `maxFileBytes`; adds UI buttons + bulk `export-xlsx` |
199
200
  | `redirectPlugin` | Configurable **301–308** redirects in `register()` — runs **before** file-based SSR routes; `rules` (`from` path or `RegExp`, `to`, `status`, `methods`), `preserveQuery`, `allowExternal`, `trailingSlash`, `defaultMethods`; docs **[`doc/index.html#plugins-redirect`](../../../doc/index.html#plugins-redirect)**, README **Redirect plugin** |
200
- | `uploadPlugin` | `POST` multipart (`multer`), `createLocalFileProvider` or custom `provider`; set **`mimeAllowlist`** / **`maxBytes`** in production |
201
+ | `uploadPlugin` | `POST` multipart (`multer`), `createLocalFileProvider` or custom `provider`; set **`mimeAllowlist`** / **`maxBytes`** in production; pairs with admin **`zdb.file()`** / **`customFields.file-upload`** when registered before **`adminPanelPlugin`** |
201
202
  | `siteAnalyticsPlugin` | Self-hosted page views + admin charts |
202
203
  | `auditLogPlugin` | Admin mutation audit trail |
203
204
  | `recaptchaPlugin` | v2/v3 + middleware |