@dmitryvim/form-builder 0.1.9 → 0.1.12
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.
- package/dist/demo.js +574 -0
- package/dist/form-builder.js +1252 -1202
- package/dist/images/final_video.mp4 +0 -0
- package/dist/images/infographic_draft.jpg +0 -0
- package/dist/index.html +120 -1500
- package/package.json +3 -5
- package/dist/README.md +0 -284
- package/dist/example.html +0 -108
- package/dist/sample.html +0 -1703
package/dist/form-builder.js
CHANGED
|
@@ -1,1327 +1,1377 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
}
|
|
26
|
+
function pathJoin(base, key) {
|
|
27
|
+
return base ? `${base}.${key}` : key;
|
|
28
|
+
}
|
|
228
29
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
271
|
-
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
321
|
-
|
|
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
|
-
|
|
336
|
-
|
|
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 (
|
|
437
|
-
|
|
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 (
|
|
444
|
-
|
|
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
|
-
|
|
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
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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
|
-
|
|
461
|
-
|
|
462
|
-
const
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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
|
-
|
|
162
|
+
const pathKey = pathJoin(ctx.path, element.key);
|
|
475
163
|
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
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
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
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
|
|
555
|
-
|
|
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
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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
|
-
|
|
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
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
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
|
-
|
|
574
|
-
}
|
|
232
|
+
});
|
|
575
233
|
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
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
|
-
|
|
604
|
-
|
|
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
|
-
//
|
|
612
|
-
const
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
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
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
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(
|
|
641
|
-
wrapper.appendChild(makeFieldHint(element, options.readonly ? 'Read-only' : 'Returns resource ID for download/submission'));
|
|
642
|
-
break;
|
|
256
|
+
wrapper.appendChild(fileWrapper);
|
|
643
257
|
}
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
const
|
|
651
|
-
|
|
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
|
-
|
|
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 (
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
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
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
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
|
-
|
|
732
|
-
|
|
733
|
-
|
|
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 = '
|
|
300
|
+
list.className = 'files-list';
|
|
745
301
|
|
|
746
|
-
const
|
|
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
|
-
|
|
761
|
-
list
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
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
|
-
|
|
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
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
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
|
-
|
|
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
|
-
|
|
831
|
-
if (
|
|
832
|
-
|
|
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
|
-
|
|
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
|
-
|
|
844
|
-
|
|
845
|
-
|
|
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
|
-
|
|
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
|
-
|
|
857
|
-
|
|
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
|
-
|
|
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
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
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
|
-
|
|
875
|
-
|
|
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
|
-
|
|
878
|
-
|
|
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
|
|
881
|
-
|
|
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
|
-
|
|
884
|
-
|
|
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
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
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
|
-
|
|
899
|
-
|
|
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
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
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
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
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
|
-
|
|
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
|
-
|
|
971
|
-
|
|
972
|
-
|
|
708
|
+
const url = state.config.getThumbnail(rid);
|
|
709
|
+
if (url) {
|
|
710
|
+
img.src = url;
|
|
711
|
+
slot.appendChild(img);
|
|
973
712
|
} else {
|
|
974
|
-
|
|
975
|
-
|
|
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
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
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
|
-
|
|
990
|
-
|
|
991
|
-
|
|
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
|
-
|
|
994
|
-
|
|
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
|
-
|
|
759
|
+
container.appendChild(slot);
|
|
998
760
|
}
|
|
761
|
+
}
|
|
999
762
|
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
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
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
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
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
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 (
|
|
1063
|
-
errors.push(`${key}:
|
|
1064
|
-
markValidity(input,
|
|
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 (
|
|
1067
|
-
|
|
1068
|
-
|
|
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
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
962
|
+
} else if (skipValidation) {
|
|
963
|
+
markValidity(input, null);
|
|
964
|
+
} else {
|
|
1073
965
|
markValidity(input, null);
|
|
1074
|
-
return r;
|
|
1075
966
|
}
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
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
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
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
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
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
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
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
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
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
|
-
|
|
1038
|
+
const childKey = `${key}[${i}].${child.key}`;
|
|
1039
|
+
itemData[child.key] = validateElement({...child, key: childKey}, ctx);
|
|
1165
1040
|
});
|
|
1166
|
-
|
|
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
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
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
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
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
|
-
//
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
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
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
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
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
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
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1292
|
+
// Public API
|
|
1293
|
+
function setFormRoot(element) {
|
|
1294
|
+
state.formRoot = element;
|
|
1295
|
+
}
|
|
1268
1296
|
|
|
1269
|
-
|
|
1270
|
-
|
|
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
|
-
|
|
1274
|
-
|
|
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
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
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
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
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
|
+
}
|