webspresso 0.0.78 → 0.0.80
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -6
- package/core/orm/json-fields.js +1 -1
- package/core/orm/migrations/scaffold.js +5 -0
- package/core/orm/schema-helpers.js +18 -0
- package/core/orm/types.js +1 -1
- package/package.json +1 -1
- package/plugins/admin-panel/client/parts/04-field-renderers.js +166 -0
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +32 -1
- package/plugins/admin-panel/client/parts/07-model-list.js +29 -1
- package/plugins/admin-panel/client/parts/08-record-list.js +1 -1
- package/plugins/admin-panel/field-renderers/file-upload.js +223 -0
- package/plugins/admin-panel/field-renderers/index.js +1 -0
- package/plugins/admin-panel/index.js +29 -0
- package/plugins/upload/index.js +53 -30
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
|
|
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
|
-
- **
|
|
2174
|
-
- **Admin:**
|
|
2175
|
-
- **
|
|
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()`
|
|
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
|
});
|
package/core/orm/json-fields.js
CHANGED
|
@@ -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,4 +1,160 @@
|
|
|
1
1
|
// Field Renderers - render appropriate input based on column type
|
|
2
|
+
|
|
3
|
+
// BelongsTo relation dropdown select component
|
|
4
|
+
const BelongsToField = {
|
|
5
|
+
oninit: (vnode) => {
|
|
6
|
+
const { col } = vnode.attrs;
|
|
7
|
+
const customField = col.customField || {};
|
|
8
|
+
const relationName = customField.relation || col.name.replace(/_id$/, '');
|
|
9
|
+
const modelName = m.route.param('model');
|
|
10
|
+
vnode.state.loading = true;
|
|
11
|
+
vnode.state.options = [];
|
|
12
|
+
|
|
13
|
+
api.get('/models/' + modelName + '/relations/' + relationName)
|
|
14
|
+
.then(result => {
|
|
15
|
+
vnode.state.options = result.data || [];
|
|
16
|
+
})
|
|
17
|
+
.catch(err => {
|
|
18
|
+
console.error('Failed to load relation data:', err);
|
|
19
|
+
})
|
|
20
|
+
.finally(() => {
|
|
21
|
+
vnode.state.loading = false;
|
|
22
|
+
m.redraw();
|
|
23
|
+
});
|
|
24
|
+
},
|
|
25
|
+
view: (vnode) => {
|
|
26
|
+
const { col, value, onChange, readonly } = vnode.attrs;
|
|
27
|
+
const customField = col.customField || {};
|
|
28
|
+
const label = col.ui?.label || formatColumnLabel(col.name);
|
|
29
|
+
const hint = col.ui?.hint || '';
|
|
30
|
+
const valueKey = customField.valueKey || 'id';
|
|
31
|
+
const displayKey = customField.displayKey ||
|
|
32
|
+
(vnode.state.options.length > 0 ? ['name', 'title', 'label', 'username', 'email'].find(k => k in vnode.state.options[0]) : null) ||
|
|
33
|
+
'id';
|
|
34
|
+
|
|
35
|
+
if (vnode.state.loading) {
|
|
36
|
+
return m('.mb-4', [
|
|
37
|
+
m('label.block.text-sm.font-medium.text-gray-700.dark:text-slate-300.mb-1', label),
|
|
38
|
+
m('.text-xs.text-gray-500.dark:text-slate-400', 'Loading options...')
|
|
39
|
+
]);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return m('.mb-4', [
|
|
43
|
+
m('label.block.text-sm.font-medium.text-gray-700.dark:text-slate-300.mb-1', { for: col.name }, [
|
|
44
|
+
label,
|
|
45
|
+
!col.nullable && !readonly ? m('span.text-red-500', ' *') : null
|
|
46
|
+
]),
|
|
47
|
+
m('select.w-full.px-3.py-2.border.border-gray-300.dark:border-slate-600.rounded-md.bg-white.dark:bg-slate-900/70.text-gray-900.dark:text-slate-100.focus:outline-none.focus:ring-2.focus:ring-blue-500.dark:focus:ring-blue-400', {
|
|
48
|
+
id: col.name,
|
|
49
|
+
name: col.name,
|
|
50
|
+
value: value !== null && value !== undefined ? String(value) : '',
|
|
51
|
+
disabled: readonly,
|
|
52
|
+
class: readonly ? 'bg-gray-100 dark:bg-slate-800 cursor-not-allowed' : '',
|
|
53
|
+
onchange: (e) => {
|
|
54
|
+
const selectedValue = e.target.value === '' ? null : e.target.value;
|
|
55
|
+
const typedValue = (col.type === 'integer' || col.type === 'bigint' || col.type === 'float' || col.type === 'decimal') && selectedValue !== null
|
|
56
|
+
? Number(selectedValue)
|
|
57
|
+
: selectedValue;
|
|
58
|
+
onChange(typedValue);
|
|
59
|
+
},
|
|
60
|
+
}, [
|
|
61
|
+
col.nullable ? m('option', { value: '' }, '-- Select --') : m('option', { value: '' }, '-- Select --'),
|
|
62
|
+
vnode.state.options.map(item => {
|
|
63
|
+
const itemValue = item[valueKey];
|
|
64
|
+
const itemDisplay = item[displayKey] || String(itemValue);
|
|
65
|
+
return m('option', {
|
|
66
|
+
value: String(itemValue),
|
|
67
|
+
selected: value !== null && value !== undefined && String(value) === String(itemValue)
|
|
68
|
+
}, itemDisplay);
|
|
69
|
+
}),
|
|
70
|
+
]),
|
|
71
|
+
hint ? m('p.text-xs.text-gray-500.dark:text-slate-400.mt-1', hint) : null,
|
|
72
|
+
]);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// HasMany relation multi-select checkbox component
|
|
77
|
+
const HasManyField = {
|
|
78
|
+
oninit: (vnode) => {
|
|
79
|
+
const { col } = vnode.attrs;
|
|
80
|
+
const customField = col.customField || {};
|
|
81
|
+
const relationName = customField.relation || col.name.replace(/_ids$/, '').replace(/_id$/, '');
|
|
82
|
+
const modelName = m.route.param('model');
|
|
83
|
+
vnode.state.loading = true;
|
|
84
|
+
vnode.state.options = [];
|
|
85
|
+
|
|
86
|
+
api.get('/models/' + modelName + '/relations/' + relationName)
|
|
87
|
+
.then(result => {
|
|
88
|
+
vnode.state.options = result.data || [];
|
|
89
|
+
})
|
|
90
|
+
.catch(err => {
|
|
91
|
+
console.error('Failed to load relation data:', err);
|
|
92
|
+
})
|
|
93
|
+
.finally(() => {
|
|
94
|
+
vnode.state.loading = false;
|
|
95
|
+
m.redraw();
|
|
96
|
+
});
|
|
97
|
+
},
|
|
98
|
+
view: (vnode) => {
|
|
99
|
+
const { col, value = [], onChange, readonly } = vnode.attrs;
|
|
100
|
+
const customField = col.customField || {};
|
|
101
|
+
const label = col.ui?.label || formatColumnLabel(col.name);
|
|
102
|
+
const hint = col.ui?.hint || '';
|
|
103
|
+
const valueKey = customField.valueKey || 'id';
|
|
104
|
+
const displayKey = customField.displayKey ||
|
|
105
|
+
(vnode.state.options.length > 0 ? ['name', 'title', 'label', 'username', 'email'].find(k => k in vnode.state.options[0]) : null) ||
|
|
106
|
+
'id';
|
|
107
|
+
const selectedIds = Array.isArray(value) ? value.map(v => String(v)) : [];
|
|
108
|
+
|
|
109
|
+
if (vnode.state.loading) {
|
|
110
|
+
return m('.mb-4', [
|
|
111
|
+
m('label.block.text-sm.font-medium.text-gray-700.dark:text-slate-300.mb-1', label),
|
|
112
|
+
m('.text-xs.text-gray-500.dark:text-slate-400', 'Loading options...')
|
|
113
|
+
]);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return m('.mb-4', [
|
|
117
|
+
m('label.block.text-sm.font-medium.text-gray-700.dark:text-slate-300.mb-1', [
|
|
118
|
+
label,
|
|
119
|
+
!col.nullable && !readonly ? m('span.text-red-500', ' *') : null
|
|
120
|
+
]),
|
|
121
|
+
m('.border.border-gray-300.dark:border-slate-600.rounded-md.p-3.max-h-48.overflow-y-auto.bg-white.dark:bg-slate-900/70', [
|
|
122
|
+
vnode.state.options.map(item => {
|
|
123
|
+
const itemValue = String(item[valueKey]);
|
|
124
|
+
const itemDisplay = item[displayKey] || itemValue;
|
|
125
|
+
const isSelected = selectedIds.includes(itemValue);
|
|
126
|
+
|
|
127
|
+
return m('label.flex.items-center.mb-2.cursor-pointer.text-sm.text-gray-700.dark:text-slate-300', [
|
|
128
|
+
m('input.mr-2.rounded.border-gray-300.dark:border-slate-600.text-indigo-600.focus:ring-indigo-500', {
|
|
129
|
+
type: 'checkbox',
|
|
130
|
+
checked: isSelected,
|
|
131
|
+
disabled: readonly,
|
|
132
|
+
onchange: (e) => {
|
|
133
|
+
let newSelected = [...selectedIds];
|
|
134
|
+
if (e.target.checked) {
|
|
135
|
+
if (!newSelected.includes(itemValue)) {
|
|
136
|
+
newSelected.push(itemValue);
|
|
137
|
+
}
|
|
138
|
+
} else {
|
|
139
|
+
newSelected = newSelected.filter(id => id !== itemValue);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Convert type if needed
|
|
143
|
+
const typedSelected = col.type === 'array' || typeof value[0] === 'string'
|
|
144
|
+
? newSelected
|
|
145
|
+
: newSelected.map(Number);
|
|
146
|
+
onChange(typedSelected);
|
|
147
|
+
}
|
|
148
|
+
}),
|
|
149
|
+
m('span', itemDisplay),
|
|
150
|
+
]);
|
|
151
|
+
}),
|
|
152
|
+
]),
|
|
153
|
+
hint ? m('p.text-xs.text-gray-500.dark:text-slate-400.mt-1', hint) : null,
|
|
154
|
+
]);
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
|
|
2
158
|
const FieldRenderers = {
|
|
3
159
|
// Text input (string)
|
|
4
160
|
string: (col, value, onChange, readonly) => {
|
|
@@ -284,4 +440,14 @@ const FieldRenderers = {
|
|
|
284
440
|
hint ? m('p.text-xs.text-gray-500 dark:text-slate-400.mt-1', hint) : null,
|
|
285
441
|
]);
|
|
286
442
|
},
|
|
443
|
+
|
|
444
|
+
// belongsTo relation dropdown
|
|
445
|
+
belongsTo: (col, value, onChange, readonly) => {
|
|
446
|
+
return m(BelongsToField, { col, value, onChange, readonly });
|
|
447
|
+
},
|
|
448
|
+
|
|
449
|
+
// hasMany relation checklist
|
|
450
|
+
hasMany: (col, value, onChange, readonly) => {
|
|
451
|
+
return m(HasManyField, { col, value, onChange, readonly });
|
|
452
|
+
},
|
|
287
453
|
};
|
|
@@ -372,7 +372,36 @@ function getFieldRenderer(col, modelMeta) {
|
|
|
372
372
|
});
|
|
373
373
|
};
|
|
374
374
|
}
|
|
375
|
-
|
|
375
|
+
if (col.customField.type === 'belongsTo') {
|
|
376
|
+
return FieldRenderers.belongsTo;
|
|
377
|
+
}
|
|
378
|
+
if (col.customField.type === 'hasMany') {
|
|
379
|
+
return FieldRenderers.hasMany;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Check registered custom field renderers in browser
|
|
383
|
+
const custom = window.__customFieldRenderers && window.__customFieldRenderers[col.customField.type];
|
|
384
|
+
if (custom) {
|
|
385
|
+
return (col, value, onChange, readonly) => {
|
|
386
|
+
if (custom.edit && typeof custom.edit === 'object' && custom.edit.view) {
|
|
387
|
+
return m(custom.edit, { col, value, onChange, readonly });
|
|
388
|
+
} else if (typeof custom.edit === 'function') {
|
|
389
|
+
return custom.edit(value, onChange, col);
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Check schema type custom field renderers
|
|
396
|
+
if (col.type && window.__customFieldRenderers && window.__customFieldRenderers[col.type]) {
|
|
397
|
+
const custom = window.__customFieldRenderers[col.type];
|
|
398
|
+
return (col, value, onChange, readonly) => {
|
|
399
|
+
if (custom.edit && typeof custom.edit === 'object' && custom.edit.view) {
|
|
400
|
+
return m(custom.edit, { col, value, onChange, readonly });
|
|
401
|
+
} else if (typeof custom.edit === 'function') {
|
|
402
|
+
return custom.edit(value, onChange, col);
|
|
403
|
+
}
|
|
404
|
+
};
|
|
376
405
|
}
|
|
377
406
|
|
|
378
407
|
if (col.type === 'file') {
|
|
@@ -403,6 +432,8 @@ function getFieldRenderer(col, modelMeta) {
|
|
|
403
432
|
array: 'array',
|
|
404
433
|
uuid: 'string',
|
|
405
434
|
nanoid: 'string',
|
|
435
|
+
belongsTo: 'belongsTo',
|
|
436
|
+
hasMany: 'hasMany',
|
|
406
437
|
};
|
|
407
438
|
return FieldRenderers[typeMap[col.type] || 'string'];
|
|
408
439
|
}
|
|
@@ -65,10 +65,20 @@ const ModelList = {
|
|
|
65
65
|
};
|
|
66
66
|
|
|
67
67
|
// Format cell value based on column type
|
|
68
|
-
function formatCellValue(value, col) {
|
|
68
|
+
function formatCellValue(value, col, record = null) {
|
|
69
69
|
if (value === null || value === undefined) {
|
|
70
70
|
return m('span.text-gray-400 dark:text-slate-500.italic', 'null');
|
|
71
71
|
}
|
|
72
|
+
|
|
73
|
+
const customRendererType = (col && col.customField && col.customField.type) || (col && col.type);
|
|
74
|
+
const custom = window.__customFieldRenderers && window.__customFieldRenderers[customRendererType];
|
|
75
|
+
if (custom && custom.display) {
|
|
76
|
+
if (typeof custom.display === 'object' && custom.display.view) {
|
|
77
|
+
return m(custom.display, { value, col, record });
|
|
78
|
+
} else if (typeof custom.display === 'function') {
|
|
79
|
+
return custom.display(value, record);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
72
82
|
|
|
73
83
|
switch (col?.type) {
|
|
74
84
|
case 'boolean':
|
|
@@ -118,6 +128,24 @@ function formatCellValue(value, col) {
|
|
|
118
128
|
}
|
|
119
129
|
return short || m('span.text-gray-400', '—');
|
|
120
130
|
}
|
|
131
|
+
|
|
132
|
+
case 'files': {
|
|
133
|
+
if (Array.isArray(value)) {
|
|
134
|
+
return value.length > 0
|
|
135
|
+
? m('div.flex.gap-1.items-center', [
|
|
136
|
+
m('span.text-xs.bg-gray-100.dark:bg-slate-800.px-2.py-1.rounded.mr-1', `${value.length} files`),
|
|
137
|
+
...value.slice(0, 2).map(s => {
|
|
138
|
+
const isImg = /\.(jpe?g|png|gif|webp|svg|avif|bmp|ico)(\?|#|$)/i.test(s);
|
|
139
|
+
if (isImg) {
|
|
140
|
+
return m('img.h-6.w-6.rounded.object-cover.border.border-gray-200.dark:border-slate-700.bg-white', { src: s });
|
|
141
|
+
}
|
|
142
|
+
return m('a.text-xs.text-indigo-600.dark:text-indigo-400.hover:underline', { href: s, target: '_blank' }, '📄');
|
|
143
|
+
})
|
|
144
|
+
])
|
|
145
|
+
: m('span.text-gray-400.dark:text-slate-500.italic', 'empty');
|
|
146
|
+
}
|
|
147
|
+
return String(value);
|
|
148
|
+
}
|
|
121
149
|
|
|
122
150
|
default: {
|
|
123
151
|
const str = String(value);
|
|
@@ -478,7 +478,7 @@ const RecordList = {
|
|
|
478
478
|
...displayColumns.map((col, i) =>
|
|
479
479
|
m('td.px-4.py-3.text-sm.whitespace-nowrap.text-gray-700 dark:text-slate-300.bg-white dark:bg-slate-800',
|
|
480
480
|
i === 0 ? { style: 'position: sticky; left: 40px; z-index: 5; box-shadow: 4px 0 8px -4px rgba(0,0,0,0.08);' } : {},
|
|
481
|
-
formatCellValue(record[col.name], col)
|
|
481
|
+
formatCellValue(record[col.name], col, record)
|
|
482
482
|
)
|
|
483
483
|
),
|
|
484
484
|
// Sticky actions cell (sticky right, box-shadow on left)
|
|
@@ -219,6 +219,166 @@ module.exports = {
|
|
|
219
219
|
]);
|
|
220
220
|
},
|
|
221
221
|
},
|
|
222
|
+
|
|
223
|
+
FilesUploadField: {
|
|
224
|
+
oninit: (vnode) => {
|
|
225
|
+
vnode.state.uploading = false;
|
|
226
|
+
},
|
|
227
|
+
oncreate: (vnode) => {
|
|
228
|
+
const { name, meta = {} } = vnode.attrs;
|
|
229
|
+
if (!getUploadUrlFromAdminConfig()) return;
|
|
230
|
+
|
|
231
|
+
const dropZoneId = 'drop-zone-multiple-' + name;
|
|
232
|
+
const dropZone = document.getElementById(dropZoneId);
|
|
233
|
+
if (!dropZone) return;
|
|
234
|
+
|
|
235
|
+
['dragenter', 'dragover', 'dragleave', 'drop'].forEach((eventName) => {
|
|
236
|
+
dropZone.addEventListener(eventName, (e) => {
|
|
237
|
+
e.preventDefault();
|
|
238
|
+
e.stopPropagation();
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
['dragenter', 'dragover'].forEach((eventName) => {
|
|
243
|
+
dropZone.addEventListener(eventName, () => {
|
|
244
|
+
dropZone.classList.add('border-blue-500', 'bg-blue-50', 'dark:bg-slate-800');
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
['dragleave', 'drop'].forEach((eventName) => {
|
|
249
|
+
dropZone.addEventListener(eventName, () => {
|
|
250
|
+
dropZone.classList.remove('border-blue-500', 'bg-blue-50', 'dark:bg-slate-800');
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
dropZone.addEventListener('drop', (e) => {
|
|
255
|
+
const files = e.dataTransfer.files;
|
|
256
|
+
if (files.length > 0) {
|
|
257
|
+
handleMultipleFilesUpload(files, vnode, meta);
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
const fileInput = dropZone.querySelector('input[type=file]');
|
|
262
|
+
if (fileInput) {
|
|
263
|
+
fileInput.addEventListener('change', (e) => {
|
|
264
|
+
if (e.target.files.length > 0) {
|
|
265
|
+
handleMultipleFilesUpload(e.target.files, vnode, meta);
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
},
|
|
270
|
+
view: (vnode) => {
|
|
271
|
+
const { name, value = [], meta = {}, required = false, readonly = false } = vnode.attrs;
|
|
272
|
+
const state = vnode.state;
|
|
273
|
+
const dropZoneId = 'drop-zone-multiple-' + name;
|
|
274
|
+
const maxSize = meta.maxSize || meta.maxBytes || 10 * 1024 * 1024;
|
|
275
|
+
const accept = meta.accept || '*/*';
|
|
276
|
+
const uploadUrl = getUploadUrlFromAdminConfig();
|
|
277
|
+
const label = meta.label || name;
|
|
278
|
+
|
|
279
|
+
const urls = Array.isArray(value) ? value : (value ? [value] : []);
|
|
280
|
+
|
|
281
|
+
if (!uploadUrl) {
|
|
282
|
+
return m('.mb-4', [
|
|
283
|
+
m(
|
|
284
|
+
'label.block.text-sm.font-medium.mb-2',
|
|
285
|
+
label,
|
|
286
|
+
required ? m('span.text-red-500', ' *') : null
|
|
287
|
+
),
|
|
288
|
+
m('p.text-xs.text-amber-700.dark:text-amber-400.mb-2', 'Upload URL is not configured.'),
|
|
289
|
+
]);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return m('.mb-4', [
|
|
293
|
+
m(
|
|
294
|
+
'label.block.text-sm.font-medium.mb-2',
|
|
295
|
+
label,
|
|
296
|
+
required ? m('span.text-red-500', ' *') : null
|
|
297
|
+
),
|
|
298
|
+
|
|
299
|
+
urls.length > 0
|
|
300
|
+
? m('.grid.grid-cols-2.sm:grid-cols-3.md:grid-cols-4.gap-4.mb-3', urls.map((url, idx) => {
|
|
301
|
+
return m('.relative.group.border.border-gray-200.dark:border-slate-700.rounded-lg.p-2.bg-gray-50.dark:bg-slate-900', [
|
|
302
|
+
shouldShowImagePreview(url, accept)
|
|
303
|
+
? m('a.block', { href: url, target: '_blank', rel: 'noopener noreferrer' },
|
|
304
|
+
m('img.h-32.w-full.rounded.object-contain.bg-white.dark:bg-slate-950', {
|
|
305
|
+
src: url,
|
|
306
|
+
alt: `${label} ${idx + 1}`,
|
|
307
|
+
loading: 'lazy',
|
|
308
|
+
})
|
|
309
|
+
)
|
|
310
|
+
: m('a.flex.items-center.justify-center.h-32.w-full.text-sm.text-indigo-600.dark:text-indigo-400.break-all.p-2', {
|
|
311
|
+
href: url,
|
|
312
|
+
target: '_blank',
|
|
313
|
+
rel: 'noopener noreferrer',
|
|
314
|
+
}, url.substring(url.lastIndexOf('/') + 1) || url),
|
|
315
|
+
!readonly
|
|
316
|
+
? m('button.absolute.top-1.right-1.bg-red-500.text-white.rounded-full.p-1.shadow-md.opacity-0.group-hover:opacity-100.hover:bg-red-700.transition-opacity', {
|
|
317
|
+
type: 'button',
|
|
318
|
+
onclick: () => {
|
|
319
|
+
const newUrls = urls.filter((_, i) => i !== idx);
|
|
320
|
+
if (vnode.attrs.onchange) vnode.attrs.onchange(newUrls);
|
|
321
|
+
},
|
|
322
|
+
title: 'Remove file',
|
|
323
|
+
}, [
|
|
324
|
+
m('svg.w-4.h-4', { fill: 'none', stroke: 'currentColor', viewBox: '0 0 24 24' },
|
|
325
|
+
m('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M6 18L18 6M6 6l12 12' })
|
|
326
|
+
)
|
|
327
|
+
])
|
|
328
|
+
: null,
|
|
329
|
+
]);
|
|
330
|
+
}))
|
|
331
|
+
: null,
|
|
332
|
+
|
|
333
|
+
m(
|
|
334
|
+
'div.border-2.border-dashed.border-gray-300.dark:border-slate-600.rounded.p-6.text-center',
|
|
335
|
+
{
|
|
336
|
+
id: dropZoneId,
|
|
337
|
+
style: readonly || state.uploading ? undefined : 'cursor: pointer;',
|
|
338
|
+
},
|
|
339
|
+
[
|
|
340
|
+
m('input[type=file]', {
|
|
341
|
+
class: 'hidden',
|
|
342
|
+
id: 'file-input-multiple-' + name,
|
|
343
|
+
accept,
|
|
344
|
+
multiple: true,
|
|
345
|
+
disabled: readonly || state.uploading,
|
|
346
|
+
onchange: (e) => {
|
|
347
|
+
if (e.target.files.length > 0) {
|
|
348
|
+
handleMultipleFilesUpload(e.target.files, vnode, meta);
|
|
349
|
+
}
|
|
350
|
+
},
|
|
351
|
+
}),
|
|
352
|
+
readonly
|
|
353
|
+
? null
|
|
354
|
+
: m('div', [
|
|
355
|
+
state.uploading
|
|
356
|
+
? m('p.text-blue-600.dark:text-blue-400.animate-pulse', 'Uploading files...')
|
|
357
|
+
: m('div', [
|
|
358
|
+
m(
|
|
359
|
+
'p.text-gray-600.dark:text-slate-400.mb-2',
|
|
360
|
+
'Drag and drop files here, or'
|
|
361
|
+
),
|
|
362
|
+
m(
|
|
363
|
+
'label.text-blue-600.hover:text-blue-800.cursor-pointer',
|
|
364
|
+
{ for: 'file-input-multiple-' + name },
|
|
365
|
+
'browse'
|
|
366
|
+
),
|
|
367
|
+
]),
|
|
368
|
+
]),
|
|
369
|
+
]
|
|
370
|
+
),
|
|
371
|
+
m('input[type=hidden]', {
|
|
372
|
+
name,
|
|
373
|
+
value: JSON.stringify(urls),
|
|
374
|
+
}),
|
|
375
|
+
m(
|
|
376
|
+
'p.text-xs.text-gray-500.mt-1',
|
|
377
|
+
'Max ' + Math.round(maxSize / 1024 / 1024) + ' MB per file (server enforces limits)'
|
|
378
|
+
),
|
|
379
|
+
]);
|
|
380
|
+
},
|
|
381
|
+
},
|
|
222
382
|
};
|
|
223
383
|
|
|
224
384
|
/**
|
|
@@ -272,3 +432,66 @@ async function handleFileUpload(file, vnode, meta) {
|
|
|
272
432
|
alert(err.message || 'Upload failed');
|
|
273
433
|
}
|
|
274
434
|
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* @param {FileList} files
|
|
438
|
+
* @param {import('mithril').Vnode} vnode
|
|
439
|
+
* @param {Object} meta
|
|
440
|
+
*/
|
|
441
|
+
async function handleMultipleFilesUpload(files, vnode, meta) {
|
|
442
|
+
const maxSize = meta.maxSize || meta.maxBytes || 10 * 1024 * 1024;
|
|
443
|
+
const uploadUrl = getUploadUrlFromAdminConfig();
|
|
444
|
+
if (!uploadUrl) {
|
|
445
|
+
alert('Upload URL is not configured.');
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const currentUrls = Array.isArray(vnode.attrs.value)
|
|
450
|
+
? [...vnode.attrs.value]
|
|
451
|
+
: (vnode.attrs.value ? [vnode.attrs.value] : []);
|
|
452
|
+
|
|
453
|
+
vnode.state.uploading = true;
|
|
454
|
+
if (typeof m !== 'undefined' && m.redraw) m.redraw();
|
|
455
|
+
|
|
456
|
+
const uploadPromises = Array.from(files).map(async (file) => {
|
|
457
|
+
if (file.size > maxSize) {
|
|
458
|
+
alert(`File ${file.name} is too large (max ${Math.round(maxSize / 1024 / 1024)} MB) and was skipped.`);
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const fd = new FormData();
|
|
463
|
+
fd.append('file', file);
|
|
464
|
+
|
|
465
|
+
try {
|
|
466
|
+
const res = await fetch(uploadUrl, { method: 'POST', body: fd, credentials: 'include' });
|
|
467
|
+
let data = {};
|
|
468
|
+
try {
|
|
469
|
+
data = await res.json();
|
|
470
|
+
} catch {
|
|
471
|
+
data = {};
|
|
472
|
+
}
|
|
473
|
+
if (!res.ok) {
|
|
474
|
+
alert(`Upload failed for ${file.name}: ${data.message || data.error || res.status}`);
|
|
475
|
+
return null;
|
|
476
|
+
}
|
|
477
|
+
return data.url || data.publicUrl || '';
|
|
478
|
+
} catch (err) {
|
|
479
|
+
alert(`Upload failed for ${file.name}: ${err.message}`);
|
|
480
|
+
return null;
|
|
481
|
+
}
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
try {
|
|
485
|
+
const results = await Promise.all(uploadPromises);
|
|
486
|
+
const newUrls = results.filter(Boolean);
|
|
487
|
+
if (newUrls.length > 0) {
|
|
488
|
+
const updatedUrls = [...currentUrls, ...newUrls];
|
|
489
|
+
if (vnode.attrs.onchange) {
|
|
490
|
+
vnode.attrs.onchange(updatedUrls);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
} finally {
|
|
494
|
+
vnode.state.uploading = false;
|
|
495
|
+
if (typeof m !== 'undefined' && m.redraw) m.redraw();
|
|
496
|
+
}
|
|
497
|
+
}
|
|
@@ -79,6 +79,7 @@ function initializeDefaultRenderers() {
|
|
|
79
79
|
registerFieldRenderer('rich-text', richTextRenderer.RichTextField);
|
|
80
80
|
registerFieldRenderer('file-upload', fileUploadRenderer.FileUploadField);
|
|
81
81
|
registerFieldRenderer('file', fileUploadRenderer.FileUploadField);
|
|
82
|
+
registerFieldRenderer('files', fileUploadRenderer.FilesUploadField);
|
|
82
83
|
|
|
83
84
|
// Relation types
|
|
84
85
|
registerFieldRenderer('belongsTo', relationRenderers.BelongsToField);
|
|
@@ -329,6 +329,25 @@ function adminPanelPlugin(options = {}) {
|
|
|
329
329
|
};
|
|
330
330
|
}
|
|
331
331
|
|
|
332
|
+
/**
|
|
333
|
+
* Helper to serialize arbitrary JS structures, including functions
|
|
334
|
+
*/
|
|
335
|
+
function serializeJs(obj) {
|
|
336
|
+
if (typeof obj === 'function') {
|
|
337
|
+
return obj.toString();
|
|
338
|
+
}
|
|
339
|
+
if (Array.isArray(obj)) {
|
|
340
|
+
return '[' + obj.map(serializeJs).join(', ') + ']';
|
|
341
|
+
}
|
|
342
|
+
if (obj && typeof obj === 'object') {
|
|
343
|
+
const entries = Object.entries(obj).map(([key, val]) => {
|
|
344
|
+
return `${JSON.stringify(key)}: ${serializeJs(val)}`;
|
|
345
|
+
});
|
|
346
|
+
return '{' + entries.join(',\n') + '}';
|
|
347
|
+
}
|
|
348
|
+
return JSON.stringify(obj);
|
|
349
|
+
}
|
|
350
|
+
|
|
332
351
|
/**
|
|
333
352
|
* Generate admin panel HTML
|
|
334
353
|
* @param {string} adminPath - Admin panel path
|
|
@@ -343,6 +362,12 @@ function generateAdminPanelHtml(adminPath, registry) {
|
|
|
343
362
|
const customPageComponent = generateCustomPageComponent();
|
|
344
363
|
|
|
345
364
|
const settings = registry.settings;
|
|
365
|
+
const fieldRenderersCode = Array.from(registry.fieldRenderers.entries()).map(([type, r]) => {
|
|
366
|
+
return `window.__customFieldRenderers['${type}'] = {
|
|
367
|
+
display: ${r.display ? serializeJs(r.display) : 'null'},
|
|
368
|
+
edit: ${r.edit ? serializeJs(r.edit) : 'null'}
|
|
369
|
+
};`;
|
|
370
|
+
}).join('\n');
|
|
346
371
|
|
|
347
372
|
return `<!DOCTYPE html>
|
|
348
373
|
<html lang="en">
|
|
@@ -418,6 +443,10 @@ function generateAdminPanelHtml(adminPath, registry) {
|
|
|
418
443
|
// Custom page components container
|
|
419
444
|
window.__customPages = window.__customPages || {};
|
|
420
445
|
${registry.getClientComponents()}
|
|
446
|
+
|
|
447
|
+
// Custom field renderers container
|
|
448
|
+
window.__customFieldRenderers = window.__customFieldRenderers || {};
|
|
449
|
+
${fieldRenderersCode}
|
|
421
450
|
</script>
|
|
422
451
|
<script>${appScript}</script>
|
|
423
452
|
</body>
|
package/plugins/upload/index.js
CHANGED
|
@@ -54,12 +54,12 @@ function extensionFromName(name) {
|
|
|
54
54
|
* @param {string} fieldName
|
|
55
55
|
* @returns {import('express').RequestHandler}
|
|
56
56
|
*/
|
|
57
|
-
function
|
|
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 =
|
|
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
|
|
137
|
-
if (
|
|
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
|
-
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
(
|
|
152
|
-
|
|
153
|
-
|
|
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
|
|
158
|
-
|
|
159
|
-
originalName
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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 });
|