webspresso 0.0.98 → 0.0.100

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 (88) hide show
  1. package/.agents/ADMIN.md +11 -1
  2. package/.agents/AGENTS.md +2 -2
  3. package/.agents/AUTH.md +415 -0
  4. package/.agents/CLI.md +47 -0
  5. package/.agents/ERRORS.md +59 -0
  6. package/.agents/ORM.md +178 -4
  7. package/.agents/PLUGINS.md +113 -8
  8. package/.agents/QUEUE.md +92 -0
  9. package/.agents/ROUTING.md +160 -7
  10. package/.agents/STREAMING.md +35 -0
  11. package/AGENTS.md +2 -0
  12. package/README.md +145 -93
  13. package/bin/commands/add-docker.js +467 -0
  14. package/bin/commands/deploy-check.js +339 -0
  15. package/bin/webspresso.js +4 -0
  16. package/core/auth/index.js +123 -1
  17. package/core/auth/magic-link.js +170 -0
  18. package/core/auth/manager.js +477 -3
  19. package/core/auth/middleware.js +118 -5
  20. package/core/auth/oauth.js +517 -0
  21. package/core/auth/qrcode.js +499 -0
  22. package/core/auth/session-manager.js +255 -0
  23. package/core/auth/sessions.js +351 -0
  24. package/core/auth/tokens.js +1 -0
  25. package/core/auth/totp.js +507 -0
  26. package/core/auth/user-agent.js +161 -0
  27. package/core/orm/eager-loader.js +144 -10
  28. package/core/orm/index.js +10 -1
  29. package/core/orm/model.js +55 -1
  30. package/core/orm/query-builder.js +90 -4
  31. package/core/orm/repository.js +327 -16
  32. package/core/orm/scopes.js +3 -2
  33. package/core/orm/search.js +213 -0
  34. package/core/orm/types.js +6 -1
  35. package/core/queue/cron.js +544 -0
  36. package/core/queue/index.js +8 -1
  37. package/core/queue/manager.js +100 -1
  38. package/core/queue/scheduler.js +415 -0
  39. package/core/sse/index.js +19 -0
  40. package/core/sse/stream.js +430 -0
  41. package/core/ssr/stream.js +62 -2
  42. package/index.d.ts +794 -6
  43. package/index.js +48 -0
  44. package/package.json +7 -5
  45. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +323 -31
  46. package/plugins/admin-panel/client/parts/08-record-list.js +5 -0
  47. package/plugins/admin-panel/client/verify-spa-parts.js +2 -0
  48. package/plugins/admin-panel/field-renderers/file-upload.js +187 -104
  49. package/plugins/admin-panel/field-renderers/index.js +2 -0
  50. package/plugins/admin-panel/modules/admin-users.js +1 -1
  51. package/plugins/api-key/crypto.js +101 -0
  52. package/plugins/api-key/index.js +288 -0
  53. package/plugins/api-key/middleware.js +461 -0
  54. package/plugins/api-key/migration-template.js +48 -0
  55. package/plugins/api-key/model.js +50 -0
  56. package/plugins/api-key/scopes.js +125 -0
  57. package/plugins/audit-log/admin-component.js +487 -70
  58. package/plugins/audit-log/api-handlers.js +114 -0
  59. package/plugins/audit-log/index.js +24 -0
  60. package/plugins/content/client/inline-edit.js +2 -46
  61. package/plugins/cors.js +13 -2
  62. package/plugins/email/auth-bridge.js +66 -0
  63. package/plugins/email/bundled-templates.js +15 -0
  64. package/plugins/email/templates/auth-magic-link.mjml +13 -0
  65. package/plugins/file-manager/admin-component.js +69 -53
  66. package/plugins/file-manager/index.js +25 -8
  67. package/plugins/index.js +8 -0
  68. package/plugins/oauth/index.js +338 -0
  69. package/plugins/queue/index.js +4 -0
  70. package/plugins/webhooks/admin-component.js +608 -0
  71. package/plugins/webhooks/api-handlers.js +237 -0
  72. package/plugins/webhooks/crypto.js +144 -0
  73. package/plugins/webhooks/dispatcher.js +708 -0
  74. package/plugins/webhooks/index.js +263 -0
  75. package/plugins/webhooks/matcher.js +113 -0
  76. package/plugins/webhooks/migration-template.js +75 -0
  77. package/plugins/webhooks/models.js +98 -0
  78. package/plugins/webhooks/sender.js +176 -0
  79. package/src/api/api-loader.js +1 -0
  80. package/src/bundle/index.js +2 -1
  81. package/src/discovery/file-route-parser.js +44 -1
  82. package/src/discovery/scan-routes.js +2 -2
  83. package/src/file-router.js +310 -104
  84. package/src/helpers.js +16 -1
  85. package/src/njk-frontmatter.js +3 -0
  86. package/src/routing/cascading.js +490 -0
  87. package/src/routing/middleware-resolver.js +92 -0
  88. package/src/server.js +6 -0
