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
@@ -340,12 +340,24 @@ function createExtensionApiHandlers(options) {
340
340
  }
341
341
 
342
342
  // Execute action
343
- const result = await action.handler(record, modelName, {
343
+ const context = {
344
344
  db,
345
345
  req,
346
346
  user: req.session?.adminUser,
347
347
  body: req.body,
348
+ model: modelName,
349
+ modelName,
350
+ name: modelName,
351
+ };
352
+ const modelArg = Object.assign(new String(modelName), context, {
353
+ name: modelName,
354
+ modelName,
355
+ model: modelName,
356
+ valueOf: () => modelName,
357
+ toString: () => modelName,
358
+ [Symbol.toPrimitive]: () => modelName,
348
359
  });
360
+ const result = await action.handler(record, modelArg, context);
349
361
 
350
362
  res.json({ success: true, result });
351
363
  } catch (error) {
@@ -418,12 +430,24 @@ function createExtensionApiHandlers(options) {
418
430
  }
419
431
 
420
432
  // Execute action
421
- const result = await action.handler(records, modelName, {
433
+ const context = {
422
434
  db,
423
435
  req,
424
436
  user: req.session?.adminUser,
425
437
  body: req.body,
438
+ model: modelName,
439
+ modelName,
440
+ name: modelName,
441
+ };
442
+ const modelArg = Object.assign(new String(modelName), context, {
443
+ name: modelName,
444
+ modelName,
445
+ model: modelName,
446
+ valueOf: () => modelName,
447
+ toString: () => modelName,
448
+ [Symbol.toPrimitive]: () => modelName,
426
449
  });
450
+ const result = await action.handler(records, modelArg, context);
427
451
 
428
452
  res.json({ success: true, result, affected: records.length });
429
453
  } catch (error) {
@@ -675,6 +699,10 @@ function createExtensionApiHandlers(options) {
675
699
  }
676
700
  }
677
701
 
702
+ if (registry && typeof registry.runHooks === 'function') {
703
+ await registry.runHooks('onDashboardLoad', { stats, req, user: req.session?.adminUser, db });
704
+ }
705
+
678
706
  res.json({ stats });
679
707
  } catch (error) {
680
708
  res.status(500).json({ error: error.message });
@@ -4,6 +4,8 @@
4
4
  * @module plugins/admin-panel/core/registry
5
5
  */
6
6
 
7
+ const fs = require('fs');
8
+
7
9
  /**
8
10
  * Extension types
9
11
  */
@@ -46,6 +48,7 @@ class AdminRegistry {
46
48
 
47
49
  // Client components: JS code strings for custom page rendering
48
50
  this.clientComponents = new Map();
51
+ this.clientComponentFiles = new Map();
49
52
 
50
53
  // Custom scripts & styles for Admin Panel SSR rendering
51
54
  this.scripts = [];
@@ -59,6 +62,8 @@ class AdminRegistry {
59
62
  afterUpdate: [],
60
63
  beforeDelete: [],
61
64
  afterDelete: [],
65
+ beforeRestore: [],
66
+ afterRestore: [],
62
67
  onRecordView: [],
63
68
  onDashboardLoad: [],
64
69
  };
@@ -66,6 +71,7 @@ class AdminRegistry {
66
71
  // Settings
67
72
  this.settings = {
68
73
  title: 'Admin Panel',
74
+ titleTemplate: '%s | %b',
69
75
  logo: null,
70
76
  primaryColor: '#3B82F6',
71
77
  perPage: 20,
@@ -335,17 +341,41 @@ class AdminRegistry {
335
341
  * @param {string} pageId - Page ID (must match a registered page)
336
342
  * @param {string} jsCode - JavaScript code string that defines the component.
337
343
  * The code should assign a Mithril component to window.__customPages[pageId].
344
+ * @param {string} [filePath=null] - Absolute file path for dev mode live reloading
338
345
  */
339
- registerClientComponent(pageId, jsCode) {
346
+ registerClientComponent(pageId, jsCode, filePath = null) {
340
347
  this.clientComponents.set(pageId, jsCode);
348
+ if (filePath) {
349
+ this.clientComponentFiles.set(pageId, filePath);
350
+ }
341
351
  return this;
342
352
  }
343
353
 
344
354
  /**
345
- * Get all client component code strings concatenated
355
+ * Get all client component code strings concatenated.
356
+ * In development mode (NODE_ENV !== 'production'), dynamically re-reads modified
357
+ * component files from disk to eliminate manual server restarts.
346
358
  */
347
359
  getClientComponents() {
348
- return Array.from(this.clientComponents.values()).join('\n');
360
+ const isDev = process.env.NODE_ENV !== 'production';
361
+ const parts = [];
362
+ for (const [pageId, code] of this.clientComponents.entries()) {
363
+ const filePath = this.clientComponentFiles.get(pageId);
364
+ if (isDev && filePath) {
365
+ try {
366
+ if (fs.existsSync(filePath)) {
367
+ const raw = fs.readFileSync(filePath, 'utf8');
368
+ const { formatClientComponent } = require('./admin-module');
369
+ parts.push(formatClientComponent(pageId, raw));
370
+ continue;
371
+ }
372
+ } catch (e) {
373
+ console.error(`[Admin Panel] Failed to reload component for "${pageId}":`, e);
374
+ }
375
+ }
376
+ parts.push(code || '');
377
+ }
378
+ return parts.filter(Boolean).join('\n');
349
379
  }
350
380
 
351
381
  /**
@@ -382,6 +412,44 @@ class AdminRegistry {
382
412
  return this;
383
413
  }
384
414
 
415
+ /**
416
+ * Set base admin panel title
417
+ * @param {string} title - Base title (e.g., 'Acme Admin')
418
+ */
419
+ setTitle(title) {
420
+ if (typeof title === 'string') {
421
+ this.settings.title = title;
422
+ }
423
+ return this;
424
+ }
425
+
426
+ /**
427
+ * Get base admin panel title
428
+ * @returns {string}
429
+ */
430
+ getTitle() {
431
+ return this.settings.title;
432
+ }
433
+
434
+ /**
435
+ * Set admin page title template
436
+ * @param {string} template - Title template (e.g., '%s | %b', '%s — %b', '%s')
437
+ */
438
+ setTitleTemplate(template) {
439
+ if (typeof template === 'string') {
440
+ this.settings.titleTemplate = template;
441
+ }
442
+ return this;
443
+ }
444
+
445
+ /**
446
+ * Get admin page title template
447
+ * @returns {string}
448
+ */
449
+ getTitleTemplate() {
450
+ return this.settings.titleTemplate;
451
+ }
452
+
385
453
  /**
386
454
  * Enable user management
387
455
  * @param {Object} config - User management configuration
@@ -471,19 +539,30 @@ class AdminRegistry {
471
539
  toClientConfig() {
472
540
  return {
473
541
  settings: this.settings,
474
- pages: this.getPages().map(p => ({
475
- id: p.id,
476
- title: p.title,
477
- path: p.path,
478
- icon: p.icon,
479
- description: p.description,
480
- permission: p.permission,
481
- layout: p.layout !== undefined ? p.layout : true,
482
- iframe: p.iframe,
483
- url: p.url || p.iframeUrl,
484
- html: p.html,
485
- hasClientComponent: this.clientComponents.has(p.id),
486
- })),
542
+ pages: this.getPages().map(p => {
543
+ let html = p.html;
544
+ if (p.htmlFile && process.env.NODE_ENV !== 'production') {
545
+ try {
546
+ if (fs.existsSync(p.htmlFile)) {
547
+ html = fs.readFileSync(p.htmlFile, 'utf8');
548
+ }
549
+ } catch (e) {}
550
+ }
551
+ return {
552
+ id: p.id,
553
+ title: p.title,
554
+ documentTitle: p.documentTitle,
555
+ path: p.path,
556
+ icon: p.icon,
557
+ description: p.description,
558
+ permission: p.permission,
559
+ layout: p.layout !== undefined ? p.layout : true,
560
+ iframe: p.iframe,
561
+ url: p.url || p.iframeUrl,
562
+ html,
563
+ hasClientComponent: this.clientComponents.has(p.id),
564
+ };
565
+ }),
487
566
 
488
567
  widgets: this.getWidgets().map(w => ({
489
568
  id: w.id,
@@ -527,6 +606,7 @@ class AdminRegistry {
527
606
  this.menuGroups.clear();
528
607
  this.fieldRenderers.clear();
529
608
  this.clientComponents.clear();
609
+ this.clientComponentFiles.clear();
530
610
  this.scripts = [];
531
611
  this.styles = [];
532
612
  Object.keys(this.hooks).forEach(k => this.hooks[k] = []);
@@ -27,9 +27,24 @@ function shouldShowImagePreview(url, accept) {
27
27
  return isImageAccept(accept) || isImageUrl(url);
28
28
  }
29
29
 
30
- function renderUploadedFilePreview(url, { accept, readonly, onRemove, label }) {
30
+ function renderUploadedFilePreview(url, { accept, readonly, onRemove, onSelectLibrary, label }) {
31
31
  if (!url) return null;
32
32
 
33
+ const actions = !readonly ? m('.flex.items-center.gap-3.mt-1', [
34
+ onSelectLibrary
35
+ ? m('button.text-indigo-600.dark:text-indigo-400.hover:text-indigo-800.dark:hover:text-indigo-300.text-xs.font-medium', {
36
+ type: 'button',
37
+ onclick: onSelectLibrary,
38
+ }, '📁 Change from Library')
39
+ : null,
40
+ onRemove
41
+ ? m('button.text-red-600.dark:text-red-400.hover:text-red-800.dark:hover:text-red-300.text-xs', {
42
+ type: 'button',
43
+ onclick: onRemove,
44
+ }, 'Remove')
45
+ : null,
46
+ ]) : null;
47
+
33
48
  if (shouldShowImagePreview(url, accept)) {
34
49
  return m('.mt-3.flex.flex-col.items-start.gap-2', [
35
50
  m('a.block', { href: url, target: '_blank', rel: 'noopener noreferrer' },
@@ -40,12 +55,7 @@ function renderUploadedFilePreview(url, { accept, readonly, onRemove, label }) {
40
55
  })
41
56
  ),
42
57
  m('p.text-xs.text-gray-500.dark:text-slate-400.break-all', url),
43
- !readonly && onRemove
44
- ? m('button.text-red-600.dark:text-red-400.hover:text-red-800.dark:hover:text-red-300.text-sm', {
45
- type: 'button',
46
- onclick: onRemove,
47
- }, 'Remove')
48
- : null,
58
+ actions,
49
59
  ]);
50
60
  }
51
61
 
@@ -55,12 +65,7 @@ function renderUploadedFilePreview(url, { accept, readonly, onRemove, label }) {
55
65
  target: '_blank',
56
66
  rel: 'noopener noreferrer',
57
67
  }, url),
58
- !readonly && onRemove
59
- ? m('button.text-red-600.dark:text-red-400.hover:text-red-800.dark:hover:text-red-300.text-sm', {
60
- type: 'button',
61
- onclick: onRemove,
62
- }, 'Remove')
63
- : null,
68
+ actions,
64
69
  ]);
65
70
  }
66
71
 
@@ -141,6 +146,19 @@ module.exports = {
141
146
  if (vnode.attrs.onchange) vnode.attrs.onchange('');
142
147
  };
143
148
 
149
+ const openLibraryPicker = () => {
150
+ if (typeof window !== 'undefined' && window.__openFileManagerPicker) {
151
+ window.__openFileManagerPicker({
152
+ accept,
153
+ title: 'Choose from Media Library',
154
+ onSelect: (pickedUrl) => {
155
+ revokeLocalPreview(state);
156
+ if (vnode.attrs.onchange) vnode.attrs.onchange(pickedUrl);
157
+ }
158
+ });
159
+ }
160
+ };
161
+
144
162
  if (!uploadUrl) {
145
163
  return m('.mb-4', [
146
164
  m(
@@ -148,7 +166,14 @@ module.exports = {
148
166
  label,
149
167
  required ? m('span.text-red-500', ' *') : null
150
168
  ),
151
- m('p.text-xs.text-amber-700.dark:text-amber-400.mb-2', 'Upload URL is not configured.'),
169
+ typeof window !== 'undefined' && window.__openFileManagerPicker
170
+ ? m('.flex.items-center.gap-2.mb-3', [
171
+ m('button.px-3.py-1.5.bg-indigo-600.hover:bg-indigo-700.text-white.text-xs.font-medium.rounded.flex.items-center.gap-1.shadow-sm', {
172
+ type: 'button',
173
+ onclick: openLibraryPicker,
174
+ }, '📁 Choose from Media Library')
175
+ ])
176
+ : m('p.text-xs.text-amber-700.dark:text-amber-400.mb-2', 'Upload URL is not configured.'),
152
177
  m('input.w-full.px-3.py-2.border.rounded', {
153
178
  type: 'text',
154
179
  name,
@@ -161,7 +186,7 @@ module.exports = {
161
186
  },
162
187
  }),
163
188
  value
164
- ? renderUploadedFilePreview(value, { accept, readonly, onRemove: clearValue, label })
189
+ ? renderUploadedFilePreview(value, { accept, readonly, onRemove: clearValue, onSelectLibrary: openLibraryPicker, label })
165
190
  : null,
166
191
  ]);
167
192
  }
@@ -173,7 +198,7 @@ module.exports = {
173
198
  required ? m('span.text-red-500', ' *') : null
174
199
  ),
175
200
  displayUrl
176
- ? renderUploadedFilePreview(displayUrl, { accept, readonly, onRemove: clearValue, label })
201
+ ? renderUploadedFilePreview(displayUrl, { accept, readonly, onRemove: clearValue, onSelectLibrary: openLibraryPicker, label })
177
202
  : null,
178
203
  m(
179
204
  'div.border-2.border-dashed.border-gray-300.dark:border-slate-600.rounded.p-6.text-center',
@@ -200,11 +225,26 @@ module.exports = {
200
225
  'p.text-gray-600.dark:text-slate-400.mb-2',
201
226
  displayUrl ? 'Drag and drop to replace, or' : 'Drag and drop a file here, or'
202
227
  ),
203
- m(
204
- 'label.text-blue-600.hover:text-blue-800.cursor-pointer',
205
- { for: 'file-input-' + name },
206
- displayUrl ? 'choose another file' : 'browse'
207
- ),
228
+ m('.flex.items-center.justify-center.gap-3.flex-wrap', [
229
+ m(
230
+ 'label.text-blue-600.hover:text-blue-800.cursor-pointer',
231
+ { for: 'file-input-' + name },
232
+ displayUrl ? 'choose another file' : 'browse'
233
+ ),
234
+ typeof window !== 'undefined' && window.__openFileManagerPicker
235
+ ? m('button.text-indigo-600.hover:text-indigo-800.dark:text-indigo-400.cursor-pointer.font-medium.flex.items-center.gap-1.text-sm', {
236
+ type: 'button',
237
+ onclick: (e) => {
238
+ e.preventDefault();
239
+ e.stopPropagation();
240
+ openLibraryPicker();
241
+ }
242
+ }, [
243
+ m('span', '•'),
244
+ m('span', '📁 Choose from Media Library')
245
+ ])
246
+ : null,
247
+ ]),
208
248
  ]),
209
249
  ]
210
250
  ),
@@ -280,6 +320,98 @@ module.exports = {
280
320
 
281
321
  const urls = Array.isArray(value) ? value : (value ? [value] : []);
282
322
 
323
+ const openLibraryPicker = () => {
324
+ if (typeof window !== 'undefined' && window.__openFileManagerPicker) {
325
+ window.__openFileManagerPicker({
326
+ accept,
327
+ title: 'Add from Media Library',
328
+ onSelect: (pickedUrl) => {
329
+ const newUrls = [...urls, pickedUrl];
330
+ if (vnode.attrs.onchange) vnode.attrs.onchange(newUrls);
331
+ }
332
+ });
333
+ }
334
+ };
335
+
336
+ const renderGrid = () => {
337
+ if (urls.length === 0) return null;
338
+ return m('.grid.grid-cols-2.sm:grid-cols-3.md:grid-cols-4.gap-4.mb-3', urls.map((url, idx) => {
339
+ const isDragOver = state.dragOverIndex === idx;
340
+ const baseClasses = '.relative.group.border.rounded-lg.p-2.transition-all';
341
+ const cursorClass = readonly ? '' : '.cursor-move';
342
+ const borderClasses = isDragOver ? '.border-blue-500.border-2.dark:border-blue-500' : '.border-gray-200.dark:border-slate-700';
343
+ const bgClasses = isDragOver ? '.bg-blue-50.dark:bg-slate-800' : '.bg-gray-50.dark:bg-slate-900';
344
+
345
+ return m(baseClasses + cursorClass + borderClasses + bgClasses, {
346
+ draggable: !readonly,
347
+ ondragstart: (e) => {
348
+ state.draggedIndex = idx;
349
+ e.dataTransfer.effectAllowed = 'move';
350
+ e.dataTransfer.setData('text/plain', idx);
351
+ },
352
+ ondragover: (e) => {
353
+ e.preventDefault();
354
+ e.dataTransfer.dropEffect = 'move';
355
+ },
356
+ ondragenter: (e) => {
357
+ state.dragOverIndex = idx;
358
+ },
359
+ ondragleave: (e) => {
360
+ if (state.dragOverIndex === idx) {
361
+ state.dragOverIndex = null;
362
+ }
363
+ },
364
+ ondrop: (e) => {
365
+ e.preventDefault();
366
+ const fromIdx = state.draggedIndex;
367
+ const toIdx = idx;
368
+ if (fromIdx !== null && fromIdx !== undefined && fromIdx !== toIdx) {
369
+ const newUrls = [...urls];
370
+ const [movedItem] = newUrls.splice(fromIdx, 1);
371
+ newUrls.splice(toIdx, 0, movedItem);
372
+ if (vnode.attrs.onchange) vnode.attrs.onchange(newUrls);
373
+ }
374
+ state.draggedIndex = null;
375
+ state.dragOverIndex = null;
376
+ },
377
+ ondragend: (e) => {
378
+ state.draggedIndex = null;
379
+ state.dragOverIndex = null;
380
+ }
381
+ }, [
382
+ shouldShowImagePreview(url, accept)
383
+ ? m('a.block', { href: url, target: '_blank', rel: 'noopener noreferrer', draggable: false },
384
+ m('img.h-32.w-full.rounded.object-contain.bg-white.dark:bg-slate-950', {
385
+ src: url,
386
+ alt: `${label} ${idx + 1}`,
387
+ loading: 'lazy',
388
+ draggable: false,
389
+ })
390
+ )
391
+ : m('a.flex.items-center.justify-center.h-32.w-full.text-sm.text-indigo-600.dark:text-indigo-400.break-all.p-2', {
392
+ href: url,
393
+ target: '_blank',
394
+ rel: 'noopener noreferrer',
395
+ draggable: false,
396
+ }, url.substring(url.lastIndexOf('/') + 1) || url),
397
+ !readonly
398
+ ? m('button.absolute.top-1.right-1.bg-red-500.text-white.rounded-full.p-1.shadow-md.opacity-0.group-hover:opacity-100.hover:bg-red-700.transition-opacity', {
399
+ type: 'button',
400
+ onclick: () => {
401
+ const newUrls = urls.filter((_, i) => i !== idx);
402
+ if (vnode.attrs.onchange) vnode.attrs.onchange(newUrls);
403
+ },
404
+ title: 'Remove file',
405
+ }, [
406
+ m('svg.w-4.h-4', { fill: 'none', stroke: 'currentColor', viewBox: '0 0 24 24' },
407
+ m('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M6 18L18 6M6 6l12 12' })
408
+ )
409
+ ])
410
+ : null,
411
+ ]);
412
+ }));
413
+ };
414
+
283
415
  if (!uploadUrl) {
284
416
  return m('.mb-4', [
285
417
  m(
@@ -287,7 +419,19 @@ module.exports = {
287
419
  label,
288
420
  required ? m('span.text-red-500', ' *') : null
289
421
  ),
290
- m('p.text-xs.text-amber-700.dark:text-amber-400.mb-2', 'Upload URL is not configured.'),
422
+ typeof window !== 'undefined' && window.__openFileManagerPicker
423
+ ? m('.flex.items-center.gap-2.mb-3', [
424
+ m('button.px-3.py-1.5.bg-indigo-600.hover:bg-indigo-700.text-white.text-xs.font-medium.rounded.flex.items-center.gap-1.shadow-sm', {
425
+ type: 'button',
426
+ onclick: openLibraryPicker,
427
+ }, '📁 Add from Media Library')
428
+ ])
429
+ : m('p.text-xs.text-amber-700.dark:text-amber-400.mb-2', 'Upload URL is not configured.'),
430
+ renderGrid(),
431
+ m('input[type=hidden]', {
432
+ name,
433
+ value: JSON.stringify(urls),
434
+ }),
291
435
  ]);
292
436
  }
293
437
 
@@ -298,83 +442,7 @@ module.exports = {
298
442
  required ? m('span.text-red-500', ' *') : null
299
443
  ),
300
444
 
301
- urls.length > 0
302
- ? m('.grid.grid-cols-2.sm:grid-cols-3.md:grid-cols-4.gap-4.mb-3', urls.map((url, idx) => {
303
- const isDragOver = state.dragOverIndex === idx;
304
- const baseClasses = '.relative.group.border.rounded-lg.p-2.transition-all';
305
- const cursorClass = readonly ? '' : '.cursor-move';
306
- const borderClasses = isDragOver ? '.border-blue-500.border-2.dark:border-blue-500' : '.border-gray-200.dark:border-slate-700';
307
- const bgClasses = isDragOver ? '.bg-blue-50.dark:bg-slate-800' : '.bg-gray-50.dark:bg-slate-900';
308
-
309
- return m(baseClasses + cursorClass + borderClasses + bgClasses, {
310
- draggable: !readonly,
311
- ondragstart: (e) => {
312
- state.draggedIndex = idx;
313
- e.dataTransfer.effectAllowed = 'move';
314
- e.dataTransfer.setData('text/plain', idx);
315
- },
316
- ondragover: (e) => {
317
- e.preventDefault();
318
- e.dataTransfer.dropEffect = 'move';
319
- },
320
- ondragenter: (e) => {
321
- state.dragOverIndex = idx;
322
- },
323
- ondragleave: (e) => {
324
- if (state.dragOverIndex === idx) {
325
- state.dragOverIndex = null;
326
- }
327
- },
328
- ondrop: (e) => {
329
- e.preventDefault();
330
- const fromIdx = state.draggedIndex;
331
- const toIdx = idx;
332
- if (fromIdx !== null && fromIdx !== undefined && fromIdx !== toIdx) {
333
- const newUrls = [...urls];
334
- const [movedItem] = newUrls.splice(fromIdx, 1);
335
- newUrls.splice(toIdx, 0, movedItem);
336
- if (vnode.attrs.onchange) vnode.attrs.onchange(newUrls);
337
- }
338
- state.draggedIndex = null;
339
- state.dragOverIndex = null;
340
- },
341
- ondragend: (e) => {
342
- state.draggedIndex = null;
343
- state.dragOverIndex = null;
344
- }
345
- }, [
346
- shouldShowImagePreview(url, accept)
347
- ? m('a.block', { href: url, target: '_blank', rel: 'noopener noreferrer', draggable: false },
348
- m('img.h-32.w-full.rounded.object-contain.bg-white.dark:bg-slate-950', {
349
- src: url,
350
- alt: `${label} ${idx + 1}`,
351
- loading: 'lazy',
352
- draggable: false,
353
- })
354
- )
355
- : m('a.flex.items-center.justify-center.h-32.w-full.text-sm.text-indigo-600.dark:text-indigo-400.break-all.p-2', {
356
- href: url,
357
- target: '_blank',
358
- rel: 'noopener noreferrer',
359
- draggable: false,
360
- }, url.substring(url.lastIndexOf('/') + 1) || url),
361
- !readonly
362
- ? m('button.absolute.top-1.right-1.bg-red-500.text-white.rounded-full.p-1.shadow-md.opacity-0.group-hover:opacity-100.hover:bg-red-700.transition-opacity', {
363
- type: 'button',
364
- onclick: () => {
365
- const newUrls = urls.filter((_, i) => i !== idx);
366
- if (vnode.attrs.onchange) vnode.attrs.onchange(newUrls);
367
- },
368
- title: 'Remove file',
369
- }, [
370
- m('svg.w-4.h-4', { fill: 'none', stroke: 'currentColor', viewBox: '0 0 24 24' },
371
- m('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M6 18L18 6M6 6l12 12' })
372
- )
373
- ])
374
- : null,
375
- ]);
376
- }))
377
- : null,
445
+ renderGrid(),
378
446
 
379
447
  m(
380
448
  'div.border-2.border-dashed.border-gray-300.dark:border-slate-600.rounded.p-6.text-center',
@@ -405,11 +473,26 @@ module.exports = {
405
473
  'p.text-gray-600.dark:text-slate-400.mb-2',
406
474
  'Drag and drop files here, or'
407
475
  ),
408
- m(
409
- 'label.text-blue-600.hover:text-blue-800.cursor-pointer',
410
- { for: 'file-input-multiple-' + name },
411
- 'browse'
412
- ),
476
+ m('.flex.items-center.justify-center.gap-3.flex-wrap', [
477
+ m(
478
+ 'label.text-blue-600.hover:text-blue-800.cursor-pointer',
479
+ { for: 'file-input-multiple-' + name },
480
+ 'browse'
481
+ ),
482
+ typeof window !== 'undefined' && window.__openFileManagerPicker
483
+ ? m('button.text-indigo-600.hover:text-indigo-800.dark:text-indigo-400.cursor-pointer.font-medium.flex.items-center.gap-1.text-sm', {
484
+ type: 'button',
485
+ onclick: (e) => {
486
+ e.preventDefault();
487
+ e.stopPropagation();
488
+ openLibraryPicker();
489
+ }
490
+ }, [
491
+ m('span', '•'),
492
+ m('span', '📁 Add from Media Library')
493
+ ])
494
+ : null,
495
+ ]),
413
496
  ]),
414
497
  ]),
415
498
  ]
@@ -79,7 +79,9 @@ function initializeDefaultRenderers() {
79
79
  registerFieldRenderer('rich-text', richTextRenderer.RichTextField);
80
80
  registerFieldRenderer('file-upload', fileUploadRenderer.FileUploadField);
81
81
  registerFieldRenderer('file', fileUploadRenderer.FileUploadField);
82
+ registerFieldRenderer('image', fileUploadRenderer.FileUploadField);
82
83
  registerFieldRenderer('files', fileUploadRenderer.FilesUploadField);
84
+ registerFieldRenderer('files-upload', fileUploadRenderer.FilesUploadField);
83
85
 
84
86
  // Relation types
85
87
  registerFieldRenderer('belongsTo', relationRenderers.BelongsToField);