@dmitryvim/form-builder 0.1.21 → 0.1.24
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/README.md +13 -6
- package/dist/demo.js +488 -473
- package/dist/form-builder.js +1436 -1293
- package/dist/index.html +270 -142
- package/docs/13_form_builder.html +1217 -543
- package/docs/REQUIREMENTS.md +46 -14
- package/docs/integration.md +241 -206
- package/docs/schema.md +37 -31
- package/package.json +14 -2
package/dist/form-builder.js
CHANGED
|
@@ -1,240 +1,257 @@
|
|
|
1
1
|
// Form Builder Library - Core API
|
|
2
2
|
// State management
|
|
3
|
-
const state = {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
3
|
+
const state = {
|
|
4
|
+
schema: null,
|
|
5
|
+
formRoot: null,
|
|
6
|
+
resourceIndex: new Map(),
|
|
7
|
+
version: "1.0.0",
|
|
8
|
+
config: {
|
|
9
|
+
// File upload configuration
|
|
10
|
+
uploadFile: null,
|
|
11
|
+
downloadFile: null,
|
|
12
|
+
getThumbnail: null,
|
|
13
|
+
getDownloadUrl: null,
|
|
14
|
+
// Default implementations
|
|
15
|
+
enableFilePreview: true,
|
|
16
|
+
maxPreviewSize: "200px",
|
|
17
|
+
readonly: false,
|
|
18
|
+
},
|
|
19
19
|
};
|
|
20
20
|
|
|
21
21
|
// Utility functions
|
|
22
22
|
function isPlainObject(obj) {
|
|
23
|
-
|
|
23
|
+
return obj && typeof obj === "object" && obj.constructor === Object;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
function pathJoin(base, key) {
|
|
27
|
-
|
|
27
|
+
return base ? `${base}.${key}` : key;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
function pretty(obj) {
|
|
31
|
-
|
|
31
|
+
return JSON.stringify(obj, null, 2);
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
function clear(node) {
|
|
35
|
-
|
|
35
|
+
while (node.firstChild) node.removeChild(node.firstChild);
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
// Schema validation
|
|
39
39
|
function validateSchema(schema) {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
return errors;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function validateElements(elements, path) {
|
|
57
|
-
elements.forEach((element, index) => {
|
|
58
|
-
const elementPath = `${path}[${index}]`;
|
|
59
|
-
|
|
60
|
-
if (!element.type) {
|
|
61
|
-
errors.push(`${elementPath}: missing type`);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
if (!element.key) {
|
|
65
|
-
errors.push(`${elementPath}: missing key`);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
if (element.type === 'group' && element.elements) {
|
|
69
|
-
validateElements(element.elements, `${elementPath}.elements`);
|
|
70
|
-
}
|
|
71
|
-
|
|
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
|
-
}
|
|
81
|
-
});
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
if (Array.isArray(schema.elements)) validateElements(schema.elements, 'elements');
|
|
40
|
+
const errors = [];
|
|
41
|
+
|
|
42
|
+
if (!schema || typeof schema !== "object") {
|
|
43
|
+
errors.push("Schema must be an object");
|
|
44
|
+
return errors;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (!schema.version) {
|
|
48
|
+
errors.push("Schema missing version");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (!Array.isArray(schema.elements)) {
|
|
52
|
+
errors.push("Schema missing elements array");
|
|
85
53
|
return errors;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function validateElements(elements, path) {
|
|
57
|
+
elements.forEach((element, index) => {
|
|
58
|
+
const elementPath = `${path}[${index}]`;
|
|
59
|
+
|
|
60
|
+
if (!element.type) {
|
|
61
|
+
errors.push(`${elementPath}: missing type`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (!element.key) {
|
|
65
|
+
errors.push(`${elementPath}: missing key`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (element.type === "group" && element.elements) {
|
|
69
|
+
validateElements(element.elements, `${elementPath}.elements`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (element.type === "select" && element.options) {
|
|
73
|
+
const defaultValue = element.default;
|
|
74
|
+
if (
|
|
75
|
+
defaultValue !== undefined &&
|
|
76
|
+
defaultValue !== null &&
|
|
77
|
+
defaultValue !== ""
|
|
78
|
+
) {
|
|
79
|
+
const hasMatchingOption = element.options.some(
|
|
80
|
+
(opt) => opt.value === defaultValue,
|
|
81
|
+
);
|
|
82
|
+
if (!hasMatchingOption) {
|
|
83
|
+
errors.push(
|
|
84
|
+
`${elementPath}: default "${defaultValue}" not in options`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (Array.isArray(schema.elements))
|
|
93
|
+
validateElements(schema.elements, "elements");
|
|
94
|
+
return errors;
|
|
86
95
|
}
|
|
87
96
|
|
|
88
97
|
// Form rendering
|
|
89
98
|
function renderForm(schema, prefill) {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
prefill: prefill || {}
|
|
112
|
-
});
|
|
113
|
-
formEl.appendChild(block);
|
|
99
|
+
const errors = validateSchema(schema);
|
|
100
|
+
if (errors.length > 0) {
|
|
101
|
+
console.error("Schema validation errors:", errors);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
state.schema = schema;
|
|
106
|
+
if (!state.formRoot) {
|
|
107
|
+
console.error("No form root element set. Call setFormRoot() first.");
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
clear(state.formRoot);
|
|
112
|
+
|
|
113
|
+
const formEl = document.createElement("div");
|
|
114
|
+
formEl.className = "space-y-6";
|
|
115
|
+
|
|
116
|
+
schema.elements.forEach((element, index) => {
|
|
117
|
+
const block = renderElement(element, {
|
|
118
|
+
path: "",
|
|
119
|
+
prefill: prefill || {},
|
|
114
120
|
});
|
|
121
|
+
formEl.appendChild(block);
|
|
122
|
+
});
|
|
115
123
|
|
|
116
|
-
|
|
124
|
+
state.formRoot.appendChild(formEl);
|
|
117
125
|
}
|
|
118
126
|
|
|
119
127
|
function renderElement(element, ctx) {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
wrapper.className = 'mb-6';
|
|
123
|
-
|
|
124
|
-
const label = document.createElement('div');
|
|
125
|
-
label.className = 'flex items-center mb-2';
|
|
126
|
-
const title = document.createElement('label');
|
|
127
|
-
title.className = 'text-sm font-medium text-gray-900';
|
|
128
|
-
title.textContent = element.label || element.key;
|
|
129
|
-
if (element.required) {
|
|
130
|
-
const req = document.createElement('span');
|
|
131
|
-
req.className = 'text-red-500 ml-1';
|
|
132
|
-
req.textContent = '*';
|
|
133
|
-
title.appendChild(req);
|
|
134
|
-
}
|
|
135
|
-
label.appendChild(title);
|
|
136
|
-
|
|
137
|
-
// Add info button if there's description or hint
|
|
138
|
-
if (element.description || element.hint) {
|
|
139
|
-
const infoBtn = document.createElement('button');
|
|
140
|
-
infoBtn.type = 'button';
|
|
141
|
-
infoBtn.className = 'ml-2 text-gray-400 hover:text-gray-600';
|
|
142
|
-
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>';
|
|
143
|
-
|
|
144
|
-
// Create tooltip
|
|
145
|
-
const tooltipId = `tooltip-${element.key}-${Math.random().toString(36).substr(2, 9)}`;
|
|
146
|
-
const tooltip = document.createElement('div');
|
|
147
|
-
tooltip.id = tooltipId;
|
|
148
|
-
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';
|
|
149
|
-
tooltip.style.position = 'fixed';
|
|
150
|
-
tooltip.textContent = element.description || element.hint || 'Field information';
|
|
151
|
-
document.body.appendChild(tooltip);
|
|
152
|
-
|
|
153
|
-
infoBtn.onclick = (e) => {
|
|
154
|
-
e.preventDefault();
|
|
155
|
-
e.stopPropagation();
|
|
156
|
-
showTooltip(tooltipId, infoBtn);
|
|
157
|
-
};
|
|
158
|
-
|
|
159
|
-
label.appendChild(infoBtn);
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
wrapper.appendChild(label);
|
|
128
|
+
const wrapper = document.createElement("div");
|
|
129
|
+
wrapper.className = "mb-6";
|
|
163
130
|
|
|
164
|
-
|
|
131
|
+
const label = document.createElement("div");
|
|
132
|
+
label.className = "flex items-center mb-2";
|
|
133
|
+
const title = document.createElement("label");
|
|
134
|
+
title.className = "text-sm font-medium text-gray-900";
|
|
135
|
+
title.textContent = element.label || element.key;
|
|
136
|
+
if (element.required) {
|
|
137
|
+
const req = document.createElement("span");
|
|
138
|
+
req.className = "text-red-500 ml-1";
|
|
139
|
+
req.textContent = "*";
|
|
140
|
+
title.appendChild(req);
|
|
141
|
+
}
|
|
142
|
+
label.appendChild(title);
|
|
165
143
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
144
|
+
// Add info button if there's description or hint
|
|
145
|
+
if (element.description || element.hint) {
|
|
146
|
+
const infoBtn = document.createElement("button");
|
|
147
|
+
infoBtn.type = "button";
|
|
148
|
+
infoBtn.className = "ml-2 text-gray-400 hover:text-gray-600";
|
|
149
|
+
infoBtn.innerHTML =
|
|
150
|
+
'<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>';
|
|
151
|
+
|
|
152
|
+
// Create tooltip
|
|
153
|
+
const tooltipId = `tooltip-${element.key}-${Math.random().toString(36).substr(2, 9)}`;
|
|
154
|
+
const tooltip = document.createElement("div");
|
|
155
|
+
tooltip.id = tooltipId;
|
|
156
|
+
tooltip.className =
|
|
157
|
+
"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";
|
|
158
|
+
tooltip.style.position = "fixed";
|
|
159
|
+
tooltip.textContent =
|
|
160
|
+
element.description || element.hint || "Field information";
|
|
161
|
+
document.body.appendChild(tooltip);
|
|
162
|
+
|
|
163
|
+
infoBtn.onclick = (e) => {
|
|
164
|
+
e.preventDefault();
|
|
165
|
+
e.stopPropagation();
|
|
166
|
+
showTooltip(tooltipId, infoBtn);
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
label.appendChild(infoBtn);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
wrapper.appendChild(label);
|
|
173
|
+
|
|
174
|
+
const pathKey = pathJoin(ctx.path, element.key);
|
|
175
|
+
|
|
176
|
+
switch (element.type) {
|
|
177
|
+
case "text":
|
|
178
|
+
renderTextElement(element, ctx, wrapper, pathKey);
|
|
179
|
+
break;
|
|
180
|
+
|
|
181
|
+
case "textarea":
|
|
182
|
+
renderTextareaElement(element, ctx, wrapper, pathKey);
|
|
183
|
+
break;
|
|
184
|
+
|
|
185
|
+
case "number":
|
|
186
|
+
renderNumberElement(element, ctx, wrapper, pathKey);
|
|
187
|
+
break;
|
|
188
|
+
|
|
189
|
+
case "select":
|
|
190
|
+
renderSelectElement(element, ctx, wrapper, pathKey);
|
|
191
|
+
break;
|
|
192
|
+
|
|
193
|
+
case "file":
|
|
194
|
+
// TODO: Extract to renderFileElement() function
|
|
195
|
+
if (state.config.readonly) {
|
|
196
|
+
// Readonly mode: use common preview function
|
|
197
|
+
const initial = ctx.prefill[element.key] || element.default;
|
|
198
|
+
if (initial) {
|
|
199
|
+
const filePreview = renderFilePreviewReadonly(initial);
|
|
200
|
+
wrapper.appendChild(filePreview);
|
|
201
|
+
} else {
|
|
202
|
+
const emptyState = document.createElement("div");
|
|
203
|
+
emptyState.className =
|
|
204
|
+
"aspect-video bg-gray-100 rounded-lg flex items-center justify-center text-gray-500";
|
|
205
|
+
emptyState.innerHTML = '<div class="text-center">Нет файла</div>';
|
|
206
|
+
wrapper.appendChild(emptyState);
|
|
207
|
+
}
|
|
208
|
+
} else {
|
|
209
|
+
// Edit mode: normal file input
|
|
210
|
+
const fileWrapper = document.createElement("div");
|
|
211
|
+
fileWrapper.className = "space-y-2";
|
|
212
|
+
|
|
213
|
+
const picker = document.createElement("input");
|
|
214
|
+
picker.type = "file";
|
|
215
|
+
picker.name = pathKey;
|
|
216
|
+
picker.style.display = "none"; // Hide default input
|
|
217
|
+
if (element.accept) {
|
|
218
|
+
if (element.accept.extensions) {
|
|
219
|
+
picker.accept = element.accept.extensions
|
|
220
|
+
.map((ext) => `.${ext}`)
|
|
221
|
+
.join(",");
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const fileContainer = document.createElement("div");
|
|
226
|
+
fileContainer.className =
|
|
227
|
+
"file-preview-container w-full aspect-square max-w-xs bg-gray-100 rounded-lg overflow-hidden relative group cursor-pointer";
|
|
228
|
+
|
|
229
|
+
const initial = ctx.prefill[element.key] || element.default;
|
|
230
|
+
if (initial) {
|
|
231
|
+
// Add prefill data to resourceIndex so renderFilePreview can use it
|
|
232
|
+
if (!state.resourceIndex.has(initial)) {
|
|
233
|
+
// Extract filename from URL/path
|
|
234
|
+
const filename = initial.split("/").pop() || "file";
|
|
235
|
+
// Determine file type from extension
|
|
236
|
+
const extension = filename.split(".").pop()?.toLowerCase();
|
|
237
|
+
const fileType =
|
|
238
|
+
extension &&
|
|
239
|
+
["jpg", "jpeg", "png", "gif", "webp"].includes(extension)
|
|
240
|
+
? `image/${extension === "jpg" ? "jpeg" : extension}`
|
|
241
|
+
: "application/octet-stream";
|
|
242
|
+
|
|
243
|
+
state.resourceIndex.set(initial, {
|
|
244
|
+
name: filename,
|
|
245
|
+
type: fileType,
|
|
246
|
+
size: 0,
|
|
247
|
+
file: null, // No local file for prefill data
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
renderFilePreview(fileContainer, initial, initial, "", false).catch(
|
|
251
|
+
console.error,
|
|
252
|
+
);
|
|
253
|
+
} else {
|
|
254
|
+
fileContainer.innerHTML = `
|
|
238
255
|
<div class="flex flex-col items-center justify-center h-full text-gray-400">
|
|
239
256
|
<svg class="w-6 h-6 mb-2" fill="currentColor" viewBox="0 0 24 24">
|
|
240
257
|
<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"/>
|
|
@@ -242,388 +259,423 @@ function renderElement(element, ctx) {
|
|
|
242
259
|
<div class="text-sm text-center">Нажмите или перетащите файл</div>
|
|
243
260
|
</div>
|
|
244
261
|
`;
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
fileContainer.onclick = () => picker.click();
|
|
265
|
+
setupDragAndDrop(fileContainer, (files) => {
|
|
266
|
+
if (files.length > 0) {
|
|
267
|
+
handleFileSelect(files[0], fileContainer, pathKey);
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
picker.onchange = () => {
|
|
272
|
+
if (picker.files.length > 0) {
|
|
273
|
+
handleFileSelect(picker.files[0], fileContainer, pathKey);
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
fileWrapper.appendChild(fileContainer);
|
|
278
|
+
fileWrapper.appendChild(picker);
|
|
279
|
+
|
|
280
|
+
// Add upload text
|
|
281
|
+
const uploadText = document.createElement("p");
|
|
282
|
+
uploadText.className = "text-xs text-gray-600 mt-2 text-center";
|
|
283
|
+
uploadText.innerHTML = `<span class="underline cursor-pointer">Загрузите</span> или перетащите файл`;
|
|
284
|
+
uploadText.querySelector("span").onclick = () => picker.click();
|
|
285
|
+
fileWrapper.appendChild(uploadText);
|
|
286
|
+
|
|
287
|
+
// Add hint
|
|
288
|
+
const fileHint = document.createElement("p");
|
|
289
|
+
fileHint.className = "text-xs text-gray-500 mt-1 text-center";
|
|
290
|
+
fileHint.textContent = makeFieldHint(element);
|
|
291
|
+
fileWrapper.appendChild(fileHint);
|
|
292
|
+
|
|
293
|
+
wrapper.appendChild(fileWrapper);
|
|
294
|
+
}
|
|
295
|
+
break;
|
|
296
|
+
|
|
297
|
+
case "files":
|
|
298
|
+
// TODO: Extract to renderFilesElement() function
|
|
299
|
+
if (state.config.readonly) {
|
|
300
|
+
// Readonly mode: render as results list like in workflow-preview.html
|
|
301
|
+
const resultsWrapper = document.createElement("div");
|
|
302
|
+
resultsWrapper.className = "space-y-4";
|
|
303
|
+
|
|
304
|
+
const initialFiles = ctx.prefill[element.key] || [];
|
|
305
|
+
|
|
306
|
+
if (initialFiles.length > 0) {
|
|
307
|
+
initialFiles.forEach((resourceId) => {
|
|
308
|
+
const filePreview = renderFilePreviewReadonly(resourceId);
|
|
309
|
+
resultsWrapper.appendChild(filePreview);
|
|
310
|
+
});
|
|
311
|
+
} else {
|
|
312
|
+
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>`;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
wrapper.appendChild(resultsWrapper);
|
|
316
|
+
} else {
|
|
317
|
+
// Edit mode: normal files input
|
|
318
|
+
const filesWrapper = document.createElement("div");
|
|
319
|
+
filesWrapper.className = "space-y-2";
|
|
320
|
+
|
|
321
|
+
const filesPicker = document.createElement("input");
|
|
322
|
+
filesPicker.type = "file";
|
|
323
|
+
filesPicker.name = pathKey;
|
|
324
|
+
filesPicker.multiple = true;
|
|
325
|
+
filesPicker.style.display = "none"; // Hide default input
|
|
326
|
+
if (element.accept) {
|
|
327
|
+
if (element.accept.extensions) {
|
|
328
|
+
filesPicker.accept = element.accept.extensions
|
|
329
|
+
.map((ext) => `.${ext}`)
|
|
330
|
+
.join(",");
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Create container with border like in workflow-preview
|
|
335
|
+
const filesContainer = document.createElement("div");
|
|
336
|
+
filesContainer.className =
|
|
337
|
+
"border-2 border-dashed border-gray-300 rounded-lg p-3 hover:border-gray-400 transition-colors";
|
|
338
|
+
|
|
339
|
+
const list = document.createElement("div");
|
|
340
|
+
list.className = "files-list";
|
|
341
|
+
|
|
342
|
+
const initialFiles = ctx.prefill[element.key] || [];
|
|
343
|
+
|
|
344
|
+
// Add prefill files to resourceIndex so renderResourcePills can use them
|
|
345
|
+
if (initialFiles.length > 0) {
|
|
346
|
+
initialFiles.forEach((resourceId) => {
|
|
347
|
+
if (!state.resourceIndex.has(resourceId)) {
|
|
348
|
+
// Extract filename from URL/path
|
|
349
|
+
const filename = resourceId.split("/").pop() || "file";
|
|
350
|
+
// Determine file type from extension
|
|
351
|
+
const extension = filename.split(".").pop()?.toLowerCase();
|
|
352
|
+
const fileType =
|
|
353
|
+
extension &&
|
|
354
|
+
["jpg", "jpeg", "png", "gif", "webp"].includes(extension)
|
|
355
|
+
? `image/${extension === "jpg" ? "jpeg" : extension}`
|
|
356
|
+
: "application/octet-stream";
|
|
357
|
+
|
|
358
|
+
state.resourceIndex.set(resourceId, {
|
|
359
|
+
name: filename,
|
|
360
|
+
type: fileType,
|
|
361
|
+
size: 0,
|
|
362
|
+
file: null, // No local file for prefill data
|
|
363
|
+
});
|
|
277
364
|
}
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function updateFilesList() {
|
|
369
|
+
renderResourcePills(list, initialFiles, (ridToRemove) => {
|
|
370
|
+
const index = initialFiles.indexOf(ridToRemove);
|
|
371
|
+
if (index > -1) {
|
|
372
|
+
initialFiles.splice(index, 1);
|
|
373
|
+
}
|
|
374
|
+
updateFilesList(); // Re-render after removal
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Initial render
|
|
379
|
+
updateFilesList();
|
|
380
|
+
|
|
381
|
+
setupDragAndDrop(filesContainer, async (files) => {
|
|
382
|
+
const arr = Array.from(files);
|
|
383
|
+
for (const file of arr) {
|
|
384
|
+
let rid;
|
|
385
|
+
|
|
386
|
+
// If uploadHandler is configured, use it to upload the file
|
|
387
|
+
if (state.config.uploadFile) {
|
|
388
|
+
try {
|
|
389
|
+
rid = await state.config.uploadFile(file);
|
|
390
|
+
if (typeof rid !== "string") {
|
|
391
|
+
throw new Error(
|
|
392
|
+
"Upload handler must return a string resource ID",
|
|
393
|
+
);
|
|
296
394
|
}
|
|
297
|
-
|
|
298
|
-
|
|
395
|
+
} catch (error) {
|
|
396
|
+
throw new Error(`File upload failed: ${error.message}`);
|
|
397
|
+
}
|
|
299
398
|
} else {
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
const filesPicker = document.createElement('input');
|
|
305
|
-
filesPicker.type = 'file';
|
|
306
|
-
filesPicker.name = pathKey;
|
|
307
|
-
filesPicker.multiple = true;
|
|
308
|
-
filesPicker.style.display = 'none'; // Hide default input
|
|
309
|
-
if (element.accept) {
|
|
310
|
-
if (element.accept.extensions) {
|
|
311
|
-
filesPicker.accept = element.accept.extensions.map(ext => `.${ext}`).join(',');
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
// Create container with border like in workflow-preview
|
|
316
|
-
const filesContainer = document.createElement('div');
|
|
317
|
-
filesContainer.className = 'border-2 border-dashed border-gray-300 rounded-lg p-3 hover:border-gray-400 transition-colors';
|
|
318
|
-
|
|
319
|
-
const list = document.createElement('div');
|
|
320
|
-
list.className = 'files-list';
|
|
321
|
-
|
|
322
|
-
const initialFiles = ctx.prefill[element.key] || [];
|
|
323
|
-
|
|
324
|
-
// Add prefill files to resourceIndex so renderResourcePills can use them
|
|
325
|
-
if (initialFiles.length > 0) {
|
|
326
|
-
initialFiles.forEach(resourceId => {
|
|
327
|
-
if (!state.resourceIndex.has(resourceId)) {
|
|
328
|
-
// Extract filename from URL/path
|
|
329
|
-
const filename = resourceId.split('/').pop() || 'file';
|
|
330
|
-
// Determine file type from extension
|
|
331
|
-
const extension = filename.split('.').pop()?.toLowerCase();
|
|
332
|
-
const fileType = extension && ['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(extension)
|
|
333
|
-
? `image/${extension === 'jpg' ? 'jpeg' : extension}`
|
|
334
|
-
: 'application/octet-stream';
|
|
335
|
-
|
|
336
|
-
state.resourceIndex.set(resourceId, {
|
|
337
|
-
name: filename,
|
|
338
|
-
type: fileType,
|
|
339
|
-
size: 0,
|
|
340
|
-
file: null // No local file for prefill data
|
|
341
|
-
});
|
|
342
|
-
console.log(`🔧 Added prefill file to resourceIndex:`, resourceId);
|
|
343
|
-
}
|
|
344
|
-
});
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
function updateFilesList() {
|
|
348
|
-
renderResourcePills(list, initialFiles, (ridToRemove) => {
|
|
349
|
-
const index = initialFiles.indexOf(ridToRemove);
|
|
350
|
-
if (index > -1) {
|
|
351
|
-
initialFiles.splice(index, 1);
|
|
352
|
-
}
|
|
353
|
-
updateFilesList(); // Re-render after removal
|
|
354
|
-
});
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
// Initial render
|
|
358
|
-
updateFilesList();
|
|
359
|
-
|
|
360
|
-
setupDragAndDrop(filesContainer, async (files) => {
|
|
361
|
-
const arr = Array.from(files);
|
|
362
|
-
for (const file of arr) {
|
|
363
|
-
let rid;
|
|
364
|
-
|
|
365
|
-
// If uploadHandler is configured, use it to upload the file
|
|
366
|
-
if (state.config.uploadFile) {
|
|
367
|
-
try {
|
|
368
|
-
rid = await state.config.uploadFile(file);
|
|
369
|
-
if (typeof rid !== 'string') {
|
|
370
|
-
throw new Error('Upload handler must return a string resource ID');
|
|
371
|
-
}
|
|
372
|
-
} catch (error) {
|
|
373
|
-
throw new Error(`File upload failed: ${error.message}`);
|
|
374
|
-
}
|
|
375
|
-
} else {
|
|
376
|
-
throw new Error('No upload handler configured. Set uploadHandler via FormBuilder.setUploadHandler()');
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
state.resourceIndex.set(rid, {
|
|
380
|
-
name: file.name,
|
|
381
|
-
type: file.type,
|
|
382
|
-
size: file.size,
|
|
383
|
-
file: null // Files are always uploaded, never stored locally
|
|
384
|
-
});
|
|
385
|
-
initialFiles.push(rid);
|
|
386
|
-
}
|
|
387
|
-
updateFilesList();
|
|
388
|
-
});
|
|
389
|
-
|
|
390
|
-
filesPicker.onchange = async () => {
|
|
391
|
-
for (const file of Array.from(filesPicker.files)) {
|
|
392
|
-
let rid;
|
|
393
|
-
|
|
394
|
-
// If uploadHandler is configured, use it to upload the file
|
|
395
|
-
if (state.config.uploadFile) {
|
|
396
|
-
try {
|
|
397
|
-
rid = await state.config.uploadFile(file);
|
|
398
|
-
if (typeof rid !== 'string') {
|
|
399
|
-
throw new Error('Upload handler must return a string resource ID');
|
|
400
|
-
}
|
|
401
|
-
} catch (error) {
|
|
402
|
-
throw new Error(`File upload failed: ${error.message}`);
|
|
403
|
-
}
|
|
404
|
-
} else {
|
|
405
|
-
throw new Error('No upload handler configured. Set uploadHandler via FormBuilder.setUploadHandler()');
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
state.resourceIndex.set(rid, {
|
|
409
|
-
name: file.name,
|
|
410
|
-
type: file.type,
|
|
411
|
-
size: file.size,
|
|
412
|
-
file: null // Files are always uploaded, never stored locally
|
|
413
|
-
});
|
|
414
|
-
initialFiles.push(rid);
|
|
415
|
-
}
|
|
416
|
-
updateFilesList();
|
|
417
|
-
// Clear the file input
|
|
418
|
-
filesPicker.value = '';
|
|
419
|
-
};
|
|
420
|
-
|
|
421
|
-
filesContainer.appendChild(list);
|
|
422
|
-
|
|
423
|
-
filesWrapper.appendChild(filesContainer);
|
|
424
|
-
filesWrapper.appendChild(filesPicker);
|
|
425
|
-
|
|
426
|
-
// Add hint
|
|
427
|
-
const filesHint = document.createElement('p');
|
|
428
|
-
filesHint.className = 'text-xs text-gray-500 mt-1 text-center';
|
|
429
|
-
filesHint.textContent = makeFieldHint(element);
|
|
430
|
-
filesWrapper.appendChild(filesHint);
|
|
431
|
-
|
|
432
|
-
wrapper.appendChild(filesWrapper);
|
|
399
|
+
throw new Error(
|
|
400
|
+
"No upload handler configured. Set uploadHandler via FormBuilder.setUploadHandler()",
|
|
401
|
+
);
|
|
433
402
|
}
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
const min = element.repeat.min ?? 0;
|
|
459
|
-
const max = element.repeat.max ?? Infinity;
|
|
460
|
-
const pre = Array.isArray(ctx.prefill?.[element.key]) ? ctx.prefill[element.key] : null;
|
|
461
|
-
|
|
462
|
-
header.appendChild(right);
|
|
463
|
-
|
|
464
|
-
const countItems = () => itemsWrap.querySelectorAll(':scope > .groupItem').length;
|
|
465
|
-
|
|
466
|
-
const addItem = (prefillObj) => {
|
|
467
|
-
const item = document.createElement('div');
|
|
468
|
-
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';
|
|
469
|
-
const subCtx = {
|
|
470
|
-
path: pathJoin(ctx.path, element.key + `[${countItems()}]`),
|
|
471
|
-
prefill: prefillObj || {}
|
|
472
|
-
};
|
|
473
|
-
element.elements.forEach(child => item.appendChild(renderElement(child, subCtx)));
|
|
474
|
-
|
|
475
|
-
const rem = document.createElement('button');
|
|
476
|
-
rem.type = 'button';
|
|
477
|
-
rem.className = 'bg-red-500 hover:bg-red-600 text-white px-2 py-1 rounded text-xs font-medium transition-colors';
|
|
478
|
-
rem.textContent = 'Удалить';
|
|
479
|
-
rem.addEventListener('click', () => {
|
|
480
|
-
if (countItems() <= (element.repeat.min ?? 0)) return;
|
|
481
|
-
itemsWrap.removeChild(item);
|
|
482
|
-
refreshControls();
|
|
483
|
-
});
|
|
484
|
-
item.appendChild(rem);
|
|
485
|
-
itemsWrap.appendChild(item);
|
|
486
|
-
refreshControls();
|
|
487
|
-
};
|
|
488
|
-
|
|
489
|
-
groupWrap.appendChild(itemsWrap);
|
|
490
|
-
|
|
491
|
-
// Add button after items
|
|
492
|
-
const addBtn = document.createElement('button');
|
|
493
|
-
addBtn.type = 'button';
|
|
494
|
-
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';
|
|
495
|
-
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>Добавить элемент';
|
|
496
|
-
groupWrap.appendChild(addBtn);
|
|
497
|
-
|
|
498
|
-
const refreshControls = () => {
|
|
499
|
-
const n = countItems();
|
|
500
|
-
addBtn.disabled = n >= max;
|
|
501
|
-
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>`;
|
|
502
|
-
};
|
|
503
|
-
|
|
504
|
-
if (pre && pre.length) {
|
|
505
|
-
const n = Math.min(max, Math.max(min, pre.length));
|
|
506
|
-
for (let i = 0; i < n; i++) addItem(pre[i]);
|
|
507
|
-
} else {
|
|
508
|
-
const n = Math.max(min, 0);
|
|
509
|
-
for (let i = 0; i < n; i++) addItem(null);
|
|
403
|
+
|
|
404
|
+
state.resourceIndex.set(rid, {
|
|
405
|
+
name: file.name,
|
|
406
|
+
type: file.type,
|
|
407
|
+
size: file.size,
|
|
408
|
+
file: null, // Files are always uploaded, never stored locally
|
|
409
|
+
});
|
|
410
|
+
initialFiles.push(rid);
|
|
411
|
+
}
|
|
412
|
+
updateFilesList();
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
filesPicker.onchange = async () => {
|
|
416
|
+
for (const file of Array.from(filesPicker.files)) {
|
|
417
|
+
let rid;
|
|
418
|
+
|
|
419
|
+
// If uploadHandler is configured, use it to upload the file
|
|
420
|
+
if (state.config.uploadFile) {
|
|
421
|
+
try {
|
|
422
|
+
rid = await state.config.uploadFile(file);
|
|
423
|
+
if (typeof rid !== "string") {
|
|
424
|
+
throw new Error(
|
|
425
|
+
"Upload handler must return a string resource ID",
|
|
426
|
+
);
|
|
510
427
|
}
|
|
511
|
-
|
|
512
|
-
|
|
428
|
+
} catch (error) {
|
|
429
|
+
throw new Error(`File upload failed: ${error.message}`);
|
|
430
|
+
}
|
|
513
431
|
} else {
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
prefill: ctx.prefill?.[element.key] || {}
|
|
518
|
-
};
|
|
519
|
-
element.elements.forEach(child => itemsWrap.appendChild(renderElement(child, subCtx)));
|
|
520
|
-
groupWrap.appendChild(itemsWrap);
|
|
521
|
-
left.innerHTML = `<span>${element.label || element.key}</span>`;
|
|
432
|
+
throw new Error(
|
|
433
|
+
"No upload handler configured. Set uploadHandler via FormBuilder.setUploadHandler()",
|
|
434
|
+
);
|
|
522
435
|
}
|
|
523
|
-
|
|
524
|
-
wrapper.appendChild(groupWrap);
|
|
525
|
-
break;
|
|
526
|
-
|
|
527
|
-
default:
|
|
528
|
-
const unsupported = document.createElement('div');
|
|
529
|
-
unsupported.className = 'text-red-500 text-sm';
|
|
530
|
-
unsupported.textContent = `Unsupported field type: ${element.type}`;
|
|
531
|
-
wrapper.appendChild(unsupported);
|
|
532
|
-
}
|
|
533
436
|
|
|
534
|
-
|
|
437
|
+
state.resourceIndex.set(rid, {
|
|
438
|
+
name: file.name,
|
|
439
|
+
type: file.type,
|
|
440
|
+
size: file.size,
|
|
441
|
+
file: null, // Files are always uploaded, never stored locally
|
|
442
|
+
});
|
|
443
|
+
initialFiles.push(rid);
|
|
444
|
+
}
|
|
445
|
+
updateFilesList();
|
|
446
|
+
// Clear the file input
|
|
447
|
+
filesPicker.value = "";
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
filesContainer.appendChild(list);
|
|
451
|
+
|
|
452
|
+
filesWrapper.appendChild(filesContainer);
|
|
453
|
+
filesWrapper.appendChild(filesPicker);
|
|
454
|
+
|
|
455
|
+
// Add hint
|
|
456
|
+
const filesHint = document.createElement("p");
|
|
457
|
+
filesHint.className = "text-xs text-gray-500 mt-1 text-center";
|
|
458
|
+
filesHint.textContent = makeFieldHint(element);
|
|
459
|
+
filesWrapper.appendChild(filesHint);
|
|
460
|
+
|
|
461
|
+
wrapper.appendChild(filesWrapper);
|
|
462
|
+
}
|
|
463
|
+
break;
|
|
464
|
+
|
|
465
|
+
case "group":
|
|
466
|
+
// TODO: Extract to renderGroupElement() function
|
|
467
|
+
const groupWrap = document.createElement("div");
|
|
468
|
+
groupWrap.className = "border border-gray-200 rounded-lg p-4 bg-gray-50";
|
|
469
|
+
|
|
470
|
+
const header = document.createElement("div");
|
|
471
|
+
header.className = "flex justify-between items-center mb-4";
|
|
472
|
+
|
|
473
|
+
const left = document.createElement("div");
|
|
474
|
+
left.className = "flex-1";
|
|
475
|
+
|
|
476
|
+
const right = document.createElement("div");
|
|
477
|
+
right.className = "flex gap-2";
|
|
478
|
+
|
|
479
|
+
const itemsWrap = document.createElement("div");
|
|
480
|
+
itemsWrap.className = "space-y-4";
|
|
481
|
+
|
|
482
|
+
groupWrap.appendChild(header);
|
|
483
|
+
header.appendChild(left);
|
|
484
|
+
header.appendChild(right);
|
|
485
|
+
|
|
486
|
+
if (element.repeat && isPlainObject(element.repeat)) {
|
|
487
|
+
const min = element.repeat.min ?? 0;
|
|
488
|
+
const max = element.repeat.max ?? Infinity;
|
|
489
|
+
const pre = Array.isArray(ctx.prefill?.[element.key])
|
|
490
|
+
? ctx.prefill[element.key]
|
|
491
|
+
: null;
|
|
492
|
+
|
|
493
|
+
header.appendChild(right);
|
|
494
|
+
|
|
495
|
+
const countItems = () =>
|
|
496
|
+
itemsWrap.querySelectorAll(":scope > .groupItem").length;
|
|
497
|
+
|
|
498
|
+
const addItem = (prefillObj) => {
|
|
499
|
+
const item = document.createElement("div");
|
|
500
|
+
item.className =
|
|
501
|
+
"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";
|
|
502
|
+
const subCtx = {
|
|
503
|
+
path: pathJoin(ctx.path, element.key + `[${countItems()}]`),
|
|
504
|
+
prefill: prefillObj || {},
|
|
505
|
+
};
|
|
506
|
+
element.elements.forEach((child) =>
|
|
507
|
+
item.appendChild(renderElement(child, subCtx)),
|
|
508
|
+
);
|
|
509
|
+
|
|
510
|
+
const rem = document.createElement("button");
|
|
511
|
+
rem.type = "button";
|
|
512
|
+
rem.className =
|
|
513
|
+
"bg-red-500 hover:bg-red-600 text-white px-2 py-1 rounded text-xs font-medium transition-colors";
|
|
514
|
+
rem.textContent = "Удалить";
|
|
515
|
+
rem.addEventListener("click", () => {
|
|
516
|
+
if (countItems() <= (element.repeat.min ?? 0)) return;
|
|
517
|
+
itemsWrap.removeChild(item);
|
|
518
|
+
refreshControls();
|
|
519
|
+
});
|
|
520
|
+
item.appendChild(rem);
|
|
521
|
+
itemsWrap.appendChild(item);
|
|
522
|
+
refreshControls();
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
groupWrap.appendChild(itemsWrap);
|
|
526
|
+
|
|
527
|
+
// Add button after items
|
|
528
|
+
const addBtn = document.createElement("button");
|
|
529
|
+
addBtn.type = "button";
|
|
530
|
+
addBtn.className =
|
|
531
|
+
"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";
|
|
532
|
+
addBtn.innerHTML =
|
|
533
|
+
'<svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 24 24"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>Добавить элемент';
|
|
534
|
+
groupWrap.appendChild(addBtn);
|
|
535
|
+
|
|
536
|
+
const refreshControls = () => {
|
|
537
|
+
const n = countItems();
|
|
538
|
+
addBtn.disabled = n >= max;
|
|
539
|
+
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>`;
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
if (pre && pre.length) {
|
|
543
|
+
const n = Math.min(max, Math.max(min, pre.length));
|
|
544
|
+
for (let i = 0; i < n; i++) addItem(pre[i]);
|
|
545
|
+
} else {
|
|
546
|
+
const n = Math.max(min, 0);
|
|
547
|
+
for (let i = 0; i < n; i++) addItem(null);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
addBtn.addEventListener("click", () => addItem(null));
|
|
551
|
+
} else {
|
|
552
|
+
// Single object group
|
|
553
|
+
const subCtx = {
|
|
554
|
+
path: pathJoin(ctx.path, element.key),
|
|
555
|
+
prefill: ctx.prefill?.[element.key] || {},
|
|
556
|
+
};
|
|
557
|
+
element.elements.forEach((child) =>
|
|
558
|
+
itemsWrap.appendChild(renderElement(child, subCtx)),
|
|
559
|
+
);
|
|
560
|
+
groupWrap.appendChild(itemsWrap);
|
|
561
|
+
left.innerHTML = `<span>${element.label || element.key}</span>`;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
wrapper.appendChild(groupWrap);
|
|
565
|
+
break;
|
|
566
|
+
|
|
567
|
+
default:
|
|
568
|
+
const unsupported = document.createElement("div");
|
|
569
|
+
unsupported.className = "text-red-500 text-sm";
|
|
570
|
+
unsupported.textContent = `Unsupported field type: ${element.type}`;
|
|
571
|
+
wrapper.appendChild(unsupported);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
return wrapper;
|
|
535
575
|
}
|
|
536
576
|
|
|
537
577
|
function makeFieldHint(element) {
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
}
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
if (element.min != null || element.max != null) {
|
|
557
|
-
if (element.min != null && element.max != null) {
|
|
558
|
-
parts.push(`range=${element.min}-${element.max}`);
|
|
559
|
-
} else if (element.max != null) {
|
|
560
|
-
parts.push(`max=${element.max}`);
|
|
561
|
-
} else if (element.min != null) {
|
|
562
|
-
parts.push(`min=${element.min}`);
|
|
563
|
-
}
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
if (element.maxSizeMB) {
|
|
567
|
-
parts.push(`max_size=${element.maxSizeMB}MB`);
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
if (element.accept && element.accept.extensions) {
|
|
571
|
-
parts.push(`formats=${element.accept.extensions.map(ext => ext.toUpperCase()).join(',')}`);
|
|
578
|
+
const parts = [];
|
|
579
|
+
|
|
580
|
+
if (element.required) {
|
|
581
|
+
parts.push("required");
|
|
582
|
+
} else {
|
|
583
|
+
parts.push("optional");
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
if (element.minLength != null || element.maxLength != null) {
|
|
587
|
+
if (element.minLength != null && element.maxLength != null) {
|
|
588
|
+
parts.push(`length=${element.minLength}-${element.maxLength} characters`);
|
|
589
|
+
} else if (element.maxLength != null) {
|
|
590
|
+
parts.push(`max=${element.maxLength} characters`);
|
|
591
|
+
} else if (element.minLength != null) {
|
|
592
|
+
parts.push(`min=${element.minLength} characters`);
|
|
572
593
|
}
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
if (element.min != null || element.max != null) {
|
|
597
|
+
if (element.min != null && element.max != null) {
|
|
598
|
+
parts.push(`range=${element.min}-${element.max}`);
|
|
599
|
+
} else if (element.max != null) {
|
|
600
|
+
parts.push(`max=${element.max}`);
|
|
601
|
+
} else if (element.min != null) {
|
|
602
|
+
parts.push(`min=${element.min}`);
|
|
578
603
|
}
|
|
579
|
-
|
|
580
|
-
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
if (element.maxSizeMB) {
|
|
607
|
+
parts.push(`max_size=${element.maxSizeMB}MB`);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
if (element.accept && element.accept.extensions) {
|
|
611
|
+
parts.push(
|
|
612
|
+
`formats=${element.accept.extensions.map((ext) => ext.toUpperCase()).join(",")}`,
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
if (element.pattern && !element.pattern.includes("А-Я")) {
|
|
617
|
+
parts.push("plain text only");
|
|
618
|
+
} else if (element.pattern && element.pattern.includes("А-Я")) {
|
|
619
|
+
parts.push("text with punctuation");
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
return parts.join(" • ");
|
|
581
623
|
}
|
|
582
624
|
|
|
583
|
-
async function renderFilePreview(
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
625
|
+
async function renderFilePreview(
|
|
626
|
+
container,
|
|
627
|
+
resourceId,
|
|
628
|
+
fileName,
|
|
629
|
+
fileType,
|
|
630
|
+
isReadonly = false,
|
|
631
|
+
) {
|
|
632
|
+
// Don't change container className - preserve max-w-xs and other styling
|
|
633
|
+
|
|
634
|
+
// Clear container content first
|
|
635
|
+
clear(container);
|
|
636
|
+
|
|
637
|
+
if (isReadonly) {
|
|
638
|
+
container.classList.add("cursor-pointer");
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
const img = document.createElement("img");
|
|
642
|
+
img.className = "w-full h-full object-contain";
|
|
643
|
+
img.alt = fileName || "Preview";
|
|
644
|
+
|
|
645
|
+
// Use stored file from resourceIndex if available, or try getThumbnail
|
|
646
|
+
const meta = state.resourceIndex.get(resourceId);
|
|
647
|
+
|
|
648
|
+
if (meta && meta.file && meta.file instanceof File) {
|
|
649
|
+
// For local files, use FileReader to display preview
|
|
650
|
+
if (meta.type && meta.type.startsWith("image/")) {
|
|
651
|
+
const reader = new FileReader();
|
|
652
|
+
reader.onload = (e) => {
|
|
653
|
+
img.src = e.target.result;
|
|
654
|
+
};
|
|
655
|
+
reader.readAsDataURL(meta.file);
|
|
656
|
+
container.appendChild(img);
|
|
657
|
+
} else {
|
|
658
|
+
// Non-image file
|
|
659
|
+
container.innerHTML =
|
|
660
|
+
'<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">' +
|
|
661
|
+
fileName +
|
|
662
|
+
"</div></div>";
|
|
591
663
|
}
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
const reader = new FileReader();
|
|
605
|
-
reader.onload = (e) => {
|
|
606
|
-
img.src = e.target.result;
|
|
607
|
-
};
|
|
608
|
-
reader.readAsDataURL(meta.file);
|
|
609
|
-
container.appendChild(img);
|
|
610
|
-
} else {
|
|
611
|
-
// Non-image file
|
|
612
|
-
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>';
|
|
664
|
+
|
|
665
|
+
// Add delete button for edit mode
|
|
666
|
+
if (!isReadonly) {
|
|
667
|
+
addDeleteButton(container, () => {
|
|
668
|
+
// Clear the file
|
|
669
|
+
state.resourceIndex.delete(resourceId);
|
|
670
|
+
// Update hidden input
|
|
671
|
+
const hiddenInput = container.parentElement.querySelector(
|
|
672
|
+
'input[type="hidden"]',
|
|
673
|
+
);
|
|
674
|
+
if (hiddenInput) {
|
|
675
|
+
hiddenInput.value = "";
|
|
613
676
|
}
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
if (!isReadonly) {
|
|
617
|
-
addDeleteButton(container, () => {
|
|
618
|
-
// Clear the file
|
|
619
|
-
state.resourceIndex.delete(resourceId);
|
|
620
|
-
// Update hidden input
|
|
621
|
-
const hiddenInput = container.parentElement.querySelector('input[type="hidden"]');
|
|
622
|
-
if (hiddenInput) {
|
|
623
|
-
hiddenInput.value = '';
|
|
624
|
-
}
|
|
625
|
-
// Clear preview and show placeholder
|
|
626
|
-
container.innerHTML = `
|
|
677
|
+
// Clear preview and show placeholder
|
|
678
|
+
container.innerHTML = `
|
|
627
679
|
<div class="flex flex-col items-center justify-center h-full text-gray-400">
|
|
628
680
|
<svg class="w-6 h-6 mb-2" fill="currentColor" viewBox="0 0 24 24">
|
|
629
681
|
<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"/>
|
|
@@ -631,38 +683,45 @@ async function renderFilePreview(container, resourceId, fileName, fileType, isRe
|
|
|
631
683
|
<div class="text-sm text-center">Нажмите или перетащите файл</div>
|
|
632
684
|
</div>
|
|
633
685
|
`;
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
} else if (state.config.getThumbnail) {
|
|
689
|
+
// Try to get thumbnail from config for uploaded files
|
|
690
|
+
try {
|
|
691
|
+
const thumbnailUrl = await state.config.getThumbnail(resourceId);
|
|
692
|
+
|
|
693
|
+
if (thumbnailUrl) {
|
|
694
|
+
img.src = thumbnailUrl;
|
|
695
|
+
container.appendChild(img);
|
|
696
|
+
} else {
|
|
697
|
+
// Fallback to file icon
|
|
698
|
+
container.innerHTML =
|
|
699
|
+
'<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">' +
|
|
700
|
+
fileName +
|
|
701
|
+
"</div></div>";
|
|
702
|
+
}
|
|
703
|
+
} catch (error) {
|
|
704
|
+
console.warn("Thumbnail loading failed:", error);
|
|
705
|
+
container.innerHTML =
|
|
706
|
+
'<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">' +
|
|
707
|
+
fileName +
|
|
708
|
+
"</div></div>";
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// Add delete button for edit mode
|
|
712
|
+
if (!isReadonly) {
|
|
713
|
+
addDeleteButton(container, () => {
|
|
714
|
+
// Clear the file
|
|
715
|
+
state.resourceIndex.delete(resourceId);
|
|
716
|
+
// Update hidden input
|
|
717
|
+
const hiddenInput = container.parentElement.querySelector(
|
|
718
|
+
'input[type="hidden"]',
|
|
719
|
+
);
|
|
720
|
+
if (hiddenInput) {
|
|
721
|
+
hiddenInput.value = "";
|
|
652
722
|
}
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
if (!isReadonly) {
|
|
656
|
-
addDeleteButton(container, () => {
|
|
657
|
-
// Clear the file
|
|
658
|
-
state.resourceIndex.delete(resourceId);
|
|
659
|
-
// Update hidden input
|
|
660
|
-
const hiddenInput = container.parentElement.querySelector('input[type="hidden"]');
|
|
661
|
-
if (hiddenInput) {
|
|
662
|
-
hiddenInput.value = '';
|
|
663
|
-
}
|
|
664
|
-
// Clear preview and show placeholder
|
|
665
|
-
container.innerHTML = `
|
|
723
|
+
// Clear preview and show placeholder
|
|
724
|
+
container.innerHTML = `
|
|
666
725
|
<div class="flex flex-col items-center justify-center h-full text-gray-400">
|
|
667
726
|
<svg class="w-6 h-6 mb-2" fill="currentColor" viewBox="0 0 24 24">
|
|
668
727
|
<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"/>
|
|
@@ -670,630 +729,682 @@ async function renderFilePreview(container, resourceId, fileName, fileType, isRe
|
|
|
670
729
|
<div class="text-sm text-center">Нажмите или перетащите файл</div>
|
|
671
730
|
</div>
|
|
672
731
|
`;
|
|
673
|
-
|
|
674
|
-
}
|
|
675
|
-
} else {
|
|
676
|
-
// No file and no getThumbnail config - fallback
|
|
677
|
-
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>';
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
// Add click handler for download in readonly mode
|
|
681
|
-
if (isReadonly && state.config.downloadFile) {
|
|
682
|
-
container.style.cursor = 'pointer';
|
|
683
|
-
container.onclick = () => {
|
|
684
|
-
if (state.config.downloadFile) {
|
|
685
|
-
state.config.downloadFile(resourceId, fileName);
|
|
686
|
-
}
|
|
687
|
-
};
|
|
732
|
+
});
|
|
688
733
|
}
|
|
734
|
+
} else {
|
|
735
|
+
// No file and no getThumbnail config - fallback
|
|
736
|
+
container.innerHTML =
|
|
737
|
+
'<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">' +
|
|
738
|
+
fileName +
|
|
739
|
+
"</div></div>";
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// Add click handler for download in readonly mode
|
|
743
|
+
if (isReadonly && state.config.downloadFile) {
|
|
744
|
+
container.style.cursor = "pointer";
|
|
745
|
+
container.onclick = () => {
|
|
746
|
+
if (state.config.downloadFile) {
|
|
747
|
+
state.config.downloadFile(resourceId, fileName);
|
|
748
|
+
}
|
|
749
|
+
};
|
|
750
|
+
}
|
|
689
751
|
}
|
|
690
|
-
|
|
752
|
+
|
|
691
753
|
function renderResourcePills(container, rids, onRemove) {
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
uploadLink.className = 'underline cursor-pointer';
|
|
736
|
-
uploadLink.textContent = 'Загрузите';
|
|
737
|
-
uploadLink.onclick = (e) => {
|
|
738
|
-
e.stopPropagation();
|
|
739
|
-
// Look for file input in the files wrapper (go up from list -> filesContainer -> filesWrapper)
|
|
740
|
-
const filesWrapper = container.closest('.space-y-2');
|
|
741
|
-
const fileInput = filesWrapper?.querySelector('input[type="file"]');
|
|
742
|
-
if (fileInput) fileInput.click();
|
|
743
|
-
};
|
|
744
|
-
|
|
745
|
-
textContainer.appendChild(uploadLink);
|
|
746
|
-
textContainer.appendChild(document.createTextNode(' или перетащите файлы'));
|
|
747
|
-
|
|
748
|
-
// Clear and append
|
|
749
|
-
container.appendChild(gridContainer);
|
|
750
|
-
container.appendChild(textContainer);
|
|
751
|
-
return;
|
|
754
|
+
clear(container);
|
|
755
|
+
|
|
756
|
+
// Show initial placeholder only if this is the first render (no previous grid)
|
|
757
|
+
// Check if container already has grid class to determine if this is initial render
|
|
758
|
+
const isInitialRender = !container.classList.contains("grid");
|
|
759
|
+
|
|
760
|
+
if ((!rids || rids.length === 0) && isInitialRender) {
|
|
761
|
+
// Create grid container
|
|
762
|
+
const gridContainer = document.createElement("div");
|
|
763
|
+
gridContainer.className = "grid grid-cols-4 gap-3 mb-3";
|
|
764
|
+
|
|
765
|
+
// Create 4 placeholder slots
|
|
766
|
+
for (let i = 0; i < 4; i++) {
|
|
767
|
+
const slot = document.createElement("div");
|
|
768
|
+
slot.className =
|
|
769
|
+
"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";
|
|
770
|
+
|
|
771
|
+
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
772
|
+
svg.setAttribute("class", "w-12 h-12 text-gray-400");
|
|
773
|
+
svg.setAttribute("fill", "currentColor");
|
|
774
|
+
svg.setAttribute("viewBox", "0 0 24 24");
|
|
775
|
+
|
|
776
|
+
const path = document.createElementNS(
|
|
777
|
+
"http://www.w3.org/2000/svg",
|
|
778
|
+
"path",
|
|
779
|
+
);
|
|
780
|
+
path.setAttribute(
|
|
781
|
+
"d",
|
|
782
|
+
"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",
|
|
783
|
+
);
|
|
784
|
+
|
|
785
|
+
svg.appendChild(path);
|
|
786
|
+
slot.appendChild(svg);
|
|
787
|
+
|
|
788
|
+
// Add click handler to each slot
|
|
789
|
+
slot.onclick = () => {
|
|
790
|
+
// Look for file input in the files wrapper (go up from list -> filesContainer -> filesWrapper)
|
|
791
|
+
const filesWrapper = container.closest(".space-y-2");
|
|
792
|
+
const fileInput = filesWrapper?.querySelector('input[type="file"]');
|
|
793
|
+
if (fileInput) fileInput.click();
|
|
794
|
+
};
|
|
795
|
+
|
|
796
|
+
gridContainer.appendChild(slot);
|
|
752
797
|
}
|
|
753
|
-
|
|
754
|
-
//
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
798
|
+
|
|
799
|
+
// Create text container
|
|
800
|
+
const textContainer = document.createElement("div");
|
|
801
|
+
textContainer.className = "text-center text-xs text-gray-600";
|
|
802
|
+
|
|
803
|
+
const uploadLink = document.createElement("span");
|
|
804
|
+
uploadLink.className = "underline cursor-pointer";
|
|
805
|
+
uploadLink.textContent = "Загрузите";
|
|
806
|
+
uploadLink.onclick = (e) => {
|
|
807
|
+
e.stopPropagation();
|
|
808
|
+
// Look for file input in the files wrapper (go up from list -> filesContainer -> filesWrapper)
|
|
809
|
+
const filesWrapper = container.closest(".space-y-2");
|
|
810
|
+
const fileInput = filesWrapper?.querySelector('input[type="file"]');
|
|
811
|
+
if (fileInput) fileInput.click();
|
|
812
|
+
};
|
|
813
|
+
|
|
814
|
+
textContainer.appendChild(uploadLink);
|
|
815
|
+
textContainer.appendChild(document.createTextNode(" или перетащите файлы"));
|
|
816
|
+
|
|
817
|
+
// Clear and append
|
|
818
|
+
container.appendChild(gridContainer);
|
|
819
|
+
container.appendChild(textContainer);
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// Always show files grid if we have files OR if this was already a grid
|
|
824
|
+
// This prevents shrinking when deleting the last file
|
|
825
|
+
container.className = "grid grid-cols-4 gap-3 mt-2";
|
|
826
|
+
|
|
827
|
+
// Calculate how many slots we need (at least 4, then expand by rows of 4)
|
|
828
|
+
const currentImagesCount = rids ? rids.length : 0;
|
|
829
|
+
// Calculate rows needed: always show an extra slot for adding next file
|
|
830
|
+
// 0-3 files → 1 row (4 slots), 4-7 files → 2 rows (8 slots), 8-11 files → 3 rows (12 slots)
|
|
831
|
+
const rowsNeeded = Math.floor(currentImagesCount / 4) + 1;
|
|
832
|
+
const slotsNeeded = rowsNeeded * 4;
|
|
833
|
+
|
|
834
|
+
// Add all slots (filled and empty)
|
|
835
|
+
for (let i = 0; i < slotsNeeded; i++) {
|
|
836
|
+
const slot = document.createElement("div");
|
|
837
|
+
|
|
838
|
+
if (rids && i < rids.length) {
|
|
839
|
+
// Filled slot with image preview
|
|
840
|
+
const rid = rids[i];
|
|
841
|
+
const meta = state.resourceIndex.get(rid);
|
|
842
|
+
slot.className =
|
|
843
|
+
"aspect-square bg-gray-100 rounded-lg overflow-hidden relative group border border-gray-300";
|
|
844
|
+
|
|
845
|
+
// Add image or file content
|
|
846
|
+
if (meta && meta.type?.startsWith("image/")) {
|
|
847
|
+
if (meta.file && meta.file instanceof File) {
|
|
848
|
+
// Use FileReader for local files
|
|
849
|
+
const img = document.createElement("img");
|
|
850
|
+
img.className = "w-full h-full object-contain";
|
|
851
|
+
img.alt = meta.name;
|
|
852
|
+
|
|
853
|
+
const reader = new FileReader();
|
|
854
|
+
reader.onload = (e) => {
|
|
855
|
+
img.src = e.target.result;
|
|
856
|
+
};
|
|
857
|
+
reader.readAsDataURL(meta.file);
|
|
858
|
+
slot.appendChild(img);
|
|
859
|
+
} else if (state.config.getThumbnail) {
|
|
860
|
+
// Use getThumbnail for uploaded files
|
|
861
|
+
const img = document.createElement("img");
|
|
862
|
+
img.className = "w-full h-full object-contain";
|
|
863
|
+
img.alt = meta.name;
|
|
864
|
+
|
|
865
|
+
const url = state.config.getThumbnail(rid);
|
|
866
|
+
if (url) {
|
|
867
|
+
img.src = url;
|
|
868
|
+
slot.appendChild(img);
|
|
869
|
+
} else {
|
|
870
|
+
slot.innerHTML = `<div class="flex flex-col items-center justify-center h-full text-gray-400">
|
|
802
871
|
<svg class="w-12 h-12" fill="currentColor" viewBox="0 0 24 24">
|
|
803
872
|
<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"/>
|
|
804
873
|
</svg>
|
|
805
874
|
</div>`;
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
875
|
+
}
|
|
876
|
+
} else {
|
|
877
|
+
slot.innerHTML = `<div class="flex flex-col items-center justify-center h-full text-gray-400">
|
|
809
878
|
<svg class="w-12 h-12" fill="currentColor" viewBox="0 0 24 24">
|
|
810
879
|
<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"/>
|
|
811
880
|
</svg>
|
|
812
881
|
</div>`;
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
882
|
+
}
|
|
883
|
+
} else {
|
|
884
|
+
slot.innerHTML = `<div class="flex flex-col items-center justify-center h-full text-gray-400">
|
|
816
885
|
<div class="text-2xl mb-1">📁</div>
|
|
817
|
-
<div class="text-xs">${meta?.name ||
|
|
886
|
+
<div class="text-xs">${meta?.name || "File"}</div>
|
|
818
887
|
</div>`;
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// Add remove button overlay (similar to file field)
|
|
891
|
+
if (onRemove) {
|
|
892
|
+
const overlay = document.createElement("div");
|
|
893
|
+
overlay.className =
|
|
894
|
+
"absolute inset-0 bg-black bg-opacity-50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center";
|
|
895
|
+
|
|
896
|
+
const removeBtn = document.createElement("button");
|
|
897
|
+
removeBtn.className = "bg-red-600 text-white px-2 py-1 rounded text-xs";
|
|
898
|
+
removeBtn.textContent = "Удалить";
|
|
899
|
+
removeBtn.onclick = (e) => {
|
|
900
|
+
e.stopPropagation();
|
|
901
|
+
onRemove(rid);
|
|
902
|
+
};
|
|
903
|
+
|
|
904
|
+
overlay.appendChild(removeBtn);
|
|
905
|
+
slot.appendChild(overlay);
|
|
906
|
+
}
|
|
907
|
+
} else {
|
|
908
|
+
// Empty slot placeholder
|
|
909
|
+
slot.className =
|
|
910
|
+
"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";
|
|
911
|
+
slot.innerHTML =
|
|
912
|
+
'<svg class="w-12 h-12 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>';
|
|
913
|
+
slot.onclick = () => {
|
|
914
|
+
// Look for file input in the files wrapper (go up from list -> filesContainer -> filesWrapper)
|
|
915
|
+
const filesWrapper = container.closest(".space-y-2");
|
|
916
|
+
const fileInput = filesWrapper?.querySelector('input[type="file"]');
|
|
917
|
+
if (fileInput) fileInput.click();
|
|
918
|
+
};
|
|
850
919
|
}
|
|
920
|
+
|
|
921
|
+
container.appendChild(slot);
|
|
922
|
+
}
|
|
851
923
|
}
|
|
852
924
|
|
|
853
925
|
function formatFileSize(bytes) {
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
926
|
+
if (bytes === 0) return "0 B";
|
|
927
|
+
const k = 1024;
|
|
928
|
+
const sizes = ["B", "KB", "MB", "GB"];
|
|
929
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
930
|
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
|
859
931
|
}
|
|
860
932
|
|
|
861
933
|
function generateResourceId() {
|
|
862
|
-
|
|
934
|
+
return (
|
|
935
|
+
"res_" + Math.random().toString(36).substr(2, 9) + Date.now().toString(36)
|
|
936
|
+
);
|
|
863
937
|
}
|
|
864
938
|
|
|
865
939
|
async function handleFileSelect(file, container, fieldName) {
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
}
|
|
878
|
-
} else {
|
|
879
|
-
throw new Error('No upload handler configured. Set uploadHandler via FormBuilder.setUploadHandler()');
|
|
880
|
-
}
|
|
881
|
-
|
|
882
|
-
state.resourceIndex.set(rid, {
|
|
883
|
-
name: file.name,
|
|
884
|
-
type: file.type,
|
|
885
|
-
size: file.size,
|
|
886
|
-
file: null // Files are always uploaded, never stored locally
|
|
887
|
-
});
|
|
888
|
-
|
|
889
|
-
// Create hidden input to store the resource ID
|
|
890
|
-
let hiddenInput = container.parentElement.querySelector('input[type="hidden"]');
|
|
891
|
-
if (!hiddenInput) {
|
|
892
|
-
hiddenInput = document.createElement('input');
|
|
893
|
-
hiddenInput.type = 'hidden';
|
|
894
|
-
hiddenInput.name = fieldName;
|
|
895
|
-
container.parentElement.appendChild(hiddenInput);
|
|
940
|
+
let rid;
|
|
941
|
+
|
|
942
|
+
// If uploadHandler is configured, use it to upload the file
|
|
943
|
+
if (state.config.uploadFile) {
|
|
944
|
+
try {
|
|
945
|
+
rid = await state.config.uploadFile(file);
|
|
946
|
+
if (typeof rid !== "string") {
|
|
947
|
+
throw new Error("Upload handler must return a string resource ID");
|
|
948
|
+
}
|
|
949
|
+
} catch (error) {
|
|
950
|
+
throw new Error(`File upload failed: ${error.message}`);
|
|
896
951
|
}
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
952
|
+
} else {
|
|
953
|
+
throw new Error(
|
|
954
|
+
"No upload handler configured. Set uploadHandler via FormBuilder.setUploadHandler()",
|
|
955
|
+
);
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
state.resourceIndex.set(rid, {
|
|
959
|
+
name: file.name,
|
|
960
|
+
type: file.type,
|
|
961
|
+
size: file.size,
|
|
962
|
+
file: null, // Files are always uploaded, never stored locally
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
// Create hidden input to store the resource ID
|
|
966
|
+
let hiddenInput = container.parentElement.querySelector(
|
|
967
|
+
'input[type="hidden"]',
|
|
968
|
+
);
|
|
969
|
+
if (!hiddenInput) {
|
|
970
|
+
hiddenInput = document.createElement("input");
|
|
971
|
+
hiddenInput.type = "hidden";
|
|
972
|
+
hiddenInput.name = fieldName;
|
|
973
|
+
container.parentElement.appendChild(hiddenInput);
|
|
974
|
+
}
|
|
975
|
+
hiddenInput.value = rid;
|
|
976
|
+
|
|
977
|
+
renderFilePreview(container, rid, file.name, file.type, false).catch(
|
|
978
|
+
console.error,
|
|
979
|
+
);
|
|
900
980
|
}
|
|
901
981
|
|
|
902
982
|
function setupDragAndDrop(element, dropHandler) {
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
983
|
+
element.addEventListener("dragover", (e) => {
|
|
984
|
+
e.preventDefault();
|
|
985
|
+
element.classList.add("border-blue-500", "bg-blue-50");
|
|
986
|
+
});
|
|
907
987
|
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
988
|
+
element.addEventListener("dragleave", (e) => {
|
|
989
|
+
e.preventDefault();
|
|
990
|
+
element.classList.remove("border-blue-500", "bg-blue-50");
|
|
991
|
+
});
|
|
912
992
|
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
993
|
+
element.addEventListener("drop", (e) => {
|
|
994
|
+
e.preventDefault();
|
|
995
|
+
element.classList.remove("border-blue-500", "bg-blue-50");
|
|
996
|
+
dropHandler(e.dataTransfer.files);
|
|
997
|
+
});
|
|
918
998
|
}
|
|
919
999
|
|
|
920
1000
|
function addDeleteButton(container, onDelete) {
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
1001
|
+
// Remove existing overlay if any
|
|
1002
|
+
const existingOverlay = container.querySelector(".delete-overlay");
|
|
1003
|
+
if (existingOverlay) {
|
|
1004
|
+
existingOverlay.remove();
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
// Create overlay with center delete button (like in files)
|
|
1008
|
+
const overlay = document.createElement("div");
|
|
1009
|
+
overlay.className =
|
|
1010
|
+
"delete-overlay absolute inset-0 bg-black bg-opacity-50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center";
|
|
1011
|
+
|
|
1012
|
+
const deleteBtn = document.createElement("button");
|
|
1013
|
+
deleteBtn.className =
|
|
1014
|
+
"bg-red-600 text-white px-3 py-1 rounded text-sm hover:bg-red-700 transition-colors";
|
|
1015
|
+
deleteBtn.textContent = "Удалить";
|
|
1016
|
+
deleteBtn.onclick = (e) => {
|
|
1017
|
+
e.stopPropagation();
|
|
1018
|
+
onDelete();
|
|
1019
|
+
};
|
|
1020
|
+
|
|
1021
|
+
overlay.appendChild(deleteBtn);
|
|
1022
|
+
container.appendChild(overlay);
|
|
941
1023
|
}
|
|
942
1024
|
|
|
943
1025
|
function showTooltip(tooltipId, button) {
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1026
|
+
const tooltip = document.getElementById(tooltipId);
|
|
1027
|
+
const isCurrentlyVisible = !tooltip.classList.contains("hidden");
|
|
1028
|
+
|
|
1029
|
+
// Hide all tooltips first
|
|
1030
|
+
document.querySelectorAll('[id^="tooltip-"]').forEach((t) => {
|
|
1031
|
+
t.classList.add("hidden");
|
|
1032
|
+
});
|
|
1033
|
+
|
|
1034
|
+
// If the tooltip was already visible, keep it hidden (toggle behavior)
|
|
1035
|
+
if (isCurrentlyVisible) {
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
// Position the tooltip intelligently
|
|
1040
|
+
const rect = button.getBoundingClientRect();
|
|
1041
|
+
const viewportWidth = window.innerWidth;
|
|
1042
|
+
const viewportHeight = window.innerHeight;
|
|
1043
|
+
|
|
1044
|
+
// Ensure tooltip is appended to body for proper positioning
|
|
1045
|
+
if (tooltip && tooltip.parentElement !== document.body) {
|
|
1046
|
+
document.body.appendChild(tooltip);
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// Show tooltip temporarily to measure its size
|
|
1050
|
+
tooltip.style.visibility = "hidden";
|
|
1051
|
+
tooltip.style.position = "fixed";
|
|
1052
|
+
tooltip.classList.remove("hidden");
|
|
1053
|
+
const tooltipRect = tooltip.getBoundingClientRect();
|
|
1054
|
+
tooltip.classList.add("hidden");
|
|
1055
|
+
tooltip.style.visibility = "visible";
|
|
1056
|
+
|
|
1057
|
+
let left = rect.left;
|
|
1058
|
+
let top = rect.bottom + 5;
|
|
1059
|
+
|
|
1060
|
+
// Adjust horizontal position if tooltip would go off-screen
|
|
1061
|
+
if (left + tooltipRect.width > viewportWidth) {
|
|
1062
|
+
left = rect.right - tooltipRect.width;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
// Adjust vertical position if tooltip would go off-screen
|
|
1066
|
+
if (top + tooltipRect.height > viewportHeight) {
|
|
1067
|
+
top = rect.top - tooltipRect.height - 5;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
// Ensure tooltip doesn't go off the left edge
|
|
1071
|
+
if (left < 10) {
|
|
1072
|
+
left = 10;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
// Ensure tooltip doesn't go off the top edge
|
|
1076
|
+
if (top < 10) {
|
|
1077
|
+
top = rect.bottom + 5;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
tooltip.style.left = left + "px";
|
|
1081
|
+
tooltip.style.top = top + "px";
|
|
1082
|
+
|
|
1083
|
+
// Show the tooltip
|
|
1084
|
+
tooltip.classList.remove("hidden");
|
|
1085
|
+
|
|
1086
|
+
// Hide after 25 seconds
|
|
1087
|
+
setTimeout(() => {
|
|
1088
|
+
tooltip.classList.add("hidden");
|
|
1089
|
+
}, 25000);
|
|
1008
1090
|
}
|
|
1009
1091
|
|
|
1010
|
-
// Close tooltips when clicking outside
|
|
1011
|
-
|
|
1012
|
-
|
|
1092
|
+
// Close tooltips when clicking outside (only in browser)
|
|
1093
|
+
if (typeof document !== "undefined") {
|
|
1094
|
+
document.addEventListener("click", function (e) {
|
|
1095
|
+
const isInfoButton =
|
|
1096
|
+
e.target.closest("button") && e.target.closest("button").onclick;
|
|
1013
1097
|
const isTooltip = e.target.closest('[id^="tooltip-"]');
|
|
1014
|
-
|
|
1098
|
+
|
|
1015
1099
|
if (!isInfoButton && !isTooltip) {
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1100
|
+
document.querySelectorAll('[id^="tooltip-"]').forEach((tooltip) => {
|
|
1101
|
+
tooltip.classList.add("hidden");
|
|
1102
|
+
});
|
|
1019
1103
|
}
|
|
1020
|
-
});
|
|
1104
|
+
});
|
|
1105
|
+
}
|
|
1021
1106
|
|
|
1022
1107
|
// Form validation and data extraction
|
|
1023
1108
|
function validateForm(skipValidation = false) {
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
}
|
|
1109
|
+
if (!state.schema || !state.formRoot) return { valid: true, data: {} };
|
|
1110
|
+
|
|
1111
|
+
const errors = [];
|
|
1112
|
+
const data = {};
|
|
1113
|
+
|
|
1114
|
+
function markValidity(input, errorMessage) {
|
|
1115
|
+
if (!input) return;
|
|
1116
|
+
if (errorMessage) {
|
|
1117
|
+
input.classList.add("invalid");
|
|
1118
|
+
input.title = errorMessage;
|
|
1119
|
+
} else {
|
|
1120
|
+
input.classList.remove("invalid");
|
|
1121
|
+
input.title = "";
|
|
1038
1122
|
}
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
}
|
|
1075
|
-
} else if (skipValidation) {
|
|
1076
|
-
markValidity(input, null);
|
|
1077
|
-
} else {
|
|
1078
|
-
markValidity(input, null);
|
|
1079
|
-
}
|
|
1080
|
-
return val;
|
|
1081
|
-
}
|
|
1082
|
-
case 'number': {
|
|
1083
|
-
const input = scopeRoot.querySelector(`[name$="${key}"]`);
|
|
1084
|
-
const raw = input?.value ?? '';
|
|
1085
|
-
if (!skipValidation && element.required && raw === '') {
|
|
1086
|
-
errors.push(`${key}: required`);
|
|
1087
|
-
markValidity(input, 'required');
|
|
1088
|
-
return null;
|
|
1089
|
-
}
|
|
1090
|
-
if (raw === '') {
|
|
1091
|
-
markValidity(input, null);
|
|
1092
|
-
return null;
|
|
1093
|
-
}
|
|
1094
|
-
const v = parseFloat(raw);
|
|
1095
|
-
if (!skipValidation && !Number.isFinite(v)) {
|
|
1096
|
-
errors.push(`${key}: not a number`);
|
|
1097
|
-
markValidity(input, 'not a number');
|
|
1098
|
-
return null;
|
|
1099
|
-
}
|
|
1100
|
-
if (!skipValidation && element.min != null && v < element.min) {
|
|
1101
|
-
errors.push(`${key}: < min=${element.min}`);
|
|
1102
|
-
markValidity(input, `< min=${element.min}`);
|
|
1103
|
-
}
|
|
1104
|
-
if (!skipValidation && element.max != null && v > element.max) {
|
|
1105
|
-
errors.push(`${key}: > max=${element.max}`);
|
|
1106
|
-
markValidity(input, `> max=${element.max}`);
|
|
1107
|
-
}
|
|
1108
|
-
const d = Number.isInteger(element.decimals ?? 0) ? element.decimals : 0;
|
|
1109
|
-
markValidity(input, null);
|
|
1110
|
-
return Number(v.toFixed(d));
|
|
1111
|
-
}
|
|
1112
|
-
case 'select': {
|
|
1113
|
-
const input = scopeRoot.querySelector(`[name$="${key}"]`);
|
|
1114
|
-
const val = input?.value ?? '';
|
|
1115
|
-
if (!skipValidation && element.required && val === '') {
|
|
1116
|
-
errors.push(`${key}: required`);
|
|
1117
|
-
markValidity(input, 'required');
|
|
1118
|
-
return '';
|
|
1119
|
-
}
|
|
1120
|
-
markValidity(input, null);
|
|
1121
|
-
return val;
|
|
1122
|
-
}
|
|
1123
|
-
case 'file': {
|
|
1124
|
-
const input = scopeRoot.querySelector(`input[name$="${key}"][type="hidden"]`);
|
|
1125
|
-
const rid = input?.value ?? '';
|
|
1126
|
-
if (!skipValidation && element.required && rid === '') {
|
|
1127
|
-
errors.push(`${key}: required`);
|
|
1128
|
-
return null;
|
|
1129
|
-
}
|
|
1130
|
-
return rid || null;
|
|
1131
|
-
}
|
|
1132
|
-
case 'files': {
|
|
1133
|
-
// For files, we need to collect all resource IDs
|
|
1134
|
-
const container = scopeRoot.querySelector(`[name$="${key}"]`)?.parentElement?.querySelector('.files-list');
|
|
1135
|
-
const rids = [];
|
|
1136
|
-
if (container) {
|
|
1137
|
-
// Extract resource IDs from the current state
|
|
1138
|
-
// This is a simplified approach - in practice you'd track this better
|
|
1139
|
-
}
|
|
1140
|
-
return rids;
|
|
1141
|
-
}
|
|
1142
|
-
case 'group': {
|
|
1143
|
-
if (element.repeat && isPlainObject(element.repeat)) {
|
|
1144
|
-
const items = [];
|
|
1145
|
-
const itemElements = scopeRoot.querySelectorAll(`[name^="${key}["]`);
|
|
1146
|
-
const itemCount = Math.max(0, Math.floor(itemElements.length / element.elements.length));
|
|
1147
|
-
|
|
1148
|
-
for (let i = 0; i < itemCount; i++) {
|
|
1149
|
-
const itemData = {};
|
|
1150
|
-
element.elements.forEach(child => {
|
|
1151
|
-
const childKey = `${key}[${i}].${child.key}`;
|
|
1152
|
-
itemData[child.key] = validateElement({...child, key: childKey}, ctx);
|
|
1153
|
-
});
|
|
1154
|
-
items.push(itemData);
|
|
1155
|
-
}
|
|
1156
|
-
return items;
|
|
1157
|
-
} else {
|
|
1158
|
-
const groupData = {};
|
|
1159
|
-
element.elements.forEach(child => {
|
|
1160
|
-
const childKey = `${key}.${child.key}`;
|
|
1161
|
-
groupData[child.key] = validateElement({...child, key: childKey}, ctx);
|
|
1162
|
-
});
|
|
1163
|
-
return groupData;
|
|
1164
|
-
}
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
function validateElement(element, ctx) {
|
|
1126
|
+
const key = element.key;
|
|
1127
|
+
const scopeRoot = state.formRoot;
|
|
1128
|
+
|
|
1129
|
+
switch (element.type) {
|
|
1130
|
+
case "text":
|
|
1131
|
+
case "textarea": {
|
|
1132
|
+
const input = scopeRoot.querySelector(`[name$="${key}"]`);
|
|
1133
|
+
const val = input?.value ?? "";
|
|
1134
|
+
if (!skipValidation && element.required && val === "") {
|
|
1135
|
+
errors.push(`${key}: required`);
|
|
1136
|
+
markValidity(input, "required");
|
|
1137
|
+
return "";
|
|
1138
|
+
}
|
|
1139
|
+
if (!skipValidation && val) {
|
|
1140
|
+
if (element.minLength != null && val.length < element.minLength) {
|
|
1141
|
+
errors.push(`${key}: minLength=${element.minLength}`);
|
|
1142
|
+
markValidity(input, `minLength=${element.minLength}`);
|
|
1143
|
+
}
|
|
1144
|
+
if (element.maxLength != null && val.length > element.maxLength) {
|
|
1145
|
+
errors.push(`${key}: maxLength=${element.maxLength}`);
|
|
1146
|
+
markValidity(input, `maxLength=${element.maxLength}`);
|
|
1147
|
+
}
|
|
1148
|
+
if (element.pattern) {
|
|
1149
|
+
try {
|
|
1150
|
+
const re = new RegExp(element.pattern);
|
|
1151
|
+
if (!re.test(val)) {
|
|
1152
|
+
errors.push(`${key}: pattern mismatch`);
|
|
1153
|
+
markValidity(input, "pattern mismatch");
|
|
1154
|
+
}
|
|
1155
|
+
} catch {
|
|
1156
|
+
errors.push(`${key}: invalid pattern`);
|
|
1157
|
+
markValidity(input, "invalid pattern");
|
|
1165
1158
|
}
|
|
1166
|
-
|
|
1167
|
-
|
|
1159
|
+
}
|
|
1160
|
+
} else if (skipValidation) {
|
|
1161
|
+
markValidity(input, null);
|
|
1162
|
+
} else {
|
|
1163
|
+
markValidity(input, null);
|
|
1164
|
+
}
|
|
1165
|
+
return val;
|
|
1166
|
+
}
|
|
1167
|
+
case "number": {
|
|
1168
|
+
const input = scopeRoot.querySelector(`[name$="${key}"]`);
|
|
1169
|
+
const raw = input?.value ?? "";
|
|
1170
|
+
if (!skipValidation && element.required && raw === "") {
|
|
1171
|
+
errors.push(`${key}: required`);
|
|
1172
|
+
markValidity(input, "required");
|
|
1173
|
+
return null;
|
|
1174
|
+
}
|
|
1175
|
+
if (raw === "") {
|
|
1176
|
+
markValidity(input, null);
|
|
1177
|
+
return null;
|
|
1178
|
+
}
|
|
1179
|
+
const v = parseFloat(raw);
|
|
1180
|
+
if (!skipValidation && !Number.isFinite(v)) {
|
|
1181
|
+
errors.push(`${key}: not a number`);
|
|
1182
|
+
markValidity(input, "not a number");
|
|
1183
|
+
return null;
|
|
1184
|
+
}
|
|
1185
|
+
if (!skipValidation && element.min != null && v < element.min) {
|
|
1186
|
+
errors.push(`${key}: < min=${element.min}`);
|
|
1187
|
+
markValidity(input, `< min=${element.min}`);
|
|
1188
|
+
}
|
|
1189
|
+
if (!skipValidation && element.max != null && v > element.max) {
|
|
1190
|
+
errors.push(`${key}: > max=${element.max}`);
|
|
1191
|
+
markValidity(input, `> max=${element.max}`);
|
|
1192
|
+
}
|
|
1193
|
+
const d = Number.isInteger(element.decimals ?? 0)
|
|
1194
|
+
? element.decimals
|
|
1195
|
+
: 0;
|
|
1196
|
+
markValidity(input, null);
|
|
1197
|
+
return Number(v.toFixed(d));
|
|
1198
|
+
}
|
|
1199
|
+
case "select": {
|
|
1200
|
+
const input = scopeRoot.querySelector(`[name$="${key}"]`);
|
|
1201
|
+
const val = input?.value ?? "";
|
|
1202
|
+
if (!skipValidation && element.required && val === "") {
|
|
1203
|
+
errors.push(`${key}: required`);
|
|
1204
|
+
markValidity(input, "required");
|
|
1205
|
+
return "";
|
|
1206
|
+
}
|
|
1207
|
+
markValidity(input, null);
|
|
1208
|
+
return val;
|
|
1209
|
+
}
|
|
1210
|
+
case "file": {
|
|
1211
|
+
const input = scopeRoot.querySelector(
|
|
1212
|
+
`input[name$="${key}"][type="hidden"]`,
|
|
1213
|
+
);
|
|
1214
|
+
const rid = input?.value ?? "";
|
|
1215
|
+
if (!skipValidation && element.required && rid === "") {
|
|
1216
|
+
errors.push(`${key}: required`);
|
|
1217
|
+
return null;
|
|
1218
|
+
}
|
|
1219
|
+
return rid || null;
|
|
1220
|
+
}
|
|
1221
|
+
case "files": {
|
|
1222
|
+
// For files, we need to collect all resource IDs
|
|
1223
|
+
const container = scopeRoot
|
|
1224
|
+
.querySelector(`[name$="${key}"]`)
|
|
1225
|
+
?.parentElement?.querySelector(".files-list");
|
|
1226
|
+
const rids = [];
|
|
1227
|
+
if (container) {
|
|
1228
|
+
// Extract resource IDs from the current state
|
|
1229
|
+
// This is a simplified approach - in practice you'd track this better
|
|
1230
|
+
}
|
|
1231
|
+
return rids;
|
|
1232
|
+
}
|
|
1233
|
+
case "group": {
|
|
1234
|
+
if (element.repeat && isPlainObject(element.repeat)) {
|
|
1235
|
+
const items = [];
|
|
1236
|
+
const itemElements = scopeRoot.querySelectorAll(`[name^="${key}["]`);
|
|
1237
|
+
const itemCount = Math.max(
|
|
1238
|
+
0,
|
|
1239
|
+
Math.floor(itemElements.length / element.elements.length),
|
|
1240
|
+
);
|
|
1241
|
+
|
|
1242
|
+
for (let i = 0; i < itemCount; i++) {
|
|
1243
|
+
const itemData = {};
|
|
1244
|
+
element.elements.forEach((child) => {
|
|
1245
|
+
const childKey = `${key}[${i}].${child.key}`;
|
|
1246
|
+
itemData[child.key] = validateElement(
|
|
1247
|
+
{ ...child, key: childKey },
|
|
1248
|
+
ctx,
|
|
1249
|
+
);
|
|
1250
|
+
});
|
|
1251
|
+
items.push(itemData);
|
|
1252
|
+
}
|
|
1253
|
+
return items;
|
|
1254
|
+
} else {
|
|
1255
|
+
const groupData = {};
|
|
1256
|
+
element.elements.forEach((child) => {
|
|
1257
|
+
const childKey = `${key}.${child.key}`;
|
|
1258
|
+
groupData[child.key] = validateElement(
|
|
1259
|
+
{ ...child, key: childKey },
|
|
1260
|
+
ctx,
|
|
1261
|
+
);
|
|
1262
|
+
});
|
|
1263
|
+
return groupData;
|
|
1168
1264
|
}
|
|
1265
|
+
}
|
|
1266
|
+
default:
|
|
1267
|
+
return null;
|
|
1169
1268
|
}
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
});
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
state.schema.elements.forEach((element) => {
|
|
1272
|
+
data[element.key] = validateElement(element, { path: "" });
|
|
1273
|
+
});
|
|
1274
|
+
|
|
1275
|
+
return {
|
|
1276
|
+
valid: errors.length === 0,
|
|
1277
|
+
errors,
|
|
1278
|
+
data,
|
|
1279
|
+
};
|
|
1180
1280
|
}
|
|
1181
1281
|
|
|
1182
1282
|
// Element rendering functions
|
|
1183
1283
|
function renderTextElement(element, ctx, wrapper, pathKey) {
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1284
|
+
const textInput = document.createElement("input");
|
|
1285
|
+
textInput.type = "text";
|
|
1286
|
+
textInput.className =
|
|
1287
|
+
"w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500";
|
|
1288
|
+
textInput.name = pathKey;
|
|
1289
|
+
textInput.placeholder = element.placeholder || "Введите текст";
|
|
1290
|
+
textInput.value = ctx.prefill[element.key] || element.default || "";
|
|
1291
|
+
textInput.readOnly = state.config.readonly;
|
|
1292
|
+
wrapper.appendChild(textInput);
|
|
1293
|
+
|
|
1294
|
+
// Add hint
|
|
1295
|
+
const textHint = document.createElement("p");
|
|
1296
|
+
textHint.className = "text-xs text-gray-500 mt-1";
|
|
1297
|
+
textHint.textContent = makeFieldHint(element);
|
|
1298
|
+
wrapper.appendChild(textHint);
|
|
1198
1299
|
}
|
|
1199
1300
|
|
|
1200
1301
|
function renderTextareaElement(element, ctx, wrapper, pathKey) {
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1302
|
+
const textareaInput = document.createElement("textarea");
|
|
1303
|
+
textareaInput.className =
|
|
1304
|
+
"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";
|
|
1305
|
+
textareaInput.name = pathKey;
|
|
1306
|
+
textareaInput.placeholder = element.placeholder || "Введите текст";
|
|
1307
|
+
textareaInput.rows = element.rows || 4;
|
|
1308
|
+
textareaInput.value = ctx.prefill[element.key] || element.default || "";
|
|
1309
|
+
textareaInput.readOnly = state.config.readonly;
|
|
1310
|
+
wrapper.appendChild(textareaInput);
|
|
1311
|
+
|
|
1312
|
+
// Add hint
|
|
1313
|
+
const textareaHint = document.createElement("p");
|
|
1314
|
+
textareaHint.className = "text-xs text-gray-500 mt-1";
|
|
1315
|
+
textareaHint.textContent = makeFieldHint(element);
|
|
1316
|
+
wrapper.appendChild(textareaHint);
|
|
1215
1317
|
}
|
|
1216
1318
|
|
|
1217
1319
|
function renderNumberElement(element, ctx, wrapper, pathKey) {
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1320
|
+
const numberInput = document.createElement("input");
|
|
1321
|
+
numberInput.type = "number";
|
|
1322
|
+
numberInput.className =
|
|
1323
|
+
"w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500";
|
|
1324
|
+
numberInput.name = pathKey;
|
|
1325
|
+
numberInput.placeholder = element.placeholder || "0";
|
|
1326
|
+
if (element.min !== undefined) numberInput.min = element.min;
|
|
1327
|
+
if (element.max !== undefined) numberInput.max = element.max;
|
|
1328
|
+
if (element.step !== undefined) numberInput.step = element.step;
|
|
1329
|
+
numberInput.value = ctx.prefill[element.key] || element.default || "";
|
|
1330
|
+
numberInput.readOnly = state.config.readonly;
|
|
1331
|
+
wrapper.appendChild(numberInput);
|
|
1332
|
+
|
|
1333
|
+
// Add hint
|
|
1334
|
+
const numberHint = document.createElement("p");
|
|
1335
|
+
numberHint.className = "text-xs text-gray-500 mt-1";
|
|
1336
|
+
numberHint.textContent = makeFieldHint(element);
|
|
1337
|
+
wrapper.appendChild(numberHint);
|
|
1235
1338
|
}
|
|
1236
1339
|
|
|
1237
1340
|
function renderSelectElement(element, ctx, wrapper, pathKey) {
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1341
|
+
const selectInput = document.createElement("select");
|
|
1342
|
+
selectInput.className =
|
|
1343
|
+
"w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500";
|
|
1344
|
+
selectInput.name = pathKey;
|
|
1345
|
+
selectInput.disabled = state.config.readonly;
|
|
1346
|
+
|
|
1347
|
+
(element.options || []).forEach((option) => {
|
|
1348
|
+
const optionEl = document.createElement("option");
|
|
1349
|
+
optionEl.value = option.value;
|
|
1350
|
+
optionEl.textContent = option.label;
|
|
1351
|
+
if ((ctx.prefill[element.key] || element.default) === option.value) {
|
|
1352
|
+
optionEl.selected = true;
|
|
1353
|
+
}
|
|
1354
|
+
selectInput.appendChild(optionEl);
|
|
1355
|
+
});
|
|
1356
|
+
|
|
1357
|
+
wrapper.appendChild(selectInput);
|
|
1358
|
+
|
|
1359
|
+
// Add hint
|
|
1360
|
+
const selectHint = document.createElement("p");
|
|
1361
|
+
selectHint.className = "text-xs text-gray-500 mt-1";
|
|
1362
|
+
selectHint.textContent = makeFieldHint(element);
|
|
1363
|
+
wrapper.appendChild(selectHint);
|
|
1260
1364
|
}
|
|
1261
1365
|
|
|
1262
1366
|
// Common file preview rendering function for readonly mode
|
|
1263
1367
|
function renderFilePreviewReadonly(resourceId, fileName) {
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
}
|
|
1286
|
-
|
|
1287
|
-
}
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1368
|
+
const meta = state.resourceIndex.get(resourceId);
|
|
1369
|
+
const actualFileName = fileName || meta?.name || "file";
|
|
1370
|
+
|
|
1371
|
+
// Individual file result container
|
|
1372
|
+
const fileResult = document.createElement("div");
|
|
1373
|
+
fileResult.className = "space-y-3";
|
|
1374
|
+
|
|
1375
|
+
// Large preview container
|
|
1376
|
+
const previewContainer = document.createElement("div");
|
|
1377
|
+
previewContainer.className =
|
|
1378
|
+
"bg-gray-100 rounded-lg overflow-hidden cursor-pointer hover:opacity-90 transition-opacity";
|
|
1379
|
+
|
|
1380
|
+
// Check file type and render appropriate preview
|
|
1381
|
+
if (
|
|
1382
|
+
meta?.type?.startsWith("image/") ||
|
|
1383
|
+
actualFileName.toLowerCase().match(/\.(jpg|jpeg|png|gif|webp)$/)
|
|
1384
|
+
) {
|
|
1385
|
+
// Image preview
|
|
1386
|
+
if (state.config.getThumbnail) {
|
|
1387
|
+
const thumbnailUrl = state.config.getThumbnail(resourceId);
|
|
1388
|
+
if (thumbnailUrl) {
|
|
1389
|
+
previewContainer.innerHTML = `<img src="${thumbnailUrl}" alt="${actualFileName}" class="w-full h-auto">`;
|
|
1390
|
+
} else {
|
|
1391
|
+
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>`;
|
|
1392
|
+
}
|
|
1393
|
+
} else {
|
|
1394
|
+
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>`;
|
|
1395
|
+
}
|
|
1396
|
+
} else if (
|
|
1397
|
+
meta?.type?.startsWith("video/") ||
|
|
1398
|
+
actualFileName.toLowerCase().match(/\.(mp4|webm|avi|mov)$/)
|
|
1399
|
+
) {
|
|
1400
|
+
// Video preview
|
|
1401
|
+
if (state.config.getThumbnail) {
|
|
1402
|
+
const thumbnailUrl = state.config.getThumbnail(resourceId);
|
|
1403
|
+
if (thumbnailUrl) {
|
|
1404
|
+
previewContainer.innerHTML = `
|
|
1294
1405
|
<div class="relative group">
|
|
1295
1406
|
<video class="w-full h-auto" controls preload="auto" muted>
|
|
1296
|
-
<source src="${thumbnailUrl}" type="${meta?.type ||
|
|
1407
|
+
<source src="${thumbnailUrl}" type="${meta?.type || "video/mp4"}">
|
|
1297
1408
|
Ваш браузер не поддерживает видео.
|
|
1298
1409
|
</video>
|
|
1299
1410
|
<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">
|
|
@@ -1305,186 +1416,218 @@ function renderFilePreviewReadonly(resourceId, fileName) {
|
|
|
1305
1416
|
</div>
|
|
1306
1417
|
</div>
|
|
1307
1418
|
`;
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
} else {
|
|
1312
|
-
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>`;
|
|
1313
|
-
}
|
|
1419
|
+
} else {
|
|
1420
|
+
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>`;
|
|
1421
|
+
}
|
|
1314
1422
|
} else {
|
|
1315
|
-
|
|
1316
|
-
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>`;
|
|
1423
|
+
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>`;
|
|
1317
1424
|
}
|
|
1318
|
-
|
|
1319
|
-
//
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1425
|
+
} else {
|
|
1426
|
+
// Other file types
|
|
1427
|
+
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>`;
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
// File name
|
|
1431
|
+
const fileNameElement = document.createElement("p");
|
|
1432
|
+
fileNameElement.className = "text-sm font-medium text-gray-900 text-center";
|
|
1433
|
+
fileNameElement.textContent = actualFileName;
|
|
1434
|
+
|
|
1435
|
+
// Download button
|
|
1436
|
+
const downloadButton = document.createElement("button");
|
|
1437
|
+
downloadButton.className =
|
|
1438
|
+
"w-full px-3 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors";
|
|
1439
|
+
downloadButton.textContent = "Скачать";
|
|
1440
|
+
downloadButton.onclick = (e) => {
|
|
1441
|
+
e.preventDefault();
|
|
1442
|
+
e.stopPropagation();
|
|
1443
|
+
if (state.config.downloadFile) {
|
|
1444
|
+
state.config.downloadFile(resourceId, actualFileName);
|
|
1445
|
+
} else {
|
|
1446
|
+
forceDownload(resourceId, actualFileName);
|
|
1447
|
+
}
|
|
1448
|
+
};
|
|
1449
|
+
|
|
1450
|
+
fileResult.appendChild(previewContainer);
|
|
1451
|
+
fileResult.appendChild(fileNameElement);
|
|
1452
|
+
fileResult.appendChild(downloadButton);
|
|
1453
|
+
|
|
1454
|
+
return fileResult;
|
|
1343
1455
|
}
|
|
1344
1456
|
|
|
1345
1457
|
// TODO: Extract large file, files, and group rendering logic to separate functions:
|
|
1346
|
-
// - renderFileElement(element, ctx, wrapper, pathKey)
|
|
1458
|
+
// - renderFileElement(element, ctx, wrapper, pathKey)
|
|
1347
1459
|
// - renderFilesElement(element, ctx, wrapper, pathKey)
|
|
1348
1460
|
// - renderGroupElement(element, ctx, wrapper, pathKey)
|
|
1349
1461
|
|
|
1350
1462
|
// Force download helper function
|
|
1351
1463
|
function forceDownload(resourceId, fileName) {
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1464
|
+
// Try to get URL from thumbnail handler first
|
|
1465
|
+
let fileUrl = null;
|
|
1466
|
+
if (state.config.getThumbnail) {
|
|
1467
|
+
fileUrl = state.config.getThumbnail(resourceId);
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
if (fileUrl) {
|
|
1471
|
+
// Always try to fetch and create blob for true download behavior
|
|
1472
|
+
// This prevents files from opening in browser
|
|
1473
|
+
const finalUrl = fileUrl.startsWith("http")
|
|
1474
|
+
? fileUrl
|
|
1475
|
+
: new URL(fileUrl, window.location.href).href;
|
|
1476
|
+
|
|
1477
|
+
fetch(finalUrl)
|
|
1478
|
+
.then((response) => {
|
|
1479
|
+
if (!response.ok) {
|
|
1480
|
+
throw new Error(`HTTP error! status: ${response.status}`);
|
|
1481
|
+
}
|
|
1482
|
+
return response.blob();
|
|
1483
|
+
})
|
|
1484
|
+
.then((blob) => {
|
|
1485
|
+
downloadBlob(blob, fileName);
|
|
1486
|
+
})
|
|
1487
|
+
.catch((error) => {
|
|
1488
|
+
throw new Error(`File download failed: ${error.message}`);
|
|
1489
|
+
});
|
|
1490
|
+
} else {
|
|
1491
|
+
console.warn("No download URL available for resource:", resourceId);
|
|
1492
|
+
}
|
|
1379
1493
|
}
|
|
1380
1494
|
|
|
1381
1495
|
// Helper to download blob with proper cleanup
|
|
1382
1496
|
function downloadBlob(blob, fileName) {
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
}
|
|
1497
|
+
try {
|
|
1498
|
+
const blobUrl = URL.createObjectURL(blob);
|
|
1499
|
+
const link = document.createElement("a");
|
|
1500
|
+
link.href = blobUrl;
|
|
1501
|
+
link.download = fileName;
|
|
1502
|
+
link.style.display = "none";
|
|
1503
|
+
|
|
1504
|
+
// Important: add to DOM, click, then remove
|
|
1505
|
+
document.body.appendChild(link);
|
|
1506
|
+
link.click();
|
|
1507
|
+
document.body.removeChild(link);
|
|
1508
|
+
|
|
1509
|
+
// Clean up blob URL after download
|
|
1510
|
+
setTimeout(() => {
|
|
1511
|
+
URL.revokeObjectURL(blobUrl);
|
|
1512
|
+
}, 100);
|
|
1513
|
+
} catch (error) {
|
|
1514
|
+
throw new Error(`Blob download failed: ${error.message}`);
|
|
1515
|
+
}
|
|
1403
1516
|
}
|
|
1404
1517
|
|
|
1405
1518
|
// Public API
|
|
1406
1519
|
function setFormRoot(element) {
|
|
1407
|
-
|
|
1520
|
+
state.formRoot = element;
|
|
1408
1521
|
}
|
|
1409
1522
|
|
|
1410
1523
|
function configure(config) {
|
|
1411
|
-
|
|
1524
|
+
Object.assign(state.config, config);
|
|
1412
1525
|
}
|
|
1413
1526
|
|
|
1414
1527
|
function setUploadHandler(uploadFn) {
|
|
1415
|
-
|
|
1528
|
+
state.config.uploadFile = uploadFn;
|
|
1416
1529
|
}
|
|
1417
1530
|
|
|
1418
1531
|
function setDownloadHandler(downloadFn) {
|
|
1419
|
-
|
|
1532
|
+
state.config.downloadFile = downloadFn;
|
|
1420
1533
|
}
|
|
1421
1534
|
|
|
1422
1535
|
function setThumbnailHandler(thumbnailFn) {
|
|
1423
|
-
|
|
1536
|
+
state.config.getThumbnail = thumbnailFn;
|
|
1424
1537
|
}
|
|
1425
1538
|
|
|
1426
1539
|
function setMode(mode) {
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1540
|
+
if (mode === "readonly") {
|
|
1541
|
+
state.config.readonly = true;
|
|
1542
|
+
} else {
|
|
1543
|
+
state.config.readonly = false;
|
|
1544
|
+
}
|
|
1432
1545
|
}
|
|
1433
1546
|
|
|
1434
1547
|
function getFormData() {
|
|
1435
|
-
|
|
1548
|
+
return validateForm(false);
|
|
1436
1549
|
}
|
|
1437
1550
|
|
|
1438
1551
|
function submitForm() {
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
}
|
|
1552
|
+
const result = validateForm(false);
|
|
1553
|
+
if (result.valid) {
|
|
1554
|
+
// Emit event for successful submission
|
|
1555
|
+
if (typeof window !== "undefined" && window.parent) {
|
|
1556
|
+
window.parent.postMessage(
|
|
1557
|
+
{
|
|
1558
|
+
type: "formSubmit",
|
|
1559
|
+
data: result.data,
|
|
1560
|
+
schema: state.schema,
|
|
1561
|
+
},
|
|
1562
|
+
"*",
|
|
1563
|
+
);
|
|
1449
1564
|
}
|
|
1450
|
-
|
|
1565
|
+
}
|
|
1566
|
+
return result;
|
|
1451
1567
|
}
|
|
1452
1568
|
|
|
1453
1569
|
function saveDraft() {
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1570
|
+
const result = validateForm(true); // Skip validation for drafts
|
|
1571
|
+
// Emit event for draft save
|
|
1572
|
+
if (typeof window !== "undefined" && window.parent) {
|
|
1573
|
+
window.parent.postMessage(
|
|
1574
|
+
{
|
|
1575
|
+
type: "formDraft",
|
|
1576
|
+
data: result.data,
|
|
1577
|
+
schema: state.schema,
|
|
1578
|
+
},
|
|
1579
|
+
"*",
|
|
1580
|
+
);
|
|
1581
|
+
}
|
|
1582
|
+
return result;
|
|
1464
1583
|
}
|
|
1465
1584
|
|
|
1466
1585
|
function clearForm() {
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1586
|
+
if (state.formRoot) {
|
|
1587
|
+
clear(state.formRoot);
|
|
1588
|
+
}
|
|
1470
1589
|
}
|
|
1471
1590
|
|
|
1472
|
-
//
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1591
|
+
// Create the API object
|
|
1592
|
+
const formBuilderAPI = {
|
|
1593
|
+
setFormRoot,
|
|
1594
|
+
renderForm,
|
|
1595
|
+
configure,
|
|
1596
|
+
setUploadHandler,
|
|
1597
|
+
setDownloadHandler,
|
|
1598
|
+
setThumbnailHandler,
|
|
1599
|
+
setMode,
|
|
1600
|
+
getFormData,
|
|
1601
|
+
submitForm,
|
|
1602
|
+
saveDraft,
|
|
1603
|
+
clearForm,
|
|
1604
|
+
validateSchema,
|
|
1605
|
+
pretty,
|
|
1606
|
+
state,
|
|
1607
|
+
};
|
|
1608
|
+
|
|
1609
|
+
// Browser global export (existing behavior)
|
|
1610
|
+
if (typeof window !== "undefined") {
|
|
1611
|
+
window.FormBuilder = formBuilderAPI;
|
|
1490
1612
|
}
|
|
1613
|
+
|
|
1614
|
+
// ES6 Module exports for modern bundlers
|
|
1615
|
+
export {
|
|
1616
|
+
setFormRoot,
|
|
1617
|
+
renderForm,
|
|
1618
|
+
configure,
|
|
1619
|
+
setUploadHandler,
|
|
1620
|
+
setDownloadHandler,
|
|
1621
|
+
setThumbnailHandler,
|
|
1622
|
+
setMode,
|
|
1623
|
+
getFormData,
|
|
1624
|
+
submitForm,
|
|
1625
|
+
saveDraft,
|
|
1626
|
+
clearForm,
|
|
1627
|
+
validateSchema,
|
|
1628
|
+
pretty,
|
|
1629
|
+
state,
|
|
1630
|
+
};
|
|
1631
|
+
|
|
1632
|
+
// Default export
|
|
1633
|
+
export default formBuilderAPI;
|