@dmitryvim/form-builder 0.1.9 → 0.1.11

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.
@@ -1,1327 +1,1377 @@
1
- /**
2
- * Form Builder Library v0.1.4
3
- * A reusable JSON schema form builder library
4
- * https://github.com/picazru/form-builder
5
- */
6
-
7
- (function (global, factory) {
8
- typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
9
- typeof define === 'function' && define.amd ? define(['exports'], factory) :
10
- (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.FormBuilder = {}));
11
- })(this, (function (exports) {
12
- 'use strict';
13
-
14
- // Utility functions
15
- const sleep = (ms) => new Promise(r => setTimeout(r, ms));
16
- const pretty = (obj) => JSON.stringify(obj, null, 2);
17
- const deepClone = (obj) => structuredClone ? structuredClone(obj) : JSON.parse(JSON.stringify(obj));
18
- const isPlainObject = (v) => Object.prototype.toString.call(v) === '[object Object]';
19
- const setText = (node, text) => { node.textContent = text || ''; };
20
- const pathJoin = (base, key) => base ? `${base}.${key}` : key;
21
- const assert = (c, m) => { if(!c) throw new Error(m); };
22
- const warn = (m) => console.warn('[FormBuilder]', m);
23
-
24
- function clear(node) {
25
- while (node.firstChild) node.removeChild(node.firstChild);
26
- }
27
-
28
- function parseJSONSafe(text, fallback = null) {
29
- try {
30
- return JSON.parse(text);
31
- } catch {
32
- return fallback;
33
- }
34
- }
35
-
36
- async function makeResourceIdFromFile(file) {
37
- try {
38
- const buf = await file.arrayBuffer();
39
- if (crypto?.subtle?.digest) {
40
- const hash = await crypto.subtle.digest('SHA-256', buf);
41
- const hex = [...new Uint8Array(hash)].map(b => b.toString(16).padStart(2, '0')).join('');
42
- return `res_${hex.slice(0, 24)}`;
43
- }
44
- } catch(_) {}
45
- const rnd = Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2);
46
- return `res_${rnd.slice(0, 24)}`;
47
- }
48
-
49
- function formatFileSize(bytes) {
50
- if (bytes === 0) return '0 B';
51
- const k = 1024;
52
- const sizes = ['B', 'KB', 'MB', 'GB'];
53
- const i = Math.floor(Math.log(bytes) / Math.log(k));
54
- return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
55
- }
56
-
57
- // Schema validation
58
- function validateSchema(schema) {
59
- const errors = [];
60
- try { assert(schema && schema.version === '0.3', 'schema.version must be "0.3"'); } catch(e) { errors.push(e.message); }
61
- try { assert(Array.isArray(schema.elements), 'schema.elements must be an array'); } catch(e) { errors.push(e.message); }
62
-
63
- function validateElements(elements, path) {
64
- const seen = new Set();
65
- elements.forEach((el, idx) => {
66
- const here = `${path}[${idx}]`;
67
- if (!el || typeof el !== 'object') { errors.push(`${here}: element must be object`); return; }
68
- if (!el.type) errors.push(`${here}: missing "type"`);
69
- if (!el.key) errors.push(`${here}: missing "key"`);
70
- if (el.key) {
71
- if (seen.has(el.key)) errors.push(`${path}: duplicate key "${el.key}"`);
72
- seen.add(el.key);
73
- }
74
- if (el.default !== undefined && (el.type === 'file' || el.type === 'files' || el.type === 'videos')) {
75
- errors.push(`${here}: default forbidden for "${el.type}"`);
76
- }
77
-
78
- // Type-specific validation
79
- if (el.type === 'text' || el.type === 'textarea') {
80
- if (el.minLength != null && el.maxLength != null && el.minLength > el.maxLength) {
81
- errors.push(`${here}: minLength > maxLength`);
82
- }
83
- if (el.pattern != null) {
84
- try { new RegExp(el.pattern); } catch { errors.push(`${here}: invalid pattern regex`); }
85
- }
86
- }
87
- if (el.type === 'number') {
88
- if (typeof el.min === 'number' && typeof el.max === 'number' && el.min > el.max) {
89
- errors.push(`${here}: min > max`);
90
- }
91
- if (el.decimals != null && (!Number.isInteger(el.decimals) || el.decimals < 0 || el.decimals > 8)) {
92
- errors.push(`${here}: decimals must be 0..8`);
93
- }
94
- }
95
- if (el.type === 'select') {
96
- if (!Array.isArray(el.options) || el.options.length === 0) {
97
- errors.push(`${here}: select.options must be non-empty array`);
98
- } else {
99
- const values = new Set(el.options.map(o => o.value));
100
- if (el.default != null && !values.has(el.default)) {
101
- errors.push(`${here}: default "${el.default}" not in options`);
102
- }
103
- }
104
- }
105
- if (el.type === 'file') {
106
- if (el.maxSizeMB != null && el.maxSizeMB <= 0) {
107
- errors.push(`${here}: maxSizeMB must be > 0`);
108
- }
109
- }
110
- if (el.type === 'files') {
111
- if (el.minCount != null && el.maxCount != null && el.minCount > el.maxCount) {
112
- errors.push(`${here}: minCount > maxCount`);
113
- }
114
- }
115
- if (el.type === 'videos') {
116
- if (el.minCount != null && el.maxCount != null && el.minCount > el.maxCount) {
117
- errors.push(`${here}: minCount > maxCount`);
118
- }
119
- }
120
- if (el.type === 'group') {
121
- if (!Array.isArray(el.elements)) errors.push(`${here}: group.elements must be array`);
122
- if (el.repeat) {
123
- if (el.repeat.min != null && el.repeat.max != null && el.repeat.min > el.repeat.max) {
124
- errors.push(`${here}: repeat.min > repeat.max`);
125
- }
126
- }
127
- // Validate element_label if provided (optional)
128
- if (el.element_label != null && typeof el.element_label !== 'string') {
129
- errors.push(`${here}: element_label must be a string`);
130
- }
131
- if (Array.isArray(el.elements)) validateElements(el.elements, pathJoin(path, el.key));
132
- }
133
- });
134
- }
135
-
136
- if (Array.isArray(schema.elements)) validateElements(schema.elements, 'elements');
137
- return errors;
138
- }
139
-
140
- // Form rendering state
141
- let resourceIndex = new Map();
142
- let config = {
1
+ // Form Builder Library - Core API
2
+ // State management
3
+ const state = {
4
+ schema: null,
5
+ formRoot: null,
6
+ resourceIndex: new Map(),
7
+ version: '1.0.0',
8
+ config: {
9
+ // File upload configuration
143
10
  uploadFile: null,
144
11
  downloadFile: null,
145
12
  getThumbnail: null,
146
- getPreviewUrl: null,
13
+ getDownloadUrl: null,
14
+ // Default implementations
147
15
  enableFilePreview: true,
148
- maxPreviewSize: '200px'
149
- };
150
-
151
- // Lightweight preview modal
152
- function ensurePreviewModal() {
153
- let modal = document.getElementById('fb-preview-modal');
154
- if (modal) return modal;
155
- modal = document.createElement('div');
156
- modal.id = 'fb-preview-modal';
157
- modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.75);display:none;align-items:center;justify-content:center;z-index:9999;padding:24px;';
158
- const inner = document.createElement('div');
159
- inner.style.cssText = 'position:relative;max-width:90vw;max-height:90vh;background:#0b0b0b;border-radius:12px;padding:16px;display:flex;flex-direction:column;gap:12px;';
160
- const close = document.createElement('button');
161
- close.textContent = '✕';
162
- close.title = 'Close';
163
- close.style.cssText = 'position:absolute;top:8px;right:8px;background:#111;color:#fff;border:1px solid #333;border-radius:6px;padding:4px 8px;cursor:pointer;';
164
- close.addEventListener('click', () => hidePreviewModal());
165
- const mediaWrap = document.createElement('div');
166
- mediaWrap.style.cssText = 'display:flex;align-items:center;justify-content:center;max-height:70vh;';
167
- const actions = document.createElement('div');
168
- actions.style.cssText = 'display:flex;gap:8px;justify-content:flex-end;';
169
- const downloadBtn = document.createElement('button');
170
- downloadBtn.textContent = 'Download';
171
- downloadBtn.style.cssText = 'background:#e5e7eb;color:#111;border-radius:8px;padding:6px 10px;border:0;cursor:pointer;';
172
- actions.appendChild(downloadBtn);
173
- inner.appendChild(close);
174
- inner.appendChild(mediaWrap);
175
- inner.appendChild(actions);
176
- modal.appendChild(inner);
177
- modal.addEventListener('click', (e) => { if (e.target === modal) hidePreviewModal(); });
178
- document.addEventListener('keydown', (e) => { if (e.key === 'Escape') hidePreviewModal(); });
179
- document.body.appendChild(modal);
180
- modal._mediaWrap = mediaWrap;
181
- modal._downloadBtn = downloadBtn;
182
- return modal;
16
+ maxPreviewSize: '200px',
17
+ readonly: false
183
18
  }
19
+ };
184
20
 
185
- async function showPreviewModal(resourceId, fileName, fileType) {
186
- const modal = ensurePreviewModal();
187
- const wrap = modal._mediaWrap;
188
- wrap.innerHTML = '';
189
- let src = null;
190
- if (config.getPreviewUrl && typeof config.getPreviewUrl === 'function') {
191
- try { src = await config.getPreviewUrl(resourceId); } catch {}
192
- }
193
- if (!src && config.getThumbnail && typeof config.getThumbnail === 'function') {
194
- try { src = await config.getThumbnail(resourceId); } catch {}
195
- }
196
- if (fileType?.startsWith?.('image/')) {
197
- const img = document.createElement('img');
198
- if (src) img.src = src;
199
- img.alt = fileName || resourceId;
200
- img.style.cssText = 'max-width:85vw;max-height:80vh;border-radius:8px;object-fit:contain;background:#111';
201
- wrap.appendChild(img);
202
- } else if (fileType?.startsWith?.('video/')) {
203
- const video = document.createElement('video');
204
- video.controls = true;
205
- if (src) video.src = src;
206
- video.style.cssText = 'max-width:85vw;max-height:80vh;border-radius:8px;background:#000';
207
- wrap.appendChild(video);
208
- } else {
209
- const box = document.createElement('div');
210
- box.style.cssText = 'color:#e5e7eb;padding:24px;';
211
- box.textContent = fileName || resourceId;
212
- wrap.appendChild(box);
213
- }
214
- modal._downloadBtn.onclick = async () => {
215
- if (config.downloadFile && typeof config.downloadFile === 'function') {
216
- try { await config.downloadFile(resourceId, fileName || 'file'); } catch {}
217
- } else {
218
- console.log('Download simulated:', resourceId, fileName || 'file');
219
- }
220
- };
221
- modal.style.display = 'flex';
222
- }
21
+ // Utility functions
22
+ function isPlainObject(obj) {
23
+ return obj && typeof obj === 'object' && obj.constructor === Object;
24
+ }
223
25
 
224
- function hidePreviewModal() {
225
- const modal = document.getElementById('fb-preview-modal');
226
- if (modal) modal.style.display = 'none';
227
- }
26
+ function pathJoin(base, key) {
27
+ return base ? `${base}.${key}` : key;
28
+ }
228
29
 
229
- function setTextValueFromPrefill(input, element, prefillObj, key) {
230
- let v = undefined;
231
- if (prefillObj && Object.prototype.hasOwnProperty.call(prefillObj, key)) v = prefillObj[key];
232
- else if (element.default !== undefined) v = element.default;
233
- if (v !== undefined) input.value = String(v);
234
- }
235
-
236
- function setNumberFromPrefill(input, element, prefillObj, key) {
237
- let v = undefined;
238
- if (prefillObj && Object.prototype.hasOwnProperty.call(prefillObj, key)) v = prefillObj[key];
239
- else if (element.default !== undefined) v = element.default;
240
- if (v !== undefined && v !== null && v !== '') input.value = String(v);
241
- }
30
+ function pretty(obj) {
31
+ return JSON.stringify(obj, null, 2);
32
+ }
242
33
 
243
- function setSelectFromPrefill(select, element, prefillObj, key) {
244
- const values = new Set(element.options.map(o => String(o.value)));
245
- let v = undefined;
246
- if (prefillObj && Object.prototype.hasOwnProperty.call(prefillObj, key)) v = prefillObj[key];
247
- else if (element.default !== undefined) v = element.default;
248
- if (v !== undefined && values.has(String(v))) select.value = String(v);
249
- else if (!element.required) select.value = '';
250
- }
34
+ function clear(node) {
35
+ while (node.firstChild) node.removeChild(node.firstChild);
36
+ }
251
37
 
252
- function fileValidationError(element, file) {
253
- if (!file) return 'no file';
254
- if (element.maxSizeMB != null && file.size > element.maxSizeMB * 1024 * 1024) {
255
- return `file too large > ${element.maxSizeMB}MB`;
256
- }
257
- if (element.accept) {
258
- const { extensions, mime } = element.accept;
259
- if (mime && Array.isArray(mime) && mime.length && !mime.includes(file.type)) {
260
- return `mime not allowed: ${file.type}`;
261
- }
262
- if (extensions && Array.isArray(extensions) && extensions.length) {
263
- const ext = (file.name.split('.').pop() || '').toLowerCase();
264
- if (!extensions.includes(ext)) return `extension .${ext} not allowed`;
265
- }
266
- }
267
- return null;
38
+ // Schema validation
39
+ function validateSchema(schema) {
40
+ const errors = [];
41
+
42
+ if (!schema || typeof schema !== 'object') {
43
+ errors.push('Schema must be an object');
44
+ return errors;
268
45
  }
269
-
270
- function markValidity(input, msg) {
271
- const prev = input?.parentElement?.querySelector?.('.error-message');
272
- if (prev) prev.remove();
273
-
274
- if (input) {
275
- input.classList.toggle('border-red-500', !!msg);
276
- input.classList.toggle('border-gray-300', !msg);
277
- }
278
-
279
- if (msg && input?.parentElement) {
280
- const m = document.createElement('div');
281
- m.className = 'error-message text-red-500 text-xs mt-1';
282
- m.textContent = msg;
283
- input.parentElement.appendChild(m);
284
- }
46
+
47
+ if (!schema.version) {
48
+ errors.push('Schema missing version');
285
49
  }
286
-
287
- function makeFieldHint(element, extra = '') {
288
- const hint = document.createElement('div');
289
- hint.className = 'text-gray-500 text-xs mt-1';
290
- const bits = [];
291
-
292
- if (element.required) bits.push('required');
293
-
294
- if (element.type === 'text' || element.type === 'textarea') {
295
- if (element.minLength != null) bits.push(`minLength=${element.minLength}`);
296
- if (element.maxLength != null) bits.push(`maxLength=${element.maxLength}`);
297
- if (element.pattern) bits.push(`pattern=/${element.pattern}/`);
298
- }
299
- if (element.type === 'number') {
300
- if (element.min != null) bits.push(`min=${element.min}`);
301
- if (element.max != null) bits.push(`max=${element.max}`);
302
- if (element.decimals != null) bits.push(`decimals=${element.decimals}`);
303
- }
304
- if (element.type === 'select') {
305
- bits.push(`${element.options.length} options`);
306
- }
307
- if (element.type === 'files') {
308
- if (element.minCount != null) bits.push(`minCount=${element.minCount}`);
309
- if (element.maxCount != null) bits.push(`maxCount=${element.maxCount}`);
310
- }
311
- if (element.type === 'videos') {
312
- if (element.minCount != null) bits.push(`minCount=${element.minCount}`);
313
- if (element.maxCount != null) bits.push(`maxCount=${element.maxCount}`);
314
- }
315
-
316
- hint.textContent = [bits.join(' • '), extra].filter(Boolean).join(' | ');
317
- return hint;
50
+
51
+ if (!Array.isArray(schema.elements)) {
52
+ errors.push('Schema missing elements array');
53
+ return errors;
318
54
  }
319
-
320
- async function renderFilePreview(container, resourceId, fileName, fileType) {
321
- container.innerHTML = '';
322
-
323
- const preview = document.createElement('div');
324
- preview.className = 'flex items-center gap-3 p-2';
325
- const isReadonly = container.closest('.file-container')?.dataset.readonly === 'true';
326
-
327
- // File icon/thumbnail
328
- const iconContainer = document.createElement('div');
329
- iconContainer.className = 'w-12 h-12 rounded-lg flex items-center justify-center bg-blue-600 text-white text-xl flex-shrink-0';
330
-
331
- if (fileType.startsWith('image/')) {
332
- const img = document.createElement('img');
333
- img.className = 'w-12 h-12 object-cover rounded-lg';
55
+
56
+ function validateElements(elements, path) {
57
+ elements.forEach((element, index) => {
58
+ const elementPath = `${path}[${index}]`;
334
59
 
335
- // Try to get thumbnail using custom function or fallback
336
- if (config.getThumbnail && typeof config.getThumbnail === 'function') {
337
- try {
338
- const thumbnailUrl = await config.getThumbnail(resourceId);
339
- img.src = thumbnailUrl;
340
- img.onerror = () => { iconContainer.textContent = '🖼️'; };
341
- iconContainer.innerHTML = '';
342
- iconContainer.appendChild(img);
343
- } catch {
344
- iconContainer.textContent = '🖼️';
345
- }
346
- } else {
347
- iconContainer.textContent = '🖼️';
348
- }
349
- } else if (fileType.startsWith('video/')) {
350
- iconContainer.textContent = '🎥';
351
- } else if (fileType.includes('pdf')) {
352
- iconContainer.textContent = '📄';
353
- } else {
354
- iconContainer.textContent = '📎';
355
- }
356
-
357
- preview.appendChild(iconContainer);
358
-
359
- // File info
360
- const info = document.createElement('div');
361
- info.className = 'flex-1';
362
-
363
- const name = document.createElement('div');
364
- name.className = 'font-medium text-sm text-gray-900';
365
- name.textContent = fileName;
366
-
367
- const details = document.createElement('div');
368
- details.className = 'text-xs text-gray-500 mt-1';
369
- details.textContent = `${fileType} • ${resourceId.slice(0, 12)}...`;
370
-
371
- info.appendChild(name);
372
- info.appendChild(details);
373
- preview.appendChild(info);
374
-
375
- // Action buttons
376
- const actions = document.createElement('div');
377
- actions.className = 'flex gap-2';
378
-
379
- // Download button
380
- const downloadBtn = document.createElement('button');
381
- downloadBtn.className = 'px-2 py-1 text-xs bg-gray-100 hover:bg-gray-200 rounded transition-colors';
382
- downloadBtn.textContent = '⬇️';
383
- downloadBtn.title = 'Download';
384
- downloadBtn.addEventListener('click', async () => {
385
- if (config.downloadFile && typeof config.downloadFile === 'function') {
386
- try {
387
- await config.downloadFile(resourceId, fileName);
388
- } catch (error) {
389
- console.error('Download failed:', error);
390
- }
391
- } else {
392
- console.log('Download simulated:', resourceId, fileName);
60
+ if (!element.type) {
61
+ errors.push(`${elementPath}: missing type`);
393
62
  }
394
- });
395
-
396
- actions.appendChild(downloadBtn);
397
- // Open button (preview modal)
398
- const openBtn = document.createElement('button');
399
- openBtn.className = 'px-2 py-1 text-xs bg-gray-100 hover:bg-gray-200 rounded transition-colors';
400
- openBtn.textContent = 'Open';
401
- openBtn.title = 'Open preview';
402
- openBtn.addEventListener('click', async () => {
403
- await showPreviewModal(resourceId, fileName, fileType);
404
- });
405
- actions.appendChild(openBtn);
406
- if (!isReadonly) {
407
- // Remove button (editable only)
408
- const removeBtn = document.createElement('button');
409
- removeBtn.className = 'px-2 py-1 text-xs bg-red-500 hover:bg-red-600 text-white rounded transition-colors';
410
- removeBtn.textContent = '✕';
411
- removeBtn.title = 'Remove';
412
- removeBtn.addEventListener('click', () => {
413
- const hiddenInput = container.closest('.file-container')?.querySelector('input[type="hidden"]');
414
- if (hiddenInput) {
415
- hiddenInput.value = '';
416
- }
417
- container.innerHTML = '<div class="flex flex-col items-center justify-center h-full text-gray-400"><div class="text-2xl mb-2">📁</div><div class="text-sm">Click to upload</div></div>';
418
- });
419
- actions.appendChild(removeBtn);
420
- }
421
- preview.appendChild(actions);
422
-
423
- container.appendChild(preview);
424
- }
425
-
426
- function renderResourcePills(container, rids, onRemove) {
427
- clear(container);
428
- container.className = 'flex flex-wrap gap-1.5 mt-2';
429
-
430
- rids.forEach(rid => {
431
- const meta = resourceIndex.get(rid);
432
- const pill = document.createElement('span');
433
- pill.className = 'inline-flex items-center gap-1.5 bg-blue-50 border border-gray-300 rounded-full px-2.5 py-1 font-mono text-xs m-0.5';
434
- pill.textContent = rid;
435
63
 
436
- if (meta) {
437
- const small = document.createElement('span');
438
- small.className = 'text-gray-500';
439
- small.textContent = ` (${meta.name ?? 'file'}, ${formatFileSize(meta.size ?? 0)})`;
440
- pill.appendChild(small);
64
+ if (!element.key) {
65
+ errors.push(`${elementPath}: missing key`);
441
66
  }
442
67
 
443
- if (onRemove) {
444
- const x = document.createElement('button');
445
- x.type = 'button';
446
- x.className = 'bg-red-500 hover:bg-red-600 text-white text-xs px-1.5 py-0.5 rounded ml-1.5';
447
- x.textContent = '×';
448
- x.addEventListener('click', () => onRemove(rid));
449
- pill.appendChild(x);
68
+ if (element.type === 'group' && element.elements) {
69
+ validateElements(element.elements, `${elementPath}.elements`);
450
70
  }
451
71
 
452
- container.appendChild(pill);
72
+ if (element.type === 'select' && element.options) {
73
+ const defaultValue = element.default;
74
+ if (defaultValue !== undefined && defaultValue !== null && defaultValue !== '') {
75
+ const hasMatchingOption = element.options.some(opt => opt.value === defaultValue);
76
+ if (!hasMatchingOption) {
77
+ errors.push(`${elementPath}: default "${defaultValue}" not in options`);
78
+ }
79
+ }
80
+ }
453
81
  });
454
82
  }
83
+
84
+ if (Array.isArray(schema.elements)) validateElements(schema.elements, 'elements');
85
+ return errors;
86
+ }
87
+
88
+ // Form rendering
89
+ function renderForm(schema, prefill) {
90
+ const errors = validateSchema(schema);
91
+ if (errors.length > 0) {
92
+ console.error('Schema validation errors:', errors);
93
+ return;
94
+ }
95
+
96
+ state.schema = schema;
97
+ if (!state.formRoot) {
98
+ console.error('No form root element set. Call setFormRoot() first.');
99
+ return;
100
+ }
101
+
102
+ clear(state.formRoot);
103
+
104
+ const formEl = document.createElement('div');
105
+ formEl.className = 'space-y-6';
106
+
107
+ schema.elements.forEach(element => {
108
+ const block = renderElement(element, {
109
+ path: '',
110
+ prefill: prefill || {}
111
+ });
112
+ formEl.appendChild(block);
113
+ });
455
114
 
456
- function renderElement(element, ctx, options = {}) {
457
- const wrapper = document.createElement('div');
458
- wrapper.className = 'mb-6';
115
+ state.formRoot.appendChild(formEl);
116
+ }
117
+
118
+ function renderElement(element, ctx) {
119
+ const wrapper = document.createElement('div');
120
+ wrapper.className = 'mb-6';
121
+
122
+ const label = document.createElement('div');
123
+ label.className = 'flex items-center mb-2';
124
+ const title = document.createElement('label');
125
+ title.className = 'text-sm font-medium text-gray-900';
126
+ title.textContent = element.label || element.key;
127
+ if (element.required) {
128
+ const req = document.createElement('span');
129
+ req.className = 'text-red-500 ml-1';
130
+ req.textContent = '*';
131
+ title.appendChild(req);
132
+ }
133
+ label.appendChild(title);
134
+
135
+ // Add info button if there's description or hint
136
+ if (element.description || element.hint) {
137
+ const infoBtn = document.createElement('button');
138
+ infoBtn.type = 'button';
139
+ infoBtn.className = 'ml-2 text-gray-400 hover:text-gray-600';
140
+ infoBtn.innerHTML = '<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/></svg>';
459
141
 
460
- const label = document.createElement('div');
461
- label.className = 'flex items-center mb-2';
462
- const title = document.createElement('label');
463
- title.className = 'text-sm font-medium text-gray-900';
464
- title.textContent = element.label || element.key;
465
- if (element.required) {
466
- const req = document.createElement('span');
467
- req.className = 'text-red-500 ml-1';
468
- req.textContent = '*';
469
- title.appendChild(req);
470
- }
471
- label.appendChild(title);
472
- wrapper.appendChild(label);
142
+ // Create tooltip
143
+ const tooltipId = `tooltip-${element.key}-${Math.random().toString(36).substr(2, 9)}`;
144
+ const tooltip = document.createElement('div');
145
+ tooltip.id = tooltipId;
146
+ tooltip.className = 'hidden absolute z-50 bg-gray-200 text-gray-900 text-sm rounded-lg p-3 max-w-sm border border-gray-300 shadow-lg';
147
+ tooltip.style.position = 'fixed';
148
+ tooltip.textContent = element.description || element.hint || 'Field information';
149
+ document.body.appendChild(tooltip);
150
+
151
+ infoBtn.onclick = (e) => {
152
+ e.preventDefault();
153
+ e.stopPropagation();
154
+ showTooltip(tooltipId, infoBtn);
155
+ };
156
+
157
+ label.appendChild(infoBtn);
158
+ }
159
+
160
+ wrapper.appendChild(label);
473
161
 
474
- const pathKey = pathJoin(ctx.path, element.key);
162
+ const pathKey = pathJoin(ctx.path, element.key);
475
163
 
476
- switch (element.type) {
477
- case 'text': {
478
- const input = document.createElement('input');
479
- input.type = 'text';
480
- input.name = pathKey;
481
- input.dataset.type = 'text';
482
- input.className = 'w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500';
483
- setTextValueFromPrefill(input, element, ctx.prefill, element.key);
484
- input.addEventListener('input', () => markValidity(input, null));
485
- wrapper.appendChild(input);
486
- wrapper.appendChild(makeFieldHint(element));
487
- break;
488
- }
489
- case 'textarea': {
490
- const ta = document.createElement('textarea');
491
- ta.name = pathKey;
492
- ta.rows = 4;
493
- ta.dataset.type = 'textarea';
494
- ta.className = 'w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-none';
495
- setTextValueFromPrefill(ta, element, ctx.prefill, element.key);
496
- ta.addEventListener('input', () => markValidity(ta, null));
497
- wrapper.appendChild(ta);
498
- wrapper.appendChild(makeFieldHint(element));
499
- break;
500
- }
501
- case 'number': {
502
- const input = document.createElement('input');
503
- input.type = 'number';
504
- input.name = pathKey;
505
- input.dataset.type = 'number';
506
- input.className = 'w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500';
507
- if (element.step != null) input.step = String(element.step);
508
- if (element.min != null) input.min = String(element.min);
509
- if (element.max != null) input.max = String(element.max);
510
- setNumberFromPrefill(input, element, ctx.prefill, element.key);
511
- input.addEventListener('blur', () => {
512
- if (input.value === '') return;
513
- const v = parseFloat(input.value);
514
- if (Number.isFinite(v) && Number.isInteger(element.decimals ?? 0)) {
515
- input.value = String(Number(v.toFixed(element.decimals)));
516
- }
517
- });
518
- input.addEventListener('input', () => markValidity(input, null));
519
- wrapper.appendChild(input);
520
- wrapper.appendChild(makeFieldHint(element, `decimals=${element.decimals ?? 0}`));
521
- break;
522
- }
523
- case 'select': {
524
- const sel = document.createElement('select');
525
- sel.name = pathKey;
526
- sel.dataset.type = 'select';
527
- sel.className = 'w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500';
528
-
529
- if (!element.required) {
530
- const opt = document.createElement('option');
531
- opt.value = '';
532
- opt.textContent = '—';
533
- sel.appendChild(opt);
164
+ switch (element.type) {
165
+ case 'text':
166
+ renderTextElement(element, ctx, wrapper, pathKey);
167
+ break;
168
+
169
+ case 'textarea':
170
+ renderTextareaElement(element, ctx, wrapper, pathKey);
171
+ break;
172
+
173
+ case 'number':
174
+ renderNumberElement(element, ctx, wrapper, pathKey);
175
+ break;
176
+
177
+ case 'select':
178
+ renderSelectElement(element, ctx, wrapper, pathKey);
179
+ break;
180
+
181
+ case 'file':
182
+ // TODO: Extract to renderFileElement() function
183
+ if (state.config.readonly) {
184
+ // Readonly mode: use common preview function
185
+ const initial = ctx.prefill[element.key] || element.default;
186
+ if (initial) {
187
+ const filePreview = renderFilePreviewReadonly(initial);
188
+ wrapper.appendChild(filePreview);
189
+ } else {
190
+ const emptyState = document.createElement('div');
191
+ emptyState.className = 'aspect-video bg-gray-100 rounded-lg flex items-center justify-center text-gray-500';
192
+ emptyState.innerHTML = '<div class="text-center">Нет файла</div>';
193
+ wrapper.appendChild(emptyState);
534
194
  }
195
+ } else {
196
+ // Edit mode: normal file input
197
+ const fileWrapper = document.createElement('div');
198
+ fileWrapper.className = 'space-y-2';
535
199
 
536
- element.options.forEach(o => {
537
- const opt = document.createElement('option');
538
- opt.value = String(o.value);
539
- opt.textContent = o.label ?? String(o.value);
540
- sel.appendChild(opt);
541
- });
542
-
543
- setSelectFromPrefill(sel, element, ctx.prefill, element.key);
544
- sel.addEventListener('input', () => markValidity(sel, null));
545
- wrapper.appendChild(sel);
546
- break;
547
- }
548
- case 'file': {
549
- const hid = document.createElement('input');
550
- hid.type = 'hidden';
551
- hid.name = pathKey;
552
- hid.dataset.type = 'file';
200
+ const picker = document.createElement('input');
201
+ picker.type = 'file';
202
+ picker.name = pathKey;
203
+ picker.style.display = 'none'; // Hide default input
204
+ if (element.accept) {
205
+ if (element.accept.extensions) {
206
+ picker.accept = element.accept.extensions.map(ext => `.${ext}`).join(',');
207
+ }
208
+ }
553
209
 
554
- const container = document.createElement('div');
555
- container.className = 'file-container';
210
+ const fileContainer = document.createElement('div');
211
+ fileContainer.className = 'file-preview-container w-full aspect-square max-w-xs bg-gray-100 rounded-lg overflow-hidden relative group cursor-pointer';
556
212
 
557
- // Preview container
558
- const previewContainer = document.createElement('div');
559
- if (options.readonly) {
560
- container.dataset.readonly = 'true';
561
- previewContainer.className = 'aspect-square w-full max-w-xs bg-gray-100 rounded-lg overflow-hidden border-2 border-gray-300 transition-colors relative mb-3';
213
+ const initial = ctx.prefill[element.key] || element.default;
214
+ if (initial) {
215
+ renderFilePreview(fileContainer, initial, initial, '');
562
216
  } else {
563
- previewContainer.className = 'aspect-square w-full max-w-xs bg-gray-100 rounded-lg overflow-hidden border-2 border-dashed border-gray-300 hover:border-gray-400 transition-colors relative group cursor-pointer mb-3';
217
+ fileContainer.innerHTML = `
218
+ <div class="flex flex-col items-center justify-center h-full text-gray-400">
219
+ <svg class="w-6 h-6 mb-2" fill="currentColor" viewBox="0 0 24 24">
220
+ <path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
221
+ </svg>
222
+ <div class="text-sm text-center">Нажмите или перетащите файл</div>
223
+ </div>
224
+ `;
564
225
  }
565
226
 
566
- let picker = null;
567
- if (!options.readonly) {
568
- picker = document.createElement('input');
569
- picker.type = 'file';
570
- if (element.accept?.extensions) {
571
- picker.accept = element.accept.extensions.map(ext => `.${ext}`).join(',');
227
+ fileContainer.onclick = () => picker.click();
228
+ setupDragAndDrop(fileContainer, (files) => {
229
+ if (files.length > 0) {
230
+ handleFileSelect(files[0], fileContainer, pathKey);
572
231
  }
573
- previewContainer.onclick = () => picker.click();
574
- }
232
+ });
575
233
 
576
- const handleFileSelect = async (file) => {
577
- const err = fileValidationError(element, file);
578
- if (err) {
579
- markValidity(picker, err);
580
- return;
581
- }
582
-
583
- try {
584
- let resourceId;
585
-
586
- // Use custom upload function if provided
587
- if (config.uploadFile && typeof config.uploadFile === 'function') {
588
- resourceId = await config.uploadFile(file);
589
- } else {
590
- // Fallback to simulated resource ID
591
- resourceId = await makeResourceIdFromFile(file);
592
- resourceIndex.set(resourceId, { name: file.name, type: file.type, size: file.size });
593
- }
594
-
595
- hid.value = resourceId;
596
- await renderFilePreview(previewContainer, resourceId, file.name, file.type);
597
- markValidity(picker, null);
598
- } catch (error) {
599
- markValidity(picker, `Upload failed: ${error.message}`);
234
+ picker.onchange = () => {
235
+ if (picker.files.length > 0) {
236
+ handleFileSelect(picker.files[0], fileContainer, pathKey);
600
237
  }
601
238
  };
602
239
 
603
- if (!options.readonly) {
604
- picker.addEventListener('change', async () => {
605
- if (picker.files && picker.files[0]) {
606
- await handleFileSelect(picker.files[0]);
607
- }
608
- });
609
- }
240
+ fileWrapper.appendChild(fileContainer);
241
+ fileWrapper.appendChild(picker);
610
242
 
611
- // Handle prefilled values
612
- const pv = ctx.prefill && ctx.prefill[element.key];
613
- if (typeof pv === 'string' && pv) {
614
- hid.value = pv;
615
- // Try to render preview for existing resource
616
- const fileName = `file_${pv.slice(-8)}`;
617
- renderFilePreview(previewContainer, pv, fileName, 'application/octet-stream');
618
- } else {
619
- // Show upload prompt (editable only)
620
- if (!options.readonly) {
621
- previewContainer.innerHTML = '<div class="flex flex-col items-center justify-center h-full text-gray-400"><div class="text-2xl mb-2">📁</div><div class="text-sm">Click to upload</div></div>';
622
- } else {
623
- previewContainer.innerHTML = '<div class="flex flex-col items-center justify-center h-full text-gray-400"><div class="text-2xl mb-2">📁</div><div class="text-sm">No file</div></div>';
624
- }
625
- }
626
-
627
- let helpText = null;
628
- if (!options.readonly) {
629
- helpText = document.createElement('p');
630
- helpText.className = 'text-xs text-gray-600 mt-2 text-center';
631
- helpText.innerHTML = '<span class="underline cursor-pointer">Upload</span> or drag and drop file';
632
- helpText.onclick = () => picker.click();
633
- }
243
+ // Add upload text
244
+ const uploadText = document.createElement('p');
245
+ uploadText.className = 'text-xs text-gray-600 mt-2 text-center';
246
+ uploadText.innerHTML = `<span class="underline cursor-pointer">Загрузите</span> или перетащите файл`;
247
+ uploadText.querySelector('span').onclick = () => picker.click();
248
+ fileWrapper.appendChild(uploadText);
634
249
 
635
- container.appendChild(previewContainer);
636
- if (helpText) container.appendChild(helpText);
637
- if (picker) container.appendChild(picker);
638
- container.appendChild(hid);
250
+ // Add hint
251
+ const fileHint = document.createElement('p');
252
+ fileHint.className = 'text-xs text-gray-500 mt-1 text-center';
253
+ fileHint.textContent = makeFieldHint(element);
254
+ fileWrapper.appendChild(fileHint);
639
255
 
640
- wrapper.appendChild(container);
641
- wrapper.appendChild(makeFieldHint(element, options.readonly ? 'Read-only' : 'Returns resource ID for download/submission'));
642
- break;
256
+ wrapper.appendChild(fileWrapper);
643
257
  }
644
- case 'files': {
645
- const hid = document.createElement('input');
646
- hid.type = 'hidden';
647
- hid.name = pathKey;
648
- hid.dataset.type = 'files';
649
-
650
- const list = document.createElement('div');
651
- list.className = options.readonly ? 'flex flex-col gap-2 mt-2' : 'flex flex-wrap gap-1.5 mt-2';
258
+ break;
259
+
260
+ case 'files':
261
+ // TODO: Extract to renderFilesElement() function
262
+ if (state.config.readonly) {
263
+ // Readonly mode: render as results list like in workflow-preview.html
264
+ const resultsWrapper = document.createElement('div');
265
+ resultsWrapper.className = 'space-y-4';
652
266
 
653
- let picker = null;
654
- if (!options.readonly) {
655
- picker = document.createElement('input');
656
- picker.type = 'file';
657
- picker.multiple = true;
658
- if (element.accept?.extensions) {
659
- picker.accept = element.accept.extensions.map(ext => `.${ext}`).join(',');
660
- }
661
- }
267
+ const initialFiles = ctx.prefill[element.key] || [];
662
268
 
663
- if (picker) {
664
- picker.addEventListener('change', async () => {
665
- let arr = parseJSONSafe(hid.value, []);
666
- if (!Array.isArray(arr)) arr = [];
667
-
668
- if (picker.files && picker.files.length) {
669
- for (const file of picker.files) {
670
- const err = fileValidationError(element, file);
671
- if (err) {
672
- markValidity(picker, err);
673
- return;
674
- }
675
- }
676
-
677
- for (const file of picker.files) {
678
- try {
679
- let resourceId;
680
- if (config.uploadFile && typeof config.uploadFile === 'function') {
681
- resourceId = await config.uploadFile(file);
682
- } else {
683
- resourceId = await makeResourceIdFromFile(file);
684
- }
685
- resourceIndex.set(resourceId, { name: file.name, type: file.type, size: file.size });
686
- arr.push(resourceId);
687
- } catch (error) {
688
- markValidity(picker, `Upload failed: ${error.message}`);
689
- return;
690
- }
691
- }
692
-
693
- hid.value = JSON.stringify(arr);
694
- renderResourcePills(list, arr, (ridToRemove) => {
695
- const next = arr.filter(x => x !== ridToRemove);
696
- hid.value = JSON.stringify(next);
697
- arr = next;
698
- renderResourcePills(list, next, arguments.callee);
699
- });
700
- markValidity(picker, null);
701
- }
269
+ if (initialFiles.length > 0) {
270
+ initialFiles.forEach(resourceId => {
271
+ const filePreview = renderFilePreviewReadonly(resourceId);
272
+ resultsWrapper.appendChild(filePreview);
702
273
  });
274
+ } else {
275
+ resultsWrapper.innerHTML = `<div class="aspect-video bg-gray-100 rounded-lg flex items-center justify-center text-gray-500"><div class="text-center">Нет файлов</div></div>`;
703
276
  }
704
277
 
705
- const pv = ctx.prefill && ctx.prefill[element.key];
706
- let initial = Array.isArray(pv) ? pv.filter(Boolean) : [];
707
- if (initial.length) {
708
- hid.value = JSON.stringify(initial);
709
- if (options.readonly) {
710
- // Render each as small preview with download
711
- list.innerHTML = '';
712
- for (const rid of initial) {
713
- const row = document.createElement('div');
714
- row.className = 'file-container';
715
- row.dataset.readonly = 'true';
716
- const itemPreview = document.createElement('div');
717
- row.appendChild(itemPreview);
718
- renderFilePreview(itemPreview, rid, `file_${rid.slice(-8)}`, 'application/octet-stream');
719
- list.appendChild(row);
720
- }
721
- } else {
722
- renderResourcePills(list, initial, (ridToRemove) => {
723
- const next = initial.filter(x => x !== ridToRemove);
724
- hid.value = JSON.stringify(next);
725
- initial = next;
726
- renderResourcePills(list, next, arguments.callee);
727
- });
278
+ wrapper.appendChild(resultsWrapper);
279
+ } else {
280
+ // Edit mode: normal files input
281
+ const filesWrapper = document.createElement('div');
282
+ filesWrapper.className = 'space-y-2';
283
+
284
+ const filesPicker = document.createElement('input');
285
+ filesPicker.type = 'file';
286
+ filesPicker.name = pathKey;
287
+ filesPicker.multiple = true;
288
+ filesPicker.style.display = 'none'; // Hide default input
289
+ if (element.accept) {
290
+ if (element.accept.extensions) {
291
+ filesPicker.accept = element.accept.extensions.map(ext => `.${ext}`).join(',');
728
292
  }
729
293
  }
730
294
 
731
- if (picker) wrapper.appendChild(picker);
732
- wrapper.appendChild(list);
733
- wrapper.appendChild(hid);
734
- wrapper.appendChild(makeFieldHint(element, options.readonly ? 'Read-only' : 'Multiple files return resource ID array'));
735
- break;
736
- }
737
- case 'videos': {
738
- const hid = document.createElement('input');
739
- hid.type = 'hidden';
740
- hid.name = pathKey;
741
- hid.dataset.type = 'videos';
295
+ // Create container with border like in workflow-preview
296
+ const filesContainer = document.createElement('div');
297
+ filesContainer.className = 'border-2 border-dashed border-gray-300 rounded-lg p-3 hover:border-gray-400 transition-colors';
742
298
 
743
299
  const list = document.createElement('div');
744
- list.className = 'flex flex-col gap-3 mt-2';
300
+ list.className = 'files-list';
745
301
 
746
- const picker = document.createElement('input');
747
- picker.type = 'file';
748
- picker.multiple = true;
749
- {
750
- const acc = [];
751
- if (element.accept?.mime && Array.isArray(element.accept.mime) && element.accept.mime.length) {
752
- acc.push(...element.accept.mime);
753
- }
754
- if (element.accept?.extensions && Array.isArray(element.accept.extensions) && element.accept.extensions.length) {
755
- acc.push(...element.accept.extensions.map(ext => `.${ext}`));
756
- }
757
- picker.accept = acc.length ? acc.join(',') : 'video/*';
758
- }
302
+ const initialFiles = ctx.prefill[element.key] || [];
759
303
 
760
- const renderVideos = (rids) => {
761
- list.innerHTML = '';
762
- rids.forEach(rid => {
763
- const meta = resourceIndex.get(rid) || {};
764
- const row = document.createElement('div');
765
- row.className = 'flex items-start gap-3';
766
- const video = document.createElement('video');
767
- video.controls = true;
768
- video.className = 'w-48 max-w-full rounded border border-gray-300';
769
- // Use thumbnail as poster instead of loading video src
770
- if (config.getThumbnail) {
771
- Promise.resolve(config.getThumbnail(rid)).then(url => {
772
- if (url) {
773
- video.poster = url;
774
- }
775
- }).catch(() => {});
304
+ function updateFilesList() {
305
+ renderResourcePills(list, initialFiles, (ridToRemove) => {
306
+ const index = initialFiles.indexOf(ridToRemove);
307
+ if (index > -1) {
308
+ initialFiles.splice(index, 1);
776
309
  }
777
- const info = document.createElement('div');
778
- info.className = 'flex-1 text-sm text-gray-700';
779
- info.textContent = `${meta.name || 'video'} (${formatFileSize(meta.size || 0)})`;
780
- const actions = document.createElement('div');
781
- actions.className = 'flex items-center gap-2';
782
- const downloadBtn = document.createElement('button');
783
- downloadBtn.type = 'button';
784
- downloadBtn.className = 'px-2 py-1 text-xs bg-gray-100 hover:bg-gray-200 rounded';
785
- downloadBtn.textContent = 'Download';
786
- downloadBtn.addEventListener('click', async () => {
787
- if (config.downloadFile && typeof config.downloadFile === 'function') {
788
- try { await config.downloadFile(rid, meta.name || 'video'); } catch(_) {}
789
- } else {
790
- console.log('Download simulated:', rid, meta.name || 'video');
791
- }
792
- });
793
- const remove = document.createElement('button');
794
- remove.type = 'button';
795
- remove.className = 'px-2 py-1 text-xs bg-red-500 hover:bg-red-600 text-white rounded';
796
- remove.textContent = 'Remove';
797
- remove.addEventListener('click', () => {
798
- const arr = parseJSONSafe(hid.value, []);
799
- const next = Array.isArray(arr) ? arr.filter(x => x !== rid) : [];
800
- hid.value = JSON.stringify(next);
801
- renderVideos(next);
802
- });
803
- row.appendChild(video);
804
- row.appendChild(info);
805
- actions.appendChild(downloadBtn);
806
- actions.appendChild(remove);
807
- row.appendChild(actions);
808
- list.appendChild(row);
310
+ updateFilesList(); // Re-render after removal
809
311
  });
810
- };
312
+ }
811
313
 
812
- picker.addEventListener('change', async () => {
813
- let arr = parseJSONSafe(hid.value, []);
814
- if (!Array.isArray(arr)) arr = [];
815
- if (picker.files && picker.files.length) {
816
- for (const file of picker.files) {
817
- const err = fileValidationError(element, file);
818
- if (err) {
819
- markValidity(picker, err);
820
- return;
821
- }
822
- // additionally ensure it's a video
823
- if (!file.type.startsWith('video/')) {
824
- markValidity(picker, 'mime not allowed: ' + file.type);
825
- return;
314
+ // Initial render
315
+ updateFilesList();
316
+
317
+ setupDragAndDrop(filesContainer, async (files) => {
318
+ const arr = Array.from(files);
319
+ for (const file of arr) {
320
+ let rid;
321
+
322
+ // If uploadHandler is configured, use it to upload the file
323
+ if (state.config.uploadFile) {
324
+ try {
325
+ rid = await state.config.uploadFile(file);
326
+ if (typeof rid !== 'string') {
327
+ throw new Error('Upload handler must return a string resource ID');
328
+ }
329
+ } catch (error) {
330
+ throw new Error(`File upload failed: ${error.message}`);
826
331
  }
332
+ } else {
333
+ throw new Error('No upload handler configured. Set uploadHandler via FormBuilder.setUploadHandler()');
827
334
  }
828
- for (const file of picker.files) {
335
+
336
+ state.resourceIndex.set(rid, {
337
+ name: file.name,
338
+ type: file.type,
339
+ size: file.size,
340
+ file: null // Files are always uploaded, never stored locally
341
+ });
342
+ initialFiles.push(rid);
343
+ }
344
+ updateFilesList();
345
+ });
346
+
347
+ filesPicker.onchange = async () => {
348
+ for (const file of Array.from(filesPicker.files)) {
349
+ let rid;
350
+
351
+ // If uploadHandler is configured, use it to upload the file
352
+ if (state.config.uploadFile) {
829
353
  try {
830
- let resourceId;
831
- if (config.uploadFile && typeof config.uploadFile === 'function') {
832
- resourceId = await config.uploadFile(file);
833
- } else {
834
- resourceId = await makeResourceIdFromFile(file);
354
+ rid = await state.config.uploadFile(file);
355
+ if (typeof rid !== 'string') {
356
+ throw new Error('Upload handler must return a string resource ID');
835
357
  }
836
- resourceIndex.set(resourceId, { name: file.name, type: file.type, size: file.size });
837
- arr.push(resourceId);
838
358
  } catch (error) {
839
- markValidity(picker, `Upload failed: ${error.message}`);
840
- return;
359
+ throw new Error(`File upload failed: ${error.message}`);
841
360
  }
361
+ } else {
362
+ throw new Error('No upload handler configured. Set uploadHandler via FormBuilder.setUploadHandler()');
842
363
  }
843
- hid.value = JSON.stringify(arr);
844
- renderVideos(arr);
845
- markValidity(picker, null);
364
+
365
+ state.resourceIndex.set(rid, {
366
+ name: file.name,
367
+ type: file.type,
368
+ size: file.size,
369
+ file: null // Files are always uploaded, never stored locally
370
+ });
371
+ initialFiles.push(rid);
846
372
  }
847
- });
373
+ updateFilesList();
374
+ // Clear the file input
375
+ filesPicker.value = '';
376
+ };
848
377
 
849
- const pv = ctx.prefill && ctx.prefill[element.key];
850
- let initial = Array.isArray(pv) ? pv.filter(Boolean) : [];
851
- if (initial.length) {
852
- hid.value = JSON.stringify(initial);
853
- renderVideos(initial);
854
- }
378
+ filesContainer.appendChild(list);
855
379
 
856
- wrapper.appendChild(picker);
857
- wrapper.appendChild(list);
858
- wrapper.appendChild(hid);
859
- wrapper.appendChild(makeFieldHint(element, 'Multiple videos return resource ID array'));
860
- break;
861
- }
862
- case 'group': {
863
- wrapper.dataset.group = element.key;
864
- wrapper.dataset.groupPath = pathKey;
380
+ filesWrapper.appendChild(filesContainer);
381
+ filesWrapper.appendChild(filesPicker);
865
382
 
866
- const groupWrap = document.createElement('div');
383
+ // Add hint
384
+ const filesHint = document.createElement('p');
385
+ filesHint.className = 'text-xs text-gray-500 mt-1 text-center';
386
+ filesHint.textContent = makeFieldHint(element);
387
+ filesWrapper.appendChild(filesHint);
867
388
 
868
- // Group title (above the whole group)
869
- const groupTitle = document.createElement('div');
870
- groupTitle.className = 'text-lg font-semibold text-gray-900 mb-4';
871
- groupTitle.textContent = element.label || element.key;
872
- groupWrap.appendChild(groupTitle);
389
+ wrapper.appendChild(filesWrapper);
390
+ }
391
+ break;
392
+
393
+ case 'group':
394
+ // TODO: Extract to renderGroupElement() function
395
+ const groupWrap = document.createElement('div');
396
+ groupWrap.className = 'border border-gray-200 rounded-lg p-4 bg-gray-50';
397
+
398
+ const header = document.createElement('div');
399
+ header.className = 'flex justify-between items-center mb-4';
400
+
401
+ const left = document.createElement('div');
402
+ left.className = 'flex-1';
403
+
404
+ const right = document.createElement('div');
405
+ right.className = 'flex gap-2';
406
+
407
+ const itemsWrap = document.createElement('div');
408
+ itemsWrap.className = 'space-y-4';
409
+
410
+ groupWrap.appendChild(header);
411
+ header.appendChild(left);
412
+ header.appendChild(right);
413
+
414
+ if (element.repeat && isPlainObject(element.repeat)) {
415
+ const min = element.repeat.min ?? 0;
416
+ const max = element.repeat.max ?? Infinity;
417
+ const pre = Array.isArray(ctx.prefill?.[element.key]) ? ctx.prefill[element.key] : null;
873
418
 
874
- const header = document.createElement('div');
875
- header.className = 'flex items-center justify-between my-2 pb-2 border-b border-gray-200';
419
+ header.appendChild(right);
420
+
421
+ const countItems = () => itemsWrap.querySelectorAll(':scope > .groupItem').length;
422
+
423
+ const addItem = (prefillObj) => {
424
+ const item = document.createElement('div');
425
+ item.className = 'groupItem border border-dashed border-slate-300 dark:border-slate-600 rounded-lg p-3 mb-3 bg-blue-50/30 dark:bg-blue-900/10';
426
+ const subCtx = {
427
+ path: pathJoin(ctx.path, element.key + `[${countItems()}]`),
428
+ prefill: prefillObj || {}
429
+ };
430
+ element.elements.forEach(child => item.appendChild(renderElement(child, subCtx)));
431
+
432
+ const rem = document.createElement('button');
433
+ rem.type = 'button';
434
+ rem.className = 'bg-red-500 hover:bg-red-600 text-white px-2 py-1 rounded text-xs font-medium transition-colors';
435
+ rem.textContent = 'Удалить';
436
+ rem.addEventListener('click', () => {
437
+ if (countItems() <= (element.repeat.min ?? 0)) return;
438
+ itemsWrap.removeChild(item);
439
+ refreshControls();
440
+ });
441
+ item.appendChild(rem);
442
+ itemsWrap.appendChild(item);
443
+ refreshControls();
444
+ };
445
+
446
+ groupWrap.appendChild(itemsWrap);
876
447
 
877
- const left = document.createElement('div');
878
- header.appendChild(left);
448
+ // Add button after items
449
+ const addBtn = document.createElement('button');
450
+ addBtn.type = 'button';
451
+ addBtn.className = 'w-full py-2 px-4 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 flex items-center justify-center mt-3';
452
+ addBtn.innerHTML = '<svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 24 24"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>Добавить элемент';
453
+ groupWrap.appendChild(addBtn);
879
454
 
880
- const right = document.createElement('div');
881
- groupWrap.appendChild(header);
455
+ const refreshControls = () => {
456
+ const n = countItems();
457
+ addBtn.disabled = n >= max;
458
+ left.innerHTML = `<span>${element.label || element.key}</span> <span class="text-slate-500 dark:text-slate-400 text-xs">[${n} / ${max === Infinity ? '∞' : max}, min=${min}]</span>`;
459
+ };
460
+
461
+ if (pre && pre.length) {
462
+ const n = Math.min(max, Math.max(min, pre.length));
463
+ for (let i = 0; i < n; i++) addItem(pre[i]);
464
+ } else {
465
+ const n = Math.max(min, 0);
466
+ for (let i = 0; i < n; i++) addItem(null);
467
+ }
882
468
 
883
- const itemsWrap = document.createElement('div');
884
- itemsWrap.dataset.itemsFor = element.key;
469
+ addBtn.addEventListener('click', () => addItem(null));
470
+ } else {
471
+ // Single object group
472
+ const subCtx = {
473
+ path: pathJoin(ctx.path, element.key),
474
+ prefill: ctx.prefill?.[element.key] || {}
475
+ };
476
+ element.elements.forEach(child => itemsWrap.appendChild(renderElement(child, subCtx)));
477
+ groupWrap.appendChild(itemsWrap);
478
+ left.innerHTML = `<span>${element.label || element.key}</span>`;
479
+ }
480
+
481
+ wrapper.appendChild(groupWrap);
482
+ break;
885
483
 
886
- if (element.repeat && isPlainObject(element.repeat)) {
887
- const min = element.repeat.min ?? 0;
888
- const max = element.repeat.max ?? Infinity;
889
- const pre = Array.isArray(ctx.prefill?.[element.key]) ? ctx.prefill[element.key] : null;
890
-
891
- const addBtn = document.createElement('button');
892
- addBtn.type = 'button';
893
- addBtn.className = 'bg-blue-600 hover:bg-blue-700 text-white px-3 py-1.5 rounded-md text-sm font-medium transition-colors';
894
- addBtn.textContent = 'Add';
895
- right.appendChild(addBtn);
896
- header.appendChild(right);
484
+ default:
485
+ const unsupported = document.createElement('div');
486
+ unsupported.className = 'text-red-500 text-sm';
487
+ unsupported.textContent = `Unsupported field type: ${element.type}`;
488
+ wrapper.appendChild(unsupported);
489
+ }
897
490
 
898
- const countItems = () => itemsWrap.querySelectorAll(':scope > .groupItem').length;
899
- const refreshControls = () => {
900
- const n = countItems();
901
- addBtn.disabled = n >= max;
902
- left.innerHTML = `<span class="text-sm text-gray-600">Items: ${n} / ${max === Infinity ? '∞' : max} (min: ${min})</span>`;
903
- };
491
+ return wrapper;
492
+ }
904
493
 
905
- const updateItemIndexes = () => {
906
- const items = itemsWrap.querySelectorAll(':scope > .groupItem');
907
- items.forEach((item, index) => {
908
- const titleElement = item.querySelector('h4');
909
- if (titleElement) {
910
- let labelText;
911
- if (element.element_label) {
912
- labelText = element.element_label.replace('$index', index + 1);
913
- } else {
914
- labelText = `${element.label || element.key} #${index + 1}`;
915
- }
916
- titleElement.textContent = labelText;
917
- }
918
- });
919
- };
494
+ function makeFieldHint(element) {
495
+ const parts = [];
496
+
497
+ if (element.required) {
498
+ parts.push('required');
499
+ } else {
500
+ parts.push('optional');
501
+ }
502
+
503
+ if (element.minLength != null || element.maxLength != null) {
504
+ if (element.minLength != null && element.maxLength != null) {
505
+ parts.push(`length=${element.minLength}-${element.maxLength} characters`);
506
+ } else if (element.maxLength != null) {
507
+ parts.push(`max=${element.maxLength} characters`);
508
+ } else if (element.minLength != null) {
509
+ parts.push(`min=${element.minLength} characters`);
510
+ }
511
+ }
512
+
513
+ if (element.min != null || element.max != null) {
514
+ if (element.min != null && element.max != null) {
515
+ parts.push(`range=${element.min}-${element.max}`);
516
+ } else if (element.max != null) {
517
+ parts.push(`max=${element.max}`);
518
+ } else if (element.min != null) {
519
+ parts.push(`min=${element.min}`);
520
+ }
521
+ }
522
+
523
+ if (element.maxSizeMB) {
524
+ parts.push(`max_size=${element.maxSizeMB}MB`);
525
+ }
526
+
527
+ if (element.accept && element.accept.extensions) {
528
+ parts.push(`formats=${element.accept.extensions.map(ext => ext.toUpperCase()).join(',')}`);
529
+ }
530
+
531
+ if (element.pattern && !element.pattern.includes('А-Я')) {
532
+ parts.push('plain text only');
533
+ } else if (element.pattern && element.pattern.includes('А-Я')) {
534
+ parts.push('text with punctuation');
535
+ }
536
+
537
+ return parts.join(' • ');
538
+ }
920
539
 
921
- const addItem = (prefillObj) => {
922
- const itemIndex = countItems() + 1;
923
- const item = document.createElement('div');
924
- item.className = 'groupItem border border-dashed border-gray-300 rounded-lg p-4 mb-3 bg-blue-50/30';
925
-
926
- // Individual item title with index
927
- const itemTitle = document.createElement('div');
928
- itemTitle.className = 'flex items-center justify-between mb-4 pb-2 border-b border-gray-300';
929
-
930
- const itemLabel = document.createElement('h4');
931
- itemLabel.className = 'text-md font-medium text-gray-800';
932
-
933
- // Use element_label if provided, with $index placeholder support
934
- let labelText;
935
- if (element.element_label) {
936
- labelText = element.element_label.replace('$index', itemIndex);
937
- } else {
938
- labelText = `${element.label || element.key} #${itemIndex}`;
939
- }
940
- itemLabel.textContent = labelText;
941
- itemTitle.appendChild(itemLabel);
942
-
943
- // Add remove button to title
944
- const rem = document.createElement('button');
945
- rem.type = 'button';
946
- rem.className = 'bg-red-500 hover:bg-red-600 text-white px-2 py-1 rounded text-xs font-medium transition-colors';
947
- rem.textContent = 'Remove';
948
- rem.addEventListener('click', () => {
949
- if (countItems() <= (element.repeat.min ?? 0)) return;
950
- itemsWrap.removeChild(item);
951
- refreshControls();
952
- // Re-index remaining items
953
- updateItemIndexes();
954
- });
955
- itemTitle.appendChild(rem);
956
-
957
- const subCtx = {
958
- path: pathJoin(ctx.path, element.key + `[${countItems()}]`),
959
- prefill: prefillObj || {}
960
- };
961
-
962
- item.appendChild(itemTitle);
963
- element.elements.forEach(child => item.appendChild(renderElement(child, subCtx, options)));
964
- itemsWrap.appendChild(item);
965
- refreshControls();
540
+ async function renderFilePreview(container, resourceId, fileName, fileType, isReadonly = false) {
541
+ // Don't change container className - preserve max-w-xs and other styling
542
+
543
+ // Clear container content first
544
+ clear(container);
545
+
546
+ if (isReadonly) {
547
+ container.classList.add('cursor-pointer');
548
+ }
549
+
550
+ const img = document.createElement('img');
551
+ img.className = 'w-full h-full object-contain';
552
+ img.alt = fileName || 'Preview';
553
+
554
+ // Use stored file from resourceIndex if available, or try getThumbnail
555
+ const meta = state.resourceIndex.get(resourceId);
556
+ if (meta && meta.file && meta.file instanceof File) {
557
+ // For local files, use FileReader to display preview
558
+ if (meta.type && meta.type.startsWith('image/')) {
559
+ const reader = new FileReader();
560
+ reader.onload = (e) => {
561
+ img.src = e.target.result;
562
+ };
563
+ reader.readAsDataURL(meta.file);
564
+ container.appendChild(img);
565
+ } else {
566
+ // Non-image file
567
+ container.innerHTML = '<div class="flex flex-col items-center justify-center h-full text-gray-400"><div class="text-2xl mb-2">📁</div><div class="text-sm">' + fileName + '</div></div>';
568
+ }
569
+ } else if (state.config.getThumbnail) {
570
+ // Try to get thumbnail from config for uploaded files
571
+ try {
572
+ const thumbnailUrl = await state.config.getThumbnail(resourceId);
573
+ if (thumbnailUrl) {
574
+ img.src = thumbnailUrl;
575
+ container.appendChild(img);
576
+ } else {
577
+ // Fallback to file icon
578
+ container.innerHTML = '<div class="flex flex-col items-center justify-center h-full text-gray-400"><div class="text-2xl mb-2">🖼️</div><div class="text-sm">' + fileName + '</div></div>';
579
+ }
580
+ } catch (error) {
581
+ console.warn('Thumbnail loading failed:', error);
582
+ container.innerHTML = '<div class="flex flex-col items-center justify-center h-full text-gray-400"><div class="text-2xl mb-2">📁</div><div class="text-sm">' + fileName + '</div></div>';
583
+ }
584
+ } else {
585
+ // No file and no getThumbnail config - fallback
586
+ container.innerHTML = '<div class="flex flex-col items-center justify-center h-full text-gray-400"><div class="text-2xl mb-2">🖼️</div><div class="text-sm">' + fileName + '</div></div>';
587
+ }
588
+
589
+ // Add overlay with download/remove buttons if needed
590
+ if (!isReadonly) {
591
+ const overlay = document.createElement('div');
592
+ overlay.className = 'absolute inset-0 bg-black bg-opacity-50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center';
593
+ const buttonContainer = document.createElement('div');
594
+ buttonContainer.className = 'flex gap-2';
595
+
596
+ const removeBtn = document.createElement('button');
597
+ removeBtn.className = 'bg-red-600 text-white px-2 py-1 rounded text-xs';
598
+ removeBtn.textContent = 'Удалить';
599
+ removeBtn.onclick = (e) => {
600
+ e.stopPropagation();
601
+ const hiddenInput = container.parentElement.querySelector('input[type="hidden"]');
602
+ if (hiddenInput) hiddenInput.value = '';
603
+ container.innerHTML = '<div class="flex flex-col items-center justify-center h-full text-gray-400"><div class="text-2xl mb-2">📁</div><div class="text-sm">Click or drop to upload</div></div>';
604
+ };
605
+
606
+ buttonContainer.appendChild(removeBtn);
607
+ overlay.appendChild(buttonContainer);
608
+ container.appendChild(overlay);
609
+ } else if (isReadonly && state.config.downloadFile) {
610
+ // Add click handler for download in readonly mode
611
+ container.style.cursor = 'pointer';
612
+ container.onclick = () => {
613
+ if (state.config.downloadFile) {
614
+ state.config.downloadFile(resourceId, fileName);
615
+ }
616
+ };
617
+ }
618
+ }
619
+
620
+ function renderResourcePills(container, rids, onRemove) {
621
+ clear(container);
622
+
623
+ // Show empty placeholder if no files
624
+ if (!rids || rids.length === 0) {
625
+ container.innerHTML = `
626
+ <div class="grid grid-cols-4 gap-3 mb-3">
627
+ <div class="aspect-square bg-gray-100 border-2 border-dashed border-gray-300 rounded flex items-center justify-center cursor-pointer hover:border-gray-400 transition-colors">
628
+ <svg class="w-6 h-6 text-gray-400" fill="currentColor" viewBox="0 0 24 24">
629
+ <path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
630
+ </svg>
631
+ </div>
632
+ <div class="aspect-square bg-gray-100 border-2 border-dashed border-gray-300 rounded flex items-center justify-center cursor-pointer hover:border-gray-400 transition-colors">
633
+ <svg class="w-6 h-6 text-gray-400" fill="currentColor" viewBox="0 0 24 24">
634
+ <path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
635
+ </svg>
636
+ </div>
637
+ <div class="aspect-square bg-gray-100 border-2 border-dashed border-gray-300 rounded flex items-center justify-center cursor-pointer hover:border-gray-400 transition-colors">
638
+ <svg class="w-6 h-6 text-gray-400" fill="currentColor" viewBox="0 0 24 24">
639
+ <path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
640
+ </svg>
641
+ </div>
642
+ <div class="aspect-square bg-gray-100 border-2 border-dashed border-gray-300 rounded flex items-center justify-center cursor-pointer hover:border-gray-400 transition-colors">
643
+ <svg class="w-6 h-6 text-gray-400" fill="currentColor" viewBox="0 0 24 24">
644
+ <path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
645
+ </svg>
646
+ </div>
647
+ </div>
648
+ <div class="text-center text-xs text-gray-600">
649
+ <span class="underline cursor-pointer">Загрузите</span> или перетащите файлы
650
+ </div>
651
+ `;
652
+ // Add click handler to the entire placeholder
653
+ container.onclick = () => {
654
+ const fileInput = container.parentElement?.querySelector('input[type="file"]');
655
+ if (fileInput) fileInput.click();
656
+ };
657
+
658
+ // Also add specific handler to the "Загрузите" link
659
+ const uploadLink = container.querySelector('span.underline');
660
+ if (uploadLink) {
661
+ uploadLink.onclick = (e) => {
662
+ e.stopPropagation(); // Prevent double trigger
663
+ const fileInput = container.parentElement?.querySelector('input[type="file"]');
664
+ if (fileInput) fileInput.click();
665
+ };
666
+ }
667
+ return;
668
+ }
669
+
670
+ // Show files grid
671
+ container.className = 'grid grid-cols-4 gap-3 mt-2';
672
+
673
+ // Calculate how many slots we need (at least 4, then expand by rows of 4)
674
+ const minSlots = 4;
675
+ const currentImagesCount = rids.length;
676
+ const slotsNeeded = Math.max(minSlots, Math.ceil((currentImagesCount + 1) / 4) * 4);
677
+
678
+ // Add all slots (filled and empty)
679
+ for (let i = 0; i < slotsNeeded; i++) {
680
+ const slot = document.createElement('div');
681
+
682
+ if (i < rids.length) {
683
+ // Filled slot with image preview
684
+ const rid = rids[i];
685
+ const meta = state.resourceIndex.get(rid);
686
+ slot.className = 'aspect-square bg-gray-100 rounded-lg overflow-hidden relative group border border-gray-300';
687
+
688
+ // Add image or file content
689
+ if (meta && meta.type?.startsWith('image/')) {
690
+ if (meta.file && meta.file instanceof File) {
691
+ // Use FileReader for local files
692
+ const img = document.createElement('img');
693
+ img.className = 'w-full h-full object-contain';
694
+ img.alt = meta.name;
695
+
696
+ const reader = new FileReader();
697
+ reader.onload = (e) => {
698
+ img.src = e.target.result;
966
699
  };
967
-
968
- groupWrap.appendChild(itemsWrap);
700
+ reader.readAsDataURL(meta.file);
701
+ slot.appendChild(img);
702
+ } else if (state.config.getThumbnail) {
703
+ // Use getThumbnail for uploaded files
704
+ const img = document.createElement('img');
705
+ img.className = 'w-full h-full object-contain';
706
+ img.alt = meta.name;
969
707
 
970
- if (pre && pre.length) {
971
- const n = Math.min(max, Math.max(min, pre.length));
972
- for (let i = 0; i < n; i++) addItem(pre[i]);
708
+ const url = state.config.getThumbnail(rid);
709
+ if (url) {
710
+ img.src = url;
711
+ slot.appendChild(img);
973
712
  } else {
974
- const n = Math.max(min, 0);
975
- for (let i = 0; i < n; i++) addItem(null);
713
+ slot.innerHTML = `<div class="flex flex-col items-center justify-center h-full text-gray-400">
714
+ <svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
715
+ <path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
716
+ </svg>
717
+ </div>`;
976
718
  }
977
-
978
- addBtn.addEventListener('click', () => addItem(null));
979
719
  } else {
980
- // Single object group
981
- const subCtx = {
982
- path: pathJoin(ctx.path, element.key),
983
- prefill: ctx.prefill?.[element.key] || {}
984
- };
985
- element.elements.forEach(child => itemsWrap.appendChild(renderElement(child, subCtx, options)));
986
- groupWrap.appendChild(itemsWrap);
720
+ slot.innerHTML = `<div class="flex flex-col items-center justify-center h-full text-gray-400">
721
+ <svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
722
+ <path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
723
+ </svg>
724
+ </div>`;
987
725
  }
988
-
989
- wrapper.innerHTML = '';
990
- wrapper.appendChild(groupWrap);
991
- break;
726
+ } else {
727
+ slot.innerHTML = `<div class="flex flex-col items-center justify-center h-full text-gray-400">
728
+ <div class="text-2xl mb-1">📁</div>
729
+ <div class="text-xs">${meta?.name || 'File'}</div>
730
+ </div>`;
992
731
  }
993
- default:
994
- wrapper.appendChild(document.createTextNode(`Unsupported type: ${element.type}`));
732
+
733
+ // Add remove button overlay (similar to file field)
734
+ if (onRemove) {
735
+ const overlay = document.createElement('div');
736
+ overlay.className = 'absolute inset-0 bg-black bg-opacity-50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center';
737
+
738
+ const removeBtn = document.createElement('button');
739
+ removeBtn.className = 'bg-red-600 text-white px-2 py-1 rounded text-xs';
740
+ removeBtn.textContent = 'Удалить';
741
+ removeBtn.onclick = (e) => {
742
+ e.stopPropagation();
743
+ onRemove(rid);
744
+ };
745
+
746
+ overlay.appendChild(removeBtn);
747
+ slot.appendChild(overlay);
748
+ }
749
+ } else {
750
+ // Empty slot placeholder
751
+ slot.className = 'aspect-square bg-gray-100 border-2 border-dashed border-gray-300 rounded-lg flex items-center justify-center cursor-pointer hover:border-gray-400 transition-colors';
752
+ slot.innerHTML = '<svg class="w-6 h-6 text-gray-400" fill="currentColor" viewBox="0 0 24 24"><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/></svg>';
753
+ slot.onclick = () => {
754
+ const fileInput = container.parentElement?.querySelector('input[type="file"]');
755
+ if (fileInput) fileInput.click();
756
+ };
995
757
  }
996
758
 
997
- return wrapper;
759
+ container.appendChild(slot);
998
760
  }
761
+ }
999
762
 
1000
- // Form data collection and validation
1001
- function collectAndValidate(schema, formRoot, skipValidation = false) {
1002
- const errors = [];
763
+ function formatFileSize(bytes) {
764
+ if (bytes === 0) return '0 B';
765
+ const k = 1024;
766
+ const sizes = ['B', 'KB', 'MB', 'GB'];
767
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
768
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
769
+ }
1003
770
 
1004
- function collectElement(element, scopeRoot, elementPath = '') {
1005
- const key = element.key;
1006
- const fullPath = elementPath ? `${elementPath}.${key}` : key;
1007
-
1008
- switch (element.type) {
1009
- case 'text':
1010
- case 'textarea': {
1011
- const input = scopeRoot.querySelector(`[name$="${key}"]`);
1012
- const val = (input?.value ?? '').trim();
1013
- if (!skipValidation && element.required && val === '') {
1014
- errors.push(`Field "${fullPath}" is required`);
1015
- markValidity(input, 'required');
1016
- } else if (!skipValidation && val !== '') {
1017
- if (element.minLength != null && val.length < element.minLength) {
1018
- errors.push(`Field "${fullPath}" must be at least ${element.minLength} characters`);
1019
- markValidity(input, `minLength=${element.minLength}`);
1020
- }
1021
- if (element.maxLength != null && val.length > element.maxLength) {
1022
- errors.push(`Field "${fullPath}" must be at most ${element.maxLength} characters`);
1023
- markValidity(input, `maxLength=${element.maxLength}`);
1024
- }
1025
- if (element.pattern) {
1026
- try {
1027
- const re = new RegExp(element.pattern);
1028
- if (!re.test(val)) {
1029
- errors.push(`Field "${fullPath}" does not match required format`);
1030
- markValidity(input, 'pattern mismatch');
1031
- }
1032
- } catch {
1033
- errors.push(`Field "${fullPath}" has invalid validation pattern`);
1034
- markValidity(input, 'invalid pattern');
1035
- }
1036
- }
1037
- } else if (skipValidation) {
1038
- markValidity(input, null);
1039
- } else {
1040
- markValidity(input, null);
1041
- }
1042
- return val;
771
+ function generateResourceId() {
772
+ return 'res_' + Math.random().toString(36).substr(2, 9) + Date.now().toString(36);
773
+ }
774
+
775
+ async function handleFileSelect(file, container, fieldName) {
776
+ let rid;
777
+
778
+ // If uploadHandler is configured, use it to upload the file
779
+ if (state.config.uploadFile) {
780
+ try {
781
+ rid = await state.config.uploadFile(file);
782
+ if (typeof rid !== 'string') {
783
+ throw new Error('Upload handler must return a string resource ID');
784
+ }
785
+ } catch (error) {
786
+ throw new Error(`File upload failed: ${error.message}`);
787
+ }
788
+ } else {
789
+ throw new Error('No upload handler configured. Set uploadHandler via FormBuilder.setUploadHandler()');
790
+ }
791
+
792
+ state.resourceIndex.set(rid, {
793
+ name: file.name,
794
+ type: file.type,
795
+ size: file.size,
796
+ file: null // Files are always uploaded, never stored locally
797
+ });
798
+
799
+ // Create hidden input to store the resource ID
800
+ let hiddenInput = container.parentElement.querySelector('input[type="hidden"]');
801
+ if (!hiddenInput) {
802
+ hiddenInput = document.createElement('input');
803
+ hiddenInput.type = 'hidden';
804
+ hiddenInput.name = fieldName;
805
+ container.parentElement.appendChild(hiddenInput);
806
+ }
807
+ hiddenInput.value = rid;
808
+
809
+ renderFilePreview(container, rid, file.name, file.type);
810
+ }
811
+
812
+ function setupDragAndDrop(element, dropHandler) {
813
+ element.addEventListener('dragover', (e) => {
814
+ e.preventDefault();
815
+ element.classList.add('border-blue-500', 'bg-blue-50');
816
+ });
817
+
818
+ element.addEventListener('dragleave', (e) => {
819
+ e.preventDefault();
820
+ element.classList.remove('border-blue-500', 'bg-blue-50');
821
+ });
822
+
823
+ element.addEventListener('drop', (e) => {
824
+ e.preventDefault();
825
+ element.classList.remove('border-blue-500', 'bg-blue-50');
826
+ dropHandler(e.dataTransfer.files);
827
+ });
828
+ }
829
+
830
+ function showTooltip(tooltipId, button) {
831
+ const tooltip = document.getElementById(tooltipId);
832
+ const isCurrentlyVisible = !tooltip.classList.contains('hidden');
833
+
834
+ // Hide all tooltips first
835
+ document.querySelectorAll('[id^="tooltip-"]').forEach(t => {
836
+ t.classList.add('hidden');
837
+ });
838
+
839
+ // If the tooltip was already visible, keep it hidden (toggle behavior)
840
+ if (isCurrentlyVisible) {
841
+ return;
842
+ }
843
+
844
+ // Position the tooltip intelligently
845
+ const rect = button.getBoundingClientRect();
846
+ const viewportWidth = window.innerWidth;
847
+ const viewportHeight = window.innerHeight;
848
+
849
+ // Ensure tooltip is appended to body for proper positioning
850
+ if (tooltip && tooltip.parentElement !== document.body) {
851
+ document.body.appendChild(tooltip);
852
+ }
853
+
854
+ // Show tooltip temporarily to measure its size
855
+ tooltip.style.visibility = 'hidden';
856
+ tooltip.style.position = 'fixed';
857
+ tooltip.classList.remove('hidden');
858
+ const tooltipRect = tooltip.getBoundingClientRect();
859
+ tooltip.classList.add('hidden');
860
+ tooltip.style.visibility = 'visible';
861
+
862
+ let left = rect.left;
863
+ let top = rect.bottom + 5;
864
+
865
+ // Adjust horizontal position if tooltip would go off-screen
866
+ if (left + tooltipRect.width > viewportWidth) {
867
+ left = rect.right - tooltipRect.width;
868
+ }
869
+
870
+ // Adjust vertical position if tooltip would go off-screen
871
+ if (top + tooltipRect.height > viewportHeight) {
872
+ top = rect.top - tooltipRect.height - 5;
873
+ }
874
+
875
+ // Ensure tooltip doesn't go off the left edge
876
+ if (left < 10) {
877
+ left = 10;
878
+ }
879
+
880
+ // Ensure tooltip doesn't go off the top edge
881
+ if (top < 10) {
882
+ top = rect.bottom + 5;
883
+ }
884
+
885
+ tooltip.style.left = left + 'px';
886
+ tooltip.style.top = top + 'px';
887
+
888
+ // Show the tooltip
889
+ tooltip.classList.remove('hidden');
890
+
891
+ // Hide after 25 seconds
892
+ setTimeout(() => {
893
+ tooltip.classList.add('hidden');
894
+ }, 25000);
895
+ }
896
+
897
+ // Close tooltips when clicking outside
898
+ document.addEventListener('click', function(e) {
899
+ const isInfoButton = e.target.closest('button') && e.target.closest('button').onclick;
900
+ const isTooltip = e.target.closest('[id^="tooltip-"]');
901
+
902
+ if (!isInfoButton && !isTooltip) {
903
+ document.querySelectorAll('[id^="tooltip-"]').forEach(tooltip => {
904
+ tooltip.classList.add('hidden');
905
+ });
906
+ }
907
+ });
908
+
909
+ // Form validation and data extraction
910
+ function validateForm(skipValidation = false) {
911
+ if (!state.schema || !state.formRoot) return { valid: true, data: {} };
912
+
913
+ const errors = [];
914
+ const data = {};
915
+
916
+ function markValidity(input, errorMessage) {
917
+ if (!input) return;
918
+ if (errorMessage) {
919
+ input.classList.add('invalid');
920
+ input.title = errorMessage;
921
+ } else {
922
+ input.classList.remove('invalid');
923
+ input.title = '';
924
+ }
925
+ }
926
+
927
+ function validateElement(element, ctx) {
928
+ const key = element.key;
929
+ const scopeRoot = state.formRoot;
930
+
931
+ switch (element.type) {
932
+ case 'text':
933
+ case 'textarea': {
934
+ const input = scopeRoot.querySelector(`[name$="${key}"]`);
935
+ const val = input?.value ?? '';
936
+ if (!skipValidation && element.required && val === '') {
937
+ errors.push(`${key}: required`);
938
+ markValidity(input, 'required');
939
+ return '';
1043
940
  }
1044
- case 'number': {
1045
- const input = scopeRoot.querySelector(`[name$="${key}"]`);
1046
- const raw = input?.value ?? '';
1047
- if (!skipValidation && element.required && raw === '') {
1048
- errors.push(`${key}: required`);
1049
- markValidity(input, 'required');
1050
- return null;
1051
- }
1052
- if (raw === '') {
1053
- markValidity(input, null);
1054
- return null;
1055
- }
1056
- const v = parseFloat(raw);
1057
- if (!skipValidation && !Number.isFinite(v)) {
1058
- errors.push(`${key}: not a number`);
1059
- markValidity(input, 'not a number');
1060
- return null;
941
+ if (!skipValidation && val) {
942
+ if (element.minLength != null && val.length < element.minLength) {
943
+ errors.push(`${key}: minLength=${element.minLength}`);
944
+ markValidity(input, `minLength=${element.minLength}`);
1061
945
  }
1062
- if (!skipValidation && element.min != null && v < element.min) {
1063
- errors.push(`${key}: < min=${element.min}`);
1064
- markValidity(input, `< min=${element.min}`);
946
+ if (element.maxLength != null && val.length > element.maxLength) {
947
+ errors.push(`${key}: maxLength=${element.maxLength}`);
948
+ markValidity(input, `maxLength=${element.maxLength}`);
1065
949
  }
1066
- if (!skipValidation && element.max != null && v > element.max) {
1067
- errors.push(`${key}: > max=${element.max}`);
1068
- markValidity(input, `> max=${element.max}`);
950
+ if (element.pattern) {
951
+ try {
952
+ const re = new RegExp(element.pattern);
953
+ if (!re.test(val)) {
954
+ errors.push(`${key}: pattern mismatch`);
955
+ markValidity(input, 'pattern mismatch');
956
+ }
957
+ } catch {
958
+ errors.push(`${key}: invalid pattern`);
959
+ markValidity(input, 'invalid pattern');
960
+ }
1069
961
  }
1070
- const d = Number.isInteger(element.decimals ?? 0) ? element.decimals : 0;
1071
- const r = Number(v.toFixed(d));
1072
- input.value = String(r);
962
+ } else if (skipValidation) {
963
+ markValidity(input, null);
964
+ } else {
1073
965
  markValidity(input, null);
1074
- return r;
1075
966
  }
1076
- case 'select': {
1077
- const sel = scopeRoot.querySelector(`select[name$="${key}"]`);
1078
- const val = sel?.value ?? '';
1079
- const values = new Set(element.options.map(o => String(o.value)));
1080
- if (!skipValidation && element.required && val === '') {
1081
- errors.push(`${key}: required`);
1082
- markValidity(sel, 'required');
1083
- return '';
1084
- }
1085
- if (!skipValidation && val !== '' && !values.has(String(val))) {
1086
- errors.push(`${key}: value not in options`);
1087
- markValidity(sel, 'not in options');
1088
- } else {
1089
- markValidity(sel, null);
1090
- }
1091
- return val === '' ? null : val;
967
+ return val;
968
+ }
969
+ case 'number': {
970
+ const input = scopeRoot.querySelector(`[name$="${key}"]`);
971
+ const raw = input?.value ?? '';
972
+ if (!skipValidation && element.required && raw === '') {
973
+ errors.push(`${key}: required`);
974
+ markValidity(input, 'required');
975
+ return null;
1092
976
  }
1093
- case 'file': {
1094
- const hid = scopeRoot.querySelector(`input[type="hidden"][name$="${key}"]`);
1095
- const rid = hid?.value ?? '';
1096
- if (!skipValidation && element.required && !rid) {
1097
- errors.push(`${key}: required (file missing)`);
1098
- const picker = hid?.previousElementSibling;
1099
- if (picker) markValidity(picker, 'required');
1100
- } else {
1101
- if (hid?.previousElementSibling) markValidity(hid.previousElementSibling, null);
1102
- }
1103
- return rid || null;
977
+ if (raw === '') {
978
+ markValidity(input, null);
979
+ return null;
1104
980
  }
1105
- case 'files': {
1106
- const hid = scopeRoot.querySelector(`input[type="hidden"][name$="${key}"]`);
1107
- const arr = parseJSONSafe(hid?.value ?? '[]', []);
1108
- const count = Array.isArray(arr) ? arr.length : 0;
1109
- if (!skipValidation && !Array.isArray(arr)) errors.push(`${key}: internal value corrupted`);
1110
- if (!skipValidation && element.minCount != null && count < element.minCount) {
1111
- errors.push(`${key}: < minCount=${element.minCount}`);
1112
- }
1113
- if (!skipValidation && element.maxCount != null && count > element.maxCount) {
1114
- errors.push(`${key}: > maxCount=${element.maxCount}`);
1115
- }
1116
- if (hid?.previousElementSibling) markValidity(hid.previousElementSibling, null);
1117
- return Array.isArray(arr) ? arr : [];
981
+ const v = parseFloat(raw);
982
+ if (!skipValidation && !Number.isFinite(v)) {
983
+ errors.push(`${key}: not a number`);
984
+ markValidity(input, 'not a number');
985
+ return null;
1118
986
  }
1119
- case 'videos': {
1120
- const hid = scopeRoot.querySelector(`input[type="hidden"][name$="${key}"]`);
1121
- const arr = parseJSONSafe(hid?.value ?? '[]', []);
1122
- const count = Array.isArray(arr) ? arr.length : 0;
1123
- if (!skipValidation && !Array.isArray(arr)) errors.push(`${key}: internal value corrupted`);
1124
- if (!skipValidation && element.minCount != null && count < element.minCount) {
1125
- errors.push(`${key}: < minCount=${element.minCount}`);
1126
- }
1127
- if (!skipValidation && element.maxCount != null && count > element.maxCount) {
1128
- errors.push(`${key}: > maxCount=${element.maxCount}`);
1129
- }
1130
- if (hid?.previousElementSibling) markValidity(hid.previousElementSibling, null);
1131
- return Array.isArray(arr) ? arr : [];
987
+ if (!skipValidation && element.min != null && v < element.min) {
988
+ errors.push(`${key}: < min=${element.min}`);
989
+ markValidity(input, `< min=${element.min}`);
1132
990
  }
1133
- case 'group': {
1134
- const groupWrapper = scopeRoot.querySelector(`[data-group="${key}"]`);
1135
- if (!groupWrapper) {
1136
- errors.push(`${key}: internal group wrapper not found`);
1137
- return element.repeat ? [] : {};
1138
- }
1139
- const itemsWrap = groupWrapper.querySelector(`[data-items-for="${key}"]`);
1140
- if (!itemsWrap) {
1141
- errors.push(`${key}: internal items container not found`);
1142
- return element.repeat ? [] : {};
1143
- }
1144
-
1145
- if (element.repeat && isPlainObject(element.repeat)) {
1146
- const items = itemsWrap.querySelectorAll(':scope > .groupItem');
1147
- const out = [];
1148
- const n = items.length;
1149
- const min = element.repeat.min ?? 0;
1150
- const max = element.repeat.max ?? Infinity;
1151
- if (!skipValidation && n < min) errors.push(`${key}: count < min=${min}`);
1152
- if (!skipValidation && n > max) errors.push(`${key}: count > max=${max}`);
1153
- items.forEach((item, index) => {
1154
- const obj = {};
1155
- element.elements.forEach(child => {
1156
- obj[child.key] = collectElement(child, item, `${fullPath}[${index}]`);
1157
- });
1158
- out.push(obj);
1159
- });
1160
- return out;
1161
- } else {
1162
- const obj = {};
991
+ if (!skipValidation && element.max != null && v > element.max) {
992
+ errors.push(`${key}: > max=${element.max}`);
993
+ markValidity(input, `> max=${element.max}`);
994
+ }
995
+ const d = Number.isInteger(element.decimals ?? 0) ? element.decimals : 0;
996
+ markValidity(input, null);
997
+ return Number(v.toFixed(d));
998
+ }
999
+ case 'select': {
1000
+ const input = scopeRoot.querySelector(`[name$="${key}"]`);
1001
+ const val = input?.value ?? '';
1002
+ if (!skipValidation && element.required && val === '') {
1003
+ errors.push(`${key}: required`);
1004
+ markValidity(input, 'required');
1005
+ return '';
1006
+ }
1007
+ markValidity(input, null);
1008
+ return val;
1009
+ }
1010
+ case 'file': {
1011
+ const input = scopeRoot.querySelector(`input[name$="${key}"][type="hidden"]`);
1012
+ const rid = input?.value ?? '';
1013
+ if (!skipValidation && element.required && rid === '') {
1014
+ errors.push(`${key}: required`);
1015
+ return null;
1016
+ }
1017
+ return rid || null;
1018
+ }
1019
+ case 'files': {
1020
+ // For files, we need to collect all resource IDs
1021
+ const container = scopeRoot.querySelector(`[name$="${key}"]`)?.parentElement?.querySelector('.files-list');
1022
+ const rids = [];
1023
+ if (container) {
1024
+ // Extract resource IDs from the current state
1025
+ // This is a simplified approach - in practice you'd track this better
1026
+ }
1027
+ return rids;
1028
+ }
1029
+ case 'group': {
1030
+ if (element.repeat && isPlainObject(element.repeat)) {
1031
+ const items = [];
1032
+ const itemElements = scopeRoot.querySelectorAll(`[name^="${key}["]`);
1033
+ const itemCount = Math.max(0, Math.floor(itemElements.length / element.elements.length));
1034
+
1035
+ for (let i = 0; i < itemCount; i++) {
1036
+ const itemData = {};
1163
1037
  element.elements.forEach(child => {
1164
- obj[child.key] = collectElement(child, itemsWrap, fullPath);
1038
+ const childKey = `${key}[${i}].${child.key}`;
1039
+ itemData[child.key] = validateElement({...child, key: childKey}, ctx);
1165
1040
  });
1166
- return obj;
1041
+ items.push(itemData);
1167
1042
  }
1043
+ return items;
1044
+ } else {
1045
+ const groupData = {};
1046
+ element.elements.forEach(child => {
1047
+ const childKey = `${key}.${child.key}`;
1048
+ groupData[child.key] = validateElement({...child, key: childKey}, ctx);
1049
+ });
1050
+ return groupData;
1168
1051
  }
1169
- default:
1170
- errors.push(`${key}: unsupported type ${element.type}`);
1171
- return null;
1172
1052
  }
1053
+ default:
1054
+ return null;
1173
1055
  }
1174
-
1175
- const result = {};
1176
- schema.elements.forEach(element => {
1177
- result[element.key] = collectElement(element, formRoot, '');
1178
- });
1179
-
1180
- return { result, errors };
1181
1056
  }
1057
+
1058
+ state.schema.elements.forEach(element => {
1059
+ data[element.key] = validateElement(element, { path: '' });
1060
+ });
1061
+
1062
+ return {
1063
+ valid: errors.length === 0,
1064
+ errors,
1065
+ data
1066
+ };
1067
+ }
1182
1068
 
1183
- // Main form rendering function
1184
- function renderForm(schema, container, options = {}) {
1185
- const {
1186
- prefill = {},
1187
- readonly = false,
1188
- debug = false,
1189
- onSubmit,
1190
- onDraft,
1191
- onError,
1192
- buttons = {}
1193
- } = options;
1194
-
1195
- if (debug) {
1196
- console.log('[FormBuilder Debug] Rendering form with schema:', schema);
1197
- console.log('[FormBuilder Debug] Options:', options);
1069
+ // Element rendering functions
1070
+ function renderTextElement(element, ctx, wrapper, pathKey) {
1071
+ const textInput = document.createElement('input');
1072
+ textInput.type = 'text';
1073
+ textInput.className = 'w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500';
1074
+ textInput.name = pathKey;
1075
+ textInput.placeholder = element.placeholder || 'Введите текст';
1076
+ textInput.value = ctx.prefill[element.key] || element.default || '';
1077
+ textInput.readOnly = state.config.readonly;
1078
+ wrapper.appendChild(textInput);
1079
+
1080
+ // Add hint
1081
+ const textHint = document.createElement('p');
1082
+ textHint.className = 'text-xs text-gray-500 mt-1';
1083
+ textHint.textContent = makeFieldHint(element);
1084
+ wrapper.appendChild(textHint);
1085
+ }
1086
+
1087
+ function renderTextareaElement(element, ctx, wrapper, pathKey) {
1088
+ const textareaInput = document.createElement('textarea');
1089
+ textareaInput.className = 'w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-none';
1090
+ textareaInput.name = pathKey;
1091
+ textareaInput.placeholder = element.placeholder || 'Введите текст';
1092
+ textareaInput.rows = element.rows || 4;
1093
+ textareaInput.value = ctx.prefill[element.key] || element.default || '';
1094
+ textareaInput.readOnly = state.config.readonly;
1095
+ wrapper.appendChild(textareaInput);
1096
+
1097
+ // Add hint
1098
+ const textareaHint = document.createElement('p');
1099
+ textareaHint.className = 'text-xs text-gray-500 mt-1';
1100
+ textareaHint.textContent = makeFieldHint(element);
1101
+ wrapper.appendChild(textareaHint);
1102
+ }
1103
+
1104
+ function renderNumberElement(element, ctx, wrapper, pathKey) {
1105
+ const numberInput = document.createElement('input');
1106
+ numberInput.type = 'number';
1107
+ numberInput.className = 'w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500';
1108
+ numberInput.name = pathKey;
1109
+ numberInput.placeholder = element.placeholder || '0';
1110
+ if (element.min !== undefined) numberInput.min = element.min;
1111
+ if (element.max !== undefined) numberInput.max = element.max;
1112
+ if (element.step !== undefined) numberInput.step = element.step;
1113
+ numberInput.value = ctx.prefill[element.key] || element.default || '';
1114
+ numberInput.readOnly = state.config.readonly;
1115
+ wrapper.appendChild(numberInput);
1116
+
1117
+ // Add hint
1118
+ const numberHint = document.createElement('p');
1119
+ numberHint.className = 'text-xs text-gray-500 mt-1';
1120
+ numberHint.textContent = makeFieldHint(element);
1121
+ wrapper.appendChild(numberHint);
1122
+ }
1123
+
1124
+ function renderSelectElement(element, ctx, wrapper, pathKey) {
1125
+ const selectInput = document.createElement('select');
1126
+ selectInput.className = 'w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500';
1127
+ selectInput.name = pathKey;
1128
+ selectInput.disabled = state.config.readonly;
1129
+
1130
+ (element.options || []).forEach(option => {
1131
+ const optionEl = document.createElement('option');
1132
+ optionEl.value = option.value;
1133
+ optionEl.textContent = option.label;
1134
+ if ((ctx.prefill[element.key] || element.default) === option.value) {
1135
+ optionEl.selected = true;
1198
1136
  }
1199
-
1200
- // Validate schema first
1201
- const schemaErrors = validateSchema(schema);
1202
- if (schemaErrors.length > 0) {
1203
- if (onError) onError(schemaErrors);
1204
- throw new Error('Schema validation failed: ' + schemaErrors.join(', '));
1137
+ selectInput.appendChild(optionEl);
1138
+ });
1139
+
1140
+ wrapper.appendChild(selectInput);
1141
+
1142
+ // Add hint
1143
+ const selectHint = document.createElement('p');
1144
+ selectHint.className = 'text-xs text-gray-500 mt-1';
1145
+ selectHint.textContent = makeFieldHint(element);
1146
+ wrapper.appendChild(selectHint);
1147
+ }
1148
+
1149
+ // Common file preview rendering function for readonly mode
1150
+ function renderFilePreviewReadonly(resourceId, fileName) {
1151
+ const meta = state.resourceIndex.get(resourceId);
1152
+ const actualFileName = fileName || meta?.name || 'file';
1153
+
1154
+ // Individual file result container
1155
+ const fileResult = document.createElement('div');
1156
+ fileResult.className = 'space-y-3';
1157
+
1158
+ // Large preview container
1159
+ const previewContainer = document.createElement('div');
1160
+ previewContainer.className = 'bg-gray-100 rounded-lg overflow-hidden cursor-pointer hover:opacity-90 transition-opacity';
1161
+
1162
+ // Check file type and render appropriate preview
1163
+ if (meta?.type?.startsWith('image/') || actualFileName.toLowerCase().match(/\.(jpg|jpeg|png|gif|webp)$/)) {
1164
+ // Image preview
1165
+ if (state.config.getThumbnail) {
1166
+ const thumbnailUrl = state.config.getThumbnail(resourceId);
1167
+ if (thumbnailUrl) {
1168
+ previewContainer.innerHTML = `<img src="${thumbnailUrl}" alt="${actualFileName}" class="w-full h-auto">`;
1169
+ } else {
1170
+ previewContainer.innerHTML = `<div class="aspect-video flex items-center justify-center text-gray-400"><div class="text-center"><div class="text-4xl mb-2">🖼️</div><div class="text-sm">${actualFileName}</div></div></div>`;
1171
+ }
1172
+ } else {
1173
+ previewContainer.innerHTML = `<div class="aspect-video flex items-center justify-center text-gray-400"><div class="text-center"><div class="text-4xl mb-2">🖼️</div><div class="text-sm">${actualFileName}</div></div></div>`;
1205
1174
  }
1206
-
1207
- // Clear container
1208
- clear(container);
1209
-
1210
- // Create form element
1211
- const formEl = document.createElement('form');
1212
- formEl.addEventListener('submit', (e) => e.preventDefault());
1175
+ } else if (meta?.type?.startsWith('video/') || actualFileName.toLowerCase().match(/\.(mp4|webm|avi|mov)$/)) {
1176
+ // Video preview
1177
+ if (state.config.getThumbnail) {
1178
+ const thumbnailUrl = state.config.getThumbnail(resourceId);
1179
+ if (thumbnailUrl) {
1180
+ previewContainer.innerHTML = `
1181
+ <div class="relative group">
1182
+ <video class="w-full h-auto" controls preload="auto" muted>
1183
+ <source src="${thumbnailUrl}" type="${meta?.type || 'video/mp4'}">
1184
+ Ваш браузер не поддерживает видео.
1185
+ </video>
1186
+ <div class="absolute inset-0 bg-black bg-opacity-20 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center pointer-events-none">
1187
+ <div class="bg-white bg-opacity-90 rounded-full p-3">
1188
+ <svg class="w-8 h-8 text-gray-800" fill="currentColor" viewBox="0 0 24 24">
1189
+ <path d="M8 5v14l11-7z"/>
1190
+ </svg>
1191
+ </div>
1192
+ </div>
1193
+ </div>
1194
+ `;
1195
+ } else {
1196
+ previewContainer.innerHTML = `<div class="aspect-video flex items-center justify-center text-gray-400"><div class="text-center"><div class="text-4xl mb-2">🎥</div><div class="text-sm">${actualFileName}</div></div></div>`;
1197
+ }
1198
+ } else {
1199
+ previewContainer.innerHTML = `<div class="aspect-video flex items-center justify-center text-gray-400"><div class="text-center"><div class="text-4xl mb-2">🎥</div><div class="text-sm">${actualFileName}</div></div></div>`;
1200
+ }
1201
+ } else {
1202
+ // Other file types
1203
+ previewContainer.innerHTML = `<div class="aspect-video flex items-center justify-center text-gray-400"><div class="text-center"><div class="text-4xl mb-2">📁</div><div class="text-sm">${actualFileName}</div></div></div>`;
1204
+ }
1205
+
1206
+ // File name
1207
+ const fileNameElement = document.createElement('p');
1208
+ fileNameElement.className = 'text-sm font-medium text-gray-900 text-center';
1209
+ fileNameElement.textContent = actualFileName;
1210
+
1211
+ // Download button
1212
+ const downloadButton = document.createElement('button');
1213
+ downloadButton.className = 'w-full px-3 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors';
1214
+ downloadButton.textContent = 'Скачать';
1215
+ downloadButton.onclick = (e) => {
1216
+ e.preventDefault();
1217
+ e.stopPropagation();
1218
+ if (state.config.downloadFile) {
1219
+ state.config.downloadFile(resourceId, actualFileName);
1220
+ } else {
1221
+ forceDownload(resourceId, actualFileName);
1222
+ }
1223
+ };
1224
+
1225
+ fileResult.appendChild(previewContainer);
1226
+ fileResult.appendChild(fileNameElement);
1227
+ fileResult.appendChild(downloadButton);
1228
+
1229
+ return fileResult;
1230
+ }
1213
1231
 
1214
- const ctx = { path: '', prefill: prefill || {} };
1215
- schema.elements.forEach(element => {
1216
- const block = renderElement(element, ctx, { readonly });
1217
- formEl.appendChild(block);
1218
- });
1232
+ // TODO: Extract large file, files, and group rendering logic to separate functions:
1233
+ // - renderFileElement(element, ctx, wrapper, pathKey)
1234
+ // - renderFilesElement(element, ctx, wrapper, pathKey)
1235
+ // - renderGroupElement(element, ctx, wrapper, pathKey)
1219
1236
 
1220
- // Add action buttons if not readonly
1221
- if (!readonly) {
1222
- const buttonContainer = document.createElement('div');
1223
- buttonContainer.className = 'flex gap-2 pt-4 border-t border-gray-200 mt-6';
1224
-
1225
- const submitBtn = document.createElement('button');
1226
- submitBtn.type = 'button';
1227
- submitBtn.className = 'bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors';
1228
- submitBtn.textContent = buttons.submit || 'Submit Form';
1229
- submitBtn.addEventListener('click', () => {
1230
- if (debug) console.log('[FormBuilder Debug] Submit button clicked');
1231
- const { result, errors } = collectAndValidate(schema, formEl, false);
1232
- if (debug) console.log('[FormBuilder Debug] Validation result:', { result, errors });
1233
- if (errors.length > 0) {
1234
- if (onError) onError(errors);
1235
- } else {
1236
- if (onSubmit) onSubmit(result);
1237
+ // Force download helper function
1238
+ function forceDownload(resourceId, fileName) {
1239
+ // Try to get URL from thumbnail handler first
1240
+ let fileUrl = null;
1241
+ if (state.config.getThumbnail) {
1242
+ fileUrl = state.config.getThumbnail(resourceId);
1243
+ }
1244
+
1245
+ if (fileUrl) {
1246
+ // Always try to fetch and create blob for true download behavior
1247
+ // This prevents files from opening in browser
1248
+ const finalUrl = fileUrl.startsWith('http') ? fileUrl : new URL(fileUrl, window.location.href).href;
1249
+
1250
+ fetch(finalUrl)
1251
+ .then(response => {
1252
+ if (!response.ok) {
1253
+ throw new Error(`HTTP error! status: ${response.status}`);
1237
1254
  }
1255
+ return response.blob();
1256
+ })
1257
+ .then(blob => {
1258
+ downloadBlob(blob, fileName);
1259
+ })
1260
+ .catch(error => {
1261
+ throw new Error(`File download failed: ${error.message}`);
1238
1262
  });
1239
-
1240
- const draftBtn = document.createElement('button');
1241
- draftBtn.type = 'button';
1242
- draftBtn.className = 'bg-gray-600 text-white px-4 py-2 rounded-lg hover:bg-gray-700 transition-colors';
1243
- draftBtn.textContent = buttons.draft || 'Save Draft';
1244
- draftBtn.addEventListener('click', () => {
1245
- if (debug) console.log('[FormBuilder Debug] Draft button clicked');
1246
- const { result } = collectAndValidate(schema, formEl, true); // Skip validation for drafts
1247
- if (debug) console.log('[FormBuilder Debug] Draft result:', result);
1248
- if (onDraft) onDraft(result);
1249
- });
1250
-
1251
- buttonContainer.appendChild(submitBtn);
1252
- buttonContainer.appendChild(draftBtn);
1253
- formEl.appendChild(buttonContainer);
1254
- }
1255
-
1256
- container.appendChild(formEl);
1257
- return formEl;
1263
+ } else {
1264
+ console.warn('No download URL available for resource:', resourceId);
1258
1265
  }
1266
+ }
1259
1267
 
1260
- // Configuration functions
1261
- function setConfig(newConfig) {
1262
- Object.assign(config, newConfig);
1268
+ // Helper to download blob with proper cleanup
1269
+ function downloadBlob(blob, fileName) {
1270
+ try {
1271
+ const blobUrl = URL.createObjectURL(blob);
1272
+ const link = document.createElement('a');
1273
+ link.href = blobUrl;
1274
+ link.download = fileName;
1275
+ link.style.display = 'none';
1276
+
1277
+ // Important: add to DOM, click, then remove
1278
+ document.body.appendChild(link);
1279
+ link.click();
1280
+ document.body.removeChild(link);
1281
+
1282
+ // Clean up blob URL after download
1283
+ setTimeout(() => {
1284
+ URL.revokeObjectURL(blobUrl);
1285
+ }, 100);
1286
+
1287
+ } catch (error) {
1288
+ throw new Error(`Blob download failed: ${error.message}`);
1263
1289
  }
1290
+ }
1264
1291
 
1265
- function setUploadHandler(uploadFn) {
1266
- config.uploadFile = uploadFn;
1267
- }
1292
+ // Public API
1293
+ function setFormRoot(element) {
1294
+ state.formRoot = element;
1295
+ }
1268
1296
 
1269
- function setDownloadHandler(downloadFn) {
1270
- config.downloadFile = downloadFn;
1271
- }
1297
+ function configure(config) {
1298
+ Object.assign(state.config, config);
1299
+ }
1300
+
1301
+ function setUploadHandler(uploadFn) {
1302
+ state.config.uploadFile = uploadFn;
1303
+ }
1272
1304
 
1273
- function setThumbnailHandler(thumbnailFn) {
1274
- config.getThumbnail = thumbnailFn;
1305
+ function setDownloadHandler(downloadFn) {
1306
+ state.config.downloadFile = downloadFn;
1307
+ }
1308
+
1309
+ function setThumbnailHandler(thumbnailFn) {
1310
+ state.config.getThumbnail = thumbnailFn;
1311
+ }
1312
+
1313
+ function setMode(mode) {
1314
+ if (mode === 'readonly') {
1315
+ state.config.readonly = true;
1316
+ } else {
1317
+ state.config.readonly = false;
1275
1318
  }
1319
+ }
1276
1320
 
1277
- // Generate prefill template
1278
- function generatePrefillTemplate(schema) {
1279
- function walk(elements) {
1280
- const obj = {};
1281
- for (const el of elements) {
1282
- switch (el.type) {
1283
- case 'text':
1284
- case 'textarea':
1285
- case 'select':
1286
- case 'number':
1287
- obj[el.key] = el.default ?? null;
1288
- break;
1289
- case 'file':
1290
- obj[el.key] = null;
1291
- break;
1292
- case 'files':
1293
- obj[el.key] = [];
1294
- break;
1295
- case 'videos':
1296
- obj[el.key] = [];
1297
- break;
1298
- case 'group':
1299
- if (el.repeat && isPlainObject(el.repeat)) {
1300
- const sample = walk(el.elements);
1301
- const n = Math.max(el.repeat.min ?? 0, 1);
1302
- obj[el.key] = Array.from({ length: n }, () => deepClone(sample));
1303
- } else {
1304
- obj[el.key] = walk(el.elements);
1305
- }
1306
- break;
1307
- default:
1308
- obj[el.key] = null;
1309
- }
1310
- }
1311
- return obj;
1321
+ function getFormData() {
1322
+ return validateForm(false);
1323
+ }
1324
+
1325
+ function submitForm() {
1326
+ const result = validateForm(false);
1327
+ if (result.valid) {
1328
+ // Emit event for successful submission
1329
+ if (typeof window !== 'undefined' && window.parent) {
1330
+ window.parent.postMessage({
1331
+ type: 'formSubmit',
1332
+ data: result.data,
1333
+ schema: state.schema
1334
+ }, '*');
1312
1335
  }
1313
- return walk(schema.elements);
1314
1336
  }
1337
+ return result;
1338
+ }
1315
1339
 
1316
- // Public API
1317
- exports.renderForm = renderForm;
1318
- exports.validateSchema = validateSchema;
1319
- exports.collectAndValidate = collectAndValidate;
1320
- exports.generatePrefillTemplate = generatePrefillTemplate;
1321
- exports.setConfig = setConfig;
1322
- exports.setUploadHandler = setUploadHandler;
1323
- exports.setDownloadHandler = setDownloadHandler;
1324
- exports.setThumbnailHandler = setThumbnailHandler;
1325
- exports.version = '0.1.5';
1340
+ function saveDraft() {
1341
+ const result = validateForm(true); // Skip validation for drafts
1342
+ // Emit event for draft save
1343
+ if (typeof window !== 'undefined' && window.parent) {
1344
+ window.parent.postMessage({
1345
+ type: 'formDraft',
1346
+ data: result.data,
1347
+ schema: state.schema
1348
+ }, '*');
1349
+ }
1350
+ return result;
1351
+ }
1326
1352
 
1327
- }));
1353
+ function clearForm() {
1354
+ if (state.formRoot) {
1355
+ clear(state.formRoot);
1356
+ }
1357
+ }
1358
+
1359
+ // Export the API
1360
+ if (typeof window !== 'undefined') {
1361
+ window.FormBuilder = {
1362
+ setFormRoot,
1363
+ renderForm,
1364
+ configure,
1365
+ setUploadHandler,
1366
+ setDownloadHandler,
1367
+ setThumbnailHandler,
1368
+ setMode,
1369
+ getFormData,
1370
+ submitForm,
1371
+ saveDraft,
1372
+ clearForm,
1373
+ validateSchema,
1374
+ pretty,
1375
+ state
1376
+ };
1377
+ }