package/index.js CHANGED
@@ -29,6 +29,8 @@ const {
29
29
  mountPages,
30
30
  filePathToRoute,
31
31
  extractMethodFromFilename,
32
+ isRouteGroupSegment,
33
+ stripRouteGroups,
32
34
  scanDirectory,
33
35
  loadI18n,
34
36
  createTranslator,
@@ -144,6 +146,8 @@ module.exports = {
144
146
  mountPages,
145
147
  filePathToRoute,
146
148
  extractMethodFromFilename,
149
+ isRouteGroupSegment,
150
+ stripRouteGroups,
147
151
  scanDirectory,
148
152
  loadI18n,
149
153
  createTranslator,
@@ -186,20 +190,56 @@ module.exports = {
186
190
  compileRouteTable,
187
191
  RouteTable,
188
192
  mountDiscoveredRoutes,
193
+ resolveMiddlewares: require('./src/routing/middleware-resolver').resolveMiddlewares,
189
194
  };
190
195
 
191
196
  // Define lazy getters for plugins and heavy core subsystems
192
197
  Object.defineProperties(module.exports, {
193
198
  ...Object.getOwnPropertyDescriptors(plugins),
194
199
 
200
+ // Cascading Middleware & Layouts
201
+ cascading: { get: () => require('./src/routing/cascading'), enumerable: true, configurable: true },
202
+
195
203
  // SSR Streaming
196
204
  ssr: { get: () => require('./core/ssr'), enumerable: true, configurable: true },
197
205
  renderStream: { get: () => require('./core/ssr').renderStream, enumerable: true, configurable: true },
198
206
  createHtmlStream: { get: () => require('./core/ssr').createHtmlStream, enumerable: true, configurable: true },
199
207
 
208
+ // Server-Sent Events (SSE) Streaming
209
+ sse: { get: () => require('./core/sse'), enumerable: true, configurable: true },
210
+ createSseStream: { get: () => require('./core/sse').createSseStream, enumerable: true, configurable: true },
211
+ ServerSentEventsStream: { get: () => require('./core/sse').ServerSentEventsStream, enumerable: true, configurable: true },
212
+
200
213
  /** Event bus / plugin shell / view resolver / flows (`kernel.createApp` is distinct from SSR `createApp`) */
201
214
  kernel: { get: () => require('./core/kernel'), enumerable: true, configurable: true },
202
215
 
216
+ // Authentication & OAuth
217
+ auth: { get: () => require('./core/auth'), enumerable: true, configurable: true },
218
+ createAuth: { get: () => require('./core/auth').createAuth, enumerable: true, configurable: true },
219
+ quickAuth: { get: () => require('./core/auth').quickAuth, enumerable: true, configurable: true },
220
+
221
+ // 2FA / TOTP (RFC 6238, RFC 4226, RFC 4648)
222
+ totp: { get: () => require('./core/auth/totp'), enumerable: true, configurable: true },
223
+ base32Encode: { get: () => require('./core/auth/totp').base32Encode, enumerable: true, configurable: true },
224
+ base32Decode: { get: () => require('./core/auth/totp').base32Decode, enumerable: true, configurable: true },
225
+ generateTotpSecret: { get: () => require('./core/auth/totp').generateTotpSecret, enumerable: true, configurable: true },
226
+ generateHotp: { get: () => require('./core/auth/totp').generateHotp, enumerable: true, configurable: true },
227
+ verifyHotp: { get: () => require('./core/auth/totp').verifyHotp, enumerable: true, configurable: true },
228
+ generateTotp: { get: () => require('./core/auth/totp').generateTotp, enumerable: true, configurable: true },
229
+ verifyTotp: { get: () => require('./core/auth/totp').verifyTotp, enumerable: true, configurable: true },
230
+ generateTotpUri: { get: () => require('./core/auth/totp').generateTotpUri, enumerable: true, configurable: true },
231
+ generateTotpQrSvg: { get: () => require('./core/auth/totp').generateTotpQrSvg, enumerable: true, configurable: true },
232
+ generateTotpQrDataUri: { get: () => require('./core/auth/totp').generateTotpQrDataUri, enumerable: true, configurable: true },
233
+ generateRecoveryCodes: { get: () => require('./core/auth/totp').generateRecoveryCodes, enumerable: true, configurable: true },
234
+ hashRecoveryCode: { get: () => require('./core/auth/totp').hashRecoveryCode, enumerable: true, configurable: true },
235
+ verifyRecoveryCode: { get: () => require('./core/auth/totp').verifyRecoveryCode, enumerable: true, configurable: true },
236
+ createTotpSetup: { get: () => require('./core/auth/totp').createTotpSetup, enumerable: true, configurable: true },
237
+ createQrMatrix: { get: () => require('./core/auth/totp').createQrMatrix, enumerable: true, configurable: true },
238
+ generateQrSvg: { get: () => require('./core/auth/qrcode').generateQrSvg, enumerable: true, configurable: true },
239
+ generateQrDataUri: { get: () => require('./core/auth/qrcode').generateQrDataUri, enumerable: true, configurable: true },
240
+ createAuthMiddleware: { get: () => require('./core/auth').createAuthMiddleware, enumerable: true, configurable: true },
241
+ setupAuthMiddleware: { get: () => require('./core/auth').setupAuthMiddleware, enumerable: true, configurable: true },
242
+
203
243
  // Validation
204
244
  z: { get: () => require('./core/validation').z, enumerable: true, configurable: true },
205
245
 
@@ -207,6 +247,10 @@ Object.defineProperties(module.exports, {
207
247
  queue: { get: () => require('./core/queue'), enumerable: true, configurable: true },
208
248
  createQueueManager: { get: () => require('./core/queue').createQueueManager, enumerable: true, configurable: true },
209
249
  QueueManager: { get: () => require('./core/queue').QueueManager, enumerable: true, configurable: true },
250
+ TaskScheduler: { get: () => require('./core/queue').TaskScheduler, enumerable: true, configurable: true },
251
+ CronExpression: { get: () => require('./core/queue').CronExpression, enumerable: true, configurable: true },
252
+ parseCron: { get: () => require('./core/queue').parseCron, enumerable: true, configurable: true },
253
+ isValidCron: { get: () => require('./core/queue').isValidCron, enumerable: true, configurable: true },
210
254
 
211
255
  // Schema-driven CMS core (framework-agnostic)
212
256
  content: { get: () => require('./core/content'), enumerable: true, configurable: true },
@@ -228,5 +272,9 @@ Object.defineProperties(module.exports, {
228
272
  createDevProfilerMiddleware: { get: () => require('./src/dev').createDevProfilerMiddleware, enumerable: true, configurable: true },
229
273
  generateTypes: { get: () => require('./src/dev/typegen').generateTypes, enumerable: true, configurable: true },
230
274
  typegen: { get: () => require('./src/dev/typegen'), enumerable: true, configurable: true },
275
+
276
+ // Webhooks plugin
277
+ webhooks: { get: () => require('./plugins/webhooks'), enumerable: true, configurable: true },
278
+ webhooksPlugin: { get: () => require('./plugins/webhooks').webhooksPlugin, enumerable: true, configurable: true },
231
279
  });
232
280
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webspresso",
3
- "version": "0.0.98",
3
+ "version": "0.0.100",
4
4
  "description": "Minimal, production-ready SSR framework for Node.js with file-based routing, Nunjucks templating, built-in i18n, and CLI tooling",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -126,7 +126,7 @@
126
126
  "nodemailer": "^10.0.10",
127
127
  "nunjucks": "^3.2.4",
128
128
  "sanitize-html": "^2.17.7",
129
- "sharp": "^0.35.4",
129
+ "sharp": "^0.35.5",
130
130
  "swup": "^4.8.3",
131
131
  "yaml": "^2.8.3",
132
132
  "zod": "^3.23.0",
@@ -167,7 +167,7 @@
167
167
  "@types/node": "^20.14.0",
168
168
  "@vitest/coverage-v8": "^4.1.11",
169
169
  "better-sqlite3": "^11.10.0",
170
- "chokidar": "^3.5.3",
170
+ "chokidar": "^4.0.3",
171
171
  "docboot": "^0.3.0",
172
172
  "dotenv": "^16.3.1",
173
173
  "express-rate-limit": "^8.4.1",
@@ -181,9 +181,11 @@
181
181
  "node": ">=20.0.0"
182
182
  },
183
183
  "overrides": {
184
- "basic-ftp": "^5.3.0",
184
+ "basic-ftp": "^6.2.1",
185
+ "chokidar": "^4.0.3",
186
+ "katex": "^0.18.2",
185
187
  "tar": "^7.5.11",
186
- "undici": "^6.24.0",
188
+ "undici": "^6.28.1",
187
189
  "uuid": "^11.1.1",
188
190
  "js-yaml": "^5.4.2",
189
191
  "qs": "^6.16.0"
@@ -61,6 +61,33 @@ const RichTextField = {
61
61
  onchange(content);
62
62
  }
63
63
  });
64
+
65
+ // Hook Quill image toolbar button to Media Library picker if available
66
+ try {
67
+ const toolbar = quill.getModule('toolbar');
68
+ if (toolbar && typeof window !== 'undefined') {
69
+ toolbar.addHandler('image', function() {
70
+ if (typeof window.__openFileManagerPicker === 'function') {
71
+ window.__openFileManagerPicker({
72
+ accept: 'image/*',
73
+ title: 'Insert Image (Media Library)',
74
+ onSelect: function(pickedUrl) {
75
+ const range = quill.getSelection(true) || { index: quill.getLength() };
76
+ quill.insertEmbed(range.index, 'image', pickedUrl);
77
+ quill.setSelection(range.index + 1);
78
+ }
79
+ });
80
+ } else {
81
+ const url = prompt('Image URL:');
82
+ if (url) {
83
+ const range = quill.getSelection(true) || { index: quill.getLength() };
84
+ quill.insertEmbed(range.index, 'image', url);
85
+ quill.setSelection(range.index + 1);
86
+ }
87
+ }
88
+ });
89
+ }
90
+ } catch (_) {}
64
91
  }
65
92
 
66
93
  editorEl._quill = quill;
@@ -134,9 +161,28 @@ function renderUploadedFilePreview(url, options) {
134
161
  var accept = options.accept;
135
162
  var readonly = options.readonly;
136
163
  var onRemove = options.onRemove;
164
+ var onSelectLibrary = options.onSelectLibrary;
137
165
  var label = options.label;
138
166
  if (!url) return null;
139
167
 
168
+ var actionsRow = !readonly ? m('div.flex.items-center.gap-3.flex-wrap', [
169
+ typeof window !== 'undefined' && window.__openFileManagerPicker && onSelectLibrary
170
+ ? m('button.text-indigo-600.dark:text-indigo-400.hover:text-indigo-800.dark:hover:text-indigo-300.text-xs.font-medium.flex.items-center.gap-1', {
171
+ type: 'button',
172
+ onclick: onSelectLibrary,
173
+ }, [
174
+ m('span', '📁'),
175
+ m('span', 'Change from Library')
176
+ ])
177
+ : null,
178
+ onRemove
179
+ ? m('button.text-red-600.dark:text-red-400.hover:text-red-800.dark:hover:text-red-300.text-xs.font-medium', {
180
+ type: 'button',
181
+ onclick: onRemove,
182
+ }, 'Remove')
183
+ : null,
184
+ ]) : null;
185
+
140
186
  if (shouldShowImagePreview(url, accept)) {
141
187
  return m('.mt-3.flex.flex-col.items-start.gap-2', [
142
188
  m('a.block', { href: url, target: '_blank', rel: 'noopener noreferrer' },
@@ -147,12 +193,7 @@ function renderUploadedFilePreview(url, options) {
147
193
  })
148
194
  ),
149
195
  m('p.text-xs.text-gray-500.dark:text-slate-400.break-all', url),
150
- !readonly && onRemove
151
- ? m('button.text-red-600.dark:text-red-400.hover:text-red-800.dark:hover:text-red-300.text-sm', {
152
- type: 'button',
153
- onclick: onRemove,
154
- }, 'Remove')
155
- : null,
196
+ actionsRow,
156
197
  ]);
157
198
  }
158
199
 
@@ -162,12 +203,7 @@ function renderUploadedFilePreview(url, options) {
162
203
  target: '_blank',
163
204
  rel: 'noopener noreferrer',
164
205
  }, url),
165
- !readonly && onRemove
166
- ? m('button.text-red-600.dark:text-red-400.hover:text-red-800.dark:hover:text-red-300.text-sm', {
167
- type: 'button',
168
- onclick: onRemove,
169
- }, 'Remove')
170
- : null,
206
+ actionsRow,
171
207
  ]);
