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 +25 -1
- package/bin/commands/admin-password.js +21 -57
- package/package.json +1 -1
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +99 -15
- package/plugins/admin-panel/field-renderers/file-upload.js +108 -27
- package/templates/skills/webspresso-usage/REFERENCE-framework.md +2 -1
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:**
|
|
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
|
|
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
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
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
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
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
|
-
|
|
114
|
-
|
|
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-
|
|
125
|
-
|
|
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
|
-
'
|
|
129
|
-
{
|
|
130
|
-
|
|
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 |
|