webspresso 0.0.97 → 0.0.99

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 (136) hide show
  1. package/.agents/ADMIN.md +163 -5
  2. package/.agents/AGENTS.md +1 -1
  3. package/.agents/ARCHITECTURE.md +10 -0
  4. package/.agents/AUTH.md +169 -0
  5. package/.agents/CLI.md +195 -1
  6. package/.agents/ORM.md +88 -4
  7. package/.agents/PLUGINS.md +221 -9
  8. package/.agents/QUEUE.md +127 -0
  9. package/.agents/ROUTING.md +127 -7
  10. package/AGENTS.md +1 -0
  11. package/bin/commands/add-esbuild.js +80 -0
  12. package/bin/commands/add-tailwind.js +76 -31
  13. package/bin/commands/analytics-prune.js +57 -0
  14. package/bin/commands/api.js +207 -15
  15. package/bin/commands/build.js +99 -0
  16. package/bin/commands/dev.js +64 -35
  17. package/bin/commands/doctor.js +26 -0
  18. package/bin/commands/email-prune.js +47 -0
  19. package/bin/commands/make-job.js +86 -0
  20. package/bin/commands/make-model.js +250 -0
  21. package/bin/commands/new.js +45 -28
  22. package/bin/commands/openapi.js +102 -0
  23. package/bin/commands/page.js +6 -3
  24. package/bin/commands/queue-prune.js +59 -0
  25. package/bin/commands/routes.js +52 -0
  26. package/bin/commands/service.js +6 -3
  27. package/bin/commands/typegen.js +106 -0
  28. package/bin/commands/upload-prune.js +79 -0
  29. package/bin/webspresso.js +22 -0
  30. package/core/applySchema.js +13 -6
  31. package/core/auth/hash.js +21 -12
  32. package/core/auth/index.js +60 -0
  33. package/core/auth/manager.js +134 -0
  34. package/core/auth/middleware.js +88 -0
  35. package/core/auth/oauth.js +517 -0
  36. package/core/auth/qrcode.js +499 -0
  37. package/core/auth/totp.js +504 -0
  38. package/core/compression/middleware.js +23 -17
  39. package/core/openapi/build-from-api-routes.js +160 -29
  40. package/core/orm/cache/fingerprint.js +15 -1
  41. package/core/orm/cache/memory-provider.js +27 -2
  42. package/core/orm/eager-loader.js +144 -10
  43. package/core/orm/index.js +20 -7
  44. package/core/orm/model.js +53 -7
  45. package/core/orm/query-builder.js +90 -4
  46. package/core/orm/repository.js +44 -3
  47. package/core/orm/search.js +213 -0
  48. package/core/queue/adapters/database.js +94 -0
  49. package/core/queue/adapters/memory.js +62 -0
  50. package/core/queue/adapters/redis.js +80 -0
  51. package/core/queue/cron.js +331 -0
  52. package/core/queue/index.js +8 -1
  53. package/core/queue/manager.js +205 -1
  54. package/core/queue/scheduler.js +359 -0
  55. package/core/sse/index.js +19 -0
  56. package/core/sse/stream.js +430 -0
  57. package/core/ssr/stream.js +11 -0
  58. package/index.d.ts +1188 -10
  59. package/index.js +88 -96
  60. package/package.json +1 -1
  61. package/plugins/admin-panel/api.js +279 -17
  62. package/plugins/admin-panel/app.js +114 -22
  63. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +314 -9
  64. package/plugins/admin-panel/client/parts/04-field-renderers.js +264 -67
  65. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +333 -36
  66. package/plugins/admin-panel/client/parts/06-login-setup-forms.js +119 -28
  67. package/plugins/admin-panel/client/parts/07-model-list.js +49 -8
  68. package/plugins/admin-panel/client/parts/08-record-list.js +248 -23
  69. package/plugins/admin-panel/client/parts/09-record-form.js +59 -12
  70. package/plugins/admin-panel/client/parts/10-export-registry.js +23 -0
  71. package/plugins/admin-panel/client/verify-spa-parts.js +2 -0
  72. package/plugins/admin-panel/core/admin-module.js +13 -7
  73. package/plugins/admin-panel/core/api-extensions.js +30 -2
  74. package/plugins/admin-panel/core/registry.js +96 -16
  75. package/plugins/admin-panel/field-renderers/file-upload.js +187 -104
  76. package/plugins/admin-panel/field-renderers/index.js +2 -0
  77. package/plugins/admin-panel/index.js +96 -0
  78. package/plugins/admin-panel/lib/login-rate-limiter.js +119 -0
  79. package/plugins/admin-panel/modules/admin-users.js +16 -1
  80. package/plugins/admin-panel/modules/bulk-actions.js +13 -5
  81. package/plugins/admin-panel/modules/custom-pages.js +492 -3
  82. package/plugins/admin-panel/modules/dashboard.js +283 -0
  83. package/plugins/admin-panel/modules/menu.js +56 -18
  84. package/plugins/admin-panel/modules/user-management.js +24 -6
  85. package/plugins/api-key/crypto.js +101 -0
  86. package/plugins/api-key/index.js +288 -0
  87. package/plugins/api-key/middleware.js +461 -0
  88. package/plugins/api-key/migration-template.js +48 -0
  89. package/plugins/api-key/model.js +50 -0
  90. package/plugins/api-key/scopes.js +125 -0
  91. package/plugins/audit-log/admin-component.js +487 -59
  92. package/plugins/audit-log/api-handlers.js +148 -0
  93. package/plugins/audit-log/index.js +153 -3
  94. package/plugins/audit-log/purge.js +60 -10
  95. package/plugins/dashboard/app.js +152 -27
  96. package/plugins/dashboard/index.js +143 -39
  97. package/plugins/dashboard/styles.js +78 -0
  98. package/plugins/data-exchange/export-xlsx.js +9 -2
  99. package/plugins/data-exchange/import.js +9 -2
  100. package/plugins/data-exchange/index.js +6 -3
  101. package/plugins/email/index.js +132 -2
  102. package/plugins/email/purge.js +60 -10
  103. package/plugins/file-manager/admin-component.js +20 -4
  104. package/plugins/file-manager/index.js +21 -4
  105. package/plugins/index.js +10 -0
  106. package/plugins/oauth/index.js +338 -0
  107. package/plugins/orm-cache-admin/index.js +4 -2
  108. package/plugins/queue/index.js +15 -0
  109. package/plugins/schema-explorer/admin-component.js +714 -0
  110. package/plugins/schema-explorer/relations-graph.js +205 -0
  111. package/plugins/schema-explorer.js +248 -69
  112. package/plugins/site-analytics/admin-component.js +23 -0
  113. package/plugins/site-analytics/api-handlers.js +42 -0
  114. package/plugins/site-analytics/index.js +147 -2
  115. package/plugins/site-analytics/purge.js +168 -0
  116. package/plugins/swagger.js +261 -13
  117. package/plugins/upload/index.js +26 -0
  118. package/plugins/upload/orphan-pruner.js +292 -0
  119. package/src/api/api-loader.js +4 -1
  120. package/src/bundle/index.js +517 -0
  121. package/src/bundle/manifest.js +165 -0
  122. package/src/dev/banner.js +296 -0
  123. package/src/dev/colors.js +70 -0
  124. package/src/dev/index.js +62 -0
  125. package/src/dev/profiler.js +354 -0
  126. package/src/dev/typegen.js +497 -0
  127. package/src/dev-reload/index.js +611 -0
  128. package/src/discovery/file-route-parser.js +44 -1
  129. package/src/discovery/scan-routes.js +2 -2
  130. package/src/file-router.js +347 -162
  131. package/src/helpers.js +967 -98
  132. package/src/njk-frontmatter.js +12 -0
  133. package/src/pages/page-loader.js +61 -11
  134. package/src/routing/cascading.js +378 -0
  135. package/src/routing/middleware-resolver.js +92 -0
  136. package/src/server.js +635 -63
