@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.
@@ -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, index) => {
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
- // 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 = `
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
- // 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
- });
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
- // 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);
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
- if (element.required) {
581
- parts.push("required");
582
- } else {
583
- parts.push("optional");
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
- if (element.minLength != null || element.maxLength != null) {
587
- if (element.minLength != null && element.maxLength != null) {
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 != null) {
266
+ } else if (element.maxLength !== null) {
590
267
  parts.push(`max=${element.maxLength} characters`);
591
- } else if (element.minLength != null) {
268
+ } else if (element.minLength !== null) {
592
269
  parts.push(`min=${element.minLength} characters`);
593
270
  }
594
271
  }
272
+ }
595
273
 
596
- if (element.min != null || element.max != null) {
597
- if (element.min != null && element.max != null) {
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 != null) {
278
+ } else if (element.max !== null) {
600
279
  parts.push(`max=${element.max}`);
601
- } else if (element.min != null) {
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
- if (element.accept && element.accept.extensions) {
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 && element.pattern.includes("А-Я")) {
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
- container,
627
- resourceId,
628
- fileName,
629
- fileType,
630
- isReadonly = false,
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
- '<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>";
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">Нажмите или перетащите файл</div>
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
- '<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>";
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
- '<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>";
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">Нажмите или перетащите файл</div>
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
- '<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>";
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
- container.className = "grid grid-cols-4 gap-3 mt-2";
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
- function formatFileSize(bytes) {
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];
931
- }
932
-
933
- function generateResourceId() {
934
- return (
935
- "res_" + Math.random().toString(36).substr(2, 9) + Date.now().toString(36)
936
- );
937
- }
938
-
939
- async function handleFileSelect(file, container, fieldName) {
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: null, // Files are always uploaded, never stored locally
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, file.name, file.type, false).catch(
978
- console.error,
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 + "px";
1081
- tooltip.style.top = top + "px";
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", function (e) {
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 != null && val.length < 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 != null && val.length > 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 != null && v < 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 != null && v > 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
- const container = scopeRoot
1224
- .querySelector(`[name$="${key}"]`)
1225
- ?.parentElement?.querySelector(".files-list");
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
- // Extract resource IDs from the current state
1229
- // This is a simplified approach - in practice you'd track this better
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 = fileName || meta?.name || "file";
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
- // Check file type and render appropriate preview
1381
- if (
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
- // Image preview
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
- const thumbnailUrl = state.config.getThumbnail(resourceId);
1388
- if (thumbnailUrl) {
1389
- previewContainer.innerHTML = `<img src="${thumbnailUrl}" alt="${actualFileName}" class="w-full h-auto">`;
1390
- } else {
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
- meta?.type?.startsWith("video/") ||
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
- const thumbnailUrl = state.config.getThumbnail(resourceId);
1403
- if (thumbnailUrl) {
1404
- previewContainer.innerHTML = `
1405
- <div class="relative group">
1406
- <video class="w-full h-auto" controls preload="auto" muted>
1407
- <source src="${thumbnailUrl}" type="${meta?.type || "video/mp4"}">
1408
- Ваш браузер не поддерживает видео.
1409
- </video>
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">
1411
- <div class="bg-white bg-opacity-90 rounded-full p-3">
1412
- <svg class="w-8 h-8 text-gray-800" fill="currentColor" viewBox="0 0 24 24">
1413
- <path d="M8 5v14l11-7z"/>
1414
- </svg>
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
- </div>
1418
- `;
1419
- } else {
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
- if (mode === "readonly") {
1541
- state.config.readonly = true;
1542
- } else {
1543
- state.config.readonly = false;
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,