172
208
  }
173
209
 
@@ -178,7 +214,7 @@ function revokeLocalPreview(state) {
178
214
  }
179
215
  }
180
216
 
181
- // File upload field (multipart POST to settings.uploadUrl; field name "file")
217
+ // File upload field (supports local drag & drop, manual URL, and Media Library Picker)
182
218
  const FileUploadField = {
183
219
  oninit: function (vnode) {
184
220
  vnode.state.localPreview = null;
@@ -190,7 +226,7 @@ const FileUploadField = {
190
226
  const col = vnode.attrs.col;
191
227
  const readonly = vnode.attrs.readonly;
192
228
  if (readonly) return;
193
- var cfg = window.__ADMIN_CONFIG__;
229
+ var cfg = (typeof window !== 'undefined' && window.__ADMIN_CONFIG__) ? window.__ADMIN_CONFIG__ : null;
194
230
  var uploadUrl = (cfg && cfg.settings && cfg.settings.uploadUrl) ? String(cfg.settings.uploadUrl) : '';
195
231
  if (!uploadUrl) return;
196
232
  const dropZoneId = 'drop-zone-' + col.name;
@@ -242,7 +278,7 @@ const FileUploadField = {
242
278
  const required = !col.nullable && !readonly;
243
279
  var uploadUrl = '';
244
280
  try {
245
- var cfg2 = window.__ADMIN_CONFIG__;
281
+ var cfg2 = (typeof window !== 'undefined' && window.__ADMIN_CONFIG__) ? window.__ADMIN_CONFIG__ : null;
246
282
  uploadUrl = (cfg2 && cfg2.settings && cfg2.settings.uploadUrl) ? String(cfg2.settings.uploadUrl) : '';
247
283
  } catch (e) { uploadUrl = ''; }
248
284
  var maxSize = meta.maxSize || meta.maxBytes || (10 * 1024 * 1024);
@@ -253,6 +289,18 @@ const FileUploadField = {
253
289
  revokeLocalPreview(state);
254
290
  if (onChange) onChange('');
255
291
  };
292
+ var openLibraryPicker = function() {
293
+ if (typeof window !== 'undefined' && window.__openFileManagerPicker) {
294
+ window.__openFileManagerPicker({
295
+ accept: accept,
296
+ title: 'Choose from Media Library',
297
+ onSelect: function(pickedUrl) {
298
+ revokeLocalPreview(state);
299
+ if (onChange) onChange(pickedUrl);
300
+ }
301
+ });
302
+ }
303
+ };
256
304
  var error = vnode.attrs.error;
257
305
  var borderClass = error ? 'border-red-500 dark:border-red-500' : 'border-gray-300 dark:border-slate-600';
258
306
  if (readonly) {
@@ -270,7 +318,21 @@ const FileUploadField = {
270
318
  label,
271
319
  required ? m('span.text-red-500', ' *') : null,
272
320
  ]),
273
- m('p.text-xs.text-amber-700.dark:text-amber-400.mb-2', 'Upload URL is not configured. Enter a public URL or path manually.'),
321
+ typeof window !== 'undefined' && window.__openFileManagerPicker
322
+ ? m('div.mb-2.flex.items-center.gap-2.flex-wrap', [
323
+ m('button.px-3.py-1.5.text-xs.font-semibold.bg-indigo-600.hover:bg-indigo-700.text-white.rounded-lg.flex.items-center.gap-1.5.shadow-sm.transition', {
324
+ type: 'button',
325
+ onclick: function(e) {
326
+ e.preventDefault();
327
+ openLibraryPicker();
328
+ }
329
+ }, [
330
+ m('span', '📁'),
331
+ m('span', 'Choose from Media Library')
332
+ ]),
333
+ m('span.text-xs.text-gray-400.dark:text-slate-500', 'or enter external URL:')
334
+ ])
335
+ : m('p.text-xs.text-amber-700.dark:text-amber-400.mb-2', 'Upload URL is not configured. Enter a public URL or path manually.'),
274
336
  m('input.w-full.px-3.py-2.border.rounded-md.bg-white.dark:bg-slate-900/70.text-gray-900.dark:text-slate-100.placeholder-gray-400.dark:placeholder-slate-500', {
275
337
  type: 'text',
276
338
  id: col.name,
@@ -281,7 +343,7 @@ const FileUploadField = {
281
343
  class: borderClass,
282
344
  oninput: function (e) { if (onChange) onChange(e.target.value); },
283
345
  }),
284
- value ? renderUploadedFilePreview(value, { accept: accept, readonly: false, onRemove: clearValue, label: label }) : null,
346
+ value ? renderUploadedFilePreview(value, { accept: accept, readonly: false, onRemove: clearValue, onSelectLibrary: openLibraryPicker, label: label }) : null,
285
347
  error ? m('p.text-xs.text-red-600.dark:text-red-400.mt-1.font-medium', error) : (hint ? m('p.text-xs.text-gray-500.dark:text-slate-400.mt-1', hint) : null),
286
348
  ]);
287
349
  }
@@ -291,7 +353,7 @@ const FileUploadField = {
291
353
  required ? m('span.text-red-500', ' *') : null,
292
354
  ]),
293
355
  displayUrl
294
- ? renderUploadedFilePreview(displayUrl, { accept: accept, readonly: false, onRemove: clearValue, label: label })
356
+ ? renderUploadedFilePreview(displayUrl, { accept: accept, readonly: false, onRemove: clearValue, onSelectLibrary: openLibraryPicker, label: label })
295
357
  : null,
296
358
  m('div#' + dropZoneId + '.border-2.border-dashed.rounded-lg.p-6.text-center.bg-gray-50.dark:bg-slate-900/40', { class: borderClass, style: 'cursor: pointer;' }, [
297
359
  m('input[type=file].hidden', {
@@ -313,17 +375,11 @@ const FileUploadField = {
313
375
  onclick: function(e) {
314
376
  e.preventDefault();
315
377
  e.stopPropagation();
316
- window.__openFileManagerPicker({
317
- accept: accept,
318
- title: '📁 Ortam Kütüphanesinden Seç',
319
- onSelect: function(pickedUrl) {
320
- if (onChange) onChange(pickedUrl);
321
- }
322
- });
378
+ openLibraryPicker();
323
379
  }
324
380
  }, [
325
381
  m('span', '•'),
326
- m('span', 'Kütüphaneden Seç')
382
+ m('span', 'Choose from Media Library')
327
383
  ])
328
384
  : null
329
385
  ])
@@ -336,10 +392,167 @@ const FileUploadField = {
336
392
  },
337
393
  };
338
394
 
395
+ // Multiple files upload field (supports drag-and-drop, multiple uploads, and Media Library Picker)
396
+ const FilesUploadField = {
397
+ oninit: function(vnode) {
398
+ vnode.state.uploading = false;
399
+ },
400
+ oncreate: function(vnode) {
401
+ const col = vnode.attrs.col;
402
+ const readonly = vnode.attrs.readonly;
403
+ if (readonly) return;
404
+ const dropZoneId = 'drop-zone-multiple-' + col.name;
405
+ const dropZone = document.getElementById(dropZoneId);
406
+ if (!dropZone) return;
407
+ const meta = col.ui || {};
408
+ const onChange = vnode.attrs.onChange;
409
+ const state = vnode.state;
410
+
411
+ ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(function(eventName) {
412
+ dropZone.addEventListener(eventName, function(e) {
413
+ e.preventDefault();
414
+ e.stopPropagation();
415
+ });
416
+ });
417
+ ['dragenter', 'dragover'].forEach(function(eventName) {
418
+ dropZone.addEventListener(eventName, function() {
419
+ dropZone.classList.add('border-blue-500', 'bg-blue-50', 'dark:bg-slate-800');
420
+ });
421
+ });
422
+ ['dragleave', 'drop'].forEach(function(eventName) {
423
+ dropZone.addEventListener(eventName, function() {
424
+ dropZone.classList.remove('border-blue-500', 'bg-blue-50', 'dark:bg-slate-800');
425
+ });
426
+ });
427
+ dropZone.addEventListener('drop', function(e) {
428
+ const files = e.dataTransfer.files;
429
+ if (files.length > 0) {
430
+ handleAdminMultipleFilesUpload(files, onChange, meta, state, vnode.attrs.value);
431
+ }
432
+ });
433
+ const fileInput = dropZone.querySelector('input[type=file]');
434
+ if (fileInput) {
435
+ fileInput.addEventListener('change', function(e) {
436
+ if (e.target.files.length > 0) {
437
+ handleAdminMultipleFilesUpload(e.target.files, onChange, meta, state, vnode.attrs.value);
438
+ }
439
+ });
440
+ }
441
+ },
442
+ view: function(vnode) {
443
+ const col = vnode.attrs.col;
444
+ const rawVal = vnode.attrs.value;
445
+ const urls = Array.isArray(rawVal) ? rawVal : (typeof rawVal === 'string' && rawVal ? (function() {
446
+ try {
447
+ const parsed = JSON.parse(rawVal);
448
+ return Array.isArray(parsed) ? parsed : [rawVal];
449
+ } catch (e) {
450
+ return [rawVal];
451
+ }
452
+ })() : []);
453
+ const onChange = vnode.attrs.onChange;
454
+ const readonly = vnode.attrs.readonly || false;
455
+ const state = vnode.state;
456
+ const meta = col.ui || {};
457
+ const label = meta.label || formatColumnLabel(col.name);
458
+ const hint = meta.hint || '';
459
+ const required = !col.nullable && !readonly;
460
+ const maxSize = meta.maxSize || meta.maxBytes || (10 * 1024 * 1024);
461
+ const accept = meta.accept || '*/*';
462
+ const dropZoneId = 'drop-zone-multiple-' + col.name;
463
+ const error = vnode.attrs.error;
464
+ const borderClass = error ? 'border-red-500 dark:border-red-500' : 'border-gray-300 dark:border-slate-600';
465
+
466
+ const openLibraryPicker = function() {
467
+ if (typeof window !== 'undefined' && window.__openFileManagerPicker) {
468
+ window.__openFileManagerPicker({
469
+ accept: accept,
470
+ title: 'Choose from Media Library',
471
+ onSelect: function(pickedUrl) {
472
+ if (onChange) {
473
+ onChange([...urls, pickedUrl]);
474
+ }
475
+ }
476
+ });
477
+ }
478
+ };
479
+
480
+ return m('.mb-4', [
481
+ m('label.block.text-sm.font-medium.text-gray-700.dark:text-slate-300.mb-1', [
482
+ label,
483
+ required ? m('span.text-red-500', ' *') : null,
484
+ ]),
485
+ // Thumbnails Grid
486
+ urls.length > 0 ? m('div.grid.grid-cols-2.sm:grid-cols-3.md:grid-cols-4.gap-3.mb-3', urls.map(function(url, idx) {
487
+ return m('div.relative.group.border.border-gray-200.dark:border-slate-700.rounded-lg.p-2.bg-white.dark:bg-slate-900.shadow-sm', [
488
+ shouldShowImagePreview(url, accept)
489
+ ? m('a.block', { href: url, target: '_blank', rel: 'noopener noreferrer' },
490
+ m('img.h-28.w-full.rounded.object-contain.bg-gray-50.dark:bg-slate-950', { src: url, alt: `${label} ${idx + 1}`, loading: 'lazy' })
491
+ )
492
+ : m('a.flex.items-center.justify-center.h-28.w-full.text-xs.text-indigo-600.dark:text-indigo-400.break-all.p-1.text-center', {
493
+ href: url, target: '_blank', rel: 'noopener noreferrer'
494
+ }, url.substring(url.lastIndexOf('/') + 1) || url),
495
+ !readonly && onChange ? m('button.absolute.top-1.right-1.bg-red-500.text-white.rounded-full.w-6.h-6.flex.items-center.justify-center.shadow-md.opacity-0.group-hover:opacity-100.hover:bg-red-700.transition-opacity', {
496
+ type: 'button',
497
+ title: 'Remove',
498
+ onclick: function() {
499
+ const updated = urls.filter(function(_, i) { return i !== idx; });
500
+ onChange(updated);
501
+ }
502
+ }, '✕') : null,
503
+ ]);
504
+ })) : null,
505
+
506
+ // Dropzone & Media Library Action
507
+ !readonly ? m('div#' + dropZoneId + '.border-2.border-dashed.rounded-lg.p-6.text-center.bg-gray-50.dark:bg-slate-900/40', {
508
+ class: borderClass,
509
+ style: 'cursor: pointer;'
510
+ }, [
511
+ m('input[type=file].hidden', {
512
+ id: 'file-input-multiple-' + col.name,
513
+ accept: accept,
514
+ multiple: true,
515
+ onchange: function(e) {
516
+ if (e.target.files.length > 0) {
517
+ handleAdminMultipleFilesUpload(e.target.files, onChange, meta, state, urls);
518
+ }
519
+ }
520
+ }),
521
+ state.uploading
522
+ ? m('p.text-indigo-600.dark:text-indigo-400.animate-pulse.text-sm.font-medium', 'Uploading files...')
523
+ : m('div', [
524
+ m('p.text-gray-600.dark:text-slate-400.mb-2.text-sm', 'Drag and drop files here, or'),
525
+ m('div.flex.items-center.justify-center.gap-3.flex-wrap', [
526
+ m('label.text-blue-600.hover:text-blue-800.dark:text-blue-400.cursor-pointer.font-medium.text-sm', {
527
+ for: 'file-input-multiple-' + col.name
528
+ }, 'browse'),
529
+ typeof window !== 'undefined' && window.__openFileManagerPicker
530
+ ? m('button.text-indigo-600.hover:text-indigo-800.dark:text-indigo-400.cursor-pointer.font-medium.flex.items-center.gap-1.text-sm', {
531
+ type: 'button',
532
+ onclick: function(e) {
533
+ e.preventDefault();
534
+ e.stopPropagation();
535
+ openLibraryPicker();
536
+ }
537
+ }, [
538
+ m('span', '•'),
539
+ m('span', 'Add from Media Library')
540
+ ])
541
+ : null
542
+ ])
543
+ ])
544
+ ]) : null,
545
+ m('input[type=hidden]', { name: col.name, value: JSON.stringify(urls) }),
546
+ m('p.text-xs.text-gray-500.dark:text-slate-400.mt-1', 'Max ' + Math.round(maxSize / 1024 / 1024) + ' MB per file'),
547
+ error ? m('p.text-xs.text-red-600.dark:text-red-400.mt-1.font-medium', error) : (hint ? m('p.text-xs.text-gray-500.dark:text-slate-400.mt-1', hint) : null),
548
+ ]);
549
+ }
550
+ };
551
+
339
552
  async function handleAdminFileUpload(file, onChange, meta, state) {
340
553
  var uploadUrl = '';
341
554
  try {
342
- var cfg = window.__ADMIN_CONFIG__;
555
+ var cfg = (typeof window !== 'undefined' && window.__ADMIN_CONFIG__) ? window.__ADMIN_CONFIG__ : null;
343
556
  uploadUrl = (cfg && cfg.settings && cfg.settings.uploadUrl) ? String(cfg.settings.uploadUrl) : '';
344
557
  } catch (e) {}
345
558
  if (!uploadUrl) {
@@ -379,6 +592,52 @@ async function handleAdminFileUpload(file, onChange, meta, state) {
379
592
  }
380
593
  }
381
594
 
595
+ async function handleAdminMultipleFilesUpload(files, onChange, meta, state, currentUrls = []) {
596
+ var uploadUrl = '';
597
+ try {
598
+ var cfg = (typeof window !== 'undefined' && window.__ADMIN_CONFIG__) ? window.__ADMIN_CONFIG__ : null;
599
+ uploadUrl = (cfg && cfg.settings && cfg.settings.uploadUrl) ? String(cfg.settings.uploadUrl) : '';
600
+ } catch (e) {}
601
+
602
+ if (!uploadUrl) {
603
+ notifyError('Upload URL is not configured.');
604
+ return;
605
+ }
606
+ var maxSize = meta.maxSize || meta.maxBytes || (10 * 1024 * 1024);
607
+ var baseUrls = Array.isArray(currentUrls) ? [...currentUrls] : (currentUrls ? [currentUrls] : []);
608
+
609
+ state.uploading = true;
610
+ if (typeof m !== 'undefined' && m.redraw) m.redraw();
611
+
612
+ try {
613
+ for (var i = 0; i < files.length; i++) {
614
+ var file = files[i];
615
+ if (file.size > maxSize) {
616
+ notifyWarning('File ' + file.name + ' too large.');
617
+ continue;
618
+ }
619
+ var fd = new FormData();
620
+ fd.append('file', file);
621
+ try {
622
+ var res = await fetch(uploadUrl, { method: 'POST', body: fd, credentials: 'include' });
623
+ var data = {};
624
+ try { data = await res.json(); } catch (_) { data = {}; }
625
+ if (res.ok && (data.url || data.publicUrl)) {
626
+ baseUrls.push(data.url || data.publicUrl);
627
+ } else {
628
+ notifyError('Upload failed: ' + (data.message || data.error || res.status));
629
+ }
630
+ } catch (err) {
631
+ notifyError('Upload failed for ' + file.name + ': ' + err.message);
632
+ }
633
+ }
634
+ if (onChange) onChange(baseUrls);
635
+ } finally {
636
+ state.uploading = false;
637
+ if (typeof m !== 'undefined' && m.redraw) m.redraw();
638
+ }
639
+ }
640
+
382
641
  // Get appropriate renderer for a column type
383
642
  function getFieldRenderer(col, modelMeta) {
384
643
  // Check for custom field first
@@ -395,7 +654,7 @@ function getFieldRenderer(col, modelMeta) {
395
654
  });
396
655
  };
397
656
  }
