webspresso 0.0.66 → 0.0.68

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.
@@ -495,6 +495,7 @@ const FilterDrawer = {
495
495
  if (col.primary || col.autoIncrement) return false;
496
496
  if (col.auto === 'create' || col.auto === 'update') return false;
497
497
  if (col.type === 'json') return false;
498
+ if (col.type === 'file') return false;
498
499
  return true;
499
500
  });
500
501
 
@@ -1049,6 +1050,155 @@ const RichTextField = {
1049
1050
  }
1050
1051
  };
1051
1052
 
1053
+ // File upload field (multipart POST to settings.uploadUrl; field name "file")
1054
+ const FileUploadField = {
1055
+ oncreate: (vnode) => {
1056
+ const col = vnode.attrs.col;
1057
+ const readonly = vnode.attrs.readonly;
1058
+ if (readonly) return;
1059
+ var cfg = window.__ADMIN_CONFIG__;
1060
+ var uploadUrl = (cfg && cfg.settings && cfg.settings.uploadUrl) ? String(cfg.settings.uploadUrl) : '';
1061
+ if (!uploadUrl) return;
1062
+ const dropZoneId = 'drop-zone-' + col.name;
1063
+ const dropZone = document.getElementById(dropZoneId);
1064
+ if (!dropZone) return;
1065
+ const meta = col.ui || {};
1066
+ const onChange = vnode.attrs.onChange;
1067
+ ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(function (eventName) {
1068
+ dropZone.addEventListener(eventName, function (e) {
1069
+ e.preventDefault();
1070
+ e.stopPropagation();
1071
+ });
1072
+ });
1073
+ ['dragenter', 'dragover'].forEach(function (eventName) {
1074
+ dropZone.addEventListener(eventName, function () {
1075
+ dropZone.classList.add('border-blue-500', 'bg-blue-50', 'dark:bg-slate-800');
1076
+ });
1077
+ });
1078
+ ['dragleave', 'drop'].forEach(function (eventName) {
1079
+ dropZone.addEventListener(eventName, function () {
1080
+ dropZone.classList.remove('border-blue-500', 'bg-blue-50', 'dark:bg-slate-800');
1081
+ });
1082
+ });
1083
+ dropZone.addEventListener('drop', function (e) {
1084
+ var files = e.dataTransfer.files;
1085
+ if (files.length > 0) {
1086
+ handleAdminFileUpload(files[0], onChange, meta);
1087
+ }
1088
+ });
1089
+ var fileInput = dropZone.querySelector('input[type=file]');
1090
+ if (fileInput) {
1091
+ fileInput.addEventListener('change', function (e) {
1092
+ if (e.target.files.length > 0) {
1093
+ handleAdminFileUpload(e.target.files[0], onChange, meta);
1094
+ }
1095
+ });
1096
+ }
1097
+ },
1098
+ view: (vnode) => {
1099
+ const col = vnode.attrs.col;
1100
+ const value = vnode.attrs.value || '';
1101
+ const onChange = vnode.attrs.onChange;
1102
+ const readonly = vnode.attrs.readonly || false;
1103
+ const meta = col.ui || {};
1104
+ const label = meta.label || formatColumnLabel(col.name);
1105
+ const hint = meta.hint || '';
1106
+ const required = !col.nullable && !readonly;
1107
+ var uploadUrl = '';
1108
+ try {
1109
+ var cfg2 = window.__ADMIN_CONFIG__;
1110
+ uploadUrl = (cfg2 && cfg2.settings && cfg2.settings.uploadUrl) ? String(cfg2.settings.uploadUrl) : '';
1111
+ } catch (e) { uploadUrl = ''; }
1112
+ var maxSize = meta.maxSize || meta.maxBytes || (10 * 1024 * 1024);
1113
+ var accept = meta.accept || '*/*';
1114
+ var dropZoneId = 'drop-zone-' + col.name;
1115
+ if (readonly) {
1116
+ return m('.mb-4', [
1117
+ m('label.block.text-sm.font-medium.text-gray-700.dark:text-slate-300.mb-1', label, required ? m('span.text-red-500', ' *') : null),
1118
+ value ? m('a.text-indigo-600.break-all', { href: value, target: '_blank', rel: 'noopener noreferrer' }, value) : m('span.text-gray-400', '—'),
1119
+ hint ? m('p.text-xs.text-gray-500.dark:text-slate-400.mt-1', hint) : null,
1120
+ ]);
1121
+ }
1122
+ if (!uploadUrl) {
1123
+ return m('.mb-4', [
1124
+ m('label.block.text-sm.font-medium.text-gray-700.dark:text-slate-300.mb-1', { for: col.name }, label, required ? m('span.text-red-500', ' *') : null),
1125
+ 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.'),
1126
+ m('input.w-full.px-3.py-2.border.border-gray-300.dark:border-slate-600.rounded.bg-white.dark:bg-slate-800', {
1127
+ type: 'text',
1128
+ id: col.name,
1129
+ name: col.name,
1130
+ value: value,
1131
+ placeholder: 'https://… or /uploads/…',
1132
+ required: required,
1133
+ oninput: function (e) { if (onChange) onChange(e.target.value); },
1134
+ }),
1135
+ hint ? m('p.text-xs.text-gray-500.dark:text-slate-400.mt-1', hint) : null,
1136
+ ]);
1137
+ }
1138
+ return m('.mb-4', [
1139
+ m('label.block.text-sm.font-medium.text-gray-700.dark:text-slate-300.mb-1', label, required ? m('span.text-red-500', ' *') : null),
1140
+ m('div#' + dropZoneId + '.border-2.border-dashed.border-gray-300.dark:border-slate-600.rounded.p-8.text-center', { style: 'cursor: pointer;' }, [
1141
+ m('input[type=file].hidden', {
1142
+ id: 'file-input-' + col.name,
1143
+ accept: accept,
1144
+ onchange: function (e) {
1145
+ if (e.target.files.length > 0) {
1146
+ handleAdminFileUpload(e.target.files[0], onChange, meta);
1147
+ }
1148
+ },
1149
+ }),
1150
+ m('div', [
1151
+ m('p.text-gray-600.dark:text-slate-400.mb-2', 'Drag and drop a file here, or'),
1152
+ m('label.text-blue-600.hover:text-blue-800.dark:text-blue-400.cursor-pointer', { for: 'file-input-' + col.name }, 'browse'),
1153
+ ]),
1154
+ value ? m('.mt-4.text-left', [
1155
+ m('p.text-sm.text-gray-600.dark:text-slate-400.break-all', 'Current: ' + value),
1156
+ m('button.text-red-600.hover:text-red-800.text-sm.mt-2', {
1157
+ type: 'button',
1158
+ onclick: function () { if (onChange) onChange(''); },
1159
+ }, 'Remove'),
1160
+ ]) : null,
1161
+ ]),
1162
+ m('input[type=hidden]', { name: col.name, value: typeof value === 'string' ? value : '' }),
1163
+ m('p.text-xs.text-gray-500.dark:text-slate-400.mt-1', 'Max ' + Math.round(maxSize / 1024 / 1024) + ' MB (server enforces limits)'),
1164
+ hint ? m('p.text-xs.text-gray-500.dark:text-slate-400.mt-1', hint) : null,
1165
+ ]);
1166
+ },
1167
+ };
1168
+
1169
+ async function handleAdminFileUpload(file, onChange, meta) {
1170
+ var uploadUrl = '';
1171
+ try {
1172
+ var cfg = window.__ADMIN_CONFIG__;
1173
+ uploadUrl = (cfg && cfg.settings && cfg.settings.uploadUrl) ? String(cfg.settings.uploadUrl) : '';
1174
+ } catch (e) {}
1175
+ if (!uploadUrl) {
1176
+ alert('Upload URL is not configured.');
1177
+ return;
1178
+ }
1179
+ var maxSize = meta.maxSize || meta.maxBytes || (10 * 1024 * 1024);
1180
+ if (file.size > maxSize) {
1181
+ alert('File too large (max ' + Math.round(maxSize / 1024 / 1024) + ' MB).');
1182
+ return;
1183
+ }
1184
+ var fd = new FormData();
1185
+ fd.append('file', file);
1186
+ try {
1187
+ var res = await fetch(uploadUrl, { method: 'POST', body: fd, credentials: 'include' });
1188
+ var data = {};
1189
+ try { data = await res.json(); } catch (e2) { data = {}; }
1190
+ if (!res.ok) {
1191
+ alert(data.message || data.error || ('Upload failed (' + res.status + ')'));
1192
+ return;
1193
+ }
1194
+ var url = data.url || data.publicUrl || '';
1195
+ if (onChange) onChange(url);
1196
+ m.redraw();
1197
+ } catch (err) {
1198
+ alert(err.message || 'Upload failed');
1199
+ }
1200
+ }
1201
+
1052
1202
  // Get appropriate renderer for a column type
