webspresso 0.0.89 → 0.0.91

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.
Files changed (102) hide show
  1. package/README.md +450 -53
  2. package/bin/commands/service.js +193 -0
  3. package/bin/webspresso.js +2 -0
  4. package/core/auth/manager.js +36 -26
  5. package/core/auth/middleware.js +21 -2
  6. package/core/compression/compressible.js +51 -0
  7. package/core/compression/compression-stream.js +59 -0
  8. package/core/compression/index.js +25 -0
  9. package/core/compression/middleware.js +301 -0
  10. package/core/compression/negotiate.js +153 -0
  11. package/core/errors/base.js +31 -0
  12. package/core/errors/domain.js +193 -0
  13. package/core/errors/http.js +133 -0
  14. package/core/errors/index.js +77 -0
  15. package/core/errors/middleware.js +162 -0
  16. package/core/errors/normalize.js +171 -0
  17. package/core/orm/complexity.js +120 -0
  18. package/core/orm/events.js +64 -14
  19. package/core/orm/index.js +45 -10
  20. package/core/orm/model.js +10 -2
  21. package/core/orm/query-builder.js +30 -4
  22. package/core/orm/repository.js +57 -25
  23. package/core/orm/scopes.js +2 -1
  24. package/core/orm/transaction.js +39 -5
  25. package/core/orm/utils.js +6 -3
  26. package/core/queue/adapters/database.js +284 -0
  27. package/core/queue/adapters/memory.js +155 -0
  28. package/core/queue/adapters/redis.js +336 -0
  29. package/core/queue/index.js +21 -0
  30. package/core/queue/job.js +103 -0
  31. package/core/queue/manager.js +387 -0
  32. package/core/realtime/adapters/redis.js +431 -0
  33. package/core/realtime/adapters/socket-io.js +203 -0
  34. package/core/realtime/adapters/sse.js +210 -0
  35. package/core/realtime/adapters/websocket.js +337 -0
  36. package/core/realtime/auth-manager.js +140 -0
  37. package/core/realtime/core.js +359 -0
  38. package/core/realtime/emitter.js +98 -0
  39. package/core/realtime/errors.js +71 -0
  40. package/core/realtime/index.js +79 -0
  41. package/core/realtime/reconnect-manager.js +118 -0
  42. package/core/realtime/registry.js +243 -0
  43. package/core/shutdown/http-adapter.js +134 -0
  44. package/core/shutdown/index.js +12 -0
  45. package/core/shutdown/shutdown-manager.js +312 -0
  46. package/core/ssr/index.js +13 -0
  47. package/core/ssr/stream.js +174 -0
  48. package/index.d.ts +829 -52
  49. package/index.js +103 -0
  50. package/package.json +22 -2
  51. package/plugins/admin-panel/api.js +37 -4
  52. package/plugins/admin-panel/app.js +67 -17
  53. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +4 -1
  54. package/plugins/admin-panel/client/parts/03-pagination-intro.js +25 -18
  55. package/plugins/admin-panel/client/parts/04-field-renderers.js +102 -58
  56. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +37 -23
  57. package/plugins/admin-panel/client/parts/08-record-list.js +27 -34
  58. package/plugins/admin-panel/client/parts/09-record-form.js +168 -19
  59. package/plugins/admin-panel/core/admin-module.js +2 -2
  60. package/plugins/admin-panel/core/api-extensions.js +149 -0
  61. package/plugins/admin-panel/field-renderers/rich-text.js +3 -2
  62. package/plugins/admin-panel/index.js +131 -18
  63. package/plugins/admin-panel/modules/admin-users.js +770 -0
  64. package/plugins/admin-panel/modules/custom-pages.js +144 -45
  65. package/plugins/admin-panel/modules/menu.js +85 -0
  66. package/plugins/admin-panel/modules/user-management.js +5 -2
  67. package/plugins/admin-panel/vendor/mithril.min.js +1 -0
  68. package/plugins/admin-panel/vendor/quill.min.js +7 -0
  69. package/plugins/admin-panel/vendor/quill.snow.min.css +945 -0
  70. package/plugins/admin-panel/vendor/tailwind.min.js +65 -0
  71. package/plugins/basic-auth/index.js +293 -0
  72. package/plugins/content/client/inline-edit.js +41 -17
  73. package/plugins/cors.js +12 -8
  74. package/plugins/csrf/index.js +8 -8
  75. package/plugins/data-exchange/export-xlsx.js +5 -1
  76. package/plugins/data-exchange/import.js +16 -2
  77. package/plugins/data-exchange/index.js +14 -0
  78. package/plugins/email/index.js +14 -1
  79. package/plugins/index.js +29 -0
  80. package/plugins/queue/index.js +108 -0
  81. package/plugins/realtime/index.js +150 -0
  82. package/plugins/recaptcha/index.js +11 -0
  83. package/plugins/redirect/index.js +9 -1
  84. package/plugins/rest-resources/index.js +280 -11
  85. package/plugins/upload/index.js +16 -0
  86. package/src/app-context.js +46 -6
  87. package/src/file-router.js +220 -28
  88. package/src/helpers.js +24 -5
  89. package/src/plugin-manager.js +134 -7
  90. package/src/server.js +470 -44
  91. package/src/services/builtins/auth.js +273 -0
  92. package/src/services/builtins/exchange.js +229 -0
  93. package/src/services/builtins/mail.js +134 -0
  94. package/src/services/builtins/media.js +196 -0
  95. package/src/services/builtins/system.js +221 -0
  96. package/src/services/discovery.js +140 -0
  97. package/src/services/executor.js +292 -0
  98. package/src/services/index.js +70 -0
  99. package/src/services/memoize.js +256 -0
  100. package/src/services/registry.js +288 -0
  101. package/src/services/validator.js +281 -0
  102. package/templates/skills/webspresso-usage/REFERENCE-framework.md +2 -2