@@ -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,19 +392,176 @@ 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) {
346
- alert('Upload URL is not configured.');
559
+ notifyError('Upload URL is not configured.');
347
560
  return;
348
561
  }
349
562
  var maxSize = meta.maxSize || meta.maxBytes || (10 * 1024 * 1024);
350
563
  if (file.size > maxSize) {
351
- alert('File too large (max ' + Math.round(maxSize / 1024 / 1024) + ' MB).');
564
+ notifyWarning('File too large (max ' + Math.round(maxSize / 1024 / 1024) + ' MB).');
352
565
  return;
353
566
  }
354
567
  if (state && file.type && file.type.indexOf('image/') === 0) {
@@ -365,7 +578,7 @@ async function handleAdminFileUpload(file, onChange, meta, state) {
365
578
  if (!res.ok) {
366
579
  revokeLocalPreview(state);
367
580
  m.redraw();
368
- alert(data.message || data.error || ('Upload failed (' + res.status + ')'));
581
+ notifyError(data.message || data.error || ('Upload failed (' + res.status + ')'));
369
582
  return;
370
583
  }
371
584
  revokeLocalPreview(state);
@@ -375,7 +588,53 @@ async function handleAdminFileUpload(file, onChange, meta, state) {
375
588
  } catch (err) {
376
589
  revokeLocalPreview(state);
377
590
  m.redraw();
378
- alert(err.message || 'Upload failed');
591
+ notifyError(err.message || 'Upload failed');
592
+ }
593
+ }
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();
379
638
  }
380
639
  }
381
640
 
@@ -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,
@@ -459,7 +729,31 @@ function getFieldRenderer(col, modelMeta) {
459
729
  });
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
+
745
+ // Auto-detect belongsTo relation for foreign key columns (ends with _id or has references)
746
+ if (!col.primary && !col.autoIncrement && ((col.name && col.name.endsWith('_id')) || col.references)) {
747
+ return FieldRenderers.belongsTo;
748
+ }
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
+
463
757
  // Fallback to standard type renderers
464
758
  const typeMap = {
465
759
  string: 'string',
@@ -475,6 +769,9 @@ function getFieldRenderer(col, modelMeta) {
475
769
  enum: 'enum',
476
770
  json: 'json',
477
771
  array: 'array',
772
+ file: 'file',
773
+ files: 'files',
774
+ image: 'image',
478
775
  uuid: 'string',
479
776
  nanoid: 'string',
480
777
  belongsTo: 'belongsTo',