webspresso 0.0.90 → 0.0.93
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 +98 -2509
- package/bin/commands/admin-password.js +8 -2
- package/bin/commands/api.js +7 -1
- package/bin/commands/favicon-generate.js +6 -4
- package/bin/commands/mcp.js +162 -0
- package/bin/commands/new.js +7 -1
- package/bin/commands/page.js +7 -1
- package/bin/commands/seed.js +7 -1
- package/bin/commands/service.js +199 -0
- package/bin/commands/skill.js +7 -1
- package/bin/webspresso.js +4 -0
- package/core/auth/middleware.js +9 -0
- package/core/compileSchema.js +1 -5
- package/core/compression/compressible.js +63 -0
- package/core/compression/compression-stream.js +59 -0
- package/core/compression/index.js +25 -0
- package/core/compression/middleware.js +310 -0
- package/core/compression/negotiate.js +165 -0
- package/core/errors/base.js +31 -0
- package/core/errors/domain.js +193 -0
- package/core/errors/http.js +133 -0
- package/core/errors/index.js +77 -0
- package/core/errors/middleware.js +162 -0
- package/core/errors/normalize.js +171 -0
- package/core/kernel/app.js +2 -1
- package/core/kernel/index.js +5 -1
- package/core/orm/cache/fingerprint.js +23 -5
- package/core/orm/complexity.js +120 -0
- package/core/orm/eager-loader.js +54 -9
- package/core/orm/events.js +64 -14
- package/core/orm/index.js +83 -12
- package/core/orm/model.js +13 -2
- package/core/orm/query-builder.js +30 -4
- package/core/orm/repository.js +57 -25
- package/core/orm/transaction.js +39 -5
- package/core/orm/utils.js +6 -3
- package/core/queue/adapters/database.js +317 -0
- package/core/queue/adapters/memory.js +176 -0
- package/core/queue/adapters/redis.js +357 -0
- package/core/queue/index.js +21 -0
- package/core/queue/job.js +103 -0
- package/core/queue/manager.js +413 -0
- package/core/realtime/adapters/redis.js +431 -0
- package/core/realtime/adapters/socket-io.js +203 -0
- package/core/realtime/adapters/sse.js +210 -0
- package/core/realtime/adapters/websocket.js +337 -0
- package/core/realtime/auth-manager.js +140 -0
- package/core/realtime/core.js +359 -0
- package/core/realtime/emitter.js +98 -0
- package/core/realtime/errors.js +71 -0
- package/core/realtime/index.js +79 -0
- package/core/realtime/reconnect-manager.js +118 -0
- package/core/realtime/registry.js +243 -0
- package/core/shutdown/http-adapter.js +134 -0
- package/core/shutdown/index.js +12 -0
- package/core/shutdown/shutdown-manager.js +312 -0
- package/core/ssr/index.js +13 -0
- package/core/ssr/stream.js +174 -0
- package/core/validation/index.js +70 -0
- package/index.d.ts +924 -53
- package/index.js +110 -1
- package/package.json +30 -2
- package/plugins/admin-panel/api.js +37 -4
- package/plugins/admin-panel/app.js +57 -17
- package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +10 -2
- package/plugins/admin-panel/client/parts/04-field-renderers.js +102 -58
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +51 -27
- package/plugins/admin-panel/client/parts/07-model-list.js +9 -4
- package/plugins/admin-panel/client/parts/08-record-list.js +37 -26
- package/plugins/admin-panel/client/parts/09-record-form.js +168 -19
- package/plugins/admin-panel/core/admin-module.js +2 -2
- package/plugins/admin-panel/core/api-extensions.js +149 -0
- package/plugins/admin-panel/field-renderers/rich-text.js +3 -2
- package/plugins/admin-panel/index.js +155 -23
- package/plugins/admin-panel/modules/bulk-actions.js +9 -2
- package/plugins/admin-panel/modules/custom-pages.js +144 -45
- package/plugins/admin-panel/modules/dashboard.js +8 -1
- package/plugins/admin-panel/modules/menu.js +85 -0
- package/plugins/admin-panel/modules/user-management.js +5 -2
- package/plugins/admin-panel/vendor/mithril.min.js +1 -0
- package/plugins/admin-panel/vendor/quill.min.js +7 -0
- package/plugins/admin-panel/vendor/quill.snow.min.css +945 -0
- package/plugins/admin-panel/vendor/tailwind.min.js +65 -0
- package/plugins/basic-auth/index.js +293 -0
- package/plugins/data-exchange/export-xlsx.js +28 -2
- package/plugins/data-exchange/import.js +55 -16
- package/plugins/data-exchange/index.js +19 -0
- package/plugins/data-exchange/parse-table.js +14 -5
- package/plugins/data-exchange/record-selection.js +2 -0
- package/plugins/email/index.js +14 -1
- package/plugins/index.js +33 -0
- package/plugins/mcp/auth.js +104 -0
- package/plugins/mcp/discovery.js +88 -0
- package/plugins/mcp/index.js +208 -0
- package/plugins/mcp/prompts.js +149 -0
- package/plugins/mcp/providers/orm.js +325 -0
- package/plugins/mcp/providers/services.js +190 -0
- package/plugins/mcp/providers/system.js +87 -0
- package/plugins/mcp/server.js +472 -0
- package/plugins/mcp/transports/sse.js +139 -0
- package/plugins/mcp/transports/stdio.js +108 -0
- package/plugins/queue/index.js +108 -0
- package/plugins/realtime/index.js +150 -0
- package/plugins/recaptcha/index.js +11 -0
- package/plugins/redirect/index.js +9 -1
- package/plugins/rest-resources/index.js +280 -11
- package/plugins/upload/index.js +16 -0
- package/src/app-context.js +46 -6
- package/src/file-router.js +366 -130
- package/src/helpers.js +20 -2
- package/src/njk-frontmatter.js +24 -14
- package/src/plugin-manager.js +124 -4
- package/src/server.js +332 -54
- package/src/services/builtins/auth.js +273 -0
- package/src/services/builtins/exchange.js +229 -0
- package/src/services/builtins/mail.js +134 -0
- package/src/services/builtins/media.js +196 -0
- package/src/services/builtins/system.js +221 -0
- package/src/services/discovery.js +141 -0
- package/src/services/executor.js +292 -0
- package/src/services/index.js +70 -0
- package/src/services/memoize.js +256 -0
- package/src/services/registry.js +290 -0
- package/src/services/validator.js +230 -0
- package/templates/skills/webspresso-usage/REFERENCE-framework.md +6 -4
|
@@ -73,10 +73,15 @@ function formatCellValue(value, col, record = null) {
|
|
|
73
73
|
const customRendererType = (col && col.customField && col.customField.type) || (col && col.type);
|
|
74
74
|
const custom = window.__customFieldRenderers && window.__customFieldRenderers[customRendererType];
|
|
75
75
|
if (custom && custom.display) {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
76
|
+
try {
|
|
77
|
+
if (typeof custom.display === 'object' && custom.display.view) {
|
|
78
|
+
return m(custom.display, { value, col, record });
|
|
79
|
+
} else if (typeof custom.display === 'function') {
|
|
80
|
+
return custom.display(value, record);
|
|
81
|
+
}
|
|
82
|
+
} catch (err) {
|
|
83
|
+
console.error('[Admin Panel] Error rendering custom field display for "' + customRendererType + '":', err);
|
|
84
|
+
return String(value);
|
|
80
85
|
}
|
|
81
86
|
}
|
|
82
87
|
|
|
@@ -325,10 +325,15 @@ const RecordList = {
|
|
|
325
325
|
const blob = new Blob([JSON.stringify(response.data, null, 2)], { type: 'application/json' });
|
|
326
326
|
const url = URL.createObjectURL(blob);
|
|
327
327
|
const a = document.createElement('a');
|
|
328
|
+
a.style.display = 'none';
|
|
328
329
|
a.href = url;
|
|
329
330
|
a.download = modelName + '-export.json';
|
|
331
|
+
document.body.appendChild(a);
|
|
330
332
|
a.click();
|
|
331
|
-
|
|
333
|
+
setTimeout(function () {
|
|
334
|
+
if (a.parentNode) a.parentNode.removeChild(a);
|
|
335
|
+
URL.revokeObjectURL(url);
|
|
336
|
+
}, 1000);
|
|
332
337
|
} catch (err) {
|
|
333
338
|
alert('Error: ' + err.message);
|
|
334
339
|
} finally {
|
|
@@ -351,15 +356,24 @@ const RecordList = {
|
|
|
351
356
|
const payload = state.selectAllMode
|
|
352
357
|
? { selectAll: true, filters: state.filters }
|
|
353
358
|
: { ids: Array.from(state.selectedRecords) };
|
|
359
|
+
if (!state.selectAllMode && (!payload.ids || payload.ids.length === 0)) {
|
|
360
|
+
alert('Please select at least one record to export');
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
354
363
|
const response = await api.post('/extensions/export?model=' + modelName + '&format=csv', payload);
|
|
355
364
|
// Download as file
|
|
356
365
|
const blob = new Blob([response.data], { type: 'text/csv' });
|
|
357
366
|
const url = URL.createObjectURL(blob);
|
|
358
367
|
const a = document.createElement('a');
|
|
368
|
+
a.style.display = 'none';
|
|
359
369
|
a.href = url;
|
|
360
370
|
a.download = modelName + '-export.csv';
|
|
371
|
+
document.body.appendChild(a);
|
|
361
372
|
a.click();
|
|
362
|
-
|
|
373
|
+
setTimeout(function () {
|
|
374
|
+
if (a.parentNode) a.parentNode.removeChild(a);
|
|
375
|
+
URL.revokeObjectURL(url);
|
|
376
|
+
}, 1000);
|
|
363
377
|
} catch (err) {
|
|
364
378
|
alert('Error: ' + err.message);
|
|
365
379
|
} finally {
|
|
@@ -382,6 +396,10 @@ const RecordList = {
|
|
|
382
396
|
const payload = state.selectAllMode
|
|
383
397
|
? { selectAll: true, filters: state.filters }
|
|
384
398
|
: { ids: Array.from(state.selectedRecords) };
|
|
399
|
+
if (!state.selectAllMode && (!payload.ids || payload.ids.length === 0)) {
|
|
400
|
+
alert('Please select at least one record to export');
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
385
403
|
if (state.trashedView) payload.trashed = 'only';
|
|
386
404
|
await downloadDataExchangeXlsx(modelName, payload);
|
|
387
405
|
} catch (err) {
|
|
@@ -425,8 +443,8 @@ const RecordList = {
|
|
|
425
443
|
// Sticky header
|
|
426
444
|
m('thead.bg-gray-50.dark:bg-slate-900', { style: 'position: sticky; top: 0; z-index: 20;' }, [
|
|
427
445
|
m('tr', [
|
|
428
|
-
// Checkbox column header (sticky left
|
|
429
|
-
m('th.px-4.py-3.text-left.bg-gray-50
|
|
446
|
+
// Checkbox column header (sticky left on md+, box-shadow on right)
|
|
447
|
+
m('th.px-4.py-3.text-left.bg-gray-50.dark:bg-slate-900.border-b.border-gray-200.dark:border-slate-700.admin-table-sticky-check', { style: 'width: 40px;' }, [
|
|
430
448
|
m('input[type=checkbox].rounded.border-gray-300 dark:border-slate-600.text-indigo-600.focus:ring-indigo-500', {
|
|
431
449
|
checked: state.records.length > 0 && state.selectedRecords && state.selectedRecords.size === state.records.length,
|
|
432
450
|
indeterminate: state.selectedRecords && state.selectedRecords.size > 0 && state.selectedRecords.size < state.records.length,
|
|
@@ -440,23 +458,21 @@ const RecordList = {
|
|
|
440
458
|
},
|
|
441
459
|
}),
|
|
442
460
|
]),
|
|
443
|
-
// Dynamic column headers (first column sticky left with box-shadow)
|
|
461
|
+
// Dynamic column headers (first column sticky left on md+ with box-shadow)
|
|
444
462
|
...displayColumns.map((col, i) => {
|
|
445
463
|
const isSortable = col.sortable !== false;
|
|
446
464
|
const isSorted = isSortable && state.sortColumn === col.name;
|
|
447
465
|
const dir = isSorted ? state.sortDirection : null;
|
|
448
|
-
const
|
|
466
|
+
const stickyClass = i === 0 ? '.admin-table-sticky-first' : '';
|
|
449
467
|
|
|
450
468
|
if (!isSortable) {
|
|
451
|
-
return m('th.px-4.py-3.text-left.text-xs.font-medium.text-gray-500.dark:text-slate-400.uppercase.tracking-wider.whitespace-nowrap.bg-gray-50.dark:bg-slate-900.border-b.border-gray-200.dark:border-slate-700',
|
|
452
|
-
stickyStyle ? { style: stickyStyle } : {},
|
|
469
|
+
return m('th.px-4.py-3.text-left.text-xs.font-medium.text-gray-500.dark:text-slate-400.uppercase.tracking-wider.whitespace-nowrap.bg-gray-50.dark:bg-slate-900.border-b.border-gray-200.dark:border-slate-700' + stickyClass,
|
|
453
470
|
formatColumnLabel(col.name)
|
|
454
471
|
);
|
|
455
472
|
}
|
|
456
473
|
|
|
457
|
-
return m('th.px-4.py-3.text-left.text-xs.font-medium.text-gray-500.dark:text-slate-400.uppercase.tracking-wider.whitespace-nowrap.bg-gray-50.dark:bg-slate-900.border-b.border-gray-200.dark:border-slate-700.cursor-pointer.select-none.hover:bg-gray-100.dark:hover:bg-slate-800/80.transition-colors.group',
|
|
474
|
+
return m('th.px-4.py-3.text-left.text-xs.font-medium.text-gray-500.dark:text-slate-400.uppercase.tracking-wider.whitespace-nowrap.bg-gray-50.dark:bg-slate-900.border-b.border-gray-200.dark:border-slate-700.cursor-pointer.select-none.hover:bg-gray-100.dark:hover:bg-slate-800/80.transition-colors.group' + stickyClass,
|
|
458
475
|
{
|
|
459
|
-
style: stickyStyle || undefined,
|
|
460
476
|
onclick: () => {
|
|
461
477
|
let nextSort = col.name;
|
|
462
478
|
let nextDir = 'asc';
|
|
@@ -489,21 +505,19 @@ const RecordList = {
|
|
|
489
505
|
])
|
|
490
506
|
);
|
|
491
507
|
}),
|
|
492
|
-
// Sticky actions header (sticky right
|
|
493
|
-
m('th.px-4.py-3.text-right.text-xs.font-medium.text-gray-500
|
|
494
|
-
style: '
|
|
508
|
+
// Sticky actions header (sticky right on md+, box-shadow on left)
|
|
509
|
+
m('th.px-4.py-3.text-right.text-xs.font-medium.text-gray-500.dark:text-slate-400.uppercase.tracking-wider.bg-gray-50.dark:bg-slate-900.border-b.border-gray-200.dark:border-slate-700.admin-table-sticky-actions', {
|
|
510
|
+
style: 'min-width: 120px;',
|
|
495
511
|
}, 'Actions'),
|
|
496
512
|
]),
|
|
497
513
|
]),
|
|
498
514
|
m('tbody.divide-y.divide-gray-100.dark:divide-slate-700', state.records.map(record =>
|
|
499
|
-
m('tr.hover:bg-gray-50
|
|
515
|
+
m('tr.hover:bg-gray-50.dark:hover:bg-slate-800/50.transition-colors', {
|
|
500
516
|
class: state.selectedRecords && state.selectedRecords.has(record[primaryKey]) ? 'bg-indigo-50 dark:bg-indigo-950/50' : '',
|
|
501
517
|
}, [
|
|
502
|
-
// Checkbox cell (sticky left
|
|
503
|
-
m('td.px-4.py-3.bg-white.dark:bg-slate-800',
|
|
504
|
-
|
|
505
|
-
}, [
|
|
506
|
-
m('input[type=checkbox].rounded.border-gray-300 dark:border-slate-600.text-indigo-600.focus:ring-indigo-500', {
|
|
518
|
+
// Checkbox cell (sticky left on md+, box-shadow on right)
|
|
519
|
+
m('td.px-4.py-3.bg-white.dark:bg-slate-800.admin-table-sticky-check', [
|
|
520
|
+
m('input[type=checkbox].rounded.border-gray-300.dark:border-slate-600.text-indigo-600.focus:ring-indigo-500', {
|
|
507
521
|
checked: state.selectedRecords && state.selectedRecords.has(record[primaryKey]),
|
|
508
522
|
onchange: (e) => {
|
|
509
523
|
if (!state.selectedRecords) state.selectedRecords = new Set();
|
|
@@ -516,17 +530,14 @@ const RecordList = {
|
|
|
516
530
|
},
|
|
517
531
|
}),
|
|
518
532
|
]),
|
|
519
|
-
// Dynamic cell values (first column sticky left with box-shadow)
|
|
533
|
+
// Dynamic cell values (first column sticky left on md+ with box-shadow)
|
|
520
534
|
...displayColumns.map((col, i) =>
|
|
521
|
-
m('td.px-4.py-3.text-sm.whitespace-nowrap.text-gray-700
|
|
522
|
-
i === 0 ? { style: 'position: sticky; left: 40px; z-index: 5; box-shadow: 4px 0 8px -4px rgba(0,0,0,0.08);' } : {},
|
|
535
|
+
m('td.px-4.py-3.text-sm.whitespace-nowrap.text-gray-700.dark:text-slate-300.bg-white.dark:bg-slate-800' + (i === 0 ? '.admin-table-sticky-first' : ''),
|
|
523
536
|
formatCellValue(record[col.name], col, record)
|
|
524
537
|
)
|
|
525
538
|
),
|
|
526
|
-
// Sticky actions cell (sticky right
|
|
527
|
-
m('td.px-4.py-3.text-sm.text-right.whitespace-nowrap.text-gray-700
|
|
528
|
-
style: 'position: sticky; right: 0; z-index: 5; box-shadow: -4px 0 8px -4px rgba(0,0,0,0.08);',
|
|
529
|
-
}, [
|
|
539
|
+
// Sticky actions cell (sticky right on md+, box-shadow on left)
|
|
540
|
+
m('td.px-4.py-3.text-sm.text-right.whitespace-nowrap.text-gray-700.dark:text-slate-300.bg-white.dark:bg-slate-800.admin-table-sticky-actions', [
|
|
530
541
|
state.trashedView && modelMeta?.softDelete
|
|
531
542
|
? m('button.inline-flex.items-center.px-2.py-1.text-sm.text-green-600.dark:text-green-400.hover:text-green-800.dark:hover:text-green-300.hover:bg-green-50.dark:hover:bg-green-950/40.rounded.transition-colors', {
|
|
532
543
|
onclick: async () => {
|
|
@@ -1,9 +1,141 @@
|
|
|
1
|
-
// Record Form Component - renders fields based on model schema
|
|
1
|
+
// Record Form Component - renders fields based on model schema with dynamic validation
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Validate a single column value against schema and metadata rules
|
|
5
|
+
*/
|
|
6
|
+
function validateField(col, value, formData) {
|
|
7
|
+
const validations = col.validations || {};
|
|
8
|
+
const isRequired = !col.nullable;
|
|
9
|
+
const label = col.ui?.label || formatColumnLabel(col.name);
|
|
10
|
+
|
|
11
|
+
// 1. Required / Nullable check
|
|
12
|
+
if (isRequired) {
|
|
13
|
+
if (value === undefined || value === null || (typeof value === 'string' && value.trim() === '')) {
|
|
14
|
+
return label + ' is required';
|
|
15
|
+
}
|
|
16
|
+
if (col.customField && col.customField.type === 'rich-text' && isRichTextEmpty(value)) {
|
|
17
|
+
return label + ' is required';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// If value is empty and column is nullable, it's valid
|
|
22
|
+
if (value === undefined || value === null || value === '') {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// 2. Type validation
|
|
27
|
+
if (col.type === 'integer' || col.type === 'bigint') {
|
|
28
|
+
const num = Number(value);
|
|
29
|
+
if (isNaN(num) || !Number.isInteger(num)) {
|
|
30
|
+
return label + ' must be an integer';
|
|
31
|
+
}
|
|
32
|
+
} else if (col.type === 'float' || col.type === 'decimal') {
|
|
33
|
+
const num = Number(value);
|
|
34
|
+
if (isNaN(num)) {
|
|
35
|
+
return label + ' must be a valid number';
|
|
36
|
+
}
|
|
37
|
+
} else if (col.type === 'json') {
|
|
38
|
+
if (typeof value === 'string') {
|
|
39
|
+
try {
|
|
40
|
+
JSON.parse(value);
|
|
41
|
+
} catch {
|
|
42
|
+
return label + ' must be valid JSON';
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// 3. String Length & Format Validations
|
|
48
|
+
if (typeof value === 'string') {
|
|
49
|
+
const minLen = validations.minLength !== undefined ? validations.minLength : validations.min;
|
|
50
|
+
if (minLen !== undefined && value.length < minLen) {
|
|
51
|
+
return label + ' must be at least ' + minLen + ' characters';
|
|
52
|
+
}
|
|
53
|
+
const maxLen = validations.maxLength !== undefined ? validations.maxLength : (validations.max || col.maxLength);
|
|
54
|
+
if (maxLen !== undefined && value.length > maxLen) {
|
|
55
|
+
return label + ' must be at most ' + maxLen + ' characters';
|
|
56
|
+
}
|
|
57
|
+
if (validations.email) {
|
|
58
|
+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
59
|
+
if (!emailRegex.test(value.trim())) {
|
|
60
|
+
return label + ' must be a valid email address';
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (validations.url) {
|
|
64
|
+
try {
|
|
65
|
+
new URL(value.trim());
|
|
66
|
+
} catch {
|
|
67
|
+
return label + ' must be a valid URL';
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (validations.pattern) {
|
|
71
|
+
try {
|
|
72
|
+
const regex = new RegExp(validations.pattern);
|
|
73
|
+
if (!regex.test(value)) {
|
|
74
|
+
return label + ' format is invalid';
|
|
75
|
+
}
|
|
76
|
+
} catch {}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 4. Numeric Bounds
|
|
81
|
+
if (typeof value === 'number') {
|
|
82
|
+
if (validations.min !== undefined && value < validations.min) {
|
|
83
|
+
return label + ' must be at least ' + validations.min;
|
|
84
|
+
}
|
|
85
|
+
if (validations.max !== undefined && value > validations.max) {
|
|
86
|
+
return label + ' must be at most ' + validations.max;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 5. Enum validation
|
|
91
|
+
if (col.enumValues && Array.isArray(col.enumValues) && col.enumValues.length > 0) {
|
|
92
|
+
if (!col.enumValues.includes(value)) {
|
|
93
|
+
return label + ' must be one of: ' + col.enumValues.join(', ');
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 6. Custom Field Validator
|
|
98
|
+
if (col.customField && typeof col.customField.validate === 'function') {
|
|
99
|
+
const customErr = col.customField.validate(value, formData);
|
|
100
|
+
if (customErr) return customErr;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Validate all columns in a form
|
|
108
|
+
*/
|
|
109
|
+
function validateForm(columns, formData) {
|
|
110
|
+
const errors = {};
|
|
111
|
+
if (!columns || !Array.isArray(columns)) return errors;
|
|
112
|
+
|
|
113
|
+
for (const col of columns) {
|
|
114
|
+
const autoType = isAutoColumn(col);
|
|
115
|
+
if (autoType === 'primary' || autoType === 'auto') continue;
|
|
116
|
+
if (col.ui && col.ui.hidden) continue;
|
|
117
|
+
|
|
118
|
+
let val = formData[col.name];
|
|
119
|
+
if (col.customField && col.customField.type === 'rich-text') {
|
|
120
|
+
const hiddenInput = document.getElementById(col.name + '-value');
|
|
121
|
+
if (hiddenInput) val = hiddenInput.value;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const err = validateField(col, val, formData);
|
|
125
|
+
if (err) {
|
|
126
|
+
errors[col.name] = err;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return errors;
|
|
131
|
+
}
|
|
132
|
+
|
|
2
133
|
const RecordForm = {
|
|
3
134
|
oninit: () => {
|
|
4
135
|
const modelName = m.route.param('model');
|
|
5
136
|
const id = m.route.param('id');
|
|
6
137
|
state.error = null;
|
|
138
|
+
state.fieldErrors = {};
|
|
7
139
|
state.loading = true;
|
|
8
140
|
state.formData = {};
|
|
9
141
|
state.currentModelMeta = null;
|
|
@@ -66,24 +198,32 @@ const RecordForm = {
|
|
|
66
198
|
style: 'min-height: calc(100vh - 280px);',
|
|
67
199
|
onsubmit: async (e) => {
|
|
68
200
|
e.preventDefault();
|
|
69
|
-
state.loading = true;
|
|
70
201
|
state.error = null;
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
202
|
+
state.fieldErrors = {};
|
|
203
|
+
|
|
204
|
+
// 1. Run client-side validation
|
|
205
|
+
if (modelMeta && modelMeta.columns) {
|
|
206
|
+
const validationErrors = validateForm(modelMeta.columns, state.formData);
|
|
207
|
+
if (Object.keys(validationErrors).length > 0) {
|
|
208
|
+
state.fieldErrors = validationErrors;
|
|
209
|
+
state.error = 'Please fix the errors indicated below.';
|
|
210
|
+
m.redraw();
|
|
211
|
+
setTimeout(() => {
|
|
212
|
+
const firstInvalidKey = Object.keys(validationErrors)[0];
|
|
213
|
+
const targetEl = document.getElementById(firstInvalidKey) ||
|
|
214
|
+
document.getElementById('file-input-' + firstInvalidKey) ||
|
|
215
|
+
document.getElementById('quill-editor-' + firstInvalidKey);
|
|
216
|
+
if (targetEl && targetEl.scrollIntoView) {
|
|
217
|
+
targetEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
218
|
+
if (typeof targetEl.focus === 'function') targetEl.focus();
|
|
83
219
|
}
|
|
84
|
-
}
|
|
220
|
+
}, 50);
|
|
221
|
+
return;
|
|
85
222
|
}
|
|
86
|
-
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
state.loading = true;
|
|
226
|
+
try {
|
|
87
227
|
// Build payload, excluding auto-generated fields
|
|
88
228
|
const payload = {};
|
|
89
229
|
if (modelMeta && modelMeta.columns) {
|
|
@@ -123,9 +263,13 @@ const RecordForm = {
|
|
|
123
263
|
}
|
|
124
264
|
m.route.set('/models/' + modelName);
|
|
125
265
|
} catch (err) {
|
|
126
|
-
state.error = err.message;
|
|
266
|
+
state.error = err.message || 'Failed to save record';
|
|
267
|
+
if (err.fields && typeof err.fields === 'object') {
|
|
268
|
+
state.fieldErrors = err.fields;
|
|
269
|
+
}
|
|
127
270
|
} finally {
|
|
128
271
|
state.loading = false;
|
|
272
|
+
m.redraw();
|
|
129
273
|
}
|
|
130
274
|
}
|
|
131
275
|
}, [
|
|
@@ -146,11 +290,16 @@ const RecordForm = {
|
|
|
146
290
|
const isReadonly = !!autoType || (col.ui && col.ui.readonly);
|
|
147
291
|
const renderer = getFieldRenderer(col, modelMeta);
|
|
148
292
|
const value = state.formData[col.name];
|
|
293
|
+
const fieldError = state.fieldErrors ? state.fieldErrors[col.name] : null;
|
|
294
|
+
|
|
149
295
|
const onChange = (newValue) => {
|
|
150
296
|
state.formData[col.name] = newValue;
|
|
297
|
+
if (state.fieldErrors && state.fieldErrors[col.name]) {
|
|
298
|
+
delete state.fieldErrors[col.name];
|
|
299
|
+
}
|
|
151
300
|
};
|
|
152
301
|
|
|
153
|
-
return renderer(col, value, onChange, isReadonly);
|
|
302
|
+
return renderer(col, value, onChange, isReadonly, fieldError);
|
|
154
303
|
}) : m('p.text-gray-600 dark:text-slate-400.mb-4', 'Loading form fields...'),
|
|
155
304
|
]),
|
|
156
305
|
|
|
@@ -160,7 +309,7 @@ const RecordForm = {
|
|
|
160
309
|
type: 'submit',
|
|
161
310
|
disabled: state.loading,
|
|
162
311
|
}, state.loading ? 'Saving...' : 'Save'),
|
|
163
|
-
m('button.bg-gray-200
|
|
312
|
+
m('button.bg-gray-200.dark:bg-slate-700.text-gray-800.dark:text-slate-200.px-6.py-2.rounded.hover:bg-gray-300.dark:hover:bg-slate-600[type=button]', {
|
|
164
313
|
onclick: () => m.route.set('/models/' + modelName),
|
|
165
314
|
}, 'Cancel'),
|
|
166
315
|
]),
|
|
@@ -8,7 +8,7 @@ function formatClientComponent(pageId, rawCode) {
|
|
|
8
8
|
let code = (rawCode || '').trim();
|
|
9
9
|
if (!code) return '';
|
|
10
10
|
if (code.includes('window.__customPages')) {
|
|
11
|
-
return code
|
|
11
|
+
return `try {\n${code}\n} catch (err) {\n console.error('[Admin Panel] Failed to load custom page component "${pageId}":', err);\n}`;
|
|
12
12
|
}
|
|
13
13
|
if (code.startsWith('module.exports =')) {
|
|
14
14
|
code = code.replace(/^module\.exports\s*=\s*/, '');
|
|
@@ -16,7 +16,7 @@ function formatClientComponent(pageId, rawCode) {
|
|
|
16
16
|
if (code.endsWith(';')) {
|
|
17
17
|
code = code.slice(0, -1);
|
|
18
18
|
}
|
|
19
|
-
return `window.__customPages = window.__customPages || {};\
|
|
19
|
+
return `try {\n window.__customPages = window.__customPages || {};\n window.__customPages[${JSON.stringify(pageId)}] = ${code};\n} catch (err) {\n console.error('[Admin Panel] Failed to load custom page component "${pageId}":', err);\n}`;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
/**
|
|
@@ -4,8 +4,113 @@
|
|
|
4
4
|
* @module plugins/admin-panel/core/api-extensions
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
const os = require('os');
|
|
8
|
+
const https = require('https');
|
|
7
9
|
const { sanitizeForOutput } = require('../../../core/orm/utils');
|
|
8
10
|
|
|
11
|
+
function getWebspressoVersion() {
|
|
12
|
+
try {
|
|
13
|
+
const pkg = require('../../../package.json');
|
|
14
|
+
return pkg.version || '0.0.1';
|
|
15
|
+
} catch {
|
|
16
|
+
return '0.0.1';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let npmVersionCache = {
|
|
21
|
+
latestVersion: null,
|
|
22
|
+
checkedAt: 0,
|
|
23
|
+
inFlight: null,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function fetchLatestNpmVersion(force = false) {
|
|
27
|
+
const now = Date.now();
|
|
28
|
+
if (!force && npmVersionCache.latestVersion && (now - npmVersionCache.checkedAt < 30 * 60 * 1000)) {
|
|
29
|
+
return Promise.resolve(npmVersionCache.latestVersion);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (npmVersionCache.inFlight) {
|
|
33
|
+
return npmVersionCache.inFlight;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
npmVersionCache.inFlight = new Promise((resolve) => {
|
|
37
|
+
const req = https.get('https://registry.npmjs.org/webspresso/latest', {
|
|
38
|
+
headers: {
|
|
39
|
+
'User-Agent': 'Webspresso-Admin-Version-Check',
|
|
40
|
+
'Accept': 'application/json',
|
|
41
|
+
},
|
|
42
|
+
timeout: 3000,
|
|
43
|
+
}, (res) => {
|
|
44
|
+
if (res.statusCode !== 200) {
|
|
45
|
+
res.resume();
|
|
46
|
+
return resolve(npmVersionCache.latestVersion);
|
|
47
|
+
}
|
|
48
|
+
let rawData = '';
|
|
49
|
+
res.setEncoding('utf8');
|
|
50
|
+
res.on('data', (chunk) => { rawData += chunk; });
|
|
51
|
+
res.on('end', () => {
|
|
52
|
+
try {
|
|
53
|
+
const parsed = JSON.parse(rawData);
|
|
54
|
+
if (parsed && typeof parsed.version === 'string') {
|
|
55
|
+
npmVersionCache.latestVersion = parsed.version;
|
|
56
|
+
npmVersionCache.checkedAt = Date.now();
|
|
57
|
+
return resolve(parsed.version);
|
|
58
|
+
}
|
|
59
|
+
} catch {}
|
|
60
|
+
npmVersionCache.checkedAt = Date.now();
|
|
61
|
+
resolve(npmVersionCache.latestVersion);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
req.on('timeout', () => {
|
|
66
|
+
req.destroy();
|
|
67
|
+
npmVersionCache.checkedAt = Date.now();
|
|
68
|
+
resolve(npmVersionCache.latestVersion);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
req.on('error', () => {
|
|
72
|
+
npmVersionCache.checkedAt = Date.now();
|
|
73
|
+
resolve(npmVersionCache.latestVersion);
|
|
74
|
+
});
|
|
75
|
+
}).finally(() => {
|
|
76
|
+
npmVersionCache.inFlight = null;
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
return npmVersionCache.inFlight;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function compareSemver(v1, v2) {
|
|
83
|
+
if (!v1 || !v2) return 0;
|
|
84
|
+
const p1 = String(v1).replace(/^v/, '').split('.').map(n => parseInt(n, 10) || 0);
|
|
85
|
+
const p2 = String(v2).replace(/^v/, '').split('.').map(n => parseInt(n, 10) || 0);
|
|
86
|
+
for (let i = 0; i < Math.max(p1.length, p2.length); i++) {
|
|
87
|
+
const n1 = p1[i] || 0;
|
|
88
|
+
const n2 = p2[i] || 0;
|
|
89
|
+
if (n1 > n2) return 1;
|
|
90
|
+
if (n1 < n2) return -1;
|
|
91
|
+
}
|
|
92
|
+
return 0;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function formatUptime(seconds) {
|
|
96
|
+
const sec = Math.floor(seconds || 0);
|
|
97
|
+
const d = Math.floor(sec / (3600 * 24));
|
|
98
|
+
const h = Math.floor((sec % (3600 * 24)) / 3600);
|
|
99
|
+
const m = Math.floor((sec % 3600) / 60);
|
|
100
|
+
const s = Math.floor(sec % 60);
|
|
101
|
+
const parts = [];
|
|
102
|
+
if (d > 0) parts.push(`${d}d`);
|
|
103
|
+
if (h > 0) parts.push(`${h}h`);
|
|
104
|
+
if (m > 0) parts.push(`${m}m`);
|
|
105
|
+
if (s > 0 || parts.length === 0) parts.push(`${s}s`);
|
|
106
|
+
return parts.join(' ');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function formatBytes(bytes) {
|
|
110
|
+
if (!bytes || bytes === 0) return '0 MB';
|
|
111
|
+
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
|
112
|
+
}
|
|
113
|
+
|
|
9
114
|
/**
|
|
10
115
|
* Build query with filters applied
|
|
11
116
|
* @param {Object} repo - Repository instance
|
|
@@ -730,6 +835,44 @@ function createExtensionApiHandlers(options) {
|
|
|
730
835
|
}
|
|
731
836
|
}
|
|
732
837
|
|
|
838
|
+
/**
|
|
839
|
+
* System info & Webspresso version check
|
|
840
|
+
*/
|
|
841
|
+
async function systemInfoHandler(req, res) {
|
|
842
|
+
try {
|
|
843
|
+
const force = req.query && req.query.force === 'true';
|
|
844
|
+
const currentVersion = getWebspressoVersion();
|
|
845
|
+
const latestVersion = await fetchLatestNpmVersion(force);
|
|
846
|
+
const hasUpdate = Boolean(latestVersion && compareSemver(latestVersion, currentVersion) > 0);
|
|
847
|
+
|
|
848
|
+
const mem = process.memoryUsage();
|
|
849
|
+
const dbClient = db?.knex?.client?.config?.client || 'knex';
|
|
850
|
+
|
|
851
|
+
res.json({
|
|
852
|
+
success: true,
|
|
853
|
+
webspressoVersion: currentVersion,
|
|
854
|
+
latestVersion: latestVersion || currentVersion,
|
|
855
|
+
hasUpdate,
|
|
856
|
+
nodeVersion: process.version,
|
|
857
|
+
platform: `${os.type()} ${os.release()} (${process.platform})`,
|
|
858
|
+
arch: process.arch,
|
|
859
|
+
environment: process.env.NODE_ENV || 'development',
|
|
860
|
+
uptimeSeconds: Math.floor(process.uptime()),
|
|
861
|
+
uptimeFormatted: formatUptime(process.uptime()),
|
|
862
|
+
memory: {
|
|
863
|
+
heapUsed: formatBytes(mem.heapUsed),
|
|
864
|
+
heapTotal: formatBytes(mem.heapTotal),
|
|
865
|
+
rss: formatBytes(mem.rss),
|
|
866
|
+
},
|
|
867
|
+
database: {
|
|
868
|
+
client: String(dbClient),
|
|
869
|
+
},
|
|
870
|
+
});
|
|
871
|
+
} catch (error) {
|
|
872
|
+
res.status(500).json({ error: error.message });
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
|
|
733
876
|
return {
|
|
734
877
|
configHandler,
|
|
735
878
|
widgetDataHandler,
|
|
@@ -742,6 +885,7 @@ function createExtensionApiHandlers(options) {
|
|
|
742
885
|
activityLogHandler,
|
|
743
886
|
settingsGetHandler,
|
|
744
887
|
settingsUpdateHandler,
|
|
888
|
+
systemInfoHandler,
|
|
745
889
|
};
|
|
746
890
|
}
|
|
747
891
|
|
|
@@ -750,4 +894,9 @@ module.exports = {
|
|
|
750
894
|
buildFilteredQuery,
|
|
751
895
|
getAllMatchingIds,
|
|
752
896
|
coerceBulkTemporalValue,
|
|
897
|
+
getWebspressoVersion,
|
|
898
|
+
fetchLatestNpmVersion,
|
|
899
|
+
compareSemver,
|
|
900
|
+
formatUptime,
|
|
901
|
+
formatBytes,
|
|
753
902
|
};
|
|
@@ -10,13 +10,14 @@ module.exports = {
|
|
|
10
10
|
|
|
11
11
|
// Load Quill if not already loaded
|
|
12
12
|
if (typeof window.Quill === 'undefined') {
|
|
13
|
+
const adminPath = (window.__ADMIN_PATH__) || '/_admin';
|
|
13
14
|
const link = document.createElement('link');
|
|
14
15
|
link.rel = 'stylesheet';
|
|
15
|
-
link.href = '
|
|
16
|
+
link.href = adminPath + '/vendor/quill.snow.min.css';
|
|
16
17
|
document.head.appendChild(link);
|
|
17
18
|
|
|
18
19
|
const script = document.createElement('script');
|
|
19
|
-
script.src = '
|
|
20
|
+
script.src = adminPath + '/vendor/quill.min.js';
|
|
20
21
|
script.onload = () => {
|
|
21
22
|
initEditor(vnode);
|
|
22
23
|
};
|