@dmitryvim/form-builder 0.1.25 → 0.1.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/demo.js +314 -321
- package/dist/form-builder.js +772 -487
- package/dist/index.html +89 -129
- package/package.json +6 -2
package/dist/form-builder.js
CHANGED
|
@@ -15,6 +15,30 @@ const state = {
|
|
|
15
15
|
enableFilePreview: true,
|
|
16
16
|
maxPreviewSize: "200px",
|
|
17
17
|
readonly: false,
|
|
18
|
+
// Internationalization
|
|
19
|
+
locale: "en",
|
|
20
|
+
translations: {
|
|
21
|
+
en: {
|
|
22
|
+
addElement: "Add Element",
|
|
23
|
+
removeElement: "Remove",
|
|
24
|
+
uploadText: "Upload",
|
|
25
|
+
dragDropText: "or drag and drop files",
|
|
26
|
+
dragDropTextSingle: "or drag and drop file",
|
|
27
|
+
clickDragText: "Click or drag file",
|
|
28
|
+
noFileSelected: "No file selected",
|
|
29
|
+
noFilesSelected: "No files selected",
|
|
30
|
+
},
|
|
31
|
+
ru: {
|
|
32
|
+
addElement: "Добавить элемент",
|
|
33
|
+
removeElement: "Удалить",
|
|
34
|
+
uploadText: "Загрузите",
|
|
35
|
+
dragDropText: "или перетащите файлы",
|
|
36
|
+
dragDropTextSingle: "или перетащите файл",
|
|
37
|
+
clickDragText: "Нажмите или перетащите файл",
|
|
38
|
+
noFileSelected: "Файл не выбран",
|
|
39
|
+
noFilesSelected: "Нет файлов",
|
|
40
|
+
},
|
|
41
|
+
},
|
|
18
42
|
},
|
|
19
43
|
};
|
|
20
44
|
|
|
@@ -35,6 +59,14 @@ function clear(node) {
|
|
|
35
59
|
while (node.firstChild) node.removeChild(node.firstChild);
|
|
36
60
|
}
|
|
37
61
|
|
|
62
|
+
// Translation function
|
|
63
|
+
function t(key) {
|
|
64
|
+
const locale = state.config.locale || "en";
|
|
65
|
+
const translations =
|
|
66
|
+
state.config.translations[locale] || state.config.translations.en;
|
|
67
|
+
return translations[key] || key;
|
|
68
|
+
}
|
|
69
|
+
|
|
38
70
|
// Schema validation
|
|
39
71
|
function validateSchema(schema) {
|
|
40
72
|
const errors = [];
|
|
@@ -113,7 +145,7 @@ function renderForm(schema, prefill) {
|
|
|
113
145
|
const formEl = document.createElement("div");
|
|
114
146
|
formEl.className = "space-y-6";
|
|
115
147
|
|
|
116
|
-
schema.elements.forEach((element,
|
|
148
|
+
schema.elements.forEach((element, _index) => {
|
|
117
149
|
const block = renderElement(element, {
|
|
118
150
|
path: "",
|
|
119
151
|
prefill: prefill || {},
|
|
@@ -191,384 +223,23 @@ function renderElement(element, ctx) {
|
|
|
191
223
|
break;
|
|
192
224
|
|
|
193
225
|
case "file":
|
|
194
|
-
|
|
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 = `
|
|
255
|
-
<div class="flex flex-col items-center justify-center h-full text-gray-400">
|
|
256
|
-
<svg class="w-6 h-6 mb-2" fill="currentColor" viewBox="0 0 24 24">
|
|
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"/>
|
|
258
|
-
</svg>
|
|
259
|
-
<div class="text-sm text-center">Нажмите или перетащите файл</div>
|
|
260
|
-
</div>
|
|
261
|
-
`;
|
|
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
|
-
}
|
|
226
|
+
renderFileElement(element, ctx, wrapper, pathKey);
|
|
295
227
|
break;
|
|
296
228
|
|
|
297
229
|
case "files":
|
|
298
|
-
|
|
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
|
-
});
|
|
364
|
-
}
|
|
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
|
-
);
|
|
394
|
-
}
|
|
395
|
-
} catch (error) {
|
|
396
|
-
throw new Error(`File upload failed: ${error.message}`);
|
|
397
|
-
}
|
|
398
|
-
} else {
|
|
399
|
-
throw new Error(
|
|
400
|
-
"No upload handler configured. Set uploadHandler via FormBuilder.setUploadHandler()",
|
|
401
|
-
);
|
|
402
|
-
}
|
|
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
|
-
);
|
|
427
|
-
}
|
|
428
|
-
} catch (error) {
|
|
429
|
-
throw new Error(`File upload failed: ${error.message}`);
|
|
430
|
-
}
|
|
431
|
-
} else {
|
|
432
|
-
throw new Error(
|
|
433
|
-
"No upload handler configured. Set uploadHandler via FormBuilder.setUploadHandler()",
|
|
434
|
-
);
|
|
435
|
-
}
|
|
436
|
-
|
|
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
|
-
}
|
|
230
|
+
renderFilesElement(element, ctx, wrapper, pathKey);
|
|
463
231
|
break;
|
|
464
232
|
|
|
465
233
|
case "group":
|
|
466
|
-
|
|
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);
|
|
234
|
+
renderGroupElement(element, ctx, wrapper, pathKey);
|
|
565
235
|
break;
|
|
566
236
|
|
|
567
|
-
default:
|
|
237
|
+
default: {
|
|
568
238
|
const unsupported = document.createElement("div");
|
|
569
239
|
unsupported.className = "text-red-500 text-sm";
|
|
570
240
|
unsupported.textContent = `Unsupported field type: ${element.type}`;
|
|
571
241
|
wrapper.appendChild(unsupported);
|
|
242
|
+
}
|
|
572
243
|
}
|
|
573
244
|
|
|
574
245
|
return wrapper;
|
|
@@ -577,58 +248,75 @@ function renderElement(element, ctx) {
|
|
|
577
248
|
function makeFieldHint(element) {
|
|
578
249
|
const parts = [];
|
|
579
250
|
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
251
|
+
parts.push(element.required ? "required" : "optional");
|
|
252
|
+
|
|
253
|
+
addLengthHint(element, parts);
|
|
254
|
+
addRangeHint(element, parts);
|
|
255
|
+
addFileSizeHint(element, parts);
|
|
256
|
+
addFormatHint(element, parts);
|
|
257
|
+
addPatternHint(element, parts);
|
|
258
|
+
|
|
259
|
+
return parts.join(" • ");
|
|
260
|
+
}
|
|
585
261
|
|
|
586
|
-
|
|
587
|
-
|
|
262
|
+
function addLengthHint(element, parts) {
|
|
263
|
+
if (element.minLength !== null || element.maxLength !== null) {
|
|
264
|
+
if (element.minLength !== null && element.maxLength !== null) {
|
|
588
265
|
parts.push(`length=${element.minLength}-${element.maxLength} characters`);
|
|
589
|
-
} else if (element.maxLength
|
|
266
|
+
} else if (element.maxLength !== null) {
|
|
590
267
|
parts.push(`max=${element.maxLength} characters`);
|
|
591
|
-
} else if (element.minLength
|
|
268
|
+
} else if (element.minLength !== null) {
|
|
592
269
|
parts.push(`min=${element.minLength} characters`);
|
|
593
270
|
}
|
|
594
271
|
}
|
|
272
|
+
}
|
|
595
273
|
|
|
596
|
-
|
|
597
|
-
|
|
274
|
+
function addRangeHint(element, parts) {
|
|
275
|
+
if (element.min !== null || element.max !== null) {
|
|
276
|
+
if (element.min !== null && element.max !== null) {
|
|
598
277
|
parts.push(`range=${element.min}-${element.max}`);
|
|
599
|
-
} else if (element.max
|
|
278
|
+
} else if (element.max !== null) {
|
|
600
279
|
parts.push(`max=${element.max}`);
|
|
601
|
-
} else if (element.min
|
|
280
|
+
} else if (element.min !== null) {
|
|
602
281
|
parts.push(`min=${element.min}`);
|
|
603
282
|
}
|
|
604
283
|
}
|
|
284
|
+
}
|
|
605
285
|
|
|
286
|
+
function addFileSizeHint(element, parts) {
|
|
606
287
|
if (element.maxSizeMB) {
|
|
607
288
|
parts.push(`max_size=${element.maxSizeMB}MB`);
|
|
608
289
|
}
|
|
290
|
+
}
|
|
609
291
|
|
|
610
|
-
|
|
292
|
+
function addFormatHint(element, parts) {
|
|
293
|
+
if (element.accept?.extensions) {
|
|
611
294
|
parts.push(
|
|
612
295
|
`formats=${element.accept.extensions.map((ext) => ext.toUpperCase()).join(",")}`,
|
|
613
296
|
);
|
|
614
297
|
}
|
|
298
|
+
}
|
|
615
299
|
|
|
300
|
+
function addPatternHint(element, parts) {
|
|
616
301
|
if (element.pattern && !element.pattern.includes("А-Я")) {
|
|
617
302
|
parts.push("plain text only");
|
|
618
|
-
} else if (element.pattern
|
|
303
|
+
} else if (element.pattern?.includes("А-Я")) {
|
|
619
304
|
parts.push("text with punctuation");
|
|
620
305
|
}
|
|
621
|
-
|
|
622
|
-
return parts.join(" • ");
|
|
623
306
|
}
|
|
624
307
|
|
|
625
|
-
async function renderFilePreview(
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
)
|
|
308
|
+
async function renderFilePreview(container, resourceId, options = {}) {
|
|
309
|
+
const { fileName = "", isReadonly = false, deps = null } = options;
|
|
310
|
+
// Runtime validation for dependencies when not in readonly mode
|
|
311
|
+
if (
|
|
312
|
+
!isReadonly &&
|
|
313
|
+
deps &&
|
|
314
|
+
(!deps.picker || !deps.fileUploadHandler || !deps.dragHandler)
|
|
315
|
+
) {
|
|
316
|
+
throw new Error(
|
|
317
|
+
"renderFilePreview: missing deps {picker, fileUploadHandler, dragHandler}",
|
|
318
|
+
);
|
|
319
|
+
}
|
|
632
320
|
// Don't change container className - preserve max-w-xs and other styling
|
|
633
321
|
|
|
634
322
|
// Clear container content first
|
|
@@ -654,16 +342,85 @@ async function renderFilePreview(
|
|
|
654
342
|
};
|
|
655
343
|
reader.readAsDataURL(meta.file);
|
|
656
344
|
container.appendChild(img);
|
|
345
|
+
} else if (meta.type && meta.type.startsWith("video/")) {
|
|
346
|
+
// Video file - use object URL for preview
|
|
347
|
+
const videoUrl = URL.createObjectURL(meta.file);
|
|
348
|
+
|
|
349
|
+
// Remove all conflicting handlers to prevent interference with video controls
|
|
350
|
+
container.onclick = null;
|
|
351
|
+
|
|
352
|
+
// Remove drag and drop event listeners by cloning the element
|
|
353
|
+
const newContainer = container.cloneNode(false);
|
|
354
|
+
container.parentNode.replaceChild(newContainer, container);
|
|
355
|
+
container = newContainer;
|
|
356
|
+
|
|
357
|
+
container.innerHTML = `
|
|
358
|
+
<div class="relative group h-full">
|
|
359
|
+
<video class="w-full h-full object-contain" controls preload="auto" muted>
|
|
360
|
+
<source src="${videoUrl}" type="${meta.type}">
|
|
361
|
+
Your browser does not support the video tag.
|
|
362
|
+
</video>
|
|
363
|
+
<div class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity z-10 flex gap-1">
|
|
364
|
+
<button class="bg-red-600 bg-opacity-75 hover:bg-opacity-90 text-white p-1 rounded text-xs delete-file-btn">
|
|
365
|
+
${t("removeElement")}
|
|
366
|
+
</button>
|
|
367
|
+
<button class="bg-gray-800 bg-opacity-75 hover:bg-opacity-90 text-white p-1 rounded text-xs change-file-btn">
|
|
368
|
+
Change
|
|
369
|
+
</button>
|
|
370
|
+
</div>
|
|
371
|
+
</div>
|
|
372
|
+
`;
|
|
373
|
+
|
|
374
|
+
// Add click handlers to the custom buttons
|
|
375
|
+
const changeBtn = container.querySelector(".change-file-btn");
|
|
376
|
+
if (changeBtn) {
|
|
377
|
+
changeBtn.onclick = (e) => {
|
|
378
|
+
e.stopPropagation();
|
|
379
|
+
if (deps?.picker) {
|
|
380
|
+
deps.picker.click();
|
|
381
|
+
}
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const deleteBtn = container.querySelector(".delete-file-btn");
|
|
386
|
+
if (deleteBtn) {
|
|
387
|
+
deleteBtn.onclick = (e) => {
|
|
388
|
+
e.stopPropagation();
|
|
389
|
+
// Clear the file
|
|
390
|
+
state.resourceIndex.delete(resourceId);
|
|
391
|
+
// Update hidden input
|
|
392
|
+
const hiddenInput = container.parentElement.querySelector(
|
|
393
|
+
'input[type="hidden"]',
|
|
394
|
+
);
|
|
395
|
+
if (hiddenInput) {
|
|
396
|
+
hiddenInput.value = "";
|
|
397
|
+
}
|
|
398
|
+
// Clear preview and show placeholder
|
|
399
|
+
if (deps?.fileUploadHandler) {
|
|
400
|
+
container.onclick = deps.fileUploadHandler;
|
|
401
|
+
}
|
|
402
|
+
if (deps?.dragHandler) {
|
|
403
|
+
setupDragAndDrop(container, deps.dragHandler);
|
|
404
|
+
}
|
|
405
|
+
container.innerHTML = `
|
|
406
|
+
<div class="flex flex-col items-center justify-center h-full text-gray-400">
|
|
407
|
+
<svg class="w-6 h-6 mb-2" fill="currentColor" viewBox="0 0 24 24">
|
|
408
|
+
<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"/>
|
|
409
|
+
</svg>
|
|
410
|
+
<div class="text-sm text-center">${t("clickDragText")}</div>
|
|
411
|
+
</div>
|
|
412
|
+
`;
|
|
413
|
+
};
|
|
414
|
+
}
|
|
657
415
|
} else {
|
|
658
|
-
// Non-image file
|
|
659
|
-
container.innerHTML =
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
"</div></div>";
|
|
416
|
+
// Non-image, non-video file
|
|
417
|
+
container.innerHTML = `<div class="flex flex-col items-center justify-center h-full text-gray-400"><div class="text-2xl mb-2">📁</div><div class="text-sm">${
|
|
418
|
+
fileName
|
|
419
|
+
}</div></div>`;
|
|
663
420
|
}
|
|
664
421
|
|
|
665
|
-
// Add delete button for edit mode
|
|
666
|
-
if (!isReadonly) {
|
|
422
|
+
// Add delete button for edit mode (except for videos which have custom buttons)
|
|
423
|
+
if (!isReadonly && !(meta && meta.type && meta.type.startsWith("video/"))) {
|
|
667
424
|
addDeleteButton(container, () => {
|
|
668
425
|
// Clear the file
|
|
669
426
|
state.resourceIndex.delete(resourceId);
|
|
@@ -680,7 +437,7 @@ async function renderFilePreview(
|
|
|
680
437
|
<svg class="w-6 h-6 mb-2" fill="currentColor" viewBox="0 0 24 24">
|
|
681
438
|
<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"/>
|
|
682
439
|
</svg>
|
|
683
|
-
<div class="text-sm text-center"
|
|
440
|
+
<div class="text-sm text-center">${t("clickDragText")}</div>
|
|
684
441
|
</div>
|
|
685
442
|
`;
|
|
686
443
|
});
|
|
@@ -695,17 +452,15 @@ async function renderFilePreview(
|
|
|
695
452
|
container.appendChild(img);
|
|
696
453
|
} else {
|
|
697
454
|
// Fallback to file icon
|
|
698
|
-
container.innerHTML =
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
"</div></div>";
|
|
455
|
+
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">${
|
|
456
|
+
fileName
|
|
457
|
+
}</div></div>`;
|
|
702
458
|
}
|
|
703
459
|
} catch (error) {
|
|
704
460
|
console.warn("Thumbnail loading failed:", error);
|
|
705
|
-
container.innerHTML =
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
"</div></div>";
|
|
461
|
+
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">${
|
|
462
|
+
fileName
|
|
463
|
+
}</div></div>`;
|
|
709
464
|
}
|
|
710
465
|
|
|
711
466
|
// Add delete button for edit mode
|
|
@@ -726,17 +481,16 @@ async function renderFilePreview(
|
|
|
726
481
|
<svg class="w-6 h-6 mb-2" fill="currentColor" viewBox="0 0 24 24">
|
|
727
482
|
<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"/>
|
|
728
483
|
</svg>
|
|
729
|
-
<div class="text-sm text-center"
|
|
484
|
+
<div class="text-sm text-center">${t("clickDragText")}</div>
|
|
730
485
|
</div>
|
|
731
486
|
`;
|
|
732
487
|
});
|
|
733
488
|
}
|
|
734
489
|
} else {
|
|
735
490
|
// No file and no getThumbnail config - fallback
|
|
736
|
-
container.innerHTML =
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
"</div></div>";
|
|
491
|
+
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">${
|
|
492
|
+
fileName
|
|
493
|
+
}</div></div>`;
|
|
740
494
|
}
|
|
741
495
|
|
|
742
496
|
// Add click handler for download in readonly mode
|
|
@@ -802,7 +556,7 @@ function renderResourcePills(container, rids, onRemove) {
|
|
|
802
556
|
|
|
803
557
|
const uploadLink = document.createElement("span");
|
|
804
558
|
uploadLink.className = "underline cursor-pointer";
|
|
805
|
-
uploadLink.textContent = "
|
|
559
|
+
uploadLink.textContent = t("uploadText");
|
|
806
560
|
uploadLink.onclick = (e) => {
|
|
807
561
|
e.stopPropagation();
|
|
808
562
|
// Look for file input in the files wrapper (go up from list -> filesContainer -> filesWrapper)
|
|
@@ -812,7 +566,7 @@ function renderResourcePills(container, rids, onRemove) {
|
|
|
812
566
|
};
|
|
813
567
|
|
|
814
568
|
textContainer.appendChild(uploadLink);
|
|
815
|
-
textContainer.appendChild(document.createTextNode(
|
|
569
|
+
textContainer.appendChild(document.createTextNode(` ${t("dragDropText")}`));
|
|
816
570
|
|
|
817
571
|
// Clear and append
|
|
818
572
|
container.appendChild(gridContainer);
|
|
@@ -822,7 +576,8 @@ function renderResourcePills(container, rids, onRemove) {
|
|
|
822
576
|
|
|
823
577
|
// Always show files grid if we have files OR if this was already a grid
|
|
824
578
|
// This prevents shrinking when deleting the last file
|
|
825
|
-
|
|
579
|
+
// Preserve the original "files-list" class and add grid classes
|
|
580
|
+
container.className = "files-list grid grid-cols-4 gap-3 mt-2";
|
|
826
581
|
|
|
827
582
|
// Calculate how many slots we need (at least 4, then expand by rows of 4)
|
|
828
583
|
const currentImagesCount = rids ? rids.length : 0;
|
|
@@ -840,9 +595,10 @@ function renderResourcePills(container, rids, onRemove) {
|
|
|
840
595
|
const rid = rids[i];
|
|
841
596
|
const meta = state.resourceIndex.get(rid);
|
|
842
597
|
slot.className =
|
|
843
|
-
"aspect-square bg-gray-100 rounded-lg overflow-hidden relative group border border-gray-300";
|
|
598
|
+
"resource-pill aspect-square bg-gray-100 rounded-lg overflow-hidden relative group border border-gray-300";
|
|
599
|
+
slot.dataset.resourceId = rid;
|
|
844
600
|
|
|
845
|
-
// Add image or file content
|
|
601
|
+
// Add image, video, or file content
|
|
846
602
|
if (meta && meta.type?.startsWith("image/")) {
|
|
847
603
|
if (meta.file && meta.file instanceof File) {
|
|
848
604
|
// Use FileReader for local files
|
|
@@ -880,6 +636,58 @@ function renderResourcePills(container, rids, onRemove) {
|
|
|
880
636
|
</svg>
|
|
881
637
|
</div>`;
|
|
882
638
|
}
|
|
639
|
+
} else if (meta && meta.type?.startsWith("video/")) {
|
|
640
|
+
if (meta.file && meta.file instanceof File) {
|
|
641
|
+
// Video file - use object URL for preview in thumbnail format
|
|
642
|
+
const videoUrl = URL.createObjectURL(meta.file);
|
|
643
|
+
slot.innerHTML = `
|
|
644
|
+
<div class="relative group h-full w-full">
|
|
645
|
+
<video class="w-full h-full object-contain" preload="metadata" muted>
|
|
646
|
+
<source src="${videoUrl}" type="${meta.type}">
|
|
647
|
+
</video>
|
|
648
|
+
<div class="absolute inset-0 bg-black bg-opacity-30 flex items-center justify-center">
|
|
649
|
+
<div class="bg-white bg-opacity-90 rounded-full p-1">
|
|
650
|
+
<svg class="w-4 h-4 text-gray-800" fill="currentColor" viewBox="0 0 24 24">
|
|
651
|
+
<path d="M8 5v14l11-7z"/>
|
|
652
|
+
</svg>
|
|
653
|
+
</div>
|
|
654
|
+
</div>
|
|
655
|
+
</div>
|
|
656
|
+
`;
|
|
657
|
+
} else if (state.config.getThumbnail) {
|
|
658
|
+
// Use getThumbnail for uploaded video files
|
|
659
|
+
const videoUrl = state.config.getThumbnail(rid);
|
|
660
|
+
if (videoUrl) {
|
|
661
|
+
slot.innerHTML = `
|
|
662
|
+
<div class="relative group h-full w-full">
|
|
663
|
+
<video class="w-full h-full object-contain" preload="metadata" muted>
|
|
664
|
+
<source src="${videoUrl}" type="${meta.type}">
|
|
665
|
+
</video>
|
|
666
|
+
<div class="absolute inset-0 bg-black bg-opacity-30 flex items-center justify-center">
|
|
667
|
+
<div class="bg-white bg-opacity-90 rounded-full p-1">
|
|
668
|
+
<svg class="w-4 h-4 text-gray-800" fill="currentColor" viewBox="0 0 24 24">
|
|
669
|
+
<path d="M8 5v14l11-7z"/>
|
|
670
|
+
</svg>
|
|
671
|
+
</div>
|
|
672
|
+
</div>
|
|
673
|
+
</div>
|
|
674
|
+
`;
|
|
675
|
+
} else {
|
|
676
|
+
slot.innerHTML = `<div class="flex flex-col items-center justify-center h-full text-gray-400">
|
|
677
|
+
<svg class="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
|
|
678
|
+
<path d="M8 5v14l11-7z"/>
|
|
679
|
+
</svg>
|
|
680
|
+
<div class="text-xs mt-1">${meta?.name || "Video"}</div>
|
|
681
|
+
</div>`;
|
|
682
|
+
}
|
|
683
|
+
} else {
|
|
684
|
+
slot.innerHTML = `<div class="flex flex-col items-center justify-center h-full text-gray-400">
|
|
685
|
+
<svg class="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
|
|
686
|
+
<path d="M8 5v14l11-7z"/>
|
|
687
|
+
</svg>
|
|
688
|
+
<div class="text-xs mt-1">${meta?.name || "Video"}</div>
|
|
689
|
+
</div>`;
|
|
690
|
+
}
|
|
883
691
|
} else {
|
|
884
692
|
slot.innerHTML = `<div class="flex flex-col items-center justify-center h-full text-gray-400">
|
|
885
693
|
<div class="text-2xl mb-1">📁</div>
|
|
@@ -895,7 +703,7 @@ function renderResourcePills(container, rids, onRemove) {
|
|
|
895
703
|
|
|
896
704
|
const removeBtn = document.createElement("button");
|
|
897
705
|
removeBtn.className = "bg-red-600 text-white px-2 py-1 rounded text-xs";
|
|
898
|
-
removeBtn.textContent = "
|
|
706
|
+
removeBtn.textContent = t("removeElement");
|
|
899
707
|
removeBtn.onclick = (e) => {
|
|
900
708
|
e.stopPropagation();
|
|
901
709
|
onRemove(rid);
|
|
@@ -922,21 +730,22 @@ function renderResourcePills(container, rids, onRemove) {
|
|
|
922
730
|
}
|
|
923
731
|
}
|
|
924
732
|
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
}
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
733
|
+
// Utility functions (currently unused but may be needed in future)
|
|
734
|
+
// function formatFileSize(bytes) {
|
|
735
|
+
// if (bytes === 0) return "0 B";
|
|
736
|
+
// const k = 1024;
|
|
737
|
+
// const sizes = ["B", "KB", "MB", "GB"];
|
|
738
|
+
// const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
739
|
+
// return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
|
740
|
+
// }
|
|
741
|
+
|
|
742
|
+
// function generateResourceId() {
|
|
743
|
+
// return (
|
|
744
|
+
// `res_${Math.random().toString(36).substr(2, 9)}${Date.now().toString(36)}`
|
|
745
|
+
// );
|
|
746
|
+
// }
|
|
747
|
+
|
|
748
|
+
async function handleFileSelect(file, container, fieldName, deps = null) {
|
|
940
749
|
let rid;
|
|
941
750
|
|
|
942
751
|
// If uploadHandler is configured, use it to upload the file
|
|
@@ -959,7 +768,7 @@ async function handleFileSelect(file, container, fieldName) {
|
|
|
959
768
|
name: file.name,
|
|
960
769
|
type: file.type,
|
|
961
770
|
size: file.size,
|
|
962
|
-
file
|
|
771
|
+
file, // Store the file object for local preview
|
|
963
772
|
});
|
|
964
773
|
|
|
965
774
|
// Create hidden input to store the resource ID
|
|
@@ -974,9 +783,11 @@ async function handleFileSelect(file, container, fieldName) {
|
|
|
974
783
|
}
|
|
975
784
|
hiddenInput.value = rid;
|
|
976
785
|
|
|
977
|
-
renderFilePreview(container, rid,
|
|
978
|
-
|
|
979
|
-
|
|
786
|
+
renderFilePreview(container, rid, {
|
|
787
|
+
fileName: file.name,
|
|
788
|
+
isReadonly: false,
|
|
789
|
+
deps,
|
|
790
|
+
}).catch(console.error);
|
|
980
791
|
}
|
|
981
792
|
|
|
982
793
|
function setupDragAndDrop(element, dropHandler) {
|
|
@@ -1012,7 +823,7 @@ function addDeleteButton(container, onDelete) {
|
|
|
1012
823
|
const deleteBtn = document.createElement("button");
|
|
1013
824
|
deleteBtn.className =
|
|
1014
825
|
"bg-red-600 text-white px-3 py-1 rounded text-sm hover:bg-red-700 transition-colors";
|
|
1015
|
-
deleteBtn.textContent = "
|
|
826
|
+
deleteBtn.textContent = t("removeElement");
|
|
1016
827
|
deleteBtn.onclick = (e) => {
|
|
1017
828
|
e.stopPropagation();
|
|
1018
829
|
onDelete();
|
|
@@ -1077,8 +888,8 @@ function showTooltip(tooltipId, button) {
|
|
|
1077
888
|
top = rect.bottom + 5;
|
|
1078
889
|
}
|
|
1079
890
|
|
|
1080
|
-
tooltip.style.left = left
|
|
1081
|
-
tooltip.style.top = top
|
|
891
|
+
tooltip.style.left = `${left}px`;
|
|
892
|
+
tooltip.style.top = `${top}px`;
|
|
1082
893
|
|
|
1083
894
|
// Show the tooltip
|
|
1084
895
|
tooltip.classList.remove("hidden");
|
|
@@ -1091,7 +902,7 @@ function showTooltip(tooltipId, button) {
|
|
|
1091
902
|
|
|
1092
903
|
// Close tooltips when clicking outside (only in browser)
|
|
1093
904
|
if (typeof document !== "undefined") {
|
|
1094
|
-
document.addEventListener("click",
|
|
905
|
+
document.addEventListener("click", (e) => {
|
|
1095
906
|
const isInfoButton =
|
|
1096
907
|
e.target.closest("button") && e.target.closest("button").onclick;
|
|
1097
908
|
const isTooltip = e.target.closest('[id^="tooltip-"]');
|
|
@@ -1122,9 +933,9 @@ function validateForm(skipValidation = false) {
|
|
|
1122
933
|
}
|
|
1123
934
|
}
|
|
1124
935
|
|
|
1125
|
-
function validateElement(element, ctx) {
|
|
936
|
+
function validateElement(element, ctx, customScopeRoot = null) {
|
|
1126
937
|
const key = element.key;
|
|
1127
|
-
const scopeRoot = state.formRoot;
|
|
938
|
+
const scopeRoot = customScopeRoot || state.formRoot;
|
|
1128
939
|
|
|
1129
940
|
switch (element.type) {
|
|
1130
941
|
case "text":
|
|
@@ -1137,11 +948,11 @@ function validateForm(skipValidation = false) {
|
|
|
1137
948
|
return "";
|
|
1138
949
|
}
|
|
1139
950
|
if (!skipValidation && val) {
|
|
1140
|
-
if (element.minLength
|
|
951
|
+
if (element.minLength !== null && val.length < element.minLength) {
|
|
1141
952
|
errors.push(`${key}: minLength=${element.minLength}`);
|
|
1142
953
|
markValidity(input, `minLength=${element.minLength}`);
|
|
1143
954
|
}
|
|
1144
|
-
if (element.maxLength
|
|
955
|
+
if (element.maxLength !== null && val.length > element.maxLength) {
|
|
1145
956
|
errors.push(`${key}: maxLength=${element.maxLength}`);
|
|
1146
957
|
markValidity(input, `maxLength=${element.maxLength}`);
|
|
1147
958
|
}
|
|
@@ -1182,11 +993,11 @@ function validateForm(skipValidation = false) {
|
|
|
1182
993
|
markValidity(input, "not a number");
|
|
1183
994
|
return null;
|
|
1184
995
|
}
|
|
1185
|
-
if (!skipValidation && element.min
|
|
996
|
+
if (!skipValidation && element.min !== null && v < element.min) {
|
|
1186
997
|
errors.push(`${key}: < min=${element.min}`);
|
|
1187
998
|
markValidity(input, `< min=${element.min}`);
|
|
1188
999
|
}
|
|
1189
|
-
if (!skipValidation && element.max
|
|
1000
|
+
if (!skipValidation && element.max !== null && v > element.max) {
|
|
1190
1001
|
errors.push(`${key}: > max=${element.max}`);
|
|
1191
1002
|
markValidity(input, `> max=${element.max}`);
|
|
1192
1003
|
}
|
|
@@ -1220,14 +1031,42 @@ function validateForm(skipValidation = false) {
|
|
|
1220
1031
|
}
|
|
1221
1032
|
case "files": {
|
|
1222
1033
|
// For files, we need to collect all resource IDs
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1034
|
+
// Find the correct .files-list by looking for one that's associated with this field
|
|
1035
|
+
let container = null;
|
|
1036
|
+
|
|
1037
|
+
// Strategy 1: Try to find via the input element
|
|
1038
|
+
const nameElement = scopeRoot.querySelector(`[name="${key}"]`);
|
|
1039
|
+
if (nameElement) {
|
|
1040
|
+
// Look for .files-list in the input's parent container
|
|
1041
|
+
container = nameElement.parentElement?.querySelector(".files-list");
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
// Strategy 2: If we have multiple .files-list elements, this gets tricky
|
|
1045
|
+
// For now, let's use a simpler approach for the demo: just find the first .files-list
|
|
1046
|
+
// that has resource pills (since our demo only has one files field)
|
|
1047
|
+
if (!container) {
|
|
1048
|
+
const allFilesLists = scopeRoot.querySelectorAll(".files-list");
|
|
1049
|
+
for (const filesList of allFilesLists) {
|
|
1050
|
+
const pillCount =
|
|
1051
|
+
filesList.querySelectorAll(".resource-pill").length;
|
|
1052
|
+
if (pillCount > 0) {
|
|
1053
|
+
container = filesList;
|
|
1054
|
+
break;
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1226
1059
|
const rids = [];
|
|
1227
1060
|
if (container) {
|
|
1228
|
-
|
|
1229
|
-
|
|
1061
|
+
const resourcePills = container.querySelectorAll(".resource-pill");
|
|
1062
|
+
resourcePills.forEach((pill, _index) => {
|
|
1063
|
+
const resourceId = pill.dataset.resourceId;
|
|
1064
|
+
if (resourceId) {
|
|
1065
|
+
rids.push(resourceId);
|
|
1066
|
+
}
|
|
1067
|
+
});
|
|
1230
1068
|
}
|
|
1069
|
+
|
|
1231
1070
|
return rids;
|
|
1232
1071
|
}
|
|
1233
1072
|
case "group": {
|
|
@@ -1241,11 +1080,16 @@ function validateForm(skipValidation = false) {
|
|
|
1241
1080
|
|
|
1242
1081
|
for (let i = 0; i < itemCount; i++) {
|
|
1243
1082
|
const itemData = {};
|
|
1083
|
+
// Find the specific group item container for scoped queries
|
|
1084
|
+
const itemContainer =
|
|
1085
|
+
scopeRoot.querySelector(`[data-group-item="${key}[${i}]"]`) ||
|
|
1086
|
+
scopeRoot;
|
|
1244
1087
|
element.elements.forEach((child) => {
|
|
1245
1088
|
const childKey = `${key}[${i}].${child.key}`;
|
|
1246
1089
|
itemData[child.key] = validateElement(
|
|
1247
1090
|
{ ...child, key: childKey },
|
|
1248
1091
|
ctx,
|
|
1092
|
+
itemContainer,
|
|
1249
1093
|
);
|
|
1250
1094
|
});
|
|
1251
1095
|
items.push(itemData);
|
|
@@ -1253,11 +1097,15 @@ function validateForm(skipValidation = false) {
|
|
|
1253
1097
|
return items;
|
|
1254
1098
|
} else {
|
|
1255
1099
|
const groupData = {};
|
|
1100
|
+
// Find the specific group container for scoped queries
|
|
1101
|
+
const groupContainer =
|
|
1102
|
+
scopeRoot.querySelector(`[data-group="${key}"]`) || scopeRoot;
|
|
1256
1103
|
element.elements.forEach((child) => {
|
|
1257
1104
|
const childKey = `${key}.${child.key}`;
|
|
1258
1105
|
groupData[child.key] = validateElement(
|
|
1259
1106
|
{ ...child, key: childKey },
|
|
1260
1107
|
ctx,
|
|
1108
|
+
groupContainer,
|
|
1261
1109
|
);
|
|
1262
1110
|
});
|
|
1263
1111
|
return groupData;
|
|
@@ -1363,10 +1211,429 @@ function renderSelectElement(element, ctx, wrapper, pathKey) {
|
|
|
1363
1211
|
wrapper.appendChild(selectHint);
|
|
1364
1212
|
}
|
|
1365
1213
|
|
|
1214
|
+
function renderFileElement(element, ctx, wrapper, pathKey) {
|
|
1215
|
+
if (state.config.readonly) {
|
|
1216
|
+
// Readonly mode: use common preview function
|
|
1217
|
+
const initial = ctx.prefill[element.key] || element.default;
|
|
1218
|
+
if (initial) {
|
|
1219
|
+
const filePreview = renderFilePreviewReadonly(initial);
|
|
1220
|
+
wrapper.appendChild(filePreview);
|
|
1221
|
+
} else {
|
|
1222
|
+
const emptyState = document.createElement("div");
|
|
1223
|
+
emptyState.className =
|
|
1224
|
+
"aspect-video bg-gray-100 rounded-lg flex items-center justify-center text-gray-500";
|
|
1225
|
+
emptyState.innerHTML = `<div class="text-center">${t("noFileSelected")}</div>`;
|
|
1226
|
+
wrapper.appendChild(emptyState);
|
|
1227
|
+
}
|
|
1228
|
+
} else {
|
|
1229
|
+
// Edit mode: normal file input
|
|
1230
|
+
const fileWrapper = document.createElement("div");
|
|
1231
|
+
fileWrapper.className = "space-y-2";
|
|
1232
|
+
|
|
1233
|
+
const picker = document.createElement("input");
|
|
1234
|
+
picker.type = "file";
|
|
1235
|
+
picker.name = pathKey;
|
|
1236
|
+
picker.style.display = "none"; // Hide default input
|
|
1237
|
+
if (element.accept?.extensions) {
|
|
1238
|
+
picker.accept = element.accept.extensions
|
|
1239
|
+
.map((ext) => `.${ext}`)
|
|
1240
|
+
.join(",");
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
const fileContainer = document.createElement("div");
|
|
1244
|
+
fileContainer.className =
|
|
1245
|
+
"file-preview-container w-full aspect-square max-w-xs bg-gray-100 rounded-lg overflow-hidden relative group cursor-pointer";
|
|
1246
|
+
|
|
1247
|
+
const initial = ctx.prefill[element.key] || element.default;
|
|
1248
|
+
|
|
1249
|
+
// Set up click and drag handlers
|
|
1250
|
+
const fileUploadHandler = () => picker.click();
|
|
1251
|
+
const dragHandler = (files) => {
|
|
1252
|
+
if (files.length > 0) {
|
|
1253
|
+
const deps = { picker, fileUploadHandler, dragHandler };
|
|
1254
|
+
handleFileSelect(files[0], fileContainer, pathKey, deps);
|
|
1255
|
+
}
|
|
1256
|
+
};
|
|
1257
|
+
|
|
1258
|
+
// Handle initial prefill data
|
|
1259
|
+
if (initial) {
|
|
1260
|
+
handleInitialFileData(initial, fileContainer, pathKey, fileWrapper, {
|
|
1261
|
+
picker,
|
|
1262
|
+
fileUploadHandler,
|
|
1263
|
+
dragHandler,
|
|
1264
|
+
});
|
|
1265
|
+
} else {
|
|
1266
|
+
setEmptyFileContainer(fileContainer);
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
fileContainer.onclick = fileUploadHandler;
|
|
1270
|
+
setupDragAndDrop(fileContainer, dragHandler);
|
|
1271
|
+
|
|
1272
|
+
picker.onchange = () => {
|
|
1273
|
+
if (picker.files.length > 0) {
|
|
1274
|
+
const deps = { picker, fileUploadHandler, dragHandler };
|
|
1275
|
+
handleFileSelect(picker.files[0], fileContainer, pathKey, deps);
|
|
1276
|
+
}
|
|
1277
|
+
};
|
|
1278
|
+
|
|
1279
|
+
fileWrapper.appendChild(fileContainer);
|
|
1280
|
+
fileWrapper.appendChild(picker);
|
|
1281
|
+
|
|
1282
|
+
// Add upload text
|
|
1283
|
+
const uploadText = document.createElement("p");
|
|
1284
|
+
uploadText.className = "text-xs text-gray-600 mt-2 text-center";
|
|
1285
|
+
uploadText.innerHTML = `<span class="underline cursor-pointer">${t("uploadText")}</span> ${t("dragDropTextSingle")}`;
|
|
1286
|
+
uploadText.querySelector("span").onclick = () => picker.click();
|
|
1287
|
+
fileWrapper.appendChild(uploadText);
|
|
1288
|
+
|
|
1289
|
+
// Add hint
|
|
1290
|
+
const fileHint = document.createElement("p");
|
|
1291
|
+
fileHint.className = "text-xs text-gray-500 mt-1 text-center";
|
|
1292
|
+
fileHint.textContent = makeFieldHint(element);
|
|
1293
|
+
fileWrapper.appendChild(fileHint);
|
|
1294
|
+
|
|
1295
|
+
wrapper.appendChild(fileWrapper);
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
function handleInitialFileData(
|
|
1300
|
+
initial,
|
|
1301
|
+
fileContainer,
|
|
1302
|
+
pathKey,
|
|
1303
|
+
fileWrapper,
|
|
1304
|
+
deps,
|
|
1305
|
+
) {
|
|
1306
|
+
// Add prefill data to resourceIndex so renderFilePreview can use it
|
|
1307
|
+
if (!state.resourceIndex.has(initial)) {
|
|
1308
|
+
// Extract filename from URL/path
|
|
1309
|
+
const filename = initial.split("/").pop() || "file";
|
|
1310
|
+
// Determine file type from extension
|
|
1311
|
+
const extension = filename.split(".").pop()?.toLowerCase();
|
|
1312
|
+
let fileType = "application/octet-stream";
|
|
1313
|
+
|
|
1314
|
+
if (extension) {
|
|
1315
|
+
if (["jpg", "jpeg", "png", "gif", "webp"].includes(extension)) {
|
|
1316
|
+
fileType = `image/${extension === "jpg" ? "jpeg" : extension}`;
|
|
1317
|
+
} else if (["mp4", "webm", "mov", "avi"].includes(extension)) {
|
|
1318
|
+
fileType = `video/${extension === "mov" ? "quicktime" : extension}`;
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
state.resourceIndex.set(initial, {
|
|
1323
|
+
name: filename,
|
|
1324
|
+
type: fileType,
|
|
1325
|
+
size: 0,
|
|
1326
|
+
file: null, // No local file for prefill data
|
|
1327
|
+
});
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
renderFilePreview(fileContainer, initial, {
|
|
1331
|
+
fileName: initial,
|
|
1332
|
+
isReadonly: false,
|
|
1333
|
+
deps,
|
|
1334
|
+
}).catch(console.error);
|
|
1335
|
+
|
|
1336
|
+
// Create hidden input to store the prefilled resource ID
|
|
1337
|
+
const hiddenInput = document.createElement("input");
|
|
1338
|
+
hiddenInput.type = "hidden";
|
|
1339
|
+
hiddenInput.name = pathKey;
|
|
1340
|
+
hiddenInput.value = initial;
|
|
1341
|
+
fileWrapper.appendChild(hiddenInput);
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
function setEmptyFileContainer(fileContainer) {
|
|
1345
|
+
fileContainer.innerHTML = `
|
|
1346
|
+
<div class="flex flex-col items-center justify-center h-full text-gray-400">
|
|
1347
|
+
<svg class="w-6 h-6 mb-2" fill="currentColor" viewBox="0 0 24 24">
|
|
1348
|
+
<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"/>
|
|
1349
|
+
</svg>
|
|
1350
|
+
<div class="text-sm text-center">${t("clickDragText")}</div>
|
|
1351
|
+
</div>
|
|
1352
|
+
`;
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
function renderFilesElement(element, ctx, wrapper, pathKey) {
|
|
1356
|
+
if (state.config.readonly) {
|
|
1357
|
+
// Readonly mode: render as results list like in workflow-preview.html
|
|
1358
|
+
const resultsWrapper = document.createElement("div");
|
|
1359
|
+
resultsWrapper.className = "space-y-4";
|
|
1360
|
+
|
|
1361
|
+
const initialFiles = ctx.prefill[element.key] || [];
|
|
1362
|
+
|
|
1363
|
+
if (initialFiles.length > 0) {
|
|
1364
|
+
initialFiles.forEach((resourceId) => {
|
|
1365
|
+
const filePreview = renderFilePreviewReadonly(resourceId);
|
|
1366
|
+
resultsWrapper.appendChild(filePreview);
|
|
1367
|
+
});
|
|
1368
|
+
} else {
|
|
1369
|
+
resultsWrapper.innerHTML = `<div class="aspect-video bg-gray-100 rounded-lg flex items-center justify-center text-gray-500"><div class="text-center">${t("noFilesSelected")}</div></div>`;
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
wrapper.appendChild(resultsWrapper);
|
|
1373
|
+
} else {
|
|
1374
|
+
// Edit mode: normal files input
|
|
1375
|
+
const filesWrapper = document.createElement("div");
|
|
1376
|
+
filesWrapper.className = "space-y-2";
|
|
1377
|
+
|
|
1378
|
+
const filesPicker = document.createElement("input");
|
|
1379
|
+
filesPicker.type = "file";
|
|
1380
|
+
filesPicker.name = pathKey;
|
|
1381
|
+
filesPicker.multiple = true;
|
|
1382
|
+
filesPicker.style.display = "none"; // Hide default input
|
|
1383
|
+
if (element.accept?.extensions) {
|
|
1384
|
+
filesPicker.accept = element.accept.extensions
|
|
1385
|
+
.map((ext) => `.${ext}`)
|
|
1386
|
+
.join(",");
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
// Create container with border like in workflow-preview
|
|
1390
|
+
const filesContainer = document.createElement("div");
|
|
1391
|
+
filesContainer.className =
|
|
1392
|
+
"border-2 border-dashed border-gray-300 rounded-lg p-3 hover:border-gray-400 transition-colors";
|
|
1393
|
+
|
|
1394
|
+
const list = document.createElement("div");
|
|
1395
|
+
list.className = "files-list";
|
|
1396
|
+
|
|
1397
|
+
const initialFiles = ctx.prefill[element.key] || [];
|
|
1398
|
+
|
|
1399
|
+
// Add prefill files to resourceIndex so renderResourcePills can use them
|
|
1400
|
+
addPrefillFilesToIndex(initialFiles);
|
|
1401
|
+
|
|
1402
|
+
function updateFilesList() {
|
|
1403
|
+
renderResourcePills(list, initialFiles, (ridToRemove) => {
|
|
1404
|
+
const index = initialFiles.indexOf(ridToRemove);
|
|
1405
|
+
if (index > -1) {
|
|
1406
|
+
initialFiles.splice(index, 1);
|
|
1407
|
+
}
|
|
1408
|
+
updateFilesList(); // Re-render after removal
|
|
1409
|
+
});
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
// Initial render
|
|
1413
|
+
updateFilesList();
|
|
1414
|
+
|
|
1415
|
+
setupFilesDropHandler(filesContainer, initialFiles, updateFilesList);
|
|
1416
|
+
setupFilesPickerHandler(filesPicker, initialFiles, updateFilesList);
|
|
1417
|
+
|
|
1418
|
+
filesContainer.appendChild(list);
|
|
1419
|
+
filesWrapper.appendChild(filesContainer);
|
|
1420
|
+
filesWrapper.appendChild(filesPicker);
|
|
1421
|
+
|
|
1422
|
+
// Add hint
|
|
1423
|
+
const filesHint = document.createElement("p");
|
|
1424
|
+
filesHint.className = "text-xs text-gray-500 mt-1 text-center";
|
|
1425
|
+
filesHint.textContent = makeFieldHint(element);
|
|
1426
|
+
filesWrapper.appendChild(filesHint);
|
|
1427
|
+
|
|
1428
|
+
wrapper.appendChild(filesWrapper);
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
function addPrefillFilesToIndex(initialFiles) {
|
|
1433
|
+
if (initialFiles.length > 0) {
|
|
1434
|
+
initialFiles.forEach((resourceId) => {
|
|
1435
|
+
if (!state.resourceIndex.has(resourceId)) {
|
|
1436
|
+
// Extract filename from URL/path
|
|
1437
|
+
const filename = resourceId.split("/").pop() || "file";
|
|
1438
|
+
// Determine file type from extension
|
|
1439
|
+
const extension = filename.split(".").pop()?.toLowerCase();
|
|
1440
|
+
const fileType =
|
|
1441
|
+
extension && ["jpg", "jpeg", "png", "gif", "webp"].includes(extension)
|
|
1442
|
+
? `image/${extension === "jpg" ? "jpeg" : extension}`
|
|
1443
|
+
: "application/octet-stream";
|
|
1444
|
+
|
|
1445
|
+
state.resourceIndex.set(resourceId, {
|
|
1446
|
+
name: filename,
|
|
1447
|
+
type: fileType,
|
|
1448
|
+
size: 0,
|
|
1449
|
+
file: null, // No local file for prefill data
|
|
1450
|
+
});
|
|
1451
|
+
}
|
|
1452
|
+
});
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
function setupFilesDropHandler(filesContainer, initialFiles, updateCallback) {
|
|
1457
|
+
setupDragAndDrop(filesContainer, async (files) => {
|
|
1458
|
+
const arr = Array.from(files);
|
|
1459
|
+
for (const file of arr) {
|
|
1460
|
+
const rid = await uploadSingleFile(file);
|
|
1461
|
+
state.resourceIndex.set(rid, {
|
|
1462
|
+
name: file.name,
|
|
1463
|
+
type: file.type,
|
|
1464
|
+
size: file.size,
|
|
1465
|
+
file: null, // Files are always uploaded, never stored locally
|
|
1466
|
+
});
|
|
1467
|
+
initialFiles.push(rid);
|
|
1468
|
+
}
|
|
1469
|
+
updateCallback();
|
|
1470
|
+
});
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
function setupFilesPickerHandler(filesPicker, initialFiles, updateCallback) {
|
|
1474
|
+
filesPicker.onchange = async () => {
|
|
1475
|
+
for (const file of Array.from(filesPicker.files)) {
|
|
1476
|
+
const rid = await uploadSingleFile(file);
|
|
1477
|
+
state.resourceIndex.set(rid, {
|
|
1478
|
+
name: file.name,
|
|
1479
|
+
type: file.type,
|
|
1480
|
+
size: file.size,
|
|
1481
|
+
file: null, // Files are always uploaded, never stored locally
|
|
1482
|
+
});
|
|
1483
|
+
initialFiles.push(rid);
|
|
1484
|
+
}
|
|
1485
|
+
updateCallback();
|
|
1486
|
+
// Clear the file input
|
|
1487
|
+
filesPicker.value = "";
|
|
1488
|
+
};
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
async function uploadSingleFile(file) {
|
|
1492
|
+
// If uploadHandler is configured, use it to upload the file
|
|
1493
|
+
if (state.config.uploadFile) {
|
|
1494
|
+
try {
|
|
1495
|
+
const rid = await state.config.uploadFile(file);
|
|
1496
|
+
if (typeof rid !== "string") {
|
|
1497
|
+
throw new Error("Upload handler must return a string resource ID");
|
|
1498
|
+
}
|
|
1499
|
+
return rid;
|
|
1500
|
+
} catch (error) {
|
|
1501
|
+
throw new Error(`File upload failed: ${error.message}`);
|
|
1502
|
+
}
|
|
1503
|
+
} else {
|
|
1504
|
+
throw new Error(
|
|
1505
|
+
"No upload handler configured. Set uploadHandler via FormBuilder.setUploadHandler()",
|
|
1506
|
+
);
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
function renderGroupElement(element, ctx, wrapper, _pathKey) {
|
|
1511
|
+
const groupWrap = document.createElement("div");
|
|
1512
|
+
groupWrap.className = "border border-gray-200 rounded-lg p-4 bg-gray-50";
|
|
1513
|
+
|
|
1514
|
+
const header = document.createElement("div");
|
|
1515
|
+
header.className = "flex justify-between items-center mb-4";
|
|
1516
|
+
|
|
1517
|
+
const left = document.createElement("div");
|
|
1518
|
+
left.className = "flex-1";
|
|
1519
|
+
|
|
1520
|
+
const right = document.createElement("div");
|
|
1521
|
+
right.className = "flex gap-2";
|
|
1522
|
+
|
|
1523
|
+
const itemsWrap = document.createElement("div");
|
|
1524
|
+
itemsWrap.className = "space-y-4";
|
|
1525
|
+
|
|
1526
|
+
groupWrap.appendChild(header);
|
|
1527
|
+
header.appendChild(left);
|
|
1528
|
+
if (!state.config.readonly) {
|
|
1529
|
+
header.appendChild(right);
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
if (element.repeat && isPlainObject(element.repeat)) {
|
|
1533
|
+
renderRepeatableGroup(element, ctx, itemsWrap, left, groupWrap);
|
|
1534
|
+
} else {
|
|
1535
|
+
renderSingleGroup(element, ctx, itemsWrap, left, groupWrap);
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
wrapper.appendChild(groupWrap);
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
function renderRepeatableGroup(element, ctx, itemsWrap, left, groupWrap) {
|
|
1542
|
+
const min = element.repeat.min ?? 0;
|
|
1543
|
+
const max = element.repeat.max ?? Infinity;
|
|
1544
|
+
const pre = Array.isArray(ctx.prefill?.[element.key])
|
|
1545
|
+
? ctx.prefill[element.key]
|
|
1546
|
+
: null;
|
|
1547
|
+
|
|
1548
|
+
const countItems = () =>
|
|
1549
|
+
itemsWrap.querySelectorAll(":scope > .groupItem").length;
|
|
1550
|
+
|
|
1551
|
+
const addItem = (prefillObj) => {
|
|
1552
|
+
const item = document.createElement("div");
|
|
1553
|
+
item.className =
|
|
1554
|
+
"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";
|
|
1555
|
+
const subCtx = {
|
|
1556
|
+
path: pathJoin(ctx.path, `${element.key}[${countItems()}]`),
|
|
1557
|
+
prefill: prefillObj || {},
|
|
1558
|
+
};
|
|
1559
|
+
element.elements.forEach((child) =>
|
|
1560
|
+
item.appendChild(renderElement(child, subCtx)),
|
|
1561
|
+
);
|
|
1562
|
+
|
|
1563
|
+
// Only add remove button in edit mode
|
|
1564
|
+
if (!state.config.readonly) {
|
|
1565
|
+
const rem = document.createElement("button");
|
|
1566
|
+
rem.type = "button";
|
|
1567
|
+
rem.className =
|
|
1568
|
+
"bg-red-500 hover:bg-red-600 text-white px-2 py-1 rounded text-xs font-medium transition-colors";
|
|
1569
|
+
rem.textContent = t("removeElement");
|
|
1570
|
+
rem.addEventListener("click", () => {
|
|
1571
|
+
if (countItems() <= (element.repeat.min ?? 0)) return;
|
|
1572
|
+
itemsWrap.removeChild(item);
|
|
1573
|
+
refreshControls();
|
|
1574
|
+
});
|
|
1575
|
+
item.appendChild(rem);
|
|
1576
|
+
}
|
|
1577
|
+
itemsWrap.appendChild(item);
|
|
1578
|
+
if (!state.config.readonly) {
|
|
1579
|
+
refreshControls();
|
|
1580
|
+
}
|
|
1581
|
+
};
|
|
1582
|
+
|
|
1583
|
+
groupWrap.appendChild(itemsWrap);
|
|
1584
|
+
|
|
1585
|
+
// Only add button in edit mode
|
|
1586
|
+
let addBtn;
|
|
1587
|
+
if (!state.config.readonly) {
|
|
1588
|
+
addBtn = document.createElement("button");
|
|
1589
|
+
addBtn.type = "button";
|
|
1590
|
+
addBtn.className =
|
|
1591
|
+
"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";
|
|
1592
|
+
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>${t("addElement")}`;
|
|
1593
|
+
groupWrap.appendChild(addBtn);
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
const refreshControls = () => {
|
|
1597
|
+
if (state.config.readonly) return;
|
|
1598
|
+
const n = countItems();
|
|
1599
|
+
if (addBtn) addBtn.disabled = n >= max;
|
|
1600
|
+
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>`;
|
|
1601
|
+
};
|
|
1602
|
+
|
|
1603
|
+
if (pre && pre.length) {
|
|
1604
|
+
const n = Math.min(max, Math.max(min, pre.length));
|
|
1605
|
+
for (let i = 0; i < n; i++) addItem(pre[i]);
|
|
1606
|
+
} else {
|
|
1607
|
+
const n = Math.max(min, 0);
|
|
1608
|
+
for (let i = 0; i < n; i++) addItem(null);
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
if (!state.config.readonly) {
|
|
1612
|
+
addBtn.addEventListener("click", () => addItem(null));
|
|
1613
|
+
} else {
|
|
1614
|
+
// In readonly mode, just show the label without count controls
|
|
1615
|
+
left.innerHTML = `<span>${element.label || element.key}</span>`;
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
function renderSingleGroup(element, ctx, itemsWrap, left, groupWrap) {
|
|
1620
|
+
// Single object group
|
|
1621
|
+
const subCtx = {
|
|
1622
|
+
path: pathJoin(ctx.path, element.key),
|
|
1623
|
+
prefill: ctx.prefill?.[element.key] || {},
|
|
1624
|
+
};
|
|
1625
|
+
element.elements.forEach((child) =>
|
|
1626
|
+
itemsWrap.appendChild(renderElement(child, subCtx)),
|
|
1627
|
+
);
|
|
1628
|
+
groupWrap.appendChild(itemsWrap);
|
|
1629
|
+
left.innerHTML = `<span>${element.label || element.key}</span>`;
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1366
1632
|
// Common file preview rendering function for readonly mode
|
|
1367
1633
|
function renderFilePreviewReadonly(resourceId, fileName) {
|
|
1368
1634
|
const meta = state.resourceIndex.get(resourceId);
|
|
1369
|
-
const actualFileName =
|
|
1635
|
+
const actualFileName =
|
|
1636
|
+
fileName || meta?.name || resourceId.split("/").pop() || "file";
|
|
1370
1637
|
|
|
1371
1638
|
// Individual file result container
|
|
1372
1639
|
const fileResult = document.createElement("div");
|
|
@@ -1377,46 +1644,60 @@ function renderFilePreviewReadonly(resourceId, fileName) {
|
|
|
1377
1644
|
previewContainer.className =
|
|
1378
1645
|
"bg-gray-100 rounded-lg overflow-hidden cursor-pointer hover:opacity-90 transition-opacity";
|
|
1379
1646
|
|
|
1380
|
-
//
|
|
1381
|
-
|
|
1647
|
+
// Determine if this looks like an image file
|
|
1648
|
+
const isImage =
|
|
1382
1649
|
meta?.type?.startsWith("image/") ||
|
|
1383
|
-
actualFileName.toLowerCase().match(/\.(jpg|jpeg|png|gif|webp)$/)
|
|
1384
|
-
|
|
1385
|
-
|
|
1650
|
+
actualFileName.toLowerCase().match(/\.(jpg|jpeg|png|gif|webp)$/);
|
|
1651
|
+
|
|
1652
|
+
// Determine if this looks like a video file
|
|
1653
|
+
const isVideo =
|
|
1654
|
+
meta?.type?.startsWith("video/") ||
|
|
1655
|
+
actualFileName.toLowerCase().match(/\.(mp4|webm|avi|mov)$/);
|
|
1656
|
+
|
|
1657
|
+
if (isImage) {
|
|
1658
|
+
// Image preview - try getThumbnail first
|
|
1386
1659
|
if (state.config.getThumbnail) {
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1660
|
+
try {
|
|
1661
|
+
const thumbnailUrl = state.config.getThumbnail(resourceId);
|
|
1662
|
+
if (thumbnailUrl) {
|
|
1663
|
+
previewContainer.innerHTML = `<img src="${thumbnailUrl}" alt="${actualFileName}" class="w-full h-auto">`;
|
|
1664
|
+
} else {
|
|
1665
|
+
// Fallback to icon if getThumbnail returns null/undefined
|
|
1666
|
+
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>`;
|
|
1667
|
+
}
|
|
1668
|
+
} catch (error) {
|
|
1669
|
+
console.warn("getThumbnail failed for", resourceId, error);
|
|
1391
1670
|
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
1671
|
}
|
|
1393
1672
|
} else {
|
|
1394
1673
|
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
1674
|
}
|
|
1396
|
-
} else if (
|
|
1397
|
-
|
|
1398
|
-
actualFileName.toLowerCase().match(/\.(mp4|webm|avi|mov)$/)
|
|
1399
|
-
) {
|
|
1400
|
-
// Video preview
|
|
1675
|
+
} else if (isVideo) {
|
|
1676
|
+
// Video preview - try getThumbnail for video URL
|
|
1401
1677
|
if (state.config.getThumbnail) {
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
<
|
|
1407
|
-
<
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
<div class="bg-
|
|
1412
|
-
<
|
|
1413
|
-
<
|
|
1414
|
-
|
|
1678
|
+
try {
|
|
1679
|
+
const videoUrl = state.config.getThumbnail(resourceId);
|
|
1680
|
+
if (videoUrl) {
|
|
1681
|
+
previewContainer.innerHTML = `
|
|
1682
|
+
<div class="relative group">
|
|
1683
|
+
<video class="w-full h-auto" controls preload="auto" muted>
|
|
1684
|
+
<source src="${videoUrl}" type="${meta?.type || "video/mp4"}">
|
|
1685
|
+
Ваш браузер не поддерживает видео.
|
|
1686
|
+
</video>
|
|
1687
|
+
<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">
|
|
1688
|
+
<div class="bg-white bg-opacity-90 rounded-full p-3">
|
|
1689
|
+
<svg class="w-8 h-8 text-gray-800" fill="currentColor" viewBox="0 0 24 24">
|
|
1690
|
+
<path d="M8 5v14l11-7z"/>
|
|
1691
|
+
</svg>
|
|
1692
|
+
</div>
|
|
1415
1693
|
</div>
|
|
1416
1694
|
</div>
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1695
|
+
`;
|
|
1696
|
+
} else {
|
|
1697
|
+
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>`;
|
|
1698
|
+
}
|
|
1699
|
+
} catch (error) {
|
|
1700
|
+
console.warn("getThumbnail failed for video", resourceId, error);
|
|
1420
1701
|
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
1702
|
}
|
|
1422
1703
|
} else {
|
|
@@ -1537,10 +1818,12 @@ function setThumbnailHandler(thumbnailFn) {
|
|
|
1537
1818
|
}
|
|
1538
1819
|
|
|
1539
1820
|
function setMode(mode) {
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1821
|
+
state.config.readonly = mode === "readonly";
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
function setLocale(locale) {
|
|
1825
|
+
if (state.config.translations[locale]) {
|
|
1826
|
+
state.config.locale = locale;
|
|
1544
1827
|
}
|
|
1545
1828
|
}
|
|
1546
1829
|
|
|
@@ -1597,6 +1880,7 @@ const formBuilderAPI = {
|
|
|
1597
1880
|
setDownloadHandler,
|
|
1598
1881
|
setThumbnailHandler,
|
|
1599
1882
|
setMode,
|
|
1883
|
+
setLocale,
|
|
1600
1884
|
getFormData,
|
|
1601
1885
|
submitForm,
|
|
1602
1886
|
saveDraft,
|
|
@@ -1620,6 +1904,7 @@ export {
|
|
|
1620
1904
|
setDownloadHandler,
|
|
1621
1905
|
setThumbnailHandler,
|
|
1622
1906
|
setMode,
|
|
1907
|
+
setLocale,
|
|
1623
1908
|
getFormData,
|
|
1624
1909
|
submitForm,
|
|
1625
1910
|
saveDraft,
|