@@ -61,7 +61,7 @@ const RecordList = {
61
61
 
62
62
  return m(Layout, { breadcrumbs }, [
63
63
  // Header
64
- m('.flex.items-center.justify-between.mb-4', [
64
+ m('.flex.flex-col.sm:flex-row.sm:items-center.sm:justify-between.gap-3.mb-4', [
65
65
  m('.flex.items-center.gap-3', [
66
66
  m('h2.text-2xl.font-bold', modelMeta?.label || modelName),
67
67
  m(RefreshIconButton, {
@@ -76,16 +76,16 @@ const RecordList = {
76
76
  });
77
77
  },
78
78
  }),
79
- modelMeta?.softDelete ? m('.flex.rounded-lg.border.border-gray-200 dark:border-slate-600.p-0.5', [
79
+ modelMeta?.softDelete ? m('.flex.rounded-lg.border.border-gray-200.dark:border-slate-600.p-0.5', [
80
80
  m('button.px-3.py-1.5.text-sm.font-medium.rounded-md.transition-colors', {
81
- class: !state.trashedView ? 'bg-indigo-600.text-white' : 'text-gray-600.hover:text-gray-900 dark:hover:text-slate-100 dark:hover:text-slate-100',
81
+ class: !state.trashedView ? 'bg-indigo-600.text-white' : 'text-gray-600.hover:text-gray-900.dark:hover:text-slate-100',
82
82
  onclick: () => {
83
83
  state.trashedView = false;
84
84
  loadRecords(modelName, 1);
85
85
  },
86
86
  }, 'Active'),
87
87
  m('button.px-3.py-1.5.text-sm.font-medium.rounded-md.transition-colors', {
88
- class: state.trashedView ? 'bg-indigo-600.text-white' : 'text-gray-600.hover:text-gray-900 dark:hover:text-slate-100 dark:hover:text-slate-100',
88
+ class: state.trashedView ? 'bg-indigo-600.text-white' : 'text-gray-600.hover:text-gray-900.dark:hover:text-slate-100',
89
89
  onclick: () => {
90
90
  state.trashedView = true;
91
91
  loadRecords(modelName, 1);
@@ -232,29 +232,29 @@ const RecordList = {
232
232
  ])
233
233
  : m('.bg-white dark:bg-slate-800.rounded-lg.shadow-sm.border.border-gray-200 dark:border-slate-600.overflow-hidden', [
234
234
  // Bulk Actions Toolbar (shown when items selected)
235
- (state.selectedRecords && state.selectedRecords.size > 0) || state.selectAllMode ? m('.bg-indigo-50.border-b.border-indigo-100.px-4.py-3.flex.items-center.justify-between', [
236
- m('.flex.items-center.gap-3', [
237
- m('span.text-sm.text-indigo-700.font-medium',
235
+ (state.selectedRecords && state.selectedRecords.size > 0) || state.selectAllMode ? m('.bg-indigo-50.dark:bg-indigo-950/40.border-b.border-indigo-100.dark:border-indigo-900/50.px-4.py-3.flex.flex-col.sm:flex-row.sm:items-center.sm:justify-between.gap-3', [
236
+ m('.flex.flex-wrap.items-center.gap-2', [
237
+ m('span.text-sm.text-indigo-700.dark:text-indigo-300.font-medium',
238
238
  state.selectAllMode
239
239
  ? 'All ' + state.pagination.total + ' records selected'
240
240
  : state.selectedRecords.size + ' record' + (state.selectedRecords.size > 1 ? 's' : '') + ' selected'
241
241
  ),
242
242
  // Show "Select all X records" option when current page is fully selected
243
- !state.selectAllMode && state.selectedRecords.size === state.records.length && state.pagination.total > state.records.length && m('button.text-sm.text-indigo-600.hover:text-indigo-800.underline.font-medium', {
243
+ !state.selectAllMode && state.selectedRecords.size === state.records.length && state.pagination.total > state.records.length && m('button.text-sm.text-indigo-600.dark:text-indigo-400.hover:text-indigo-800.underline.font-medium', {
244
244
  onclick: () => {
245
245
  state.selectAllMode = true;
246
246
  m.redraw();
247
247
  },
248
248
  }, 'Select all ' + state.pagination.total + ' records'),
249
249
  // Show "Select only this page" when in selectAllMode
250
- state.selectAllMode && m('button.text-sm.text-indigo-600.hover:text-indigo-800.underline', {
250
+ state.selectAllMode && m('button.text-sm.text-indigo-600.dark:text-indigo-400.hover:text-indigo-800.underline', {
251
251
  onclick: () => {
252
252
  state.selectAllMode = false;
253
253
  m.redraw();
254
254
  },
255
255
  }, 'Select only this page (' + state.selectedRecords.size + ')'),
256
256
  ]),
257
- m('.flex.items-center.gap-2', [
257
+ m('.flex.flex-wrap.items-center.gap-1.5', [
258
258
  state.trashedView && modelMeta?.softDelete
259
259
  ? m('button.inline-flex.items-center.gap-1.px-3.py-1.5.text-sm.font-medium.text-green-600.bg-white dark:bg-slate-800.border.border-green-200.rounded.hover:bg-green-50.transition-colors', {
260
260
  disabled: state.bulkActionInProgress,
@@ -425,8 +425,8 @@ const RecordList = {
425
425
  // Sticky header
426
426
  m('thead.bg-gray-50.dark:bg-slate-900', { style: 'position: sticky; top: 0; z-index: 20;' }, [
427
427
  m('tr', [
428
- // Checkbox column header (sticky left, box-shadow on right)
429
- m('th.px-4.py-3.text-left.bg-gray-50 dark:bg-slate-900.border-b.border-gray-200 dark:border-slate-700', { style: 'width: 40px; position: sticky; left: 0; z-index: 15; box-shadow: 4px 0 8px -4px rgba(0,0,0,0.08);' }, [
428
+ // Checkbox column header (sticky left on md+, box-shadow on right)
429
+ 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
430
  m('input[type=checkbox].rounded.border-gray-300 dark:border-slate-600.text-indigo-600.focus:ring-indigo-500', {
431
431
  checked: state.records.length > 0 && state.selectedRecords && state.selectedRecords.size === state.records.length,
432
432
  indeterminate: state.selectedRecords && state.selectedRecords.size > 0 && state.selectedRecords.size < state.records.length,
@@ -440,23 +440,21 @@ const RecordList = {
440
440
  },
441
441
  }),
442
442
  ]),
443
- // Dynamic column headers (first column sticky left with box-shadow)
443
+ // Dynamic column headers (first column sticky left on md+ with box-shadow)
444
444
  ...displayColumns.map((col, i) => {
445
445
  const isSortable = col.sortable !== false;
446
446
  const isSorted = isSortable && state.sortColumn === col.name;
447
447
  const dir = isSorted ? state.sortDirection : null;
448
- const stickyStyle = i === 0 ? 'position: sticky; left: 40px; z-index: 15; box-shadow: 4px 0 8px -4px rgba(0,0,0,0.08);' : '';
448
+ const stickyClass = i === 0 ? '.admin-table-sticky-first' : '';
449
449
 
450
450
  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 } : {},
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' + stickyClass,
453
452
  formatColumnLabel(col.name)
454
453
  );
455
454
  }
456
455
 
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',
456
+ 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
457
  {
459
- style: stickyStyle || undefined,
460
458
  onclick: () => {
461
459
  let nextSort = col.name;
462
460
  let nextDir = 'asc';
@@ -489,21 +487,19 @@ const RecordList = {
489
487
  ])
490
488
  );
491
489
  }),
492
- // Sticky actions header (sticky right, box-shadow on left)
493
- 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', {
494
- style: 'position: sticky; right: 0; min-width: 120px; z-index: 15; box-shadow: -4px 0 8px -4px rgba(0,0,0,0.08);',
490
+ // Sticky actions header (sticky right on md+, box-shadow on left)
491
+ 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', {
492
+ style: 'min-width: 120px;',
495
493
  }, 'Actions'),
496
494
  ]),
497
495
  ]),
498
496
  m('tbody.divide-y.divide-gray-100.dark:divide-slate-700', state.records.map(record =>
499
- m('tr.hover:bg-gray-50 dark:hover:bg-slate-800/50.transition-colors', {
497
+ m('tr.hover:bg-gray-50.dark:hover:bg-slate-800/50.transition-colors', {
500
498
  class: state.selectedRecords && state.selectedRecords.has(record[primaryKey]) ? 'bg-indigo-50 dark:bg-indigo-950/50' : '',
501
499
  }, [
502
- // Checkbox cell (sticky left, box-shadow on right)
503
- m('td.px-4.py-3.bg-white.dark:bg-slate-800', {
504
- style: 'position: sticky; left: 0; z-index: 5; box-shadow: 4px 0 8px -4px rgba(0,0,0,0.08);',
505
- }, [
506
- m('input[type=checkbox].rounded.border-gray-300 dark:border-slate-600.text-indigo-600.focus:ring-indigo-500', {
500
+ // Checkbox cell (sticky left on md+, box-shadow on right)
501
+ m('td.px-4.py-3.bg-white.dark:bg-slate-800.admin-table-sticky-check', [
502
+ m('input[type=checkbox].rounded.border-gray-300.dark:border-slate-600.text-indigo-600.focus:ring-indigo-500', {
507
503
  checked: state.selectedRecords && state.selectedRecords.has(record[primaryKey]),
508
504
  onchange: (e) => {
509
505
  if (!state.selectedRecords) state.selectedRecords = new Set();
@@ -516,17 +512,14 @@ const RecordList = {
516
512
  },
517
513
  }),
518
514
  ]),
519
- // Dynamic cell values (first column sticky left with box-shadow)
515
+ // Dynamic cell values (first column sticky left on md+ with box-shadow)
520
516
  ...displayColumns.map((col, i) =>
521
- m('td.px-4.py-3.text-sm.whitespace-nowrap.text-gray-700 dark:text-slate-300.bg-white dark:bg-slate-800',
522
- i === 0 ? { style: 'position: sticky; left: 40px; z-index: 5; box-shadow: 4px 0 8px -4px rgba(0,0,0,0.08);' } : {},
517
+ 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
518
  formatCellValue(record[col.name], col, record)
524
519
  )
525
520
  ),
526
- // Sticky actions cell (sticky right, box-shadow on left)
527
- 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', {
528
- style: 'position: sticky; right: 0; z-index: 5; box-shadow: -4px 0 8px -4px rgba(0,0,0,0.08);',
529
- }, [
521
+ // Sticky actions cell (sticky right on md+, box-shadow on left)
522
+ 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
523
  state.trashedView && modelMeta?.softDelete
531
524
  ? 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
525
  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
- try {
72
- // Validate rich-text fields first
73
- if (modelMeta && modelMeta.columns) {
74
- for (const col of modelMeta.columns) {
75
- if (col.customField && col.customField.type === 'rich-text' && !col.nullable) {
76
- const hiddenInput = document.getElementById(col.name + '-value');
77
- const value = hiddenInput ? hiddenInput.value : state.formData[col.name];
78
- if (isRichTextEmpty(value)) {
79
- state.error = (col.ui?.label || col.name) + ' is required';
80
- state.loading = false;
81
- return;
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 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 dark:hover:bg-slate-600[type=button]', {
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 || {};\nwindow.__customPages[${JSON.stringify(pageId)}] = ${code};`;
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 = 'https://cdn.quilljs.com/1.3.6/quill.snow.css';
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 = 'https://cdn.quilljs.com/1.3.6/quill.js';
20
+ script.src = adminPath + '/vendor/quill.min.js';
20
21
  script.onload = () => {
21
22
  initEditor(vnode);
22
23
  };