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.
Files changed (125) hide show
  1. package/README.md +98 -2509
  2. package/bin/commands/admin-password.js +8 -2
  3. package/bin/commands/api.js +7 -1
  4. package/bin/commands/favicon-generate.js +6 -4
  5. package/bin/commands/mcp.js +162 -0
  6. package/bin/commands/new.js +7 -1
  7. package/bin/commands/page.js +7 -1
  8. package/bin/commands/seed.js +7 -1
  9. package/bin/commands/service.js +199 -0
  10. package/bin/commands/skill.js +7 -1
  11. package/bin/webspresso.js +4 -0
  12. package/core/auth/middleware.js +9 -0
  13. package/core/compileSchema.js +1 -5
  14. package/core/compression/compressible.js +63 -0
  15. package/core/compression/compression-stream.js +59 -0
  16. package/core/compression/index.js +25 -0
  17. package/core/compression/middleware.js +310 -0
  18. package/core/compression/negotiate.js +165 -0
  19. package/core/errors/base.js +31 -0
  20. package/core/errors/domain.js +193 -0
  21. package/core/errors/http.js +133 -0
  22. package/core/errors/index.js +77 -0
  23. package/core/errors/middleware.js +162 -0
  24. package/core/errors/normalize.js +171 -0
  25. package/core/kernel/app.js +2 -1
  26. package/core/kernel/index.js +5 -1
  27. package/core/orm/cache/fingerprint.js +23 -5
  28. package/core/orm/complexity.js +120 -0
  29. package/core/orm/eager-loader.js +54 -9
  30. package/core/orm/events.js +64 -14
  31. package/core/orm/index.js +83 -12
  32. package/core/orm/model.js +13 -2
  33. package/core/orm/query-builder.js +30 -4
  34. package/core/orm/repository.js +57 -25
  35. package/core/orm/transaction.js +39 -5
  36. package/core/orm/utils.js +6 -3
  37. package/core/queue/adapters/database.js +317 -0
  38. package/core/queue/adapters/memory.js +176 -0
  39. package/core/queue/adapters/redis.js +357 -0
  40. package/core/queue/index.js +21 -0
  41. package/core/queue/job.js +103 -0
  42. package/core/queue/manager.js +413 -0
  43. package/core/realtime/adapters/redis.js +431 -0
  44. package/core/realtime/adapters/socket-io.js +203 -0
  45. package/core/realtime/adapters/sse.js +210 -0
  46. package/core/realtime/adapters/websocket.js +337 -0
  47. package/core/realtime/auth-manager.js +140 -0
  48. package/core/realtime/core.js +359 -0
  49. package/core/realtime/emitter.js +98 -0
  50. package/core/realtime/errors.js +71 -0
  51. package/core/realtime/index.js +79 -0
  52. package/core/realtime/reconnect-manager.js +118 -0
  53. package/core/realtime/registry.js +243 -0
  54. package/core/shutdown/http-adapter.js +134 -0
  55. package/core/shutdown/index.js +12 -0
  56. package/core/shutdown/shutdown-manager.js +312 -0
  57. package/core/ssr/index.js +13 -0
  58. package/core/ssr/stream.js +174 -0
  59. package/core/validation/index.js +70 -0
  60. package/index.d.ts +924 -53
  61. package/index.js +110 -1
  62. package/package.json +30 -2
  63. package/plugins/admin-panel/api.js +37 -4
  64. package/plugins/admin-panel/app.js +57 -17
  65. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +10 -2
  66. package/plugins/admin-panel/client/parts/04-field-renderers.js +102 -58
  67. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +51 -27
  68. package/plugins/admin-panel/client/parts/07-model-list.js +9 -4
  69. package/plugins/admin-panel/client/parts/08-record-list.js +37 -26
  70. package/plugins/admin-panel/client/parts/09-record-form.js +168 -19
  71. package/plugins/admin-panel/core/admin-module.js +2 -2
  72. package/plugins/admin-panel/core/api-extensions.js +149 -0
  73. package/plugins/admin-panel/field-renderers/rich-text.js +3 -2
  74. package/plugins/admin-panel/index.js +155 -23
  75. package/plugins/admin-panel/modules/bulk-actions.js +9 -2
  76. package/plugins/admin-panel/modules/custom-pages.js +144 -45
  77. package/plugins/admin-panel/modules/dashboard.js +8 -1
  78. package/plugins/admin-panel/modules/menu.js +85 -0
  79. package/plugins/admin-panel/modules/user-management.js +5 -2
  80. package/plugins/admin-panel/vendor/mithril.min.js +1 -0
  81. package/plugins/admin-panel/vendor/quill.min.js +7 -0
  82. package/plugins/admin-panel/vendor/quill.snow.min.css +945 -0
  83. package/plugins/admin-panel/vendor/tailwind.min.js +65 -0
  84. package/plugins/basic-auth/index.js +293 -0
  85. package/plugins/data-exchange/export-xlsx.js +28 -2
  86. package/plugins/data-exchange/import.js +55 -16
  87. package/plugins/data-exchange/index.js +19 -0
  88. package/plugins/data-exchange/parse-table.js +14 -5
  89. package/plugins/data-exchange/record-selection.js +2 -0
  90. package/plugins/email/index.js +14 -1
  91. package/plugins/index.js +33 -0
  92. package/plugins/mcp/auth.js +104 -0
  93. package/plugins/mcp/discovery.js +88 -0
  94. package/plugins/mcp/index.js +208 -0
  95. package/plugins/mcp/prompts.js +149 -0
  96. package/plugins/mcp/providers/orm.js +325 -0
  97. package/plugins/mcp/providers/services.js +190 -0
  98. package/plugins/mcp/providers/system.js +87 -0
  99. package/plugins/mcp/server.js +472 -0
  100. package/plugins/mcp/transports/sse.js +139 -0
  101. package/plugins/mcp/transports/stdio.js +108 -0
  102. package/plugins/queue/index.js +108 -0
  103. package/plugins/realtime/index.js +150 -0
  104. package/plugins/recaptcha/index.js +11 -0
  105. package/plugins/redirect/index.js +9 -1
  106. package/plugins/rest-resources/index.js +280 -11
  107. package/plugins/upload/index.js +16 -0
  108. package/src/app-context.js +46 -6
  109. package/src/file-router.js +366 -130
  110. package/src/helpers.js +20 -2
  111. package/src/njk-frontmatter.js +24 -14
  112. package/src/plugin-manager.js +124 -4
  113. package/src/server.js +332 -54
  114. package/src/services/builtins/auth.js +273 -0
  115. package/src/services/builtins/exchange.js +229 -0
  116. package/src/services/builtins/mail.js +134 -0
  117. package/src/services/builtins/media.js +196 -0
  118. package/src/services/builtins/system.js +221 -0
  119. package/src/services/discovery.js +141 -0
  120. package/src/services/executor.js +292 -0
  121. package/src/services/index.js +70 -0
  122. package/src/services/memoize.js +256 -0
  123. package/src/services/registry.js +290 -0
  124. package/src/services/validator.js +230 -0
  125. 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
- 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);
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
- URL.revokeObjectURL(url);
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
- URL.revokeObjectURL(url);
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, 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);' }, [
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 stickyStyle = i === 0 ? 'position: sticky; left: 40px; z-index: 15; box-shadow: 4px 0 8px -4px rgba(0,0,0,0.08);' : '';
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, 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);',
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 dark:hover:bg-slate-800/50.transition-colors', {
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, 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', {
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 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);' } : {},
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, 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
- }, [
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
- 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
  };