398
- if (col.customField.type === 'file-upload') {
657
+ if (col.customField.type === 'file-upload' || col.customField.type === 'file' || col.customField.type === 'image') {
399
658
  return (col, value, onChange, readonly, error) => {
400
659
  return m(FileUploadField, {
401
660
  col,
@@ -406,6 +665,17 @@ function getFieldRenderer(col, modelMeta) {
406
665
  });
407
666
  };
408
667
  }
668
+ if (col.customField.type === 'files' || col.customField.type === 'files-upload') {
669
+ return (col, value, onChange, readonly, error) => {
670
+ return m(FilesUploadField, {
671
+ col,
672
+ value: value || [],
673
+ onChange,
674
+ readonly: readonly || false,
675
+ error,
676
+ });
677
+ };
678
+ }
409
679
  if (col.customField.type === 'belongsTo') {
410
680
  return FieldRenderers.belongsTo;
411
681
  }
@@ -414,7 +684,7 @@ function getFieldRenderer(col, modelMeta) {
414
684
  }
415
685
 
416
686
  // Check registered custom field renderers in browser
417
- const custom = window.__customFieldRenderers && window.__customFieldRenderers[col.customField.type];
687
+ const custom = (typeof window !== 'undefined' && window.__customFieldRenderers) ? window.__customFieldRenderers[col.customField.type] : null;
418
688
  if (custom) {
419
689
  return (col, value, onChange, readonly, error) => {
420
690
  try {
@@ -432,7 +702,7 @@ function getFieldRenderer(col, modelMeta) {
432
702
  }
433
703
 
434
704
  // Check schema type custom field renderers
435
- if (col.type && window.__customFieldRenderers && window.__customFieldRenderers[col.type]) {
705
+ if (col.type && typeof window !== 'undefined' && window.__customFieldRenderers && window.__customFieldRenderers[col.type]) {
436
706
  const custom = window.__customFieldRenderers[col.type];
437
707
  return (col, value, onChange, readonly, error) => {
438
708
  try {
@@ -448,7 +718,7 @@ function getFieldRenderer(col, modelMeta) {
448
718
  };
449
719
  }
450
720
 
451
- if (col.type === 'file') {
721
+ if (col.type === 'file' || col.type === 'image' || (col.ui && (col.ui.widget === 'file' || col.ui.widget === 'image'))) {
452
722
  return (col, value, onChange, readonly, error) => {
453
723
  return m(FileUploadField, {
454
724
  col,
@@ -460,11 +730,30 @@ function getFieldRenderer(col, modelMeta) {
460
730
  };
461
731
  }
462
732
 
733
+ if (col.type === 'files' || (col.ui && col.ui.widget === 'files')) {
734
+ return (col, value, onChange, readonly, error) => {
735
+ return m(FilesUploadField, {
736
+ col,
737
+ value: value || [],
738
+ onChange,
739
+ readonly: readonly || false,
740
+ error,
741
+ });
742
+ };
743
+ }
744
+
463
745
  // Auto-detect belongsTo relation for foreign key columns (ends with _id or has references)
464
746
  if (!col.primary && !col.autoIncrement && ((col.name && col.name.endsWith('_id')) || col.references)) {
465
747
  return FieldRenderers.belongsTo;
466
748
  }
467
749
 
750
+ // Attach file renderers to FieldRenderers object
751
+ FieldRenderers['file-upload'] = (col, value, onChange, readonly, error) => m(FileUploadField, { col, value: value || '', onChange, readonly: readonly || false, error });
752
+ FieldRenderers.file = FieldRenderers['file-upload'];
753
+ FieldRenderers.image = FieldRenderers['file-upload'];
754
+ FieldRenderers.files = (col, value, onChange, readonly, error) => m(FilesUploadField, { col, value: value || [], onChange, readonly: readonly || false, error });
755
+ FieldRenderers['files-upload'] = FieldRenderers.files;
756
+
468
757
  // Fallback to standard type renderers
469
758
  const typeMap = {
470
759
  string: 'string',
@@ -480,6 +769,9 @@ function getFieldRenderer(col, modelMeta) {
480
769
  enum: 'enum',
481
770
  json: 'json',
482
771
  array: 'array',
772
+ file: 'file',
773
+ files: 'files',
774
+ image: 'image',
483
775
  uuid: 'string',
484
776
  nanoid: 'string',
485
777
  belongsTo: 'belongsTo',
@@ -134,6 +134,11 @@ async function executeRecordAction(action, record, modelName, modelMeta, onSucce
134
134
  if (msg) {
135
135
  notifySuccess(msg);
136
136
  }
137
+ const redirectUrl = res?.result?.redirect || res?.redirect;
138
+ if (redirectUrl) {
139
+ m.route.set(redirectUrl);
140
+ return;
141
+ }
137
142
  if (typeof onSuccess === 'function') {
138
143
  await onSuccess(res);
139
144
  } else if (typeof loadRecords === 'function' && state.pagination) {
@@ -9,11 +9,13 @@
9
9
  const path = require('path');
10
10
  const { buildComponentsBody } = require(path.join(__dirname, 'load-parts.js'));
11
11
 
12
+ const vm = require('vm');
12
13
  const body = buildComponentsBody();
13
14
  const checks = [
14
15
  ['has api helper', () => body.includes('const api =')],
15
16
  ['has Mithril globals', () => body.includes('window.__ADMIN_COMPONENTS__')],
16
17
  ['has reasonable size', () => body.length > 50_000],
18
+ ['valid JS syntax', () => { vm.compileFunction(body); return true; }],
17
19
  ];
18
20
 
19
21
  let failed = false;