webspresso 0.0.79 → 0.0.81
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 +104 -6
- package/bin/commands/doctor.js +83 -0
- package/bin/commands/favicon-generate.js +3 -1
- package/core/auth/hash.js +1 -1
- package/core/auth/index.js +30 -0
- package/core/auth/manager.js +190 -0
- package/core/auth/tokens.js +135 -0
- package/core/kernel/app.js +7 -1
- 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/index.d.ts +32 -0
- package/index.js +2 -0
- package/package.json +5 -2
- package/plugins/admin-panel/client/parts/07-model-list.js +18 -0
- package/plugins/admin-panel/field-renderers/file-upload.js +269 -0
- package/plugins/admin-panel/field-renderers/index.js +1 -0
- package/plugins/csrf/index.js +222 -0
- package/plugins/email/admin-component.js +226 -0
- package/plugins/email/api-handlers.js +139 -0
- package/plugins/email/auth-bridge.js +209 -0
- package/plugins/email/bundled-templates.js +68 -0
- package/plugins/email/fs-env.js +101 -0
- package/plugins/email/index.js +186 -0
- package/plugins/email/manifest.js +25 -0
- package/plugins/email/migration-template.js +40 -0
- package/plugins/email/purge.js +28 -0
- package/plugins/email/render.js +154 -0
- package/plugins/email/service.js +244 -0
- package/plugins/email/template-registry.js +134 -0
- package/plugins/email/templates/auth-email-verify.mjml +13 -0
- package/plugins/email/templates/auth-password-reset.mjml +13 -0
- package/plugins/email/templates/auth-welcome.mjml +11 -0
- package/plugins/email/transport.js +71 -0
- package/plugins/index.js +4 -0
- package/plugins/upload/index.js +53 -30
- package/src/file-router.js +75 -0
- package/templates/skills/webspresso-usage/REFERENCE-framework.md +1 -0
package/core/kernel/app.js
CHANGED
|
@@ -50,7 +50,13 @@ function createApp(options = {}) {
|
|
|
50
50
|
}
|
|
51
51
|
};
|
|
52
52
|
events.on(flowDef.trigger, handler);
|
|
53
|
-
const unregister = () =>
|
|
53
|
+
const unregister = () => {
|
|
54
|
+
events.off(flowDef.trigger, handler);
|
|
55
|
+
const idx = flowHandles.findIndex((h) => h.unregister === unregister);
|
|
56
|
+
if (idx !== -1) {
|
|
57
|
+
flowHandles.splice(idx, 1);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
54
60
|
flowHandles.push({
|
|
55
61
|
id,
|
|
56
62
|
trigger: flowDef.trigger,
|
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/index.d.ts
CHANGED
|
@@ -74,6 +74,15 @@ export interface CreateAppResult {
|
|
|
74
74
|
|
|
75
75
|
export function createApp(options?: CreateAppOptions): CreateAppResult;
|
|
76
76
|
|
|
77
|
+
export function resolveClientRuntime(options?: {
|
|
78
|
+
clientRuntime?: {
|
|
79
|
+
alpine?: boolean | Record<string, unknown>;
|
|
80
|
+
swup?: boolean | Record<string, unknown>;
|
|
81
|
+
};
|
|
82
|
+
}): { alpine: boolean; swup: boolean };
|
|
83
|
+
|
|
84
|
+
export const CLIENT_RUNTIME_BASE: string;
|
|
85
|
+
|
|
77
86
|
// --- App context ---
|
|
78
87
|
|
|
79
88
|
export function attachDbMiddleware(req: Request, res: Response, next: NextFunction): void;
|
|
@@ -595,6 +604,29 @@ export interface RateLimitPluginOptions {
|
|
|
595
604
|
|
|
596
605
|
export function rateLimitPlugin(options?: RateLimitPluginOptions): WebspressoPlugin;
|
|
597
606
|
|
|
607
|
+
export interface CsrfCookieOptions {
|
|
608
|
+
key?: string;
|
|
609
|
+
signed?: boolean;
|
|
610
|
+
path?: string;
|
|
611
|
+
httpOnly?: boolean;
|
|
612
|
+
secure?: boolean;
|
|
613
|
+
sameSite?: 'lax' | 'strict' | 'none';
|
|
614
|
+
maxAge?: number;
|
|
615
|
+
[key: string]: unknown;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
export interface CsrfPluginOptions {
|
|
619
|
+
cookie?: boolean | CsrfCookieOptions;
|
|
620
|
+
sessionKey?: string;
|
|
621
|
+
methods?: string[];
|
|
622
|
+
ignorePaths?: Array<string | RegExp | ((req: Request) => boolean)>;
|
|
623
|
+
global?: boolean;
|
|
624
|
+
errorMessage?: string;
|
|
625
|
+
errorStatus?: number;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
export function csrfPlugin(options?: CsrfPluginOptions): WebspressoPlugin;
|
|
629
|
+
|
|
598
630
|
export interface ContentPluginOptions {
|
|
599
631
|
db: import('./index').Database;
|
|
600
632
|
adminPath?: string;
|
package/index.js
CHANGED
|
@@ -63,6 +63,7 @@ const {
|
|
|
63
63
|
redirectPlugin,
|
|
64
64
|
rateLimitPlugin,
|
|
65
65
|
contentPlugin,
|
|
66
|
+
csrfPlugin,
|
|
66
67
|
} = require('./plugins');
|
|
67
68
|
|
|
68
69
|
const content = require('./core/content');
|
|
@@ -133,6 +134,7 @@ module.exports = {
|
|
|
133
134
|
redirectPlugin,
|
|
134
135
|
rateLimitPlugin,
|
|
135
136
|
contentPlugin,
|
|
137
|
+
csrfPlugin,
|
|
136
138
|
|
|
137
139
|
// Schema-driven CMS core (framework-agnostic)
|
|
138
140
|
content,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webspresso",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.81",
|
|
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",
|
|
@@ -67,7 +67,9 @@
|
|
|
67
67
|
"helmet": "^7.2.0",
|
|
68
68
|
"inquirer": "^8.2.6",
|
|
69
69
|
"knex": "^3.1.0",
|
|
70
|
+
"mjml": "^5.4.0",
|
|
70
71
|
"multer": "^2.1.1",
|
|
72
|
+
"nodemailer": "^9.0.3",
|
|
71
73
|
"nunjucks": "^3.2.4",
|
|
72
74
|
"sanitize-html": "^2.17.3",
|
|
73
75
|
"sharp": "^0.33.5",
|
|
@@ -125,6 +127,7 @@
|
|
|
125
127
|
"overrides": {
|
|
126
128
|
"basic-ftp": "^5.3.0",
|
|
127
129
|
"tar": "^7.5.11",
|
|
128
|
-
"undici": "^6.24.0"
|
|
130
|
+
"undici": "^6.24.0",
|
|
131
|
+
"uuid": "^11.1.1"
|
|
129
132
|
}
|
|
130
133
|
}
|
|
@@ -128,6 +128,24 @@ function formatCellValue(value, col, record = null) {
|
|
|
128
128
|
}
|
|
129
129
|
return short || m('span.text-gray-400', '—');
|
|
130
130
|
}
|
|
131
|
+
|
|
132
|
+
case 'files': {
|
|
133
|
+
if (Array.isArray(value)) {
|
|
134
|
+
return value.length > 0
|
|
135
|
+
? m('div.flex.gap-1.items-center', [
|
|
136
|
+
m('span.text-xs.bg-gray-100.dark:bg-slate-800.px-2.py-1.rounded.mr-1', `${value.length} files`),
|
|
137
|
+
...value.slice(0, 2).map(s => {
|
|
138
|
+
const isImg = /\.(jpe?g|png|gif|webp|svg|avif|bmp|ico)(\?|#|$)/i.test(s);
|
|
139
|
+
if (isImg) {
|
|
140
|
+
return m('img.h-6.w-6.rounded.object-cover.border.border-gray-200.dark:border-slate-700.bg-white', { src: s });
|
|
141
|
+
}
|
|
142
|
+
return m('a.text-xs.text-indigo-600.dark:text-indigo-400.hover:underline', { href: s, target: '_blank' }, '📄');
|
|
143
|
+
})
|
|
144
|
+
])
|
|
145
|
+
: m('span.text-gray-400.dark:text-slate-500.italic', 'empty');
|
|
146
|
+
}
|
|
147
|
+
return String(value);
|
|
148
|
+
}
|
|
131
149
|
|
|
132
150
|
default: {
|
|
133
151
|
const str = String(value);
|
|
@@ -219,6 +219,212 @@ module.exports = {
|
|
|
219
219
|
]);
|
|
220
220
|
},
|
|
221
221
|
},
|
|
222
|
+
|
|
223
|
+
FilesUploadField: {
|
|
224
|
+
oninit: (vnode) => {
|
|
225
|
+
vnode.state.uploading = false;
|
|
226
|
+
vnode.state.draggedIndex = null;
|
|
227
|
+
vnode.state.dragOverIndex = null;
|
|
228
|
+
},
|
|
229
|
+
oncreate: (vnode) => {
|
|
230
|
+
const { name, meta = {} } = vnode.attrs;
|
|
231
|
+
if (!getUploadUrlFromAdminConfig()) return;
|
|
232
|
+
|
|
233
|
+
const dropZoneId = 'drop-zone-multiple-' + name;
|
|
234
|
+
const dropZone = document.getElementById(dropZoneId);
|
|
235
|
+
if (!dropZone) return;
|
|
236
|
+
|
|
237
|
+
['dragenter', 'dragover', 'dragleave', 'drop'].forEach((eventName) => {
|
|
238
|
+
dropZone.addEventListener(eventName, (e) => {
|
|
239
|
+
e.preventDefault();
|
|
240
|
+
e.stopPropagation();
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
['dragenter', 'dragover'].forEach((eventName) => {
|
|
245
|
+
dropZone.addEventListener(eventName, () => {
|
|
246
|
+
dropZone.classList.add('border-blue-500', 'bg-blue-50', 'dark:bg-slate-800');
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
['dragleave', 'drop'].forEach((eventName) => {
|
|
251
|
+
dropZone.addEventListener(eventName, () => {
|
|
252
|
+
dropZone.classList.remove('border-blue-500', 'bg-blue-50', 'dark:bg-slate-800');
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
dropZone.addEventListener('drop', (e) => {
|
|
257
|
+
const files = e.dataTransfer.files;
|
|
258
|
+
if (files.length > 0) {
|
|
259
|
+
handleMultipleFilesUpload(files, vnode, meta);
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
const fileInput = dropZone.querySelector('input[type=file]');
|
|
264
|
+
if (fileInput) {
|
|
265
|
+
fileInput.addEventListener('change', (e) => {
|
|
266
|
+
if (e.target.files.length > 0) {
|
|
267
|
+
handleMultipleFilesUpload(e.target.files, vnode, meta);
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
},
|
|
272
|
+
view: (vnode) => {
|
|
273
|
+
const { name, value = [], meta = {}, required = false, readonly = false } = vnode.attrs;
|
|
274
|
+
const state = vnode.state;
|
|
275
|
+
const dropZoneId = 'drop-zone-multiple-' + name;
|
|
276
|
+
const maxSize = meta.maxSize || meta.maxBytes || 10 * 1024 * 1024;
|
|
277
|
+
const accept = meta.accept || '*/*';
|
|
278
|
+
const uploadUrl = getUploadUrlFromAdminConfig();
|
|
279
|
+
const label = meta.label || name;
|
|
280
|
+
|
|
281
|
+
const urls = Array.isArray(value) ? value : (value ? [value] : []);
|
|
282
|
+
|
|
283
|
+
if (!uploadUrl) {
|
|
284
|
+
return m('.mb-4', [
|
|
285
|
+
m(
|
|
286
|
+
'label.block.text-sm.font-medium.mb-2',
|
|
287
|
+
label,
|
|
288
|
+
required ? m('span.text-red-500', ' *') : null
|
|
289
|
+
),
|
|
290
|
+
m('p.text-xs.text-amber-700.dark:text-amber-400.mb-2', 'Upload URL is not configured.'),
|
|
291
|
+
]);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return m('.mb-4', [
|
|
295
|
+
m(
|
|
296
|
+
'label.block.text-sm.font-medium.mb-2',
|
|
297
|
+
label,
|
|
298
|
+
required ? m('span.text-red-500', ' *') : null
|
|
299
|
+
),
|
|
300
|
+
|
|
301
|
+
urls.length > 0
|
|
302
|
+
? m('.grid.grid-cols-2.sm:grid-cols-3.md:grid-cols-4.gap-4.mb-3', urls.map((url, idx) => {
|
|
303
|
+
const isDragOver = state.dragOverIndex === idx;
|
|
304
|
+
const baseClasses = '.relative.group.border.rounded-lg.p-2.transition-all';
|
|
305
|
+
const cursorClass = readonly ? '' : '.cursor-move';
|
|
306
|
+
const borderClasses = isDragOver ? '.border-blue-500.border-2.dark:border-blue-500' : '.border-gray-200.dark:border-slate-700';
|
|
307
|
+
const bgClasses = isDragOver ? '.bg-blue-50.dark:bg-slate-800' : '.bg-gray-50.dark:bg-slate-900';
|
|
308
|
+
|
|
309
|
+
return m(baseClasses + cursorClass + borderClasses + bgClasses, {
|
|
310
|
+
draggable: !readonly,
|
|
311
|
+
ondragstart: (e) => {
|
|
312
|
+
state.draggedIndex = idx;
|
|
313
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
314
|
+
e.dataTransfer.setData('text/plain', idx);
|
|
315
|
+
},
|
|
316
|
+
ondragover: (e) => {
|
|
317
|
+
e.preventDefault();
|
|
318
|
+
e.dataTransfer.dropEffect = 'move';
|
|
319
|
+
},
|
|
320
|
+
ondragenter: (e) => {
|
|
321
|
+
state.dragOverIndex = idx;
|
|
322
|
+
},
|
|
323
|
+
ondragleave: (e) => {
|
|
324
|
+
if (state.dragOverIndex === idx) {
|
|
325
|
+
state.dragOverIndex = null;
|
|
326
|
+
}
|
|
327
|
+
},
|
|
328
|
+
ondrop: (e) => {
|
|
329
|
+
e.preventDefault();
|
|
330
|
+
const fromIdx = state.draggedIndex;
|
|
331
|
+
const toIdx = idx;
|
|
332
|
+
if (fromIdx !== null && fromIdx !== undefined && fromIdx !== toIdx) {
|
|
333
|
+
const newUrls = [...urls];
|
|
334
|
+
const [movedItem] = newUrls.splice(fromIdx, 1);
|
|
335
|
+
newUrls.splice(toIdx, 0, movedItem);
|
|
336
|
+
if (vnode.attrs.onchange) vnode.attrs.onchange(newUrls);
|
|
337
|
+
}
|
|
338
|
+
state.draggedIndex = null;
|
|
339
|
+
state.dragOverIndex = null;
|
|
340
|
+
},
|
|
341
|
+
ondragend: (e) => {
|
|
342
|
+
state.draggedIndex = null;
|
|
343
|
+
state.dragOverIndex = null;
|
|
344
|
+
}
|
|
345
|
+
}, [
|
|
346
|
+
shouldShowImagePreview(url, accept)
|
|
347
|
+
? m('a.block', { href: url, target: '_blank', rel: 'noopener noreferrer', draggable: false },
|
|
348
|
+
m('img.h-32.w-full.rounded.object-contain.bg-white.dark:bg-slate-950', {
|
|
349
|
+
src: url,
|
|
350
|
+
alt: `${label} ${idx + 1}`,
|
|
351
|
+
loading: 'lazy',
|
|
352
|
+
draggable: false,
|
|
353
|
+
})
|
|
354
|
+
)
|
|
355
|
+
: m('a.flex.items-center.justify-center.h-32.w-full.text-sm.text-indigo-600.dark:text-indigo-400.break-all.p-2', {
|
|
356
|
+
href: url,
|
|
357
|
+
target: '_blank',
|
|
358
|
+
rel: 'noopener noreferrer',
|
|
359
|
+
draggable: false,
|
|
360
|
+
}, url.substring(url.lastIndexOf('/') + 1) || url),
|
|
361
|
+
!readonly
|
|
362
|
+
? 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', {
|
|
363
|
+
type: 'button',
|
|
364
|
+
onclick: () => {
|
|
365
|
+
const newUrls = urls.filter((_, i) => i !== idx);
|
|
366
|
+
if (vnode.attrs.onchange) vnode.attrs.onchange(newUrls);
|
|
367
|
+
},
|
|
368
|
+
title: 'Remove file',
|
|
369
|
+
}, [
|
|
370
|
+
m('svg.w-4.h-4', { fill: 'none', stroke: 'currentColor', viewBox: '0 0 24 24' },
|
|
371
|
+
m('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M6 18L18 6M6 6l12 12' })
|
|
372
|
+
)
|
|
373
|
+
])
|
|
374
|
+
: null,
|
|
375
|
+
]);
|
|
376
|
+
}))
|
|
377
|
+
: null,
|
|
378
|
+
|
|
379
|
+
m(
|
|
380
|
+
'div.border-2.border-dashed.border-gray-300.dark:border-slate-600.rounded.p-6.text-center',
|
|
381
|
+
{
|
|
382
|
+
id: dropZoneId,
|
|
383
|
+
style: readonly || state.uploading ? undefined : 'cursor: pointer;',
|
|
384
|
+
},
|
|
385
|
+
[
|
|
386
|
+
m('input[type=file]', {
|
|
387
|
+
class: 'hidden',
|
|
388
|
+
id: 'file-input-multiple-' + name,
|
|
389
|
+
accept,
|
|
390
|
+
multiple: true,
|
|
391
|
+
disabled: readonly || state.uploading,
|
|
392
|
+
onchange: (e) => {
|
|
393
|
+
if (e.target.files.length > 0) {
|
|
394
|
+
handleMultipleFilesUpload(e.target.files, vnode, meta);
|
|
395
|
+
}
|
|
396
|
+
},
|
|
397
|
+
}),
|
|
398
|
+
readonly
|
|
399
|
+
? null
|
|
400
|
+
: m('div', [
|
|
401
|
+
state.uploading
|
|
402
|
+
? m('p.text-blue-600.dark:text-blue-400.animate-pulse', 'Uploading files...')
|
|
403
|
+
: m('div', [
|
|
404
|
+
m(
|
|
405
|
+
'p.text-gray-600.dark:text-slate-400.mb-2',
|
|
406
|
+
'Drag and drop files here, or'
|
|
407
|
+
),
|
|
408
|
+
m(
|
|
409
|
+
'label.text-blue-600.hover:text-blue-800.cursor-pointer',
|
|
410
|
+
{ for: 'file-input-multiple-' + name },
|
|
411
|
+
'browse'
|
|
412
|
+
),
|
|
413
|
+
]),
|
|
414
|
+
]),
|
|
415
|
+
]
|
|
416
|
+
),
|
|
417
|
+
m('input[type=hidden]', {
|
|
418
|
+
name,
|
|
419
|
+
value: JSON.stringify(urls),
|
|
420
|
+
}),
|
|
421
|
+
m(
|
|
422
|
+
'p.text-xs.text-gray-500.mt-1',
|
|
423
|
+
'Max ' + Math.round(maxSize / 1024 / 1024) + ' MB per file (server enforces limits)'
|
|
424
|
+
),
|
|
425
|
+
]);
|
|
426
|
+
},
|
|
427
|
+
},
|
|
222
428
|
};
|
|
223
429
|
|
|
224
430
|
/**
|
|
@@ -272,3 +478,66 @@ async function handleFileUpload(file, vnode, meta) {
|
|
|
272
478
|
alert(err.message || 'Upload failed');
|
|
273
479
|
}
|
|
274
480
|
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* @param {FileList} files
|
|
484
|
+
* @param {import('mithril').Vnode} vnode
|
|
485
|
+
* @param {Object} meta
|
|
486
|
+
*/
|
|
487
|
+
async function handleMultipleFilesUpload(files, vnode, meta) {
|
|
488
|
+
const maxSize = meta.maxSize || meta.maxBytes || 10 * 1024 * 1024;
|
|
489
|
+
const uploadUrl = getUploadUrlFromAdminConfig();
|
|
490
|
+
if (!uploadUrl) {
|
|
491
|
+
alert('Upload URL is not configured.');
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const currentUrls = Array.isArray(vnode.attrs.value)
|
|
496
|
+
? [...vnode.attrs.value]
|
|
497
|
+
: (vnode.attrs.value ? [vnode.attrs.value] : []);
|
|
498
|
+
|
|
499
|
+
vnode.state.uploading = true;
|
|
500
|
+
if (typeof m !== 'undefined' && m.redraw) m.redraw();
|
|
501
|
+
|
|
502
|
+
const uploadPromises = Array.from(files).map(async (file) => {
|
|
503
|
+
if (file.size > maxSize) {
|
|
504
|
+
alert(`File ${file.name} is too large (max ${Math.round(maxSize / 1024 / 1024)} MB) and was skipped.`);
|
|
505
|
+
return null;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const fd = new FormData();
|
|
509
|
+
fd.append('file', file);
|
|
510
|
+
|
|
511
|
+
try {
|
|
512
|
+
const res = await fetch(uploadUrl, { method: 'POST', body: fd, credentials: 'include' });
|
|
513
|
+
let data = {};
|
|
514
|
+
try {
|
|
515
|
+
data = await res.json();
|
|
516
|
+
} catch {
|
|
517
|
+
data = {};
|
|
518
|
+
}
|
|
519
|
+
if (!res.ok) {
|
|
520
|
+
alert(`Upload failed for ${file.name}: ${data.message || data.error || res.status}`);
|
|
521
|
+
return null;
|
|
522
|
+
}
|
|
523
|
+
return data.url || data.publicUrl || '';
|
|
524
|
+
} catch (err) {
|
|
525
|
+
alert(`Upload failed for ${file.name}: ${err.message}`);
|
|
526
|
+
return null;
|
|
527
|
+
}
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
try {
|
|
531
|
+
const results = await Promise.all(uploadPromises);
|
|
532
|
+
const newUrls = results.filter(Boolean);
|
|
533
|
+
if (newUrls.length > 0) {
|
|
534
|
+
const updatedUrls = [...currentUrls, ...newUrls];
|
|
535
|
+
if (vnode.attrs.onchange) {
|
|
536
|
+
vnode.attrs.onchange(updatedUrls);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
} finally {
|
|
540
|
+
vnode.state.uploading = false;
|
|
541
|
+
if (typeof m !== 'undefined' && m.redraw) m.redraw();
|
|
542
|
+
}
|
|
543
|
+
}
|
|
@@ -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);
|