1053
1203
  function getFieldRenderer(col, modelMeta) {
1054
1204
  // Check for custom field first
@@ -1064,8 +1214,29 @@ function getFieldRenderer(col, modelMeta) {
1064
1214
  });
1065
1215
  };
1066
1216
  }
1217
+ if (col.customField.type === 'file-upload') {
1218
+ return (col, value, onChange, readonly) => {
1219
+ return m(FileUploadField, {
1220
+ col,
1221
+ value: value || '',
1222
+ onChange,
1223
+ readonly: readonly || false,
1224
+ });
1225
+ };
1226
+ }
1067
1227
  // Add other custom field types here if needed
1068
1228
  }
1229
+
1230
+ if (col.type === 'file') {
1231
+ return (col, value, onChange, readonly) => {
1232
+ return m(FileUploadField, {
1233
+ col,
1234
+ value: value || '',
1235
+ onChange,
1236
+ readonly: readonly || false,
1237
+ });
1238
+ };
1239
+ }
1069
1240
 
1070
1241
  // Fallback to standard type renderers
1071
1242
  const typeMap = {
@@ -1316,6 +1487,19 @@ function formatCellValue(value, col) {
1316
1487
  case 'text':
1317
1488
  const textStr = String(value);
1318
1489
  return textStr.length > 50 ? textStr.substring(0, 50) + '...' : textStr;
1490
+
1491
+ case 'file': {
1492
+ const s = String(value);
1493
+ const short = s.length > 72 ? s.substring(0, 72) + '…' : s;
1494
+ if (/^https?:\/\//.test(s) || s.startsWith('/')) {
1495
+ return m('a.text-indigo-600.dark:text-indigo-400.hover:underline.break-all', {
1496
+ href: s,
1497
+ target: '_blank',
1498
+ rel: 'noopener noreferrer',
1499
+ }, short);
1500
+ }
1501
+ return short || m('span.text-gray-400', '—');
1502
+ }
1319
1503
 
1320
1504
  default:
1321
1505
  const str = String(value);
@@ -67,6 +67,7 @@ class AdminRegistry {
67
67
  perPage: 20,
68
68
  dateFormat: 'YYYY-MM-DD',
69
69
  timeFormat: 'HH:mm',
70
+ uploadUrl: null,
70
71
  };
71
72
 
72
73
  // User management config
@@ -1,124 +1,193 @@
1
1
  /**
2
2
  * File Upload Field Renderer
3
- * Droppable file upload component
3
+ * POST multipart field "file" to window.__ADMIN_CONFIG__.settings.uploadUrl
4
4
  */
5
5
 
6
+ function getUploadUrlFromAdminConfig() {
7
+ try {
8
+ const cfg = typeof window !== 'undefined' ? window.__ADMIN_CONFIG__ : null;
9
+ const u = cfg && cfg.settings && cfg.settings.uploadUrl;
10
+ return u ? String(u) : '';
11
+ } catch {
12
+ return '';
13
+ }
14
+ }
15
+
6
16
  module.exports = {
7
17
  FileUploadField: {
8
18
  oncreate: (vnode) => {
9
- const { name, value = '', onchange, meta = {} } = vnode.attrs;
19
+ const { name, meta = {} } = vnode.attrs;
20
+ if (!getUploadUrlFromAdminConfig()) return;
21
+
10
22
  const dropZoneId = 'drop-zone-' + name;
11
-
12
23
  const dropZone = document.getElementById(dropZoneId);
13
24
  if (!dropZone) return;
14
-
15
- // Prevent default drag behaviors
16
- ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
25
+
26
+ ['dragenter', 'dragover', 'dragleave', 'drop'].forEach((eventName) => {
17
27
  dropZone.addEventListener(eventName, (e) => {
18
28
  e.preventDefault();
19
29
  e.stopPropagation();
20
30
  });
21
31
  });
22
-
23
- // Highlight drop zone when item is dragged over it
24
- ['dragenter', 'dragover'].forEach(eventName => {
32
+
33
+ ['dragenter', 'dragover'].forEach((eventName) => {
25
34
  dropZone.addEventListener(eventName, () => {
26
- dropZone.classList.add('border-blue-500', 'bg-blue-50');
35
+ dropZone.classList.add('border-blue-500', 'bg-blue-50', 'dark:bg-slate-800');
27
36
  });
28
37
  });
29
-
30
- ['dragleave', 'drop'].forEach(eventName => {
38
+
39
+ ['dragleave', 'drop'].forEach((eventName) => {
31
40
  dropZone.addEventListener(eventName, () => {
32
- dropZone.classList.remove('border-blue-500', 'bg-blue-50');
41
+ dropZone.classList.remove('border-blue-500', 'bg-blue-50', 'dark:bg-slate-800');
33
42
  });
34
43
  });
35
-
36
- // Handle dropped files
44
+
37
45
  dropZone.addEventListener('drop', (e) => {
38
46
  const files = e.dataTransfer.files;
39
47
  if (files.length > 0) {
40
- handleFile(files[0], vnode, meta);
48
+ handleFileUpload(files[0], vnode, meta);
41
49
  }
42
50
  });
43
-
44
- // Handle file input change
51
+
45
52
  const fileInput = dropZone.querySelector('input[type=file]');
46
53
  if (fileInput) {
47
54
  fileInput.addEventListener('change', (e) => {
48
55
  if (e.target.files.length > 0) {
49
- handleFile(e.target.files[0], vnode, meta);
56
+ handleFileUpload(e.target.files[0], vnode, meta);
50
57
  }
51
58
  });
52
59
  }
53
60
  },
54
-
61
+
55
62
  view: (vnode) => {
56
63
  const { name, value = '', meta = {}, required = false } = vnode.attrs;
57
64
  const dropZoneId = 'drop-zone-' + name;
58
- const maxSize = meta.maxSize || 5 * 1024 * 1024; // 5MB default
65
+ const maxSize = meta.maxSize || meta.maxBytes || 10 * 1024 * 1024;
59
66
  const accept = meta.accept || '*/*';
60
-
67
+ const uploadUrl = getUploadUrlFromAdminConfig();
68
+
69
+ if (!uploadUrl) {
70
+ return m('.mb-4', [
71
+ m(
72
+ 'label.block.text-sm.font-medium.mb-2',
73
+ meta.label || name,
74
+ required ? m('span.text-red-500', ' *') : null
75
+ ),
76
+ m('p.text-xs.text-amber-700.dark:text-amber-400.mb-2', 'Upload URL is not configured.'),
77
+ m('input.w-full.px-3.py-2.border.rounded', {
78
+ type: 'text',
79
+ name,
80
+ value: typeof value === 'string' ? value : '',
81
+ placeholder: 'https://… or /uploads/…',
82
+ required,
83
+ oninput: (e) => {
84
+ if (vnode.attrs.onchange) vnode.attrs.onchange(e.target.value);
85
+ },
86
+ }),
87
+ ]);
88
+ }
89
+
61
90
  return m('.mb-4', [
62
- m('label.block.text-sm.font-medium.mb-2',
91
+ m(
92
+ 'label.block.text-sm.font-medium.mb-2',
63
93
  meta.label || name,
64
94
  required ? m('span.text-red-500', ' *') : null
65
95
  ),
66
- m('div#drop-zone-' + name + '.border-2.border-dashed.border-gray-300 dark:border-slate-600.rounded.p-8.text-center', {
67
- style: 'cursor: pointer;'
68
- }, [
69
- m('input[type=file]', {
70
- class: 'hidden',
71
- id: 'file-input-' + name,
72
- accept,
73
- onchange: (e) => {
74
- if (e.target.files.length > 0) {
75
- handleFile(e.target.files[0], vnode, meta);
76
- }
77
- }
78
- }),
79
- m('div', [
80
- m('p.text-gray-600 dark:text-slate-400.mb-2', 'Drag and drop a file here, or'),
81
- m('label.text-blue-600.hover:text-blue-800.cursor-pointer', {
82
- for: 'file-input-' + name
83
- }, 'browse'),
84
- ]),
85
- value ? m('.mt-4', [
86
- m('p.text-sm.text-gray-600', 'Current file: ' + (typeof value === 'string' ? value : value.name || 'uploaded')),
87
- m('button.text-red-600.hover:text-red-800.text-sm.mt-2', {
88
- onclick: () => {
89
- if (vnode.attrs.onchange) {
90
- vnode.attrs.onchange('');
96
+ m(
97
+ 'div.border-2.border-dashed.border-gray-300.dark:border-slate-600.rounded.p-8.text-center',
98
+ {
99
+ id: dropZoneId,
100
+ style: 'cursor: pointer;',
101
+ },
102
+ [
103
+ m('input[type=file]', {
104
+ class: 'hidden',
105
+ id: 'file-input-' + name,
106
+ accept,
107
+ onchange: (e) => {
108
+ if (e.target.files.length > 0) {
109
+ handleFileUpload(e.target.files[0], vnode, meta);
91
110
  }
92
- }
93
- }, 'Remove'),
94
- ]) : null,
95
- ]),
111
+ },
112
+ }),
113
+ m('div', [
114
+ m('p.text-gray-600.dark:text-slate-400.mb-2', 'Drag and drop a file here, or'),
115
+ m(
116
+ 'label.text-blue-600.hover:text-blue-800.cursor-pointer',
117
+ { for: 'file-input-' + name },
118
+ 'browse'
119
+ ),
120
+ ]),
121
+ value
122
+ ? m('.mt-4', [
123
+ m(
124
+ 'p.text-sm.text-gray-600.break-all',
125
+ 'Current: ' + (typeof value === 'string' ? value : value.name || 'uploaded')
126
+ ),
127
+ m(
128
+ 'button.text-red-600.hover:text-red-800.text-sm.mt-2',
129
+ {
130
+ type: 'button',
131
+ onclick: () => {
132
+ if (vnode.attrs.onchange) vnode.attrs.onchange('');
133
+ },
134
+ },
135
+ 'Remove'
136
+ ),
137
+ ])
138
+ : null,
139
+ ]
140
+ ),
96
141
  m('input[type=hidden]', {
97
142
  name,
98
143
  value: typeof value === 'string' ? value : '',
99
144
  }),
145
+ m(
146
+ 'p.text-xs.text-gray-500.mt-1',
147
+ 'Max ' + Math.round(maxSize / 1024 / 1024) + ' MB (server enforces limits)'
148
+ ),
100
149
  ]);
101
- }
150
+ },
102
151
  },
103
152
  };
104
153
 
105
154
  /**
106
- * Handle file upload
107
- * @param {File} file - File object
108
- * @param {Object} vnode - Mithril vnode
109
- * @param {Object} meta - Field metadata
155
+ * @param {File} file
156
+ * @param {import('mithril').Vnode} vnode
157
+ * @param {Object} meta
110
158
  */
111
- function handleFile(file, vnode, meta) {
112
- const maxSize = meta.maxSize || 5 * 1024 * 1024;
113
-
159
+ async function handleFileUpload(file, vnode, meta) {
160
+ const maxSize = meta.maxSize || meta.maxBytes || 10 * 1024 * 1024;
114
161
  if (file.size > maxSize) {
115
- alert(`File size exceeds maximum allowed size of ${Math.round(maxSize / 1024 / 1024)}MB`);
162
+ alert(`File too large (max ${Math.round(maxSize / 1024 / 1024)} MB).`);
116
163
  return;
117
164
  }
118
-
119
- // For now, just store the file name
120
- // In a real implementation, you'd upload to server and get URL
121
- if (vnode.attrs.onchange) {
122
- vnode.attrs.onchange(file.name);
165
+
166
+ const uploadUrl = getUploadUrlFromAdminConfig();
167
+ if (!uploadUrl) {
168
+ alert('Upload URL is not configured.');
169
+ return;
170
+ }
171
+
172
+ const fd = new FormData();
173
+ fd.append('file', file);
174
+
175
+ try {
176
+ const res = await fetch(uploadUrl, { method: 'POST', body: fd, credentials: 'include' });
177
+ let data = {};
178
+ try {
179
+ data = await res.json();
180
+ } catch {
181
+ data = {};
182
+ }
183
+ if (!res.ok) {
184
+ alert(data.message || data.error || `Upload failed (${res.status})`);
185
+ return;
186
+ }
187
+ const url = data.url || data.publicUrl || '';
188
+ if (vnode.attrs.onchange) vnode.attrs.onchange(url);
189
+ if (typeof m !== 'undefined' && m.redraw) m.redraw();
190
+ } catch (err) {
191
+ alert(err.message || 'Upload failed');
123
192
  }
124
193
  }
@@ -78,6 +78,7 @@ function initializeDefaultRenderers() {
78
78
  // Custom field types
79
79
  registerFieldRenderer('rich-text', richTextRenderer.RichTextField);
80
80
  registerFieldRenderer('file-upload', fileUploadRenderer.FileUploadField);
81
+ registerFieldRenderer('file', fileUploadRenderer.FileUploadField);
81
82
 
82
83
  // Relation types
83
84
  registerFieldRenderer('belongsTo', relationRenderers.BelongsToField);
@@ -30,6 +30,7 @@ const { registerModule } = require('./core/admin-module');
30
30
  * @param {string} [options.userManagement.model='User'] - User model name
31
31
  * @param {Object} [options.userManagement.fields] - Field mappings
32
32
  * @param {Function} [options.configure] - Configuration callback (registry) => void
33
+ * @param {string} [options.uploadUrl] - POST URL for file uploads (overrides app.get('webspresso.uploadPath') from uploadPlugin)
33
34
  * @returns {Object} Plugin definition
34
35
  */
35
36
  function adminPanelPlugin(options = {}) {
@@ -41,6 +42,7 @@ function adminPanelPlugin(options = {}) {
41
42
  auth,
42
43
  userManagement: userMgmtConfig,
43
44
  configure,
45
+ uploadUrl: uploadUrlOption,
44
46
  } = options;
45
47
 
46
48
  // Validate required options
@@ -128,6 +130,12 @@ function adminPanelPlugin(options = {}) {
128
130
  this.api._ctx = ctx;
129
131
  const { app } = ctx;
130
132
 
133
+ const uploadUrlResolved =
134
+ uploadUrlOption || app.get('webspresso.uploadPath') || null;
135
+ if (uploadUrlResolved) {
136
+ registry.configure({ uploadUrl: uploadUrlResolved });
137
+ }
138
+
131
139
  // Check if admin_users table exists (migration run)
132
140
  db.knex.schema.hasTable('admin_users').then((hasAdminTable) => {
133
141
  if (!hasAdminTable) {
package/plugins/index.js CHANGED
@@ -16,6 +16,7 @@ const swaggerPlugin = require('./swagger');
16
16
  const healthCheckPlugin = require('./health-check');
17
17
  const restResourcePlugin = require('./rest-resources');
18
18
  const ormCacheAdminPlugin = require('./orm-cache-admin');
19
+ const { uploadPlugin, createLocalFileProvider } = require('./upload');
19
20
 
20
21
  module.exports = {
21
22
  sitemapPlugin,
@@ -31,5 +32,7 @@ module.exports = {
31
32
  healthCheckPlugin,
32
33
  restResourcePlugin,
33
34
  ormCacheAdminPlugin,
35
+ uploadPlugin,
36
+ createLocalFileProvider,
34
37
  };
35
38