@dmitryvim/form-builder 0.2.26 → 0.2.27

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/esm/index.js CHANGED
@@ -2088,7 +2088,7 @@ function updateSwitcherField(element, fieldPath, value, context) {
2088
2088
  }
2089
2089
  }
2090
2090
 
2091
- // src/components/file.ts
2091
+ // src/components/file/constraints.ts
2092
2092
  function getAllowedExtensions(accept) {
2093
2093
  if (!accept) return [];
2094
2094
  if (typeof accept === "object" && Array.isArray(accept.extensions)) {
@@ -2101,16 +2101,662 @@ function getAllowedExtensions(accept) {
2101
2101
  }
2102
2102
  function isFileExtensionAllowed(fileName, allowedExtensions) {
2103
2103
  if (allowedExtensions.length === 0) return true;
2104
- const ext = fileName.split(".").pop()?.toLowerCase() || "";
2104
+ const ext = fileName.split(".").pop()?.toLowerCase() ?? "";
2105
2105
  return allowedExtensions.includes(ext);
2106
2106
  }
2107
2107
  function isFileSizeAllowed(file, maxSizeMB) {
2108
2108
  if (maxSizeMB === Infinity) return true;
2109
2109
  return file.size <= maxSizeMB * 1024 * 1024;
2110
2110
  }
2111
+ function addPrefillFilesToIndex(initialFiles, resourceIndex) {
2112
+ for (const resourceId of initialFiles) {
2113
+ if (resourceIndex.has(resourceId)) continue;
2114
+ const filename = resourceId.split("/").pop() || "file";
2115
+ const extension = filename.split(".").pop()?.toLowerCase();
2116
+ let fileType = "application/octet-stream";
2117
+ if (extension) {
2118
+ if (["jpg", "jpeg", "png", "gif", "webp"].includes(extension)) {
2119
+ fileType = `image/${extension === "jpg" ? "jpeg" : extension}`;
2120
+ } else if (["mp4", "webm", "mov", "avi"].includes(extension)) {
2121
+ fileType = `video/${extension === "mov" ? "quicktime" : extension}`;
2122
+ }
2123
+ }
2124
+ resourceIndex.set(resourceId, {
2125
+ name: filename,
2126
+ type: fileType,
2127
+ size: 0,
2128
+ uploadedAt: /* @__PURE__ */ new Date(),
2129
+ file: void 0
2130
+ });
2131
+ }
2132
+ }
2133
+
2134
+ // src/components/file/styles.ts
2135
+ var STYLE_ID = "fb-file-styles";
2136
+ function ensureFileStyles() {
2137
+ if (typeof document === "undefined") return;
2138
+ if (document.getElementById(STYLE_ID)) return;
2139
+ const style = document.createElement("style");
2140
+ style.id = STYLE_ID;
2141
+ style.setAttribute("data-fb-file-styles", "true");
2142
+ style.textContent = `
2143
+ @keyframes fb-spin { to { transform: rotate(360deg); } }
2144
+
2145
+ /* Spinner used during single-file and multi-file upload */
2146
+ .fb-spinner {
2147
+ width: 36px;
2148
+ height: 36px;
2149
+ border: 3px solid rgba(0,0,0,0.12);
2150
+ border-top-color: var(--fb-text-secondary-color, #6b7280);
2151
+ border-radius: 50%;
2152
+ animation: fb-spin 0.7s linear infinite;
2153
+ flex-shrink: 0;
2154
+ }
2155
+
2156
+ /* Base tile: fixed 160\xD7160 square, theme-aware background */
2157
+ .fb-tile {
2158
+ width: var(--fb-tile-size, 160px);
2159
+ height: var(--fb-tile-size, 160px);
2160
+ flex-shrink: 0;
2161
+ position: relative;
2162
+ overflow: hidden;
2163
+ border-radius: var(--fb-border-radius, 0.5rem);
2164
+ background: var(--fb-file-upload-bg-color, #f3f4f6);
2165
+ }
2166
+
2167
+ /* Uploaded resource tile \u2014 adds a visible border */
2168
+ .fb-tile-resource {
2169
+ border: 1px solid var(--fb-file-upload-border-color, #d1d5db);
2170
+ }
2171
+
2172
+ /* Uploading placeholder tile \u2014 dashed border, uploading indicator */
2173
+ .fb-tile-uploading {
2174
+ border: 2px dashed var(--fb-file-upload-border-color, #d1d5db);
2175
+ }
2176
+
2177
+ /* "+" add-more tile */
2178
+ .fb-tile-add {
2179
+ border: 2px dashed var(--fb-file-upload-border-color, #d1d5db);
2180
+ display: flex;
2181
+ align-items: center;
2182
+ justify-content: center;
2183
+ cursor: pointer;
2184
+ font-size: 32px;
2185
+ color: var(--fb-file-upload-text-color, #9ca3af);
2186
+ transition:
2187
+ border-color var(--fb-transition-duration, 200ms),
2188
+ color var(--fb-transition-duration, 200ms);
2189
+ }
2190
+ .fb-tile-add:hover {
2191
+ border-color: var(--fb-file-upload-hover-border-color, #3b82f6);
2192
+ color: var(--fb-text-color, #1f2937);
2193
+ }
2194
+
2195
+ /* Count chip shown when at maxCount */
2196
+ .fb-tile-counter {
2197
+ font-size: 11px;
2198
+ color: var(--fb-text-secondary-color, #6b7280);
2199
+ background: var(--fb-file-upload-bg-color, #f3f4f6);
2200
+ border: 1px solid var(--fb-file-upload-border-color, #d1d5db);
2201
+ border-radius: 4px;
2202
+ padding: 2px 6px;
2203
+ align-self: flex-end;
2204
+ margin-bottom: 4px;
2205
+ }
2206
+
2207
+ /* Empty-state dropzone */
2208
+ .fb-file-dropzone {
2209
+ width: 100%;
2210
+ height: 128px;
2211
+ border: 2px dashed var(--fb-file-upload-border-color, #d1d5db);
2212
+ border-radius: var(--fb-border-radius, 0.5rem);
2213
+ display: flex;
2214
+ flex-direction: column;
2215
+ align-items: center;
2216
+ justify-content: center;
2217
+ gap: 4px;
2218
+ cursor: pointer;
2219
+ transition:
2220
+ border-color var(--fb-transition-duration, 200ms),
2221
+ background var(--fb-transition-duration, 200ms);
2222
+ }
2223
+ .fb-file-dropzone:hover {
2224
+ border-color: var(--fb-file-upload-hover-border-color, #3b82f6);
2225
+ background: var(--fb-background-hover-color, #f9fafb);
2226
+ }
2227
+
2228
+ /* Inline text inside tiles */
2229
+ .fb-tile-label {
2230
+ font-size: 9px;
2231
+ color: var(--fb-text-secondary-color, #6b7280);
2232
+ text-align: center;
2233
+ overflow: hidden;
2234
+ word-break: break-all;
2235
+ max-height: 28px;
2236
+ }
2237
+ .fb-tile-uploading-text {
2238
+ font-size: 8px;
2239
+ color: var(--fb-file-upload-text-color, #9ca3af);
2240
+ }
2241
+ .fb-tile-hint {
2242
+ font-size: 11px;
2243
+ color: var(--fb-file-upload-text-color, #9ca3af);
2244
+ margin-top: 4px;
2245
+ }
2246
+ .fb-tile-empty-text {
2247
+ font-size: 12px;
2248
+ color: var(--fb-text-secondary-color, #6b7280);
2249
+ padding: 4px 0;
2250
+ }
2251
+ .fb-dropzone-primary-text {
2252
+ font-size: 13px;
2253
+ color: var(--fb-text-secondary-color, #6b7280);
2254
+ }
2255
+ .fb-dropzone-hint-text {
2256
+ font-size: 11px;
2257
+ color: var(--fb-file-upload-text-color, #9ca3af);
2258
+ }
2259
+
2260
+ /* Hover overlay + X-button on resource tiles */
2261
+ .fb-tile-overlay {
2262
+ position: absolute;
2263
+ inset: 0;
2264
+ background: transparent;
2265
+ transition: background var(--fb-transition-duration, 200ms);
2266
+ display: flex;
2267
+ align-items: flex-start;
2268
+ justify-content: flex-end;
2269
+ }
2270
+ .fb-tile-resource:hover .fb-tile-overlay {
2271
+ background: var(--fb-tile-hover-overlay-color, rgba(0,0,0,0.4));
2272
+ }
2273
+ .fb-tile-x-btn {
2274
+ margin: 3px;
2275
+ width: 18px;
2276
+ height: 18px;
2277
+ background: var(--fb-error-color, #ef4444);
2278
+ color: var(--fb-file-bg-color, #fff);
2279
+ border: none;
2280
+ border-radius: 50%;
2281
+ font-size: 11px;
2282
+ line-height: 1;
2283
+ cursor: pointer;
2284
+ display: flex;
2285
+ align-items: center;
2286
+ justify-content: center;
2287
+ opacity: 0;
2288
+ transition: opacity var(--fb-transition-duration, 200ms);
2289
+ }
2290
+ .fb-tile-resource:hover .fb-tile-x-btn {
2291
+ opacity: 1;
2292
+ }
2293
+
2294
+ /* Video play button overlay (readonly tiles with video thumbnails) */
2295
+ .fb-video-overlay {
2296
+ position: absolute;
2297
+ inset: 0;
2298
+ display: flex;
2299
+ align-items: center;
2300
+ justify-content: center;
2301
+ background: var(--fb-tile-hover-overlay-color, rgba(0,0,0,0.25));
2302
+ }
2303
+ .fb-play-btn {
2304
+ background: var(--fb-file-bg-color, rgba(255,255,255,0.9));
2305
+ border-radius: 50%;
2306
+ display: flex;
2307
+ align-items: center;
2308
+ justify-content: center;
2309
+ }
2310
+
2311
+ /* Edit-mode local video preview wrapper */
2312
+ .fb-video-preview-wrap {
2313
+ position: relative;
2314
+ width: 100%;
2315
+ height: 100%;
2316
+ }
2317
+
2318
+ /* Hover overlay for edit-mode local video (Remove / Change buttons) */
2319
+ .fb-video-btn-overlay {
2320
+ position: absolute;
2321
+ top: 8px;
2322
+ right: 8px;
2323
+ z-index: 10;
2324
+ display: flex;
2325
+ gap: 4px;
2326
+ opacity: 0;
2327
+ transition: opacity var(--fb-transition-duration, 200ms);
2328
+ pointer-events: none;
2329
+ }
2330
+ .fb-video-preview-wrap:hover .fb-video-btn-overlay {
2331
+ opacity: 1;
2332
+ pointer-events: auto;
2333
+ }
2334
+ .fb-video-btn {
2335
+ border: none;
2336
+ border-radius: var(--fb-border-radius, 4px);
2337
+ font-size: 11px;
2338
+ padding: 4px 8px;
2339
+ cursor: pointer;
2340
+ color: #fff;
2341
+ line-height: 1.2;
2342
+ }
2343
+ .fb-video-btn-delete {
2344
+ background: rgba(220, 38, 38, 0.85);
2345
+ }
2346
+ .fb-video-btn-delete:hover {
2347
+ background: rgba(185, 28, 28, 0.95);
2348
+ }
2349
+ .fb-video-btn-change {
2350
+ background: rgba(31, 41, 55, 0.85);
2351
+ }
2352
+ .fb-video-btn-change:hover {
2353
+ background: rgba(17, 24, 39, 0.95);
2354
+ }
2355
+
2356
+ /* Tile action icon buttons (download / open / remove) \u2014 shown on tile hover */
2357
+ .fb-tile-actions {
2358
+ position: absolute;
2359
+ top: 3px;
2360
+ right: 3px;
2361
+ display: flex;
2362
+ flex-direction: row;
2363
+ gap: 3px;
2364
+ opacity: 0;
2365
+ transition: opacity var(--fb-transition-duration, 200ms);
2366
+ z-index: 10;
2367
+ }
2368
+ .fb-tile-resource:hover .fb-tile-actions {
2369
+ opacity: 1;
2370
+ }
2371
+ .fb-tile-action-btn {
2372
+ width: 28px;
2373
+ height: 28px;
2374
+ display: flex;
2375
+ align-items: center;
2376
+ justify-content: center;
2377
+ border: none;
2378
+ border-radius: 50%;
2379
+ cursor: pointer;
2380
+ background: rgba(31, 41, 55, 0.75);
2381
+ color: #fff;
2382
+ padding: 0;
2383
+ flex-shrink: 0;
2384
+ transition:
2385
+ background var(--fb-transition-duration, 200ms),
2386
+ opacity var(--fb-transition-duration, 200ms);
2387
+ }
2388
+ .fb-tile-action-btn:hover {
2389
+ background: rgba(17, 24, 39, 0.95);
2390
+ }
2391
+ .fb-tile-action-remove {
2392
+ background: rgba(220, 38, 38, 0.8);
2393
+ }
2394
+ .fb-tile-action-remove:hover {
2395
+ background: rgba(185, 28, 28, 0.95);
2396
+ }
2397
+
2398
+ /* Actions row inside zoom popup \u2014 always visible while popup is shown */
2399
+ .fb-tile-zoom-preview .fb-tile-actions {
2400
+ position: absolute;
2401
+ top: 6px;
2402
+ right: 6px;
2403
+ opacity: 1;
2404
+ z-index: 10000;
2405
+ }
2406
+
2407
+ /* Hover zoom preview popup for image tiles \u2014 appended to document.body (fixed) */
2408
+ .fb-tile-zoom-preview {
2409
+ position: fixed;
2410
+ z-index: 9999;
2411
+ background: var(--fb-background-color, #fff);
2412
+ border: 1px solid var(--fb-file-upload-border-color, #d1d5db);
2413
+ border-radius: var(--fb-border-radius, 0.5rem);
2414
+ box-shadow: 0 4px 16px rgba(0,0,0,0.18);
2415
+ padding: 4px;
2416
+ width: 350px;
2417
+ height: 350px;
2418
+ pointer-events: none;
2419
+ opacity: 0;
2420
+ transition: opacity 150ms ease;
2421
+ }
2422
+ .fb-tile-zoom-preview.fb-tile-zoom-preview--visible {
2423
+ opacity: 1;
2424
+ }
2425
+ .fb-tile-zoom-preview-img {
2426
+ width: 100%;
2427
+ height: 100%;
2428
+ object-fit: contain;
2429
+ display: block;
2430
+ background: var(--fb-file-upload-bg-color, #f3f4f6);
2431
+ border-radius: calc(var(--fb-border-radius, 0.5rem) - 2px);
2432
+ }
2433
+ `;
2434
+ document.head.appendChild(style);
2435
+ }
2436
+
2437
+ // src/components/file/dom.ts
2438
+ var TILE_SIZE = "160px";
2439
+ function createFileTile() {
2440
+ ensureFileStyles();
2441
+ const tile = document.createElement("div");
2442
+ tile.className = "fb-tile";
2443
+ return tile;
2444
+ }
2445
+ function showFileError(container, message) {
2446
+ const existing = container.closest(".space-y-2")?.querySelector(".file-error-message");
2447
+ if (existing) existing.remove();
2448
+ const errorEl = document.createElement("div");
2449
+ errorEl.className = "file-error-message error-message";
2450
+ errorEl.style.cssText = `
2451
+ color: var(--fb-error-color);
2452
+ font-size: var(--fb-font-size-small);
2453
+ margin-top: 0.25rem;
2454
+ `;
2455
+ errorEl.textContent = message;
2456
+ container.closest(".space-y-2")?.appendChild(errorEl);
2457
+ }
2458
+ function clearFileError(container) {
2459
+ const existing = container.closest(".space-y-2")?.querySelector(".file-error-message");
2460
+ if (existing) existing.remove();
2461
+ }
2462
+ function addDeleteButton(container, state, onDelete) {
2463
+ const existingOverlay = container.querySelector(".delete-overlay");
2464
+ if (existingOverlay) existingOverlay.remove();
2465
+ const overlay = document.createElement("div");
2466
+ overlay.className = "delete-overlay absolute inset-0 bg-black bg-opacity-50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center";
2467
+ const deleteBtn = document.createElement("button");
2468
+ deleteBtn.className = "bg-red-600 text-white px-3 py-1 rounded text-sm hover:bg-red-700 transition-colors";
2469
+ deleteBtn.textContent = t("removeElement", state);
2470
+ deleteBtn.onclick = (e) => {
2471
+ e.stopPropagation();
2472
+ onDelete();
2473
+ };
2474
+ overlay.appendChild(deleteBtn);
2475
+ container.appendChild(overlay);
2476
+ }
2477
+ function findFilePicker(container) {
2478
+ let el = container.parentElement;
2479
+ while (el && !el.dataset.filesWrapper) {
2480
+ el = el.parentElement;
2481
+ }
2482
+ return el?.querySelector('input[type="file"]') ?? null;
2483
+ }
2484
+ function createUploadingTile(fileName, state) {
2485
+ ensureFileStyles();
2486
+ const tile = createFileTile();
2487
+ tile.classList.add("fb-tile-uploading");
2488
+ tile.className += " fb-uploading-tile";
2489
+ const label = fileName.length > 10 ? fileName.substring(0, 8) + "\u2026" : fileName;
2490
+ tile.innerHTML = `
2491
+ <div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;gap:6px;padding:4px;">
2492
+ <div class="fb-spinner"></div>
2493
+ <div class="fb-tile-label">${escapeHtml(label)}</div>
2494
+ <div class="fb-tile-uploading-text">${escapeHtml(t("uploadingFile", state))}</div>
2495
+ </div>`;
2496
+ return tile;
2497
+ }
2498
+ function ensureTilesWrap(list) {
2499
+ const existing = list.querySelector(".fb-tiles-wrap");
2500
+ if (existing) return existing;
2501
+ const dropzone = list.querySelector(".fb-file-dropzone");
2502
+ if (dropzone) dropzone.remove();
2503
+ const tilesWrap = document.createElement("div");
2504
+ tilesWrap.className = "fb-tiles-wrap";
2505
+ tilesWrap.style.cssText = "display:flex;flex-wrap:wrap;gap:6px;align-items:flex-start;";
2506
+ const addTile = document.createElement("div");
2507
+ addTile.className = "fb-tile fb-tile-add";
2508
+ addTile.innerHTML = "+";
2509
+ tilesWrap.appendChild(addTile);
2510
+ list.appendChild(tilesWrap);
2511
+ return tilesWrap;
2512
+ }
2513
+ function setEmptyFileContainer(fileContainer, state, hint) {
2514
+ const hintHtml = hint ? `<div class="text-xs text-gray-500 mt-1">${escapeHtml(hint)}</div>` : "";
2515
+ fileContainer.innerHTML = `
2516
+ <div class="flex flex-col items-center justify-center h-full text-gray-400">
2517
+ <svg class="w-6 h-6 mb-2" fill="currentColor" viewBox="0 0 24 24">
2518
+ <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"/>
2519
+ </svg>
2520
+ <div class="text-sm text-center">${escapeHtml(t("clickDragText", state))}</div>
2521
+ ${hintHtml}
2522
+ </div>
2523
+ `;
2524
+ }
2525
+ function setupDragAndDrop(element, dropHandler) {
2526
+ element.addEventListener("dragover", (e) => {
2527
+ e.preventDefault();
2528
+ element.classList.add("border-blue-500", "bg-blue-50");
2529
+ });
2530
+ element.addEventListener("dragleave", (e) => {
2531
+ e.preventDefault();
2532
+ element.classList.remove("border-blue-500", "bg-blue-50");
2533
+ });
2534
+ element.addEventListener("drop", (e) => {
2535
+ e.preventDefault();
2536
+ element.classList.remove("border-blue-500", "bg-blue-50");
2537
+ if (e.dataTransfer?.files) {
2538
+ dropHandler(e.dataTransfer.files);
2539
+ }
2540
+ });
2541
+ }
2542
+
2543
+ // src/components/file/preview.ts
2544
+ var ICON_DOWNLOAD = `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>`;
2545
+ var ICON_OPEN = `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>`;
2546
+ var ICON_REMOVE = `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2"/></svg>`;
2547
+ function canDownload(state, meta) {
2548
+ return Boolean(
2549
+ state.config.downloadFile || state.config.getDownloadUrl || state.config.getThumbnail || meta?.file
2550
+ );
2551
+ }
2552
+ function canOpenInTab(state, meta) {
2553
+ return Boolean(
2554
+ state.config.getDownloadUrl || state.config.getThumbnail || meta?.file
2555
+ );
2556
+ }
2557
+ function createTileActions(options) {
2558
+ const { canRemove, removeHandler, state, resourceId, fileName, meta } = options;
2559
+ const group = document.createElement("div");
2560
+ group.className = "fb-tile-actions";
2561
+ const makeBtn = (icon, label, cls) => {
2562
+ const btn = document.createElement("button");
2563
+ btn.type = "button";
2564
+ btn.className = `fb-tile-action-btn ${cls}`;
2565
+ btn.innerHTML = icon;
2566
+ btn.title = label;
2567
+ btn.setAttribute("aria-label", label);
2568
+ btn.addEventListener("click", (e) => {
2569
+ e.stopPropagation();
2570
+ });
2571
+ return btn;
2572
+ };
2573
+ if (canDownload(state, meta)) {
2574
+ const dlBtn = makeBtn(ICON_DOWNLOAD, t("downloadFile", state), "fb-tile-action-download");
2575
+ dlBtn.addEventListener("click", () => {
2576
+ triggerTileDownload(resourceId, fileName, state, meta);
2577
+ });
2578
+ group.appendChild(dlBtn);
2579
+ }
2580
+ if (canOpenInTab(state, meta)) {
2581
+ const openBtn = makeBtn(ICON_OPEN, t("openInNewTab", state), "fb-tile-action-open");
2582
+ openBtn.addEventListener("click", () => {
2583
+ triggerTileOpen(resourceId, state, meta).catch((err) => {
2584
+ console.error("Open failed:", err);
2585
+ });
2586
+ });
2587
+ group.appendChild(openBtn);
2588
+ }
2589
+ if (canRemove && removeHandler) {
2590
+ const rmBtn = makeBtn(ICON_REMOVE, t("removeElement", state), "fb-tile-action-remove");
2591
+ rmBtn.addEventListener("click", () => {
2592
+ removeHandler();
2593
+ });
2594
+ group.appendChild(rmBtn);
2595
+ }
2596
+ return group;
2597
+ }
2598
+ var localFileUrlCache = /* @__PURE__ */ new WeakMap();
2599
+ function getLocalFileUrl(file) {
2600
+ let url = localFileUrlCache.get(file);
2601
+ if (!url) {
2602
+ url = URL.createObjectURL(file);
2603
+ localFileUrlCache.set(file, url);
2604
+ }
2605
+ return url;
2606
+ }
2607
+ function releaseLocalFileUrl(file) {
2608
+ if (!file) return;
2609
+ const url = localFileUrlCache.get(file);
2610
+ if (url) {
2611
+ URL.revokeObjectURL(url);
2612
+ localFileUrlCache.delete(file);
2613
+ }
2614
+ }
2615
+ function triggerTileDownload(resourceId, fileName, state, meta) {
2616
+ if (state.config.downloadFile) {
2617
+ state.config.downloadFile(resourceId, fileName);
2618
+ return;
2619
+ }
2620
+ if (meta?.file instanceof File) {
2621
+ downloadBlob(meta.file, fileName || meta.file.name);
2622
+ return;
2623
+ }
2624
+ forceDownload(resourceId, fileName, state).catch((err) => {
2625
+ console.error("Download failed:", err);
2626
+ });
2627
+ }
2628
+ async function triggerTileOpen(resourceId, state, meta) {
2629
+ let url = null;
2630
+ if (state.config.getDownloadUrl) {
2631
+ url = state.config.getDownloadUrl(resourceId);
2632
+ } else if (state.config.getThumbnail) {
2633
+ url = await state.config.getThumbnail(resourceId);
2634
+ } else if (meta?.file instanceof File) {
2635
+ url = getLocalFileUrl(meta.file);
2636
+ }
2637
+ if (url) {
2638
+ window.open(url, "_blank");
2639
+ }
2640
+ }
2641
+ var sharedZoomPopup = null;
2642
+ var zoomTimer = null;
2643
+ var zoomHideTimer = null;
2644
+ var zoomOwner = null;
2645
+ function getOrCreateZoomPopup() {
2646
+ if (!sharedZoomPopup) {
2647
+ sharedZoomPopup = document.createElement("div");
2648
+ sharedZoomPopup.className = "fb-tile-zoom-preview";
2649
+ const img = document.createElement("img");
2650
+ img.className = "fb-tile-zoom-preview-img";
2651
+ sharedZoomPopup.appendChild(img);
2652
+ sharedZoomPopup.addEventListener("mouseenter", cancelHideZoomPopup);
2653
+ sharedZoomPopup.addEventListener("mouseleave", scheduleHideZoomPopup);
2654
+ }
2655
+ return sharedZoomPopup;
2656
+ }
2657
+ function positionZoomPopup(popup, tile) {
2658
+ const tileRect = tile.getBoundingClientRect();
2659
+ const popupSize = 350;
2660
+ const margin = 6;
2661
+ const padding = 8;
2662
+ let top;
2663
+ if (tileRect.top - popupSize - margin >= padding) {
2664
+ top = tileRect.top - popupSize - margin;
2665
+ } else if (tileRect.bottom + margin + popupSize + padding <= window.innerHeight) {
2666
+ top = tileRect.bottom + margin;
2667
+ } else {
2668
+ top = Math.max(padding, Math.min(window.innerHeight - popupSize - padding, tileRect.top));
2669
+ }
2670
+ const tileCenterX = tileRect.left + tileRect.width / 2;
2671
+ let left = tileCenterX - popupSize / 2;
2672
+ left = Math.max(padding, Math.min(window.innerWidth - popupSize - padding, left));
2673
+ popup.style.top = `${top}px`;
2674
+ popup.style.left = `${left}px`;
2675
+ }
2676
+ function scheduleHideZoomPopup() {
2677
+ if (zoomHideTimer !== null) {
2678
+ clearTimeout(zoomHideTimer);
2679
+ }
2680
+ zoomHideTimer = setTimeout(() => {
2681
+ zoomHideTimer = null;
2682
+ removeZoomPopupNow();
2683
+ }, 100);
2684
+ }
2685
+ function cancelHideZoomPopup() {
2686
+ if (zoomHideTimer !== null) {
2687
+ clearTimeout(zoomHideTimer);
2688
+ zoomHideTimer = null;
2689
+ }
2690
+ }
2691
+ function removeZoomPopupNow() {
2692
+ if (zoomTimer !== null) {
2693
+ clearTimeout(zoomTimer);
2694
+ zoomTimer = null;
2695
+ }
2696
+ if (sharedZoomPopup && sharedZoomPopup.parentNode) {
2697
+ sharedZoomPopup.classList.remove("fb-tile-zoom-preview--visible");
2698
+ sharedZoomPopup.parentNode.removeChild(sharedZoomPopup);
2699
+ }
2700
+ zoomOwner = null;
2701
+ }
2702
+ function attachZoomHover(tile, src, alt, actionsEl) {
2703
+ tile.dataset.zoomSrc = src;
2704
+ tile.dataset.zoomAlt = alt;
2705
+ tile.addEventListener("mouseenter", () => {
2706
+ cancelHideZoomPopup();
2707
+ if (zoomOwner !== tile) {
2708
+ removeZoomPopupNow();
2709
+ }
2710
+ zoomOwner = tile;
2711
+ zoomTimer = setTimeout(() => {
2712
+ zoomTimer = null;
2713
+ const popup = getOrCreateZoomPopup();
2714
+ const existingActions = popup.querySelector(".fb-tile-actions");
2715
+ if (existingActions) existingActions.remove();
2716
+ const img = popup.querySelector(".fb-tile-zoom-preview-img");
2717
+ img.src = src;
2718
+ img.alt = alt;
2719
+ if (actionsEl) {
2720
+ popup.appendChild(actionsEl.cloneNode(true));
2721
+ attachClonedActionListeners(
2722
+ popup.querySelector(".fb-tile-actions"),
2723
+ actionsEl
2724
+ );
2725
+ }
2726
+ popup.style.pointerEvents = "auto";
2727
+ positionZoomPopup(popup, tile);
2728
+ document.body.appendChild(popup);
2729
+ popup.getBoundingClientRect();
2730
+ popup.classList.add("fb-tile-zoom-preview--visible");
2731
+ }, 200);
2732
+ });
2733
+ tile.addEventListener("mouseleave", () => {
2734
+ if (zoomTimer !== null) {
2735
+ clearTimeout(zoomTimer);
2736
+ zoomTimer = null;
2737
+ zoomOwner = null;
2738
+ } else {
2739
+ scheduleHideZoomPopup();
2740
+ }
2741
+ });
2742
+ }
2743
+ function attachClonedActionListeners(cloned, original) {
2744
+ const originalBtns = Array.from(original.querySelectorAll(".fb-tile-action-btn"));
2745
+ const clonedBtns = Array.from(cloned.querySelectorAll(".fb-tile-action-btn"));
2746
+ clonedBtns.forEach((clonedBtn, i) => {
2747
+ const origBtn = originalBtns[i];
2748
+ if (origBtn) {
2749
+ clonedBtn.addEventListener("click", (e) => {
2750
+ e.stopPropagation();
2751
+ origBtn.click();
2752
+ });
2753
+ }
2754
+ });
2755
+ }
2111
2756
  function renderLocalImagePreview(container, file, fileName, state) {
2112
2757
  const img = document.createElement("img");
2113
2758
  img.className = "w-full h-full object-contain";
2759
+ img.style.background = "var(--fb-file-upload-bg-color,#f3f4f6)";
2114
2760
  img.alt = fileName || t("previewAlt", state);
2115
2761
  const reader = new FileReader();
2116
2762
  reader.onload = (e) => {
@@ -2119,23 +2765,27 @@ function renderLocalImagePreview(container, file, fileName, state) {
2119
2765
  reader.readAsDataURL(file);
2120
2766
  container.appendChild(img);
2121
2767
  }
2122
- function renderLocalVideoPreview(container, file, videoType, resourceId, state, deps) {
2123
- const videoUrl = URL.createObjectURL(file);
2768
+ function setupDragDropless(container, _deps) {
2124
2769
  container.onclick = null;
2125
2770
  const newContainer = container.cloneNode(false);
2126
2771
  if (container.parentNode) {
2127
2772
  container.parentNode.replaceChild(newContainer, container);
2128
2773
  }
2774
+ return newContainer;
2775
+ }
2776
+ function renderLocalVideoPreview(container, file, videoType, resourceId, state, deps) {
2777
+ const videoUrl = URL.createObjectURL(file);
2778
+ const newContainer = setupDragDropless(container);
2129
2779
  newContainer.innerHTML = `
2130
- <div class="relative group h-full">
2780
+ <div class="fb-video-preview-wrap">
2131
2781
  <video class="w-full h-full object-contain" controls preload="auto" muted src="${videoUrl}">
2132
2782
  ${escapeHtml(t("videoNotSupported", state))}
2133
2783
  </video>
2134
- <div class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity z-10 flex gap-1">
2135
- <button class="bg-red-600 bg-opacity-75 hover:bg-opacity-90 text-white p-1 rounded text-xs delete-file-btn">
2784
+ <div class="fb-video-btn-overlay">
2785
+ <button class="fb-video-btn fb-video-btn-delete delete-file-btn">
2136
2786
  ${escapeHtml(t("removeElement", state))}
2137
2787
  </button>
2138
- <button class="bg-gray-800 bg-opacity-75 hover:bg-opacity-90 text-white p-1 rounded text-xs change-file-btn">
2788
+ <button class="fb-video-btn fb-video-btn-change change-file-btn">
2139
2789
  ${escapeHtml(t("changeButton", state))}
2140
2790
  </button>
2141
2791
  </div>
@@ -2145,20 +2795,14 @@ function renderLocalVideoPreview(container, file, videoType, resourceId, state,
2145
2795
  return newContainer;
2146
2796
  }
2147
2797
  function attachVideoButtonHandlers(container, resourceId, state, deps) {
2148
- const changeBtn = container.querySelector(
2149
- ".change-file-btn"
2150
- );
2798
+ const changeBtn = container.querySelector(".change-file-btn");
2151
2799
  if (changeBtn) {
2152
2800
  changeBtn.onclick = (e) => {
2153
2801
  e.stopPropagation();
2154
- if (deps?.picker) {
2155
- deps.picker.click();
2156
- }
2802
+ deps?.picker?.click();
2157
2803
  };
2158
2804
  }
2159
- const deleteBtn = container.querySelector(
2160
- ".delete-file-btn"
2161
- );
2805
+ const deleteBtn = container.querySelector(".delete-file-btn");
2162
2806
  if (deleteBtn) {
2163
2807
  deleteBtn.onclick = (e) => {
2164
2808
  e.stopPropagation();
@@ -2177,9 +2821,6 @@ function handleVideoDelete(container, resourceId, state, deps) {
2177
2821
  if (deps?.fileUploadHandler) {
2178
2822
  container.onclick = deps.fileUploadHandler;
2179
2823
  }
2180
- if (deps?.dragHandler) {
2181
- setupDragAndDrop(container, deps.dragHandler);
2182
- }
2183
2824
  container.innerHTML = `
2184
2825
  <div class="flex flex-col items-center justify-center h-full text-gray-400">
2185
2826
  <svg class="w-6 h-6 mb-2" fill="currentColor" viewBox="0 0 24 24">
@@ -2188,16 +2829,9 @@ function handleVideoDelete(container, resourceId, state, deps) {
2188
2829
  <div class="text-sm text-center">${escapeHtml(t("clickDragText", state))}</div>
2189
2830
  </div>
2190
2831
  `;
2191
- }
2192
- function renderUploadedVideoPreview(container, thumbnailUrl, _videoType, state) {
2193
- const video = document.createElement("video");
2194
- video.className = "w-full h-full object-contain";
2195
- video.controls = true;
2196
- video.preload = "metadata";
2197
- video.muted = true;
2198
- video.src = thumbnailUrl;
2199
- video.appendChild(document.createTextNode(t("videoNotSupported", state)));
2200
- container.appendChild(video);
2832
+ if (deps?.setupDrop) {
2833
+ deps.setupDrop(container);
2834
+ }
2201
2835
  }
2202
2836
  function renderDeleteButton(container, resourceId, state) {
2203
2837
  addDeleteButton(container, state, () => {
@@ -2219,13 +2853,11 @@ function renderDeleteButton(container, resourceId, state) {
2219
2853
  });
2220
2854
  }
2221
2855
  async function renderLocalFilePreview(container, meta, fileName, resourceId, isReadonly, state, deps) {
2222
- if (!meta.file || !(meta.file instanceof File)) {
2223
- return;
2224
- }
2225
- if (meta.type && meta.type.startsWith("image/")) {
2856
+ if (!meta.file || !(meta.file instanceof File)) return;
2857
+ if (meta.type?.startsWith("image/")) {
2226
2858
  renderLocalImagePreview(container, meta.file, fileName, state);
2227
- } else if (meta.type && meta.type.startsWith("video/")) {
2228
- const newContainer = renderLocalVideoPreview(
2859
+ } else if (meta.type?.startsWith("video/")) {
2860
+ container = renderLocalVideoPreview(
2229
2861
  container,
2230
2862
  meta.file,
2231
2863
  meta.type,
@@ -2233,14 +2865,23 @@ async function renderLocalFilePreview(container, meta, fileName, resourceId, isR
2233
2865
  state,
2234
2866
  deps
2235
2867
  );
2236
- container = newContainer;
2237
2868
  } else {
2238
- container.innerHTML = `<div class="flex flex-col items-center justify-center h-full text-gray-400"><div class="text-2xl mb-2">\u{1F4C1}</div><div class="text-sm">${escapeHtml(fileName)}</div></div>`;
2869
+ container.innerHTML = `<div class="flex flex-col items-center justify-center h-full text-gray-400"><div style="font-size:36px;" class="mb-2">\u{1F4C1}</div><div class="text-sm">${escapeHtml(fileName)}</div></div>`;
2239
2870
  }
2240
- if (!isReadonly && !(meta.type && meta.type.startsWith("video/"))) {
2871
+ if (!isReadonly && !meta.type?.startsWith("video/")) {
2241
2872
  renderDeleteButton(container, resourceId, state);
2242
2873
  }
2243
2874
  }
2875
+ function renderUploadedVideoPreview(container, thumbnailUrl, state) {
2876
+ const video = document.createElement("video");
2877
+ video.className = "w-full h-full object-contain";
2878
+ video.controls = true;
2879
+ video.preload = "metadata";
2880
+ video.muted = true;
2881
+ video.src = thumbnailUrl;
2882
+ video.appendChild(document.createTextNode(t("videoNotSupported", state)));
2883
+ container.appendChild(video);
2884
+ }
2244
2885
  async function renderUploadedFilePreview(container, resourceId, fileName, meta, state) {
2245
2886
  if (!state.config.getThumbnail) {
2246
2887
  setEmptyFileContainer(container, state);
@@ -2250,11 +2891,12 @@ async function renderUploadedFilePreview(container, resourceId, fileName, meta,
2250
2891
  const thumbnailUrl = await state.config.getThumbnail(resourceId);
2251
2892
  if (thumbnailUrl) {
2252
2893
  clear(container);
2253
- if (meta && meta.type && meta.type.startsWith("video/")) {
2254
- renderUploadedVideoPreview(container, thumbnailUrl, meta.type, state);
2894
+ if (meta?.type?.startsWith("video/")) {
2895
+ renderUploadedVideoPreview(container, thumbnailUrl, state);
2255
2896
  } else {
2256
2897
  const img = document.createElement("img");
2257
2898
  img.className = "w-full h-full object-contain";
2899
+ img.style.background = "var(--fb-file-upload-bg-color,#f3f4f6)";
2258
2900
  img.alt = fileName || t("previewAlt", state);
2259
2901
  img.src = thumbnailUrl;
2260
2902
  container.appendChild(img);
@@ -2282,9 +2924,6 @@ async function renderFilePreview(container, resourceId, state, options = {}) {
2282
2924
  );
2283
2925
  }
2284
2926
  clear(container);
2285
- if (isReadonly) {
2286
- container.classList.add("cursor-pointer");
2287
- }
2288
2927
  const meta = state.resourceIndex.get(resourceId);
2289
2928
  if (meta && meta.file && meta.file instanceof File) {
2290
2929
  await renderLocalFilePreview(
@@ -2297,364 +2936,291 @@ async function renderFilePreview(container, resourceId, state, options = {}) {
2297
2936
  deps
2298
2937
  );
2299
2938
  } else {
2300
- await renderUploadedFilePreview(
2301
- container,
2302
- resourceId,
2303
- fileName,
2304
- meta,
2305
- state
2306
- );
2939
+ await renderUploadedFilePreview(container, resourceId, fileName, meta, state);
2307
2940
  const isVideo = meta?.type?.startsWith("video/");
2308
2941
  if (!isReadonly && !isVideo) {
2309
2942
  renderDeleteButton(container, resourceId, state);
2310
2943
  }
2311
2944
  }
2312
2945
  }
2313
- async function renderFilePreviewReadonly(resourceId, state, fileName) {
2946
+ function resolveFileName(resourceId, meta, fileName) {
2947
+ if (fileName) return fileName;
2948
+ if (meta?.name?.includes(".")) return meta.name;
2949
+ const basename = resourceId.includes("/") ? resourceId.split("/").pop() : resourceId;
2950
+ return basename?.includes(".") ? basename : "";
2951
+ }
2952
+ async function renderFilePreviewReadonly(resourceId, state, fileName, options = {}) {
2314
2953
  const meta = state.resourceIndex.get(resourceId);
2315
- const actualFileName = meta?.name || resourceId.split("/").pop() || "file";
2316
- const isPSD = actualFileName.toLowerCase().match(/\.psd$/);
2317
- const fileResult = document.createElement("div");
2318
- fileResult.className = isPSD ? "space-y-2" : "space-y-3";
2319
- const previewContainer = document.createElement("div");
2320
- if (isPSD) {
2321
- previewContainer.className = "bg-gray-100 rounded-lg overflow-hidden cursor-pointer hover:opacity-90 transition-opacity flex items-center p-3 max-w-sm";
2322
- } else {
2323
- previewContainer.className = "bg-gray-100 rounded-lg overflow-hidden cursor-pointer hover:opacity-90 transition-opacity";
2324
- }
2325
- const isImage = !isPSD && (meta?.type?.startsWith("image/") || actualFileName.toLowerCase().match(/\.(jpg|jpeg|png|gif|webp)$/));
2326
- const isVideo = meta?.type?.startsWith("video/") || actualFileName.toLowerCase().match(/\.(mp4|webm|avi|mov)$/);
2327
- if (isImage) {
2954
+ const actualFileName = resolveFileName(resourceId, meta, fileName);
2955
+ const { canRemove = false, removeHandler = null } = options;
2956
+ const isImage = meta?.type?.startsWith("image/") || Boolean(actualFileName.toLowerCase().match(/\.(jpg|jpeg|png|gif|webp)$/));
2957
+ const isVideo = meta?.type?.startsWith("video/") || Boolean(actualFileName.toLowerCase().match(/\.(mp4|webm|avi|mov)$/));
2958
+ const tile = createFileTile();
2959
+ tile.classList.add("fb-tile-resource");
2960
+ tile.style.cursor = "pointer";
2961
+ if (actualFileName) {
2962
+ tile.title = actualFileName;
2963
+ }
2964
+ const localFileUrl = meta?.file instanceof File ? getLocalFileUrl(meta.file) : null;
2965
+ const resolveOpenUrl = async () => {
2966
+ if (state.config.getDownloadUrl) return state.config.getDownloadUrl(resourceId);
2967
+ if (state.config.getThumbnail) return state.config.getThumbnail(resourceId);
2968
+ return localFileUrl;
2969
+ };
2970
+ tile.onclick = async () => {
2971
+ const url = await resolveOpenUrl();
2972
+ if (url) {
2973
+ window.open(url, "_blank");
2974
+ } else if (state.config.downloadFile) {
2975
+ state.config.downloadFile(resourceId, actualFileName);
2976
+ } else {
2977
+ forceDownload(resourceId, actualFileName, state).catch((err) => {
2978
+ console.error("Download failed:", err);
2979
+ });
2980
+ }
2981
+ };
2982
+ const actionsEl = createTileActions({
2983
+ canRemove,
2984
+ removeHandler,
2985
+ state,
2986
+ resourceId,
2987
+ fileName: actualFileName,
2988
+ meta
2989
+ });
2990
+ const resolveImageDisplayUrl = async () => {
2328
2991
  if (state.config.getThumbnail) {
2329
2992
  try {
2330
- const thumbnailUrl = await state.config.getThumbnail(resourceId);
2331
- if (thumbnailUrl) {
2332
- previewContainer.innerHTML = `<img src="${thumbnailUrl}" alt="${escapeHtml(actualFileName)}" class="w-full h-auto">`;
2333
- } else {
2334
- previewContainer.innerHTML = `<div class="aspect-video flex items-center justify-center text-gray-400"><div class="text-center"><div class="text-4xl mb-2">\u{1F5BC}\uFE0F</div><div class="text-sm">${escapeHtml(actualFileName)}</div></div></div>`;
2335
- }
2336
- } catch (error) {
2337
- console.warn("getThumbnail failed for", resourceId, error);
2338
- previewContainer.innerHTML = `<div class="aspect-video flex items-center justify-center text-gray-400"><div class="text-center"><div class="text-4xl mb-2">\u{1F5BC}\uFE0F</div><div class="text-sm">${escapeHtml(actualFileName)}</div></div></div>`;
2993
+ const url = await state.config.getThumbnail(resourceId);
2994
+ if (url) return url;
2995
+ } catch {
2339
2996
  }
2997
+ }
2998
+ return localFileUrl;
2999
+ };
3000
+ const renderImageFallback = () => {
3001
+ tile.innerHTML = `<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;font-size:36px;">\u{1F5BC}\uFE0F</div>`;
3002
+ tile.appendChild(actionsEl);
3003
+ };
3004
+ if (isImage) {
3005
+ const displayUrl = await resolveImageDisplayUrl();
3006
+ if (displayUrl) {
3007
+ const img = document.createElement("img");
3008
+ img.style.cssText = "width:100%;height:100%;object-fit:contain;background:var(--fb-file-upload-bg-color,#f3f4f6);";
3009
+ img.alt = actualFileName;
3010
+ img.src = displayUrl;
3011
+ tile.appendChild(img);
3012
+ tile.appendChild(actionsEl);
3013
+ attachZoomHover(tile, displayUrl, actualFileName, actionsEl);
2340
3014
  } else {
2341
- previewContainer.innerHTML = `<div class="aspect-video flex items-center justify-center text-gray-400"><div class="text-center"><div class="text-4xl mb-2">\u{1F5BC}\uFE0F</div><div class="text-sm">${escapeHtml(actualFileName)}</div></div></div>`;
3015
+ renderImageFallback();
2342
3016
  }
2343
3017
  } else if (isVideo) {
2344
3018
  if (state.config.getThumbnail) {
2345
3019
  try {
2346
3020
  const videoUrl = await state.config.getThumbnail(resourceId);
2347
3021
  if (videoUrl) {
2348
- previewContainer.innerHTML = `
2349
- <div class="relative group">
2350
- <video class="w-full h-auto" controls preload="auto" muted src="${videoUrl}">
2351
- ${escapeHtml(t("videoNotSupported", state))}
2352
- </video>
2353
- <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">
2354
- <div class="bg-white bg-opacity-90 rounded-full p-3">
2355
- <svg class="w-8 h-8 text-gray-800" fill="currentColor" viewBox="0 0 24 24">
2356
- <path d="M8 5v14l11-7z"/>
2357
- </svg>
2358
- </div>
3022
+ tile.innerHTML = `
3023
+ <img style="width:100%;height:100%;object-fit:contain;background:var(--fb-file-upload-bg-color,#f3f4f6);" alt="${escapeHtml(actualFileName)}" src="${videoUrl}">
3024
+ <div class="fb-video-overlay">
3025
+ <div class="fb-play-btn" style="width:22px;height:22px;">
3026
+ <svg width="10" height="12" viewBox="0 0 10 12" fill="currentColor"><path d="M0 0l10 6-10 6z"/></svg>
2359
3027
  </div>
2360
- </div>
2361
- `;
3028
+ </div>`;
3029
+ tile.appendChild(actionsEl);
2362
3030
  } else {
2363
- previewContainer.innerHTML = `<div class="aspect-video flex items-center justify-center text-gray-400"><div class="text-center"><div class="text-4xl mb-2">\u{1F3A5}</div><div class="text-sm">${escapeHtml(actualFileName)}</div></div></div>`;
3031
+ tile.innerHTML = `<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;font-size:36px;">\u{1F3A5}</div>`;
3032
+ tile.appendChild(actionsEl);
2364
3033
  }
2365
- } catch (error) {
2366
- console.warn("getThumbnail failed for video", resourceId, error);
2367
- previewContainer.innerHTML = `<div class="aspect-video flex items-center justify-center text-gray-400"><div class="text-center"><div class="text-4xl mb-2">\u{1F3A5}</div><div class="text-sm">${escapeHtml(actualFileName)}</div></div></div>`;
3034
+ } catch {
3035
+ tile.innerHTML = `<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;font-size:36px;">\u{1F3A5}</div>`;
3036
+ tile.appendChild(actionsEl);
2368
3037
  }
2369
3038
  } else {
2370
- previewContainer.innerHTML = `<div class="aspect-video flex items-center justify-center text-gray-400"><div class="text-center"><div class="text-4xl mb-2">\u{1F3A5}</div><div class="text-sm">${escapeHtml(actualFileName)}</div></div></div>`;
3039
+ tile.innerHTML = `<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;font-size:36px;">\u{1F3A5}</div>`;
3040
+ tile.appendChild(actionsEl);
2371
3041
  }
2372
3042
  } else {
2373
- const fileIcon = isPSD ? "\u{1F3A8}" : "\u{1F4C1}";
2374
- const fileDescription = isPSD ? "PSD File" : "Document";
2375
- if (isPSD) {
2376
- previewContainer.innerHTML = `
2377
- <div class="flex items-center space-x-3">
2378
- <div class="text-3xl text-gray-400">${fileIcon}</div>
2379
- <div class="flex-1 min-w-0">
2380
- <div class="text-sm font-medium text-gray-900 truncate">${escapeHtml(actualFileName)}</div>
2381
- <div class="text-xs text-gray-500">${fileDescription}</div>
2382
- </div>
2383
- </div>
2384
- `;
2385
- } else {
2386
- previewContainer.innerHTML = `<div class="aspect-video flex items-center justify-center text-gray-400"><div class="text-center"><div class="text-4xl mb-2">${fileIcon}</div><div class="text-sm">${escapeHtml(actualFileName)}</div><div class="text-xs text-gray-500 mt-1">${fileDescription}</div></div></div>`;
3043
+ if (state.config.getThumbnail) {
3044
+ try {
3045
+ const thumbUrl = await state.config.getThumbnail(resourceId);
3046
+ if (thumbUrl) {
3047
+ const img = document.createElement("img");
3048
+ img.style.cssText = "width:100%;height:100%;object-fit:contain;background:var(--fb-file-upload-bg-color,#f3f4f6);";
3049
+ img.alt = actualFileName || resourceId;
3050
+ img.src = thumbUrl;
3051
+ tile.appendChild(img);
3052
+ tile.appendChild(actionsEl);
3053
+ return tile;
3054
+ }
3055
+ } catch {
3056
+ }
2387
3057
  }
3058
+ const captionHtml = actualFileName ? `<div class="fb-tile-label">${escapeHtml(actualFileName.length > 10 ? actualFileName.substring(0, 8) + "\u2026" : actualFileName)}</div>
3059
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M19 9h-4V3H9v6H5l7 7 7-7zm-7 9H5v2h14v-2h-7z"/></svg>` : "";
3060
+ tile.innerHTML = `
3061
+ <div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;padding:6px;gap:4px;">
3062
+ <div style="font-size:36px;">\u{1F4C1}</div>
3063
+ ${captionHtml}
3064
+ </div>`;
3065
+ tile.appendChild(actionsEl);
2388
3066
  }
2389
- const fileNameElement = document.createElement("p");
2390
- fileNameElement.className = isPSD ? "hidden" : "text-sm font-medium text-gray-900 text-center";
2391
- fileNameElement.textContent = actualFileName;
2392
- const downloadButton = document.createElement("button");
2393
- downloadButton.className = "w-full px-3 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors";
2394
- downloadButton.textContent = t("downloadButton", state);
2395
- downloadButton.onclick = (e) => {
2396
- e.preventDefault();
2397
- e.stopPropagation();
2398
- if (state.config.downloadFile) {
2399
- state.config.downloadFile(resourceId, actualFileName);
2400
- } else {
2401
- forceDownload(resourceId, actualFileName, state);
2402
- }
2403
- };
2404
- fileResult.appendChild(previewContainer);
2405
- fileResult.appendChild(fileNameElement);
2406
- fileResult.appendChild(downloadButton);
2407
- return fileResult;
3067
+ return tile;
2408
3068
  }
2409
- function renderResourcePills(container, rids, state, onRemove, hint, countInfo) {
2410
- clear(container);
2411
- const buildHintLine = () => {
2412
- const parts = [t("clickDragTextMultiple", state)];
2413
- if (hint) parts.push(hint);
2414
- if (countInfo) parts.push(countInfo);
2415
- return parts.join(" \u2022 ");
2416
- };
2417
- const isInitialRender = !container.classList.contains("grid");
2418
- if ((!rids || rids.length === 0) && isInitialRender) {
2419
- const gridContainer2 = document.createElement("div");
2420
- gridContainer2.className = "grid grid-cols-4 gap-3 mb-3";
2421
- for (let i = 0; i < 4; i++) {
2422
- const slot = document.createElement("div");
2423
- slot.className = "aspect-square bg-gray-100 border-2 border-dashed border-gray-300 rounded flex items-center justify-center cursor-pointer hover:border-gray-400 transition-colors";
2424
- const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
2425
- svg.setAttribute("class", "w-12 h-12 text-gray-400");
2426
- svg.setAttribute("fill", "currentColor");
2427
- svg.setAttribute("viewBox", "0 0 24 24");
2428
- const path = document.createElementNS(
2429
- "http://www.w3.org/2000/svg",
2430
- "path"
2431
- );
2432
- path.setAttribute(
2433
- "d",
2434
- "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"
2435
- );
2436
- svg.appendChild(path);
2437
- slot.appendChild(svg);
2438
- slot.onclick = () => {
2439
- let filesWrapper = container.parentElement;
2440
- while (filesWrapper && !filesWrapper.classList.contains("space-y-2")) {
2441
- filesWrapper = filesWrapper.parentElement;
2442
- }
2443
- if (!filesWrapper && container.classList.contains("space-y-2")) {
2444
- filesWrapper = container;
2445
- }
2446
- const fileInput = filesWrapper?.querySelector(
2447
- 'input[type="file"]'
2448
- );
2449
- if (fileInput) fileInput.click();
2450
- };
2451
- gridContainer2.appendChild(slot);
2452
- }
2453
- const hintText2 = document.createElement("div");
2454
- hintText2.className = "text-center text-xs text-gray-500 mt-2";
2455
- hintText2.textContent = buildHintLine();
2456
- container.appendChild(gridContainer2);
2457
- container.appendChild(hintText2);
2458
- return;
2459
- }
2460
- const gridContainer = document.createElement("div");
2461
- gridContainer.className = "files-list grid grid-cols-4 gap-3";
2462
- const currentImagesCount = rids ? rids.length : 0;
2463
- const rowsNeeded = Math.floor(currentImagesCount / 4) + 1;
2464
- const slotsNeeded = rowsNeeded * 4;
2465
- for (let i = 0; i < slotsNeeded; i++) {
2466
- const slot = document.createElement("div");
2467
- if (rids && i < rids.length) {
2468
- const rid = rids[i];
2469
- const meta = state.resourceIndex.get(rid);
2470
- slot.className = "resource-pill aspect-square bg-gray-100 rounded-lg overflow-hidden relative group border border-gray-300";
2471
- slot.dataset.resourceId = rid;
2472
- renderThumbnailForResource(slot, rid, meta, state).catch((err) => {
2473
- console.error("Failed to render thumbnail:", err);
2474
- slot.innerHTML = `<div class="flex flex-col items-center justify-center h-full text-gray-400">
2475
- <div class="text-2xl mb-1">\u{1F4C1}</div>
2476
- <div class="text-xs">${escapeHtml(t("previewError", state))}</div>
2477
- </div>`;
2478
- });
2479
- if (onRemove) {
2480
- const overlay = document.createElement("div");
2481
- overlay.className = "absolute inset-0 bg-black bg-opacity-50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center";
2482
- const removeBtn = document.createElement("button");
2483
- removeBtn.className = "bg-red-600 text-white px-2 py-1 rounded text-xs";
2484
- removeBtn.textContent = t("removeElement", state);
2485
- removeBtn.onclick = (e) => {
2486
- e.stopPropagation();
2487
- onRemove(rid);
2488
- };
2489
- overlay.appendChild(removeBtn);
2490
- slot.appendChild(overlay);
2491
- }
2492
- } else {
2493
- slot.className = "aspect-square bg-gray-100 border-2 border-dashed border-gray-300 rounded-lg flex items-center justify-center cursor-pointer hover:border-gray-400 transition-colors";
2494
- slot.innerHTML = '<svg class="w-12 h-12 text-gray-400" fill="currentColor" viewBox="0 0 24 24"><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/></svg>';
2495
- slot.onclick = () => {
2496
- let filesWrapper = container.parentElement;
2497
- while (filesWrapper && !filesWrapper.classList.contains("space-y-2")) {
2498
- filesWrapper = filesWrapper.parentElement;
2499
- }
2500
- if (!filesWrapper && container.classList.contains("space-y-2")) {
2501
- filesWrapper = container;
2502
- }
2503
- const fileInput = filesWrapper?.querySelector(
2504
- 'input[type="file"]'
2505
- );
2506
- if (fileInput) fileInput.click();
2507
- };
2508
- }
2509
- gridContainer.appendChild(slot);
2510
- }
2511
- container.appendChild(gridContainer);
2512
- const hintText = document.createElement("div");
2513
- hintText.className = "text-center text-xs text-gray-500 mt-2";
2514
- hintText.textContent = buildHintLine();
2515
- container.appendChild(hintText);
2516
- }
2517
- function renderThumbnailError(slot, state, iconSize = "w-12 h-12") {
2518
- slot.innerHTML = `<div class="flex flex-col items-center justify-center h-full text-gray-400">
2519
- <svg class="${escapeHtml(iconSize)} text-red-400" fill="currentColor" viewBox="0 0 24 24">
2520
- <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/>
2521
- </svg>
2522
- <div class="text-xs mt-1 text-red-600">${escapeHtml(t("previewError", state))}</div>
2523
- </div>`;
2524
- }
2525
- async function renderThumbnailForResource(slot, rid, meta, state) {
2526
- if (meta && meta.type?.startsWith("image/")) {
2527
- if (meta.file && meta.file instanceof File) {
2528
- const img = document.createElement("img");
2529
- img.className = "w-full h-full object-contain";
2530
- img.alt = meta.name;
2531
- const reader = new FileReader();
2532
- reader.onload = (e) => {
2533
- img.src = e.target?.result || "";
3069
+ async function renderSingleFileEditTile(fileContainer, resourceId, state, deps) {
3070
+ const meta = state.resourceIndex.get(resourceId);
3071
+ const fileName = meta?.name ?? resourceId.split("/").pop() ?? "";
3072
+ const removeHandler = deps.onRemove ?? null;
3073
+ const tile = await renderFilePreviewReadonly(resourceId, state, fileName, {
3074
+ canRemove: true,
3075
+ removeHandler
3076
+ });
3077
+ fileContainer.className = "file-preview-container";
3078
+ fileContainer.removeAttribute("style");
3079
+ clear(fileContainer);
3080
+ fileContainer.appendChild(tile);
3081
+ }
3082
+ async function fillTileContent(tile, rid, meta, state, actionsEl) {
3083
+ if (meta?.type?.startsWith("image/")) {
3084
+ if (meta.file && meta.file instanceof File) {
3085
+ const img = document.createElement("img");
3086
+ img.style.cssText = "width:100%;height:100%;object-fit:contain;background:var(--fb-file-upload-bg-color,#f3f4f6);";
3087
+ img.alt = meta.name;
3088
+ const reader = new FileReader();
3089
+ reader.onload = (e) => {
3090
+ img.src = e.target?.result || "";
3091
+ attachZoomHover(tile, img.src, meta.name, actionsEl ?? null);
2534
3092
  };
2535
3093
  reader.readAsDataURL(meta.file);
2536
- slot.appendChild(img);
3094
+ tile.appendChild(img);
2537
3095
  } else if (state.config.getThumbnail) {
2538
3096
  try {
2539
3097
  const url = await state.config.getThumbnail(rid);
2540
3098
  if (url) {
2541
3099
  const img = document.createElement("img");
2542
- img.className = "w-full h-full object-contain";
3100
+ img.style.cssText = "width:100%;height:100%;object-fit:contain;background:var(--fb-file-upload-bg-color,#f3f4f6);";
2543
3101
  img.alt = meta.name;
2544
3102
  img.src = url;
2545
- slot.appendChild(img);
3103
+ tile.appendChild(img);
3104
+ attachZoomHover(tile, url, meta.name, actionsEl ?? null);
2546
3105
  } else {
2547
- slot.innerHTML = `<div class="flex flex-col items-center justify-center h-full text-gray-400">
2548
- <svg class="w-12 h-12" fill="currentColor" viewBox="0 0 24 24">
2549
- <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"/>
2550
- </svg>
2551
- </div>`;
3106
+ tile.innerHTML = `<div style="display:flex;align-items:center;justify-content:center;height:100%;font-size:36px;">\u{1F5BC}\uFE0F</div>`;
2552
3107
  }
2553
3108
  } catch (error) {
2554
3109
  const err = error instanceof Error ? error : new Error(String(error));
2555
- if (state.config.onThumbnailError) {
2556
- state.config.onThumbnailError(err, rid);
2557
- }
2558
- renderThumbnailError(slot, state);
3110
+ if (state.config.onThumbnailError) state.config.onThumbnailError(err, rid);
3111
+ tile.innerHTML = `<div style="display:flex;align-items:center;justify-content:center;height:100%;font-size:16px;color:var(--fb-error-color,#ef4444);">\u2715</div>`;
2559
3112
  }
2560
3113
  } else {
2561
- slot.innerHTML = `<div class="flex flex-col items-center justify-center h-full text-gray-400">
2562
- <svg class="w-12 h-12" fill="currentColor" viewBox="0 0 24 24">
2563
- <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"/>
2564
- </svg>
2565
- </div>`;
3114
+ tile.innerHTML = `<div style="display:flex;align-items:center;justify-content:center;height:100%;font-size:36px;">\u{1F5BC}\uFE0F</div>`;
2566
3115
  }
2567
- } else if (meta && meta.type?.startsWith("video/")) {
3116
+ if (actionsEl) tile.appendChild(actionsEl);
3117
+ } else if (meta?.type?.startsWith("video/")) {
2568
3118
  if (meta.file && meta.file instanceof File) {
2569
3119
  const videoUrl = URL.createObjectURL(meta.file);
2570
- slot.innerHTML = `
2571
- <div class="relative group h-full w-full">
2572
- <video class="w-full h-full object-contain" preload="metadata" muted src="${videoUrl}">
2573
- </video>
2574
- <div class="absolute inset-0 bg-black bg-opacity-30 flex items-center justify-center">
2575
- <div class="bg-white bg-opacity-90 rounded-full p-1">
2576
- <svg class="w-4 h-4 text-gray-800" fill="currentColor" viewBox="0 0 24 24">
2577
- <path d="M8 5v14l11-7z"/>
2578
- </svg>
2579
- </div>
3120
+ tile.innerHTML = `
3121
+ <video style="width:100%;height:100%;" preload="metadata" muted src="${videoUrl}"></video>
3122
+ <div class="fb-video-overlay">
3123
+ <div class="fb-play-btn" style="width:20px;height:20px;">
3124
+ <svg width="8" height="10" viewBox="0 0 8 10" fill="currentColor"><path d="M0 0l8 5-8 5z"/></svg>
2580
3125
  </div>
2581
- </div>
2582
- `;
3126
+ </div>`;
2583
3127
  } else if (state.config.getThumbnail) {
2584
3128
  try {
2585
3129
  const videoUrl = await state.config.getThumbnail(rid);
2586
3130
  if (videoUrl) {
2587
- slot.innerHTML = `
2588
- <div class="relative group h-full w-full">
2589
- <video class="w-full h-full object-contain" preload="metadata" muted src="${videoUrl}">
2590
- </video>
2591
- <div class="absolute inset-0 bg-black bg-opacity-30 flex items-center justify-center">
2592
- <div class="bg-white bg-opacity-90 rounded-full p-1">
2593
- <svg class="w-4 h-4 text-gray-800" fill="currentColor" viewBox="0 0 24 24">
2594
- <path d="M8 5v14l11-7z"/>
2595
- </svg>
2596
- </div>
3131
+ tile.innerHTML = `
3132
+ <img style="width:100%;height:100%;object-fit:contain;background:var(--fb-file-upload-bg-color,#f3f4f6);" alt="${escapeHtml(meta.name)}" src="${videoUrl}">
3133
+ <div class="fb-video-overlay">
3134
+ <div class="fb-play-btn" style="width:20px;height:20px;">
3135
+ <svg width="8" height="10" viewBox="0 0 8 10" fill="currentColor"><path d="M0 0l8 5-8 5z"/></svg>
2597
3136
  </div>
2598
- </div>
2599
- `;
3137
+ </div>`;
2600
3138
  } else {
2601
- slot.innerHTML = `<div class="flex flex-col items-center justify-center h-full text-gray-400">
2602
- <svg class="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
2603
- <path d="M8 5v14l11-7z"/>
2604
- </svg>
2605
- <div class="text-xs mt-1">${escapeHtml(meta?.name || "Video")}</div>
2606
- </div>`;
3139
+ tile.innerHTML = `<div style="display:flex;align-items:center;justify-content:center;height:100%;font-size:36px;">\u{1F3A5}</div>`;
2607
3140
  }
2608
3141
  } catch (error) {
2609
3142
  const err = error instanceof Error ? error : new Error(String(error));
2610
- if (state.config.onThumbnailError) {
2611
- state.config.onThumbnailError(err, rid);
2612
- }
2613
- renderThumbnailError(slot, state, "w-8 h-8");
3143
+ if (state.config.onThumbnailError) state.config.onThumbnailError(err, rid);
3144
+ tile.innerHTML = `<div style="display:flex;align-items:center;justify-content:center;height:100%;font-size:16px;color:var(--fb-error-color,#ef4444);">\u2715</div>`;
2614
3145
  }
2615
3146
  } else {
2616
- slot.innerHTML = `<div class="flex flex-col items-center justify-center h-full text-gray-400">
2617
- <svg class="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
2618
- <path d="M8 5v14l11-7z"/>
2619
- </svg>
2620
- <div class="text-xs mt-1">${escapeHtml(meta?.name || "Video")}</div>
2621
- </div>`;
3147
+ tile.innerHTML = `<div style="display:flex;align-items:center;justify-content:center;height:100%;font-size:36px;">\u{1F3A5}</div>`;
2622
3148
  }
3149
+ if (actionsEl) tile.appendChild(actionsEl);
2623
3150
  } else {
2624
- slot.innerHTML = `<div class="flex flex-col items-center justify-center h-full text-gray-400">
2625
- <div class="text-2xl mb-1">\u{1F4C1}</div>
2626
- <div class="text-xs">${escapeHtml(meta?.name || "File")}</div>
2627
- </div>`;
3151
+ const name = meta?.name ?? "";
3152
+ const hasExtension = name.includes(".");
3153
+ const captionHtml = hasExtension ? `<div class="fb-tile-label">${escapeHtml(name.length > 10 ? name.substring(0, 8) + "\u2026" : name)}</div>` : "";
3154
+ tile.innerHTML = `
3155
+ <div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;padding:6px;gap:4px;">
3156
+ <div style="font-size:36px;">\u{1F4C1}</div>
3157
+ ${captionHtml}
3158
+ </div>`;
3159
+ if (actionsEl) tile.appendChild(actionsEl);
2628
3160
  }
2629
3161
  }
2630
- function setEmptyFileContainer(fileContainer, state, hint) {
2631
- const hintHtml = hint ? `<div class="text-xs text-gray-500 mt-1">${escapeHtml(hint)}</div>` : "";
2632
- fileContainer.innerHTML = `
2633
- <div class="flex flex-col items-center justify-center h-full text-gray-400">
2634
- <svg class="w-6 h-6 mb-2" fill="currentColor" viewBox="0 0 24 24">
2635
- <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"/>
2636
- </svg>
2637
- <div class="text-sm text-center">${escapeHtml(t("clickDragText", state))}</div>
2638
- ${hintHtml}
2639
- </div>
2640
- `;
3162
+ async function forceDownload(resourceId, fileName, state) {
3163
+ try {
3164
+ let fileUrl = null;
3165
+ if (state.config.getDownloadUrl) {
3166
+ fileUrl = state.config.getDownloadUrl(resourceId);
3167
+ } else if (state.config.getThumbnail) {
3168
+ fileUrl = await state.config.getThumbnail(resourceId);
3169
+ }
3170
+ if (fileUrl) {
3171
+ const finalUrl = fileUrl.startsWith("http") ? fileUrl : new URL(fileUrl, window.location.href).href;
3172
+ const response = await fetch(finalUrl);
3173
+ if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
3174
+ const blob = await response.blob();
3175
+ downloadBlob(blob, fileName);
3176
+ } else {
3177
+ throw new Error("No download URL available for resource");
3178
+ }
3179
+ } catch (error) {
3180
+ const err = error instanceof Error ? error : new Error(String(error));
3181
+ if (state.config.onDownloadError) {
3182
+ state.config.onDownloadError(err, resourceId, fileName);
3183
+ }
3184
+ console.error(`File download failed for ${fileName}:`, err);
3185
+ throw err;
3186
+ }
2641
3187
  }
2642
- function showFileError(container, message) {
2643
- const existing = container.closest(".space-y-2")?.querySelector(".file-error-message");
2644
- if (existing) existing.remove();
2645
- const errorEl = document.createElement("div");
2646
- errorEl.className = "file-error-message error-message";
2647
- errorEl.style.cssText = `
2648
- color: var(--fb-error-color);
2649
- font-size: var(--fb-font-size-small);
2650
- margin-top: 0.25rem;
2651
- `;
2652
- errorEl.textContent = message;
2653
- container.closest(".space-y-2")?.appendChild(errorEl);
3188
+ function downloadBlob(blob, fileName) {
3189
+ try {
3190
+ const blobUrl = URL.createObjectURL(blob);
3191
+ const link = document.createElement("a");
3192
+ link.href = blobUrl;
3193
+ link.download = fileName;
3194
+ link.style.display = "none";
3195
+ document.body.appendChild(link);
3196
+ link.click();
3197
+ document.body.removeChild(link);
3198
+ setTimeout(() => {
3199
+ URL.revokeObjectURL(blobUrl);
3200
+ }, 100);
3201
+ } catch (error) {
3202
+ throw new Error(`Blob download failed: ${error.message}`);
3203
+ }
2654
3204
  }
2655
- function clearFileError(container) {
2656
- const existing = container.closest(".space-y-2")?.querySelector(".file-error-message");
2657
- if (existing) existing.remove();
3205
+
3206
+ // src/components/file/upload.ts
3207
+ async function uploadSingleFile(file, state) {
3208
+ if (!state.config.uploadFile) {
3209
+ throw new Error(
3210
+ "No upload handler configured. Set uploadHandler via FormBuilder.setUploadHandler()"
3211
+ );
3212
+ }
3213
+ try {
3214
+ const rid = await state.config.uploadFile(file);
3215
+ if (typeof rid !== "string") {
3216
+ throw new Error("Upload handler must return a string resource ID");
3217
+ }
3218
+ return rid;
3219
+ } catch (error) {
3220
+ const err = error instanceof Error ? error : new Error(String(error));
3221
+ if (state.config.onUploadError) state.config.onUploadError(err, file);
3222
+ throw new Error(`File upload failed: ${err.message}`);
3223
+ }
2658
3224
  }
2659
3225
  async function handleFileSelect(file, container, fieldName, state, deps = null, instance, allowedExtensions = [], maxSizeMB = Infinity) {
2660
3226
  if (!isFileExtensionAllowed(file.name, allowedExtensions)) {
@@ -2673,24 +3239,18 @@ async function handleFileSelect(file, container, fieldName, state, deps = null,
2673
3239
  return;
2674
3240
  }
2675
3241
  clearFileError(container);
3242
+ ensureFileStyles();
3243
+ container.innerHTML = `
3244
+ <div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;gap:6px;padding:6px;">
3245
+ <div class="fb-spinner"></div>
3246
+ <div style="font-size:11px;color:var(--fb-text-secondary-color,#6b7280);text-align:center;">${escapeHtml(t("uploadingFile", state))}</div>
3247
+ </div>`;
2676
3248
  let rid;
2677
- if (state.config.uploadFile) {
2678
- try {
2679
- rid = await state.config.uploadFile(file);
2680
- if (typeof rid !== "string") {
2681
- throw new Error("Upload handler must return a string resource ID");
2682
- }
2683
- } catch (error) {
2684
- const err = error instanceof Error ? error : new Error(String(error));
2685
- if (state.config.onUploadError) {
2686
- state.config.onUploadError(err, file);
2687
- }
2688
- throw new Error(`File upload failed: ${err.message}`);
2689
- }
2690
- } else {
2691
- throw new Error(
2692
- "No upload handler configured. Set uploadHandler via FormBuilder.setUploadHandler()"
2693
- );
3249
+ try {
3250
+ rid = await uploadSingleFile(file, state);
3251
+ } catch (error) {
3252
+ setEmptyFileContainer(container, state);
3253
+ throw error;
2694
3254
  }
2695
3255
  state.resourceIndex.set(rid, {
2696
3256
  name: file.name,
@@ -2698,7 +3258,6 @@ async function handleFileSelect(file, container, fieldName, state, deps = null,
2698
3258
  size: file.size,
2699
3259
  uploadedAt: /* @__PURE__ */ new Date(),
2700
3260
  file
2701
- // Store the file object for local preview
2702
3261
  });
2703
3262
  let hiddenInput = container.parentElement?.querySelector(
2704
3263
  'input[type="hidden"]'
@@ -2710,140 +3269,132 @@ async function handleFileSelect(file, container, fieldName, state, deps = null,
2710
3269
  container.parentElement?.appendChild(hiddenInput);
2711
3270
  }
2712
3271
  hiddenInput.value = rid;
2713
- renderFilePreview(container, rid, state, {
2714
- fileName: file.name,
2715
- isReadonly: false,
2716
- deps
2717
- }).catch(console.error);
3272
+ const isVideo = file.type.startsWith("video/");
3273
+ if (!isVideo && deps) {
3274
+ renderSingleFileEditTile(container, rid, state, deps).catch(console.error);
3275
+ } else {
3276
+ renderFilePreview(container, rid, state, {
3277
+ fileName: file.name,
3278
+ isReadonly: false,
3279
+ deps
3280
+ }).catch(console.error);
3281
+ }
2718
3282
  if (instance && !state.config.readonly) {
2719
3283
  instance.triggerOnChange(fieldName, rid);
2720
3284
  }
2721
3285
  }
2722
- function setupDragAndDrop(element, dropHandler) {
2723
- element.addEventListener("dragover", (e) => {
2724
- e.preventDefault();
2725
- element.classList.add("border-blue-500", "bg-blue-50");
2726
- });
2727
- element.addEventListener("dragleave", (e) => {
2728
- e.preventDefault();
2729
- element.classList.remove("border-blue-500", "bg-blue-50");
2730
- });
2731
- element.addEventListener("drop", (e) => {
2732
- e.preventDefault();
2733
- element.classList.remove("border-blue-500", "bg-blue-50");
2734
- if (e.dataTransfer?.files) {
2735
- dropHandler(e.dataTransfer.files);
2736
- }
2737
- });
2738
- }
2739
- function addDeleteButton(container, state, onDelete) {
2740
- const existingOverlay = container.querySelector(".delete-overlay");
2741
- if (existingOverlay) {
2742
- existingOverlay.remove();
2743
- }
2744
- const overlay = document.createElement("div");
2745
- overlay.className = "delete-overlay absolute inset-0 bg-black bg-opacity-50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center";
2746
- const deleteBtn = document.createElement("button");
2747
- deleteBtn.className = "bg-red-600 text-white px-3 py-1 rounded text-sm hover:bg-red-700 transition-colors";
2748
- deleteBtn.textContent = t("removeElement", state);
2749
- deleteBtn.onclick = (e) => {
2750
- e.stopPropagation();
2751
- onDelete();
2752
- };
2753
- overlay.appendChild(deleteBtn);
2754
- container.appendChild(overlay);
2755
- }
2756
- async function uploadSingleFile(file, state) {
2757
- if (state.config.uploadFile) {
2758
- try {
2759
- const rid = await state.config.uploadFile(file);
2760
- if (typeof rid !== "string") {
2761
- throw new Error("Upload handler must return a string resource ID");
2762
- }
2763
- return rid;
2764
- } catch (error) {
2765
- const err = error instanceof Error ? error : new Error(String(error));
2766
- if (state.config.onUploadError) {
2767
- state.config.onUploadError(err, file);
2768
- }
2769
- throw new Error(`File upload failed: ${err.message}`);
2770
- }
2771
- } else {
2772
- throw new Error(
2773
- "No upload handler configured. Set uploadHandler via FormBuilder.setUploadHandler()"
3286
+ function filterAndSlice(allFiles, currentCount, constraints, state) {
3287
+ const rejectedByExt = allFiles.filter(
3288
+ (f) => !isFileExtensionAllowed(f.name, constraints.allowedExtensions)
3289
+ );
3290
+ const afterExt = allFiles.filter(
3291
+ (f) => isFileExtensionAllowed(f.name, constraints.allowedExtensions)
3292
+ );
3293
+ const rejectedBySize = afterExt.filter(
3294
+ (f) => !isFileSizeAllowed(f, constraints.maxSize)
3295
+ );
3296
+ const valid = afterExt.filter((f) => isFileSizeAllowed(f, constraints.maxSize));
3297
+ const remaining = constraints.maxCount === Infinity ? valid.length : Math.max(0, constraints.maxCount - currentCount);
3298
+ const accepted = valid.slice(0, remaining);
3299
+ const skippedByCount = valid.length - accepted.length;
3300
+ const errorParts = [];
3301
+ if (rejectedByExt.length > 0) {
3302
+ const formats = constraints.allowedExtensions.join(", ");
3303
+ const names = rejectedByExt.map((f) => f.name).join(", ");
3304
+ errorParts.push(t("invalidFileExtension", state, { name: names, formats }));
3305
+ }
3306
+ if (rejectedBySize.length > 0) {
3307
+ const names = rejectedBySize.map((f) => f.name).join(", ");
3308
+ errorParts.push(
3309
+ t("fileTooLarge", state, { name: names, maxSize: constraints.maxSize })
2774
3310
  );
2775
3311
  }
2776
- }
2777
- async function forceDownload(resourceId, fileName, state) {
2778
- try {
2779
- let fileUrl = null;
2780
- if (state.config.getDownloadUrl) {
2781
- fileUrl = state.config.getDownloadUrl(resourceId);
2782
- } else if (state.config.getThumbnail) {
2783
- fileUrl = await state.config.getThumbnail(resourceId);
2784
- }
2785
- if (fileUrl) {
2786
- const finalUrl = fileUrl.startsWith("http") ? fileUrl : new URL(fileUrl, window.location.href).href;
2787
- const response = await fetch(finalUrl);
2788
- if (!response.ok) {
2789
- throw new Error(`HTTP error! status: ${response.status}`);
2790
- }
2791
- const blob = await response.blob();
2792
- downloadBlob(blob, fileName);
2793
- } else {
2794
- throw new Error("No download URL available for resource");
2795
- }
2796
- } catch (error) {
2797
- const err = error instanceof Error ? error : new Error(String(error));
2798
- if (state.config.onDownloadError) {
2799
- state.config.onDownloadError(err, resourceId, fileName);
2800
- }
2801
- console.error(`File download failed for ${fileName}:`, err);
2802
- throw err;
2803
- }
2804
- }
2805
- function downloadBlob(blob, fileName) {
2806
- try {
2807
- const blobUrl = URL.createObjectURL(blob);
2808
- const link = document.createElement("a");
2809
- link.href = blobUrl;
2810
- link.download = fileName;
2811
- link.style.display = "none";
2812
- document.body.appendChild(link);
2813
- link.click();
2814
- document.body.removeChild(link);
2815
- setTimeout(() => {
2816
- URL.revokeObjectURL(blobUrl);
2817
- }, 100);
2818
- } catch (error) {
2819
- throw new Error(`Blob download failed: ${error.message}`);
3312
+ if (skippedByCount > 0) {
3313
+ errorParts.push(
3314
+ t("filesLimitExceeded", state, {
3315
+ skipped: skippedByCount,
3316
+ max: constraints.maxCount
3317
+ })
3318
+ );
2820
3319
  }
2821
- }
2822
- function addPrefillFilesToIndex(initialFiles, state) {
2823
- if (initialFiles.length > 0) {
2824
- initialFiles.forEach((resourceId) => {
2825
- if (!state.resourceIndex.has(resourceId)) {
2826
- const filename = resourceId.split("/").pop() || "file";
2827
- const extension = filename.split(".").pop()?.toLowerCase();
2828
- let fileType = "application/octet-stream";
2829
- if (extension) {
2830
- if (["jpg", "jpeg", "png", "gif", "webp"].includes(extension)) {
2831
- fileType = `image/${extension === "jpg" ? "jpeg" : extension}`;
2832
- } else if (["mp4", "webm", "mov", "avi"].includes(extension)) {
2833
- fileType = `video/${extension === "mov" ? "quicktime" : extension}`;
2834
- }
3320
+ return { accepted, errorMessage: errorParts.join(" \u2022 ") };
3321
+ }
3322
+ async function uploadBatch(accepted, resourceIds, listEl, state) {
3323
+ await Promise.all(
3324
+ accepted.map(async (file) => {
3325
+ const placeholder = createUploadingTile(file.name, state);
3326
+ if (listEl) {
3327
+ const tilesWrap = ensureTilesWrap(listEl);
3328
+ const addTile = tilesWrap.querySelector(".fb-tile-add");
3329
+ if (addTile) {
3330
+ tilesWrap.insertBefore(placeholder, addTile);
3331
+ } else {
3332
+ tilesWrap.appendChild(placeholder);
2835
3333
  }
2836
- state.resourceIndex.set(resourceId, {
2837
- name: filename,
2838
- type: fileType,
2839
- size: 0,
3334
+ }
3335
+ try {
3336
+ const rid = await uploadSingleFile(file, state);
3337
+ state.resourceIndex.set(rid, {
3338
+ name: file.name,
3339
+ type: file.type,
3340
+ size: file.size,
2840
3341
  uploadedAt: /* @__PURE__ */ new Date(),
2841
3342
  file: void 0
2842
3343
  });
3344
+ resourceIds.push(rid);
3345
+ } finally {
3346
+ placeholder.remove();
2843
3347
  }
2844
- });
2845
- }
3348
+ })
3349
+ );
3350
+ }
3351
+ function setupFilesDropHandler(filesContainer, resourceIds, state, updateCallback, constraints, pathKey, instance) {
3352
+ setupDragAndDrop(filesContainer, async (files) => {
3353
+ const { accepted, errorMessage } = filterAndSlice(
3354
+ Array.from(files),
3355
+ resourceIds.length,
3356
+ constraints,
3357
+ state
3358
+ );
3359
+ if (errorMessage) {
3360
+ showFileError(filesContainer, errorMessage);
3361
+ } else {
3362
+ clearFileError(filesContainer);
3363
+ }
3364
+ const list = filesContainer.querySelector(".files-list") ?? filesContainer;
3365
+ await uploadBatch(accepted, resourceIds, list, state);
3366
+ updateCallback();
3367
+ if (instance && pathKey && !state.config.readonly) {
3368
+ instance.triggerOnChange(pathKey, resourceIds);
3369
+ }
3370
+ });
3371
+ }
3372
+ function setupFilesPickerHandler(filesPicker, resourceIds, state, updateCallback, constraints, pathKey, instance) {
3373
+ filesPicker.onchange = async () => {
3374
+ if (!filesPicker.files) return;
3375
+ const wrapperEl = filesPicker.closest(".space-y-2") || filesPicker.parentElement;
3376
+ const { accepted, errorMessage } = filterAndSlice(
3377
+ Array.from(filesPicker.files),
3378
+ resourceIds.length,
3379
+ constraints,
3380
+ state
3381
+ );
3382
+ if (errorMessage && wrapperEl) {
3383
+ showFileError(wrapperEl, errorMessage);
3384
+ } else if (wrapperEl) {
3385
+ clearFileError(wrapperEl);
3386
+ }
3387
+ const listEl = wrapperEl?.querySelector(".files-list");
3388
+ await uploadBatch(accepted, resourceIds, listEl ?? null, state);
3389
+ updateCallback();
3390
+ filesPicker.value = "";
3391
+ if (instance && pathKey && !state.config.readonly) {
3392
+ instance.triggerOnChange(pathKey, resourceIds);
3393
+ }
3394
+ };
2846
3395
  }
3396
+
3397
+ // src/components/file/render-edit.ts
2847
3398
  function handleInitialFileData(initial, fileContainer, pathKey, fileWrapper, state, deps) {
2848
3399
  if (!state.resourceIndex.has(initial)) {
2849
3400
  const filename = initial.split("/").pop() || "file";
@@ -2864,528 +3415,553 @@ function handleInitialFileData(initial, fileContainer, pathKey, fileWrapper, sta
2864
3415
  file: void 0
2865
3416
  });
2866
3417
  }
2867
- renderFilePreview(fileContainer, initial, state, {
2868
- fileName: initial,
2869
- isReadonly: false,
2870
- deps
2871
- }).catch(console.error);
2872
- const hiddenInput = document.createElement("input");
2873
- hiddenInput.type = "hidden";
3418
+ const meta = state.resourceIndex.get(initial);
3419
+ const isVideo = meta?.type?.startsWith("video/");
3420
+ if (isVideo) {
3421
+ renderFilePreview(fileContainer, initial, state, {
3422
+ fileName: initial,
3423
+ isReadonly: false,
3424
+ deps
3425
+ }).catch(console.error);
3426
+ } else {
3427
+ renderSingleFileEditTile(fileContainer, initial, state, deps).catch(console.error);
3428
+ }
3429
+ const hiddenInput = document.createElement("input");
3430
+ hiddenInput.type = "hidden";
2874
3431
  hiddenInput.name = pathKey;
2875
3432
  hiddenInput.value = initial;
2876
3433
  fileWrapper.appendChild(hiddenInput);
2877
3434
  }
2878
- function setupFilesDropHandler(filesContainer, initialFiles, state, updateCallback, constraints, pathKey, instance) {
2879
- setupDragAndDrop(filesContainer, async (files) => {
2880
- const allFiles = Array.from(files);
2881
- const rejectedByExtension = allFiles.filter(
2882
- (f) => !isFileExtensionAllowed(f.name, constraints.allowedExtensions)
2883
- );
2884
- const afterExtension = allFiles.filter(
2885
- (f) => isFileExtensionAllowed(f.name, constraints.allowedExtensions)
2886
- );
2887
- const rejectedBySize = afterExtension.filter(
2888
- (f) => !isFileSizeAllowed(f, constraints.maxSize)
2889
- );
2890
- const validFiles = afterExtension.filter(
2891
- (f) => isFileSizeAllowed(f, constraints.maxSize)
2892
- );
2893
- const remaining = constraints.maxCount === Infinity ? validFiles.length : Math.max(0, constraints.maxCount - initialFiles.length);
2894
- const arr = validFiles.slice(0, remaining);
2895
- const skippedByCount = validFiles.length - arr.length;
2896
- const errorParts = [];
2897
- if (rejectedByExtension.length > 0) {
2898
- const formats = constraints.allowedExtensions.join(", ");
2899
- const names = rejectedByExtension.map((f) => f.name).join(", ");
2900
- errorParts.push(
2901
- t("invalidFileExtension", state, { name: names, formats })
2902
- );
2903
- }
2904
- if (rejectedBySize.length > 0) {
2905
- const names = rejectedBySize.map((f) => f.name).join(", ");
2906
- errorParts.push(
2907
- t("fileTooLarge", state, { name: names, maxSize: constraints.maxSize })
2908
- );
2909
- }
2910
- if (skippedByCount > 0) {
2911
- errorParts.push(
2912
- t("filesLimitExceeded", state, {
2913
- skipped: skippedByCount,
2914
- max: constraints.maxCount
2915
- })
2916
- );
2917
- }
2918
- if (errorParts.length > 0) {
2919
- showFileError(filesContainer, errorParts.join(" \u2022 "));
3435
+ function renderResourcePills(container, rids, state, onRemove, hint, countInfo, maxCount, isReadonly = false) {
3436
+ ensureFileStyles();
3437
+ const wrapper = container.closest("[data-files-wrapper]");
3438
+ if (wrapper) {
3439
+ wrapper.dataset.resourceIds = JSON.stringify(rids ?? []);
3440
+ }
3441
+ while (container.firstChild) container.removeChild(container.firstChild);
3442
+ const ridList = rids ?? [];
3443
+ const atMax = maxCount !== void 0 && ridList.length >= maxCount;
3444
+ const buildSubHint = () => {
3445
+ const parts = [];
3446
+ if (hint) parts.push(hint);
3447
+ if (countInfo) parts.push(countInfo);
3448
+ return parts.join(" \u2022 ");
3449
+ };
3450
+ const openPicker = () => {
3451
+ const picker = findFilePicker(container);
3452
+ if (picker) picker.click();
3453
+ };
3454
+ if (ridList.length === 0) {
3455
+ if (isReadonly) {
3456
+ const emptyEl = document.createElement("div");
3457
+ emptyEl.className = "fb-tile-empty-text";
3458
+ emptyEl.textContent = t("noFilesSelected", state);
3459
+ container.appendChild(emptyEl);
2920
3460
  } else {
2921
- clearFileError(filesContainer);
2922
- }
2923
- for (const file of arr) {
2924
- const rid = await uploadSingleFile(file, state);
2925
- state.resourceIndex.set(rid, {
2926
- name: file.name,
2927
- type: file.type,
2928
- size: file.size,
2929
- uploadedAt: /* @__PURE__ */ new Date(),
2930
- file: void 0
2931
- });
2932
- initialFiles.push(rid);
2933
- }
2934
- updateCallback();
2935
- if (instance && pathKey && !state.config.readonly) {
2936
- instance.triggerOnChange(pathKey, initialFiles);
2937
- }
2938
- });
2939
- }
2940
- function setupFilesPickerHandler(filesPicker, initialFiles, state, updateCallback, constraints, pathKey, instance) {
2941
- filesPicker.onchange = async () => {
2942
- if (filesPicker.files) {
2943
- const allFiles = Array.from(filesPicker.files);
2944
- const rejectedByExtension = allFiles.filter(
2945
- (f) => !isFileExtensionAllowed(f.name, constraints.allowedExtensions)
2946
- );
2947
- const afterExtension = allFiles.filter(
2948
- (f) => isFileExtensionAllowed(f.name, constraints.allowedExtensions)
2949
- );
2950
- const rejectedBySize = afterExtension.filter(
2951
- (f) => !isFileSizeAllowed(f, constraints.maxSize)
2952
- );
2953
- const validFiles = afterExtension.filter(
2954
- (f) => isFileSizeAllowed(f, constraints.maxSize)
2955
- );
2956
- const remaining = constraints.maxCount === Infinity ? validFiles.length : Math.max(0, constraints.maxCount - initialFiles.length);
2957
- const arr = validFiles.slice(0, remaining);
2958
- const skippedByCount = validFiles.length - arr.length;
2959
- const errorParts = [];
2960
- if (rejectedByExtension.length > 0) {
2961
- const formats = constraints.allowedExtensions.join(", ");
2962
- const names = rejectedByExtension.map((f) => f.name).join(", ");
2963
- errorParts.push(
2964
- t("invalidFileExtension", state, { name: names, formats })
2965
- );
2966
- }
2967
- if (rejectedBySize.length > 0) {
2968
- const names = rejectedBySize.map((f) => f.name).join(", ");
2969
- errorParts.push(
2970
- t("fileTooLarge", state, {
2971
- name: names,
2972
- maxSize: constraints.maxSize
2973
- })
2974
- );
2975
- }
2976
- if (skippedByCount > 0) {
2977
- errorParts.push(
2978
- t("filesLimitExceeded", state, {
2979
- skipped: skippedByCount,
2980
- max: constraints.maxCount
2981
- })
2982
- );
2983
- }
2984
- const wrapper = filesPicker.closest(".space-y-2") || filesPicker.parentElement;
2985
- if (errorParts.length > 0 && wrapper) {
2986
- showFileError(wrapper, errorParts.join(" \u2022 "));
2987
- } else if (wrapper) {
2988
- clearFileError(wrapper);
2989
- }
2990
- for (const file of arr) {
2991
- const rid = await uploadSingleFile(file, state);
2992
- state.resourceIndex.set(rid, {
2993
- name: file.name,
2994
- type: file.type,
2995
- size: file.size,
2996
- uploadedAt: /* @__PURE__ */ new Date(),
2997
- file: void 0
2998
- });
2999
- initialFiles.push(rid);
3000
- }
3001
- }
3002
- updateCallback();
3003
- filesPicker.value = "";
3004
- if (instance && pathKey && !state.config.readonly) {
3005
- instance.triggerOnChange(pathKey, initialFiles);
3461
+ const dropzone = document.createElement("div");
3462
+ dropzone.className = "fb-file-dropzone";
3463
+ const subHint2 = buildSubHint();
3464
+ dropzone.innerHTML = `
3465
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" style="flex-shrink:0;color:var(--fb-file-upload-text-color,#9ca3af);">
3466
+ <path d="M19.35 10.04A7.49 7.49 0 0012 4C9.11 4 6.6 5.64 5.35 8.04A5.994 5.994 0 000 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96zM14 13v4h-4v-4H7l5-5 5 5h-3z"/>
3467
+ </svg>
3468
+ <div class="fb-dropzone-primary-text">${escapeHtml(t("clickDragTextMultiple", state))}</div>
3469
+ ${subHint2 ? `<div class="fb-dropzone-hint-text">${escapeHtml(subHint2)}</div>` : ""}
3470
+ `;
3471
+ dropzone.onclick = openPicker;
3472
+ container.appendChild(dropzone);
3006
3473
  }
3007
- };
3474
+ return;
3475
+ }
3476
+ const tilesWrap = document.createElement("div");
3477
+ tilesWrap.className = "fb-tiles-wrap";
3478
+ tilesWrap.style.cssText = "display:flex;flex-wrap:wrap;gap:6px;align-items:flex-start;";
3479
+ for (const rid of ridList) {
3480
+ const meta = state.resourceIndex.get(rid);
3481
+ const tile = createFileTile();
3482
+ tile.classList.add("fb-tile-resource", "resource-pill");
3483
+ tile.dataset.resourceId = rid;
3484
+ const actionsEl = createTileActions({
3485
+ canRemove: !isReadonly && onRemove !== null,
3486
+ removeHandler: onRemove ? () => onRemove(rid) : null,
3487
+ state,
3488
+ resourceId: rid,
3489
+ fileName: meta?.name ?? ""
3490
+ });
3491
+ fillTileContent(tile, rid, meta, state, actionsEl).catch((err) => {
3492
+ console.error("Failed to render tile:", err);
3493
+ });
3494
+ tilesWrap.appendChild(tile);
3495
+ }
3496
+ if (!isReadonly && !atMax) {
3497
+ const addTile = document.createElement("div");
3498
+ addTile.className = "fb-tile fb-tile-add";
3499
+ addTile.innerHTML = "+";
3500
+ addTile.onclick = openPicker;
3501
+ tilesWrap.appendChild(addTile);
3502
+ } else if (!isReadonly && atMax) {
3503
+ const chip = document.createElement("div");
3504
+ chip.className = "fb-tile-counter";
3505
+ chip.textContent = t("filesCounter", state, {
3506
+ count: ridList.length,
3507
+ max: maxCount
3508
+ });
3509
+ tilesWrap.appendChild(chip);
3510
+ }
3511
+ container.appendChild(tilesWrap);
3512
+ const subHint = buildSubHint();
3513
+ if (subHint) {
3514
+ const hintEl = document.createElement("div");
3515
+ hintEl.className = "fb-tile-hint";
3516
+ hintEl.textContent = subHint;
3517
+ container.appendChild(hintEl);
3518
+ }
3008
3519
  }
3009
- function renderFileElement(element, ctx, wrapper, pathKey) {
3520
+ function renderFileElementEdit(element, ctx, wrapper, pathKey) {
3010
3521
  const state = ctx.state;
3011
- if (isElementReadonly(element, state, ctx)) {
3012
- const rawInitial = ctx.prefill[element.key];
3013
- const initial = typeof rawInitial === "string" ? rawInitial : "";
3014
- if (initial) {
3015
- const hiddenInput = document.createElement("input");
3016
- hiddenInput.type = "hidden";
3017
- hiddenInput.name = pathKey;
3018
- hiddenInput.value = initial;
3019
- wrapper.appendChild(hiddenInput);
3020
- renderFilePreviewReadonly(initial, state).then((filePreview) => {
3021
- wrapper.appendChild(filePreview);
3022
- }).catch((err) => {
3023
- console.error("Failed to render file preview:", err);
3024
- const emptyState = document.createElement("div");
3025
- emptyState.className = "aspect-video bg-gray-100 rounded-lg flex items-center justify-center text-gray-500";
3026
- emptyState.innerHTML = `<div class="text-center">${escapeHtml(t("previewUnavailable", state))}</div>`;
3027
- wrapper.appendChild(emptyState);
3028
- });
3029
- } else {
3030
- const emptyState = document.createElement("div");
3031
- emptyState.className = "aspect-video bg-gray-100 rounded-lg flex items-center justify-center text-gray-500";
3032
- emptyState.innerHTML = `<div class="text-center">${escapeHtml(t("noFileSelected", state))}</div>`;
3033
- wrapper.appendChild(emptyState);
3034
- }
3035
- } else {
3036
- const fileWrapper = document.createElement("div");
3037
- fileWrapper.className = "space-y-2";
3038
- const picker = document.createElement("input");
3039
- picker.type = "file";
3040
- picker.name = pathKey;
3041
- picker.style.display = "none";
3042
- if (element.accept) {
3043
- picker.accept = typeof element.accept === "string" ? element.accept : element.accept.extensions?.map((ext) => `.${ext}`).join(",") || "";
3044
- }
3045
- const fileContainer = document.createElement("div");
3046
- fileContainer.className = "file-preview-container w-full aspect-square max-w-xs bg-gray-100 rounded-lg overflow-hidden relative group cursor-pointer";
3047
- const initial = ctx.prefill[element.key];
3048
- const allowedExts = getAllowedExtensions(element.accept);
3049
- const maxSizeMB = element.maxSize ?? Infinity;
3050
- const fileUploadHandler = () => picker.click();
3051
- const dragHandler = (files) => {
3522
+ const fileWrapper = document.createElement("div");
3523
+ fileWrapper.className = "space-y-2";
3524
+ const picker = document.createElement("input");
3525
+ picker.type = "file";
3526
+ picker.name = pathKey;
3527
+ picker.style.display = "none";
3528
+ if (element.accept) {
3529
+ picker.accept = typeof element.accept === "string" ? element.accept : element.accept.extensions?.map((ext) => `.${ext}`).join(",") || "";
3530
+ }
3531
+ const fileContainer = document.createElement("div");
3532
+ fileContainer.className = "file-preview-container";
3533
+ const initial = ctx.prefill[element.key];
3534
+ const allowedExts = getAllowedExtensions(element.accept);
3535
+ const maxSizeMB = element.maxSize ?? Infinity;
3536
+ const handlers = {
3537
+ fileUploadHandler() {
3538
+ picker.click();
3539
+ },
3540
+ dragHandler(files) {
3052
3541
  if (files.length > 0) {
3053
- const deps = { picker, fileUploadHandler, dragHandler };
3054
3542
  handleFileSelect(
3055
3543
  files[0],
3056
3544
  fileContainer,
3057
3545
  pathKey,
3058
3546
  state,
3059
- deps,
3547
+ buildDeps(),
3060
3548
  ctx.instance,
3061
3549
  allowedExts,
3062
3550
  maxSizeMB
3063
3551
  );
3064
3552
  }
3065
- };
3066
- if (initial) {
3067
- handleInitialFileData(
3068
- initial,
3069
- fileContainer,
3070
- pathKey,
3071
- fileWrapper,
3072
- state,
3073
- {
3074
- picker,
3075
- fileUploadHandler,
3076
- dragHandler
3077
- }
3078
- );
3079
- } else {
3553
+ },
3554
+ setupDrop(container) {
3555
+ setupDragAndDrop(container, handlers.dragHandler);
3556
+ },
3557
+ restoreDropzone() {
3080
3558
  const hint = makeFieldHint(element, state);
3559
+ fileContainer.className = "file-preview-container w-full max-w-md bg-gray-100 rounded-lg overflow-hidden relative group cursor-pointer";
3560
+ fileContainer.style.height = "128px";
3081
3561
  setEmptyFileContainer(fileContainer, state, hint);
3082
- }
3083
- fileContainer.onclick = fileUploadHandler;
3084
- setupDragAndDrop(fileContainer, dragHandler);
3085
- picker.onchange = () => {
3086
- if (picker.files && picker.files.length > 0) {
3087
- const deps = { picker, fileUploadHandler, dragHandler };
3088
- handleFileSelect(
3089
- picker.files[0],
3090
- fileContainer,
3091
- pathKey,
3092
- state,
3093
- deps,
3094
- ctx.instance,
3095
- allowedExts,
3096
- maxSizeMB
3097
- );
3562
+ fileContainer.onclick = handlers.fileUploadHandler;
3563
+ setupDragAndDrop(fileContainer, handlers.dragHandler);
3564
+ },
3565
+ onRemove() {
3566
+ const hiddenInput = fileWrapper.querySelector('input[type="hidden"]');
3567
+ const currentRid = hiddenInput?.value;
3568
+ if (currentRid) {
3569
+ releaseLocalFileUrl(state.resourceIndex.get(currentRid)?.file);
3098
3570
  }
3099
- };
3100
- fileWrapper.appendChild(fileContainer);
3101
- fileWrapper.appendChild(picker);
3102
- wrapper.appendChild(fileWrapper);
3103
- }
3104
- }
3105
- function renderFilesElement(element, ctx, wrapper, pathKey) {
3106
- const state = ctx.state;
3107
- if (isElementReadonly(element, state, ctx)) {
3108
- const rawPrefill = ctx.prefill[element.key];
3109
- const initialFiles = Array.isArray(rawPrefill) ? rawPrefill : [];
3110
- const filesWrapper = document.createElement("div");
3111
- filesWrapper.className = "space-y-2";
3112
- filesWrapper.dataset.filesWrapper = pathKey;
3113
- const filesList = document.createElement("div");
3114
- filesList.className = "files-list";
3115
- initialFiles.forEach((resourceId) => {
3116
- const pill = document.createElement("div");
3117
- pill.className = "resource-pill";
3118
- pill.dataset.resourceId = resourceId;
3119
- filesList.appendChild(pill);
3120
- });
3121
- filesWrapper.appendChild(filesList);
3122
- wrapper.appendChild(filesWrapper);
3123
- const resultsWrapper = document.createElement("div");
3124
- resultsWrapper.className = "space-y-4";
3125
- if (initialFiles.length > 0) {
3126
- initialFiles.forEach((resourceId) => {
3127
- renderFilePreviewReadonly(resourceId, state).then((filePreview) => {
3128
- resultsWrapper.appendChild(filePreview);
3129
- }).catch((err) => {
3130
- console.error("Failed to render file preview:", err);
3131
- });
3132
- });
3133
- } else {
3134
- resultsWrapper.innerHTML = `<div class="aspect-video bg-gray-100 rounded-lg flex items-center justify-center text-gray-500"><div class="text-center">${escapeHtml(t("noFilesSelected", state))}</div></div>`;
3571
+ if (hiddenInput) hiddenInput.value = "";
3572
+ handlers.restoreDropzone();
3573
+ }
3574
+ };
3575
+ const buildDeps = () => ({
3576
+ picker,
3577
+ fileUploadHandler: handlers.fileUploadHandler,
3578
+ dragHandler: handlers.dragHandler,
3579
+ setupDrop: handlers.setupDrop,
3580
+ onRemove: handlers.onRemove
3581
+ });
3582
+ if (initial) {
3583
+ handleInitialFileData(
3584
+ initial,
3585
+ fileContainer,
3586
+ pathKey,
3587
+ fileWrapper,
3588
+ state,
3589
+ buildDeps()
3590
+ );
3591
+ const prefillMeta = state.resourceIndex.get(initial);
3592
+ if (prefillMeta?.type?.startsWith("video/")) {
3593
+ fileContainer.onclick = handlers.fileUploadHandler;
3594
+ setupDragAndDrop(fileContainer, handlers.dragHandler);
3135
3595
  }
3136
- wrapper.appendChild(resultsWrapper);
3137
3596
  } else {
3138
- let updateFilesList2 = function() {
3139
- renderResourcePills(
3140
- list,
3141
- initialFiles,
3597
+ handlers.restoreDropzone();
3598
+ }
3599
+ picker.onchange = () => {
3600
+ if (picker.files && picker.files.length > 0) {
3601
+ handleFileSelect(
3602
+ picker.files[0],
3603
+ fileContainer,
3604
+ pathKey,
3142
3605
  state,
3143
- (ridToRemove) => {
3144
- const index = initialFiles.indexOf(ridToRemove);
3145
- if (index > -1) {
3146
- initialFiles.splice(index, 1);
3147
- }
3148
- updateFilesList2();
3149
- },
3150
- filesFieldHint
3606
+ buildDeps(),
3607
+ ctx.instance,
3608
+ allowedExts,
3609
+ maxSizeMB
3151
3610
  );
3152
- };
3153
- const filesWrapper = document.createElement("div");
3154
- filesWrapper.className = "space-y-2";
3155
- filesWrapper.dataset.filesWrapper = pathKey;
3156
- const filesPicker = document.createElement("input");
3157
- filesPicker.type = "file";
3158
- filesPicker.name = pathKey;
3159
- filesPicker.multiple = true;
3160
- filesPicker.style.display = "none";
3161
- if (element.accept) {
3162
- filesPicker.accept = typeof element.accept === "string" ? element.accept : element.accept.extensions?.map((ext) => `.${ext}`).join(",") || "";
3163
- }
3164
- const filesContainer = document.createElement("div");
3165
- filesContainer.className = "border-2 border-dashed border-gray-300 rounded-lg p-3 hover:border-gray-400 transition-colors";
3166
- const list = document.createElement("div");
3167
- list.className = "files-list";
3168
- const initialFiles = ctx.prefill[element.key] || [];
3169
- addPrefillFilesToIndex(initialFiles, state);
3170
- const filesFieldHint = makeFieldHint(element, state);
3171
- const filesConstraints = {
3172
- maxCount: Infinity,
3173
- allowedExtensions: getAllowedExtensions(element.accept),
3174
- maxSize: element.maxSize ?? Infinity
3175
- };
3176
- updateFilesList2();
3177
- setupFilesDropHandler(
3178
- filesContainer,
3179
- initialFiles,
3180
- state,
3181
- updateFilesList2,
3182
- filesConstraints,
3183
- pathKey,
3184
- ctx.instance
3185
- );
3186
- setupFilesPickerHandler(
3187
- filesPicker,
3611
+ }
3612
+ };
3613
+ fileWrapper.appendChild(fileContainer);
3614
+ fileWrapper.appendChild(picker);
3615
+ wrapper.appendChild(fileWrapper);
3616
+ }
3617
+ function renderFilesElementEdit(element, ctx, wrapper, pathKey) {
3618
+ const state = ctx.state;
3619
+ const filesWrapper = document.createElement("div");
3620
+ filesWrapper.className = "space-y-2";
3621
+ filesWrapper.dataset.filesWrapper = pathKey;
3622
+ const filesPicker = document.createElement("input");
3623
+ filesPicker.type = "file";
3624
+ filesPicker.name = pathKey;
3625
+ filesPicker.multiple = true;
3626
+ filesPicker.style.display = "none";
3627
+ if (element.accept) {
3628
+ filesPicker.accept = typeof element.accept === "string" ? element.accept : element.accept.extensions?.map((ext) => `.${ext}`).join(",") || "";
3629
+ }
3630
+ const filesContainer = document.createElement("div");
3631
+ filesContainer.className = "files-list-wrapper";
3632
+ filesContainer.style.cssText = "border:2px dashed var(--fb-file-upload-border-color,#d1d5db);border-radius:var(--fb-border-radius,0.5rem);padding:8px;transition:border-color var(--fb-transition-duration,200ms),background var(--fb-transition-duration,200ms);";
3633
+ const list = document.createElement("div");
3634
+ list.className = "files-list";
3635
+ const initialFiles = ctx.prefill[element.key] || [];
3636
+ addPrefillFilesToIndex(initialFiles, state.resourceIndex);
3637
+ filesWrapper.dataset.resourceIds = JSON.stringify(initialFiles);
3638
+ const filesFieldHint = makeFieldHint(element, state);
3639
+ const filesConstraints = {
3640
+ maxCount: Infinity,
3641
+ allowedExtensions: getAllowedExtensions(element.accept),
3642
+ maxSize: element.maxSize ?? Infinity
3643
+ };
3644
+ filesContainer.appendChild(list);
3645
+ filesWrapper.appendChild(filesPicker);
3646
+ filesWrapper.appendChild(filesContainer);
3647
+ wrapper.appendChild(filesWrapper);
3648
+ function updateFilesList() {
3649
+ const currentlyReadonly = isElementReadonly(element, state);
3650
+ renderResourcePills(
3651
+ list,
3188
3652
  initialFiles,
3189
3653
  state,
3190
- updateFilesList2,
3191
- filesConstraints,
3192
- pathKey,
3193
- ctx.instance
3654
+ currentlyReadonly ? null : (ridToRemove) => {
3655
+ releaseLocalFileUrl(state.resourceIndex.get(ridToRemove)?.file);
3656
+ const index = initialFiles.indexOf(ridToRemove);
3657
+ if (index > -1) initialFiles.splice(index, 1);
3658
+ updateFilesList();
3659
+ },
3660
+ filesFieldHint,
3661
+ void 0,
3662
+ void 0,
3663
+ currentlyReadonly
3194
3664
  );
3195
- filesContainer.appendChild(list);
3196
- filesWrapper.appendChild(filesContainer);
3197
- filesWrapper.appendChild(filesPicker);
3198
- wrapper.appendChild(filesWrapper);
3199
3665
  }
3666
+ updateFilesList();
3667
+ setupFilesDropHandler(
3668
+ filesContainer,
3669
+ initialFiles,
3670
+ state,
3671
+ updateFilesList,
3672
+ filesConstraints,
3673
+ pathKey,
3674
+ ctx.instance
3675
+ );
3676
+ setupFilesPickerHandler(
3677
+ filesPicker,
3678
+ initialFiles,
3679
+ state,
3680
+ updateFilesList,
3681
+ filesConstraints,
3682
+ pathKey,
3683
+ ctx.instance
3684
+ );
3200
3685
  }
3201
- function renderMultipleFileElement(element, ctx, wrapper, pathKey) {
3686
+ function renderMultipleFileElementEdit(element, ctx, wrapper, pathKey) {
3202
3687
  const state = ctx.state;
3203
3688
  const minFiles = element.minCount ?? 0;
3204
3689
  const maxFiles = element.maxCount ?? Infinity;
3205
- if (isElementReadonly(element, state, ctx)) {
3206
- const rawPrefill = ctx.prefill[element.key];
3207
- const initialFiles = Array.isArray(rawPrefill) ? rawPrefill : [];
3208
- const filesWrapper = document.createElement("div");
3209
- filesWrapper.className = "space-y-2";
3210
- filesWrapper.dataset.filesWrapper = pathKey;
3211
- const filesList = document.createElement("div");
3212
- filesList.className = "files-list";
3213
- initialFiles.forEach((resourceId) => {
3214
- const pill = document.createElement("div");
3215
- pill.className = "resource-pill";
3216
- pill.dataset.resourceId = resourceId;
3217
- filesList.appendChild(pill);
3218
- });
3219
- filesWrapper.appendChild(filesList);
3220
- wrapper.appendChild(filesWrapper);
3221
- const resultsWrapper = document.createElement("div");
3222
- resultsWrapper.className = "space-y-4";
3223
- if (initialFiles.length > 0) {
3224
- initialFiles.forEach((resourceId) => {
3225
- renderFilePreviewReadonly(resourceId, state).then((filePreview) => {
3226
- resultsWrapper.appendChild(filePreview);
3227
- }).catch((err) => {
3228
- console.error("Failed to render file preview:", err);
3229
- });
3230
- });
3231
- } else {
3232
- resultsWrapper.innerHTML = `<div class="aspect-video bg-gray-100 rounded-lg flex items-center justify-center text-gray-500"><div class="text-center">${escapeHtml(t("noFilesSelected", state))}</div></div>`;
3233
- }
3234
- wrapper.appendChild(resultsWrapper);
3235
- } else {
3236
- const filesWrapper = document.createElement("div");
3237
- filesWrapper.className = "space-y-2";
3238
- filesWrapper.dataset.filesWrapper = pathKey;
3239
- const filesPicker = document.createElement("input");
3240
- filesPicker.type = "file";
3241
- filesPicker.name = pathKey;
3242
- filesPicker.multiple = true;
3243
- filesPicker.style.display = "none";
3244
- if (element.accept) {
3245
- filesPicker.accept = typeof element.accept === "string" ? element.accept : element.accept.extensions?.map((ext) => `.${ext}`).join(",") || "";
3246
- }
3247
- const filesContainer = document.createElement("div");
3248
- filesContainer.className = "files-list space-y-2";
3249
- filesWrapper.appendChild(filesPicker);
3250
- filesWrapper.appendChild(filesContainer);
3251
- const initialFiles = Array.isArray(ctx.prefill[element.key]) ? [...ctx.prefill[element.key]] : [];
3252
- addPrefillFilesToIndex(initialFiles, state);
3253
- const multipleFilesHint = makeFieldHint(element, state);
3254
- const multipleConstraints = {
3255
- maxCount: maxFiles,
3256
- allowedExtensions: getAllowedExtensions(element.accept),
3257
- maxSize: element.maxSize ?? Infinity
3258
- };
3259
- const buildCountInfo = () => {
3260
- const countText = initialFiles.length === 1 ? t("fileCountSingle", state, { count: initialFiles.length }) : t("fileCountPlural", state, { count: initialFiles.length });
3261
- const minMaxText = minFiles > 0 || maxFiles < Infinity ? ` ${t("fileCountRange", state, { min: minFiles, max: maxFiles })}` : "";
3262
- return countText + minMaxText;
3263
- };
3264
- const updateFilesDisplay = () => {
3265
- renderResourcePills(
3266
- filesContainer,
3267
- initialFiles,
3268
- state,
3269
- (index) => {
3270
- initialFiles.splice(initialFiles.indexOf(index), 1);
3271
- updateFilesDisplay();
3272
- },
3273
- multipleFilesHint,
3274
- buildCountInfo()
3275
- );
3276
- };
3277
- setupFilesDropHandler(
3278
- filesContainer,
3690
+ const filesWrapper = document.createElement("div");
3691
+ filesWrapper.className = "space-y-2";
3692
+ filesWrapper.dataset.filesWrapper = pathKey;
3693
+ const filesPicker = document.createElement("input");
3694
+ filesPicker.type = "file";
3695
+ filesPicker.name = pathKey;
3696
+ filesPicker.multiple = true;
3697
+ filesPicker.style.display = "none";
3698
+ if (element.accept) {
3699
+ filesPicker.accept = typeof element.accept === "string" ? element.accept : element.accept.extensions?.map((ext) => `.${ext}`).join(",") || "";
3700
+ }
3701
+ const filesContainer = document.createElement("div");
3702
+ filesContainer.className = "files-list-wrapper";
3703
+ filesContainer.style.cssText = "border:2px dashed var(--fb-file-upload-border-color,#d1d5db);border-radius:var(--fb-border-radius,0.5rem);padding:8px;transition:border-color var(--fb-transition-duration,200ms),background var(--fb-transition-duration,200ms);";
3704
+ const list = document.createElement("div");
3705
+ list.className = "files-list";
3706
+ filesWrapper.appendChild(filesPicker);
3707
+ filesWrapper.appendChild(filesContainer);
3708
+ filesContainer.appendChild(list);
3709
+ const initialFiles = Array.isArray(ctx.prefill[element.key]) ? [...ctx.prefill[element.key]] : [];
3710
+ addPrefillFilesToIndex(initialFiles, state.resourceIndex);
3711
+ filesWrapper.dataset.resourceIds = JSON.stringify(initialFiles);
3712
+ const multipleFilesHint = makeFieldHint(element, state);
3713
+ const multipleConstraints = {
3714
+ maxCount: maxFiles,
3715
+ allowedExtensions: getAllowedExtensions(element.accept),
3716
+ maxSize: element.maxSize ?? Infinity
3717
+ };
3718
+ const buildCountInfo = () => {
3719
+ const countText = initialFiles.length === 1 ? t("fileCountSingle", state, { count: initialFiles.length }) : t("fileCountPlural", state, { count: initialFiles.length });
3720
+ const minMaxText = minFiles > 0 || maxFiles < Infinity ? ` ${t("fileCountRange", state, { min: minFiles, max: maxFiles })}` : "";
3721
+ return countText + minMaxText;
3722
+ };
3723
+ const updateFilesDisplay = () => {
3724
+ const currentlyReadonly = isElementReadonly(element, state);
3725
+ renderResourcePills(
3726
+ list,
3279
3727
  initialFiles,
3280
3728
  state,
3281
- updateFilesDisplay,
3282
- multipleConstraints,
3283
- pathKey,
3284
- ctx.instance
3729
+ currentlyReadonly ? null : (index) => {
3730
+ releaseLocalFileUrl(state.resourceIndex.get(index)?.file);
3731
+ initialFiles.splice(initialFiles.indexOf(index), 1);
3732
+ updateFilesDisplay();
3733
+ },
3734
+ multipleFilesHint,
3735
+ buildCountInfo(),
3736
+ maxFiles < Infinity ? maxFiles : void 0,
3737
+ currentlyReadonly
3285
3738
  );
3286
- setupFilesPickerHandler(
3287
- filesPicker,
3288
- initialFiles,
3289
- state,
3290
- updateFilesDisplay,
3291
- multipleConstraints,
3292
- pathKey,
3293
- ctx.instance
3739
+ };
3740
+ setupFilesDropHandler(
3741
+ filesContainer,
3742
+ initialFiles,
3743
+ state,
3744
+ updateFilesDisplay,
3745
+ multipleConstraints,
3746
+ pathKey,
3747
+ ctx.instance
3748
+ );
3749
+ setupFilesPickerHandler(
3750
+ filesPicker,
3751
+ initialFiles,
3752
+ state,
3753
+ updateFilesDisplay,
3754
+ multipleConstraints,
3755
+ pathKey,
3756
+ ctx.instance
3757
+ );
3758
+ updateFilesDisplay();
3759
+ wrapper.appendChild(filesWrapper);
3760
+ }
3761
+
3762
+ // src/components/file/validate.ts
3763
+ function readMultiFileResourceIds(scopeRoot, fullKey) {
3764
+ const wrapper = scopeRoot.querySelector(
3765
+ `[data-files-wrapper="${fullKey}"]`
3766
+ );
3767
+ if (!wrapper) return [];
3768
+ const encoded = wrapper.dataset.resourceIds;
3769
+ if (encoded === void 0) {
3770
+ throw new Error(
3771
+ `readMultiFileResourceIds: [data-files-wrapper="${fullKey}"] is missing data-resource-ids attribute. This is a render bug.`
3294
3772
  );
3295
- updateFilesDisplay();
3296
- wrapper.appendChild(filesWrapper);
3297
3773
  }
3774
+ const parsed = JSON.parse(encoded);
3775
+ if (!Array.isArray(parsed)) {
3776
+ throw new Error(
3777
+ `readMultiFileResourceIds: data-resource-ids on [data-files-wrapper="${fullKey}"] is not a JSON array. Got: ${encoded}`
3778
+ );
3779
+ }
3780
+ return parsed;
3298
3781
  }
3299
- function validateFileElement(element, key, context) {
3300
- const errors = [];
3301
- const { scopeRoot, skipValidation, path } = context;
3302
- const isMultipleField = element.type === "files" || "multiple" in element && Boolean(element.multiple);
3303
- const validateFileCount = (key2, resourceIds, element2) => {
3304
- if (skipValidation) return;
3305
- const { state } = context;
3306
- const minFiles = "minCount" in element2 ? element2.minCount ?? 0 : 0;
3307
- const maxFiles = "maxCount" in element2 ? element2.maxCount ?? Infinity : Infinity;
3308
- if (element2.required && resourceIds.length === 0) {
3309
- errors.push(`${key2}: ${t("required", state)}`);
3310
- }
3311
- if (resourceIds.length < minFiles) {
3312
- errors.push(`${key2}: ${t("minFiles", state, { min: minFiles })}`);
3313
- }
3314
- if (resourceIds.length > maxFiles) {
3315
- errors.push(`${key2}: ${t("maxFiles", state, { max: maxFiles })}`);
3316
- }
3317
- };
3318
- const validateFileExtensions = (key2, resourceIds, element2) => {
3319
- if (skipValidation) return;
3320
- const { state } = context;
3321
- const acceptField = "accept" in element2 ? element2.accept : void 0;
3322
- const allowedExtensions = getAllowedExtensions(acceptField);
3323
- if (allowedExtensions.length === 0) return;
3324
- const formats = allowedExtensions.join(", ");
3325
- for (const rid of resourceIds) {
3326
- const meta = state.resourceIndex.get(rid);
3327
- const fileName = meta?.name ?? rid;
3328
- if (!isFileExtensionAllowed(fileName, allowedExtensions)) {
3329
- errors.push(
3330
- `${key2}: ${t("invalidFileExtension", state, { name: fileName, formats })}`
3331
- );
3332
- }
3782
+ function validateFileCount(key, resourceIds, element, state, errors) {
3783
+ const minFiles = "minCount" in element ? element.minCount ?? 0 : 0;
3784
+ const maxFiles = "maxCount" in element ? element.maxCount ?? Infinity : Infinity;
3785
+ if (element.required && resourceIds.length === 0) {
3786
+ errors.push(`${key}: ${t("required", state)}`);
3787
+ }
3788
+ if (resourceIds.length < minFiles) {
3789
+ errors.push(`${key}: ${t("minFiles", state, { min: minFiles })}`);
3790
+ }
3791
+ if (resourceIds.length > maxFiles) {
3792
+ errors.push(`${key}: ${t("maxFiles", state, { max: maxFiles })}`);
3793
+ }
3794
+ }
3795
+ function validateFileExtensions(key, resourceIds, element, state, errors) {
3796
+ const acceptField = "accept" in element ? element.accept : void 0;
3797
+ const allowedExtensions = getAllowedExtensions(acceptField);
3798
+ if (allowedExtensions.length === 0) return;
3799
+ const formats = allowedExtensions.join(", ");
3800
+ for (const rid of resourceIds) {
3801
+ const meta = state.resourceIndex.get(rid);
3802
+ const fileName = meta?.name ?? rid;
3803
+ if (!isFileExtensionAllowed(fileName, allowedExtensions)) {
3804
+ errors.push(
3805
+ `${key}: ${t("invalidFileExtension", state, { name: fileName, formats })}`
3806
+ );
3333
3807
  }
3334
- };
3335
- const validateFileSizes = (key2, resourceIds, element2) => {
3336
- if (skipValidation) return;
3337
- const { state } = context;
3338
- const maxSizeMB = "maxSize" in element2 ? element2.maxSize ?? Infinity : Infinity;
3339
- if (maxSizeMB === Infinity) return;
3340
- for (const rid of resourceIds) {
3341
- const meta = state.resourceIndex.get(rid);
3342
- if (!meta) continue;
3343
- if (meta.size > maxSizeMB * 1024 * 1024) {
3344
- errors.push(
3345
- `${key2}: ${t("fileTooLarge", state, { name: meta.name, maxSize: maxSizeMB })}`
3346
- );
3347
- }
3808
+ }
3809
+ }
3810
+ function validateFileSizes(key, resourceIds, element, state, errors) {
3811
+ const maxSizeMB = "maxSize" in element ? element.maxSize ?? Infinity : Infinity;
3812
+ if (maxSizeMB === Infinity) return;
3813
+ for (const rid of resourceIds) {
3814
+ const meta = state.resourceIndex.get(rid);
3815
+ if (!meta) continue;
3816
+ if (meta.size > maxSizeMB * 1024 * 1024) {
3817
+ errors.push(
3818
+ `${key}: ${t("fileTooLarge", state, { name: meta.name, maxSize: maxSizeMB })}`
3819
+ );
3348
3820
  }
3349
- };
3821
+ }
3822
+ }
3823
+ function validateMultiFile(element, key, context) {
3824
+ const { scopeRoot, skipValidation, path, state } = context;
3825
+ const errors = [];
3826
+ const fullKey = pathJoin(path, key);
3827
+ const resourceIds = readMultiFileResourceIds(scopeRoot, fullKey);
3828
+ if (!skipValidation) {
3829
+ validateFileCount(key, resourceIds, element, state, errors);
3830
+ validateFileExtensions(key, resourceIds, element, state, errors);
3831
+ validateFileSizes(key, resourceIds, element, state, errors);
3832
+ }
3833
+ return { value: resourceIds, errors };
3834
+ }
3835
+ function validateSingleFile(element, key, context) {
3836
+ const { scopeRoot, skipValidation, state } = context;
3837
+ const errors = [];
3838
+ const input = scopeRoot.querySelector(
3839
+ `input[name$="${key}"][type="hidden"]`
3840
+ );
3841
+ const rid = input?.value ?? "";
3842
+ if (!skipValidation && element.required && rid === "") {
3843
+ errors.push(`${key}: ${t("required", state)}`);
3844
+ return { value: null, errors };
3845
+ }
3846
+ if (!skipValidation && rid !== "") {
3847
+ validateFileExtensions(key, [rid], element, state, errors);
3848
+ validateFileSizes(key, [rid], element, state, errors);
3849
+ }
3850
+ return { value: rid || null, errors };
3851
+ }
3852
+ function validateFileElement(element, key, context) {
3853
+ const isMultipleField = element.type === "files" || "multiple" in element && Boolean(element.multiple);
3350
3854
  if (isMultipleField) {
3351
- const fullKey = pathJoin(path, key);
3352
- const filesWrapper = scopeRoot.querySelector(
3353
- `[data-files-wrapper="${fullKey}"]`
3354
- );
3355
- const container = filesWrapper?.querySelector(".files-list") || null;
3356
- const resourceIds = [];
3357
- if (container) {
3358
- const pills = container.querySelectorAll(".resource-pill");
3359
- pills.forEach((pill) => {
3360
- const resourceId = pill.dataset.resourceId;
3361
- if (resourceId) {
3362
- resourceIds.push(resourceId);
3363
- }
3364
- });
3365
- }
3366
- validateFileCount(key, resourceIds, element);
3367
- validateFileExtensions(key, resourceIds, element);
3368
- validateFileSizes(key, resourceIds, element);
3369
- return { value: resourceIds, errors };
3855
+ return validateMultiFile(element, key, context);
3856
+ }
3857
+ return validateSingleFile(element, key, context);
3858
+ }
3859
+
3860
+ // src/components/file/render-readonly.ts
3861
+ function renderFileElementReadonly(element, ctx, wrapper, pathKey) {
3862
+ const state = ctx.state;
3863
+ const rawInitial = ctx.prefill[element.key];
3864
+ const initial = typeof rawInitial === "string" ? rawInitial : "";
3865
+ if (initial) {
3866
+ addPrefillFilesToIndex([initial], state.resourceIndex);
3867
+ const hiddenInput = document.createElement("input");
3868
+ hiddenInput.type = "hidden";
3869
+ hiddenInput.name = pathKey;
3870
+ hiddenInput.value = initial;
3871
+ wrapper.appendChild(hiddenInput);
3872
+ renderFilePreviewReadonly(initial, state).then((filePreview) => {
3873
+ wrapper.appendChild(filePreview);
3874
+ }).catch((err) => {
3875
+ console.error("Failed to render file preview:", err);
3876
+ wrapper.appendChild(buildEmptyReadonlyTile(state));
3877
+ });
3370
3878
  } else {
3371
- const input = scopeRoot.querySelector(
3372
- `input[name$="${key}"][type="hidden"]`
3373
- );
3374
- const rid = input?.value ?? "";
3375
- if (!skipValidation && element.required && rid === "") {
3376
- errors.push(`${key}: ${t("required", context.state)}`);
3377
- return { value: null, errors };
3378
- }
3379
- if (!skipValidation && rid !== "") {
3380
- validateFileExtensions(key, [rid], element);
3381
- validateFileSizes(key, [rid], element);
3382
- }
3383
- return { value: rid || null, errors };
3879
+ wrapper.appendChild(buildEmptyReadonlyTile(state));
3880
+ }
3881
+ }
3882
+ function buildEmptyReadonlyTile(state) {
3883
+ const emptyState = document.createElement("div");
3884
+ emptyState.style.cssText = `
3885
+ width:${TILE_SIZE};
3886
+ height:${TILE_SIZE};
3887
+ display:flex;
3888
+ align-items:center;
3889
+ justify-content:center;
3890
+ background:var(--fb-file-upload-bg-color,#f3f4f6);
3891
+ border-radius:var(--fb-border-radius,0.5rem);
3892
+ border:1px solid var(--fb-file-upload-border-color,#d1d5db);
3893
+ `;
3894
+ emptyState.innerHTML = `<div style="font-size:11px;text-align:center;color:var(--fb-text-secondary-color,#6b7280);">${escapeHtml(t("noFileSelected", state))}</div>`;
3895
+ return emptyState;
3896
+ }
3897
+ function renderMultiFileReadonly(rids, state, wrapper, pathKey, marginTop) {
3898
+ addPrefillFilesToIndex(rids, state.resourceIndex);
3899
+ const filesWrapper = document.createElement("div");
3900
+ filesWrapper.dataset.filesWrapper = pathKey;
3901
+ filesWrapper.dataset.resourceIds = JSON.stringify(rids);
3902
+ wrapper.appendChild(filesWrapper);
3903
+ if (rids.length === 0) {
3904
+ const emptyEl = document.createElement("div");
3905
+ emptyEl.className = "fb-tile-empty-text";
3906
+ emptyEl.textContent = t("noFilesSelected", state);
3907
+ filesWrapper.appendChild(emptyEl);
3908
+ return;
3909
+ }
3910
+ const tilesWrap = document.createElement("div");
3911
+ tilesWrap.style.cssText = `display:flex;flex-wrap:wrap;gap:6px;${marginTop ? `margin-top:${marginTop};` : ""}`;
3912
+ filesWrapper.appendChild(tilesWrap);
3913
+ const placeholders = rids.map(() => {
3914
+ const placeholder = document.createElement("div");
3915
+ placeholder.style.cssText = `width:${TILE_SIZE};height:${TILE_SIZE};`;
3916
+ tilesWrap.appendChild(placeholder);
3917
+ return placeholder;
3918
+ });
3919
+ for (let i = 0; i < rids.length; i++) {
3920
+ const resourceId = rids[i];
3921
+ const placeholder = placeholders[i];
3922
+ renderFilePreviewReadonly(resourceId, state).then((tileEl) => {
3923
+ placeholder.replaceWith(tileEl);
3924
+ }).catch((err) => {
3925
+ console.error("Failed to render readonly tile:", err);
3926
+ });
3927
+ }
3928
+ }
3929
+ function renderFilesElementReadonly(element, ctx, wrapper, pathKey) {
3930
+ const rawPrefill = ctx.prefill[element.key];
3931
+ const initialFiles = Array.isArray(rawPrefill) ? rawPrefill : [];
3932
+ renderMultiFileReadonly(initialFiles, ctx.state, wrapper, pathKey);
3933
+ }
3934
+ function renderMultipleFileElementReadonly(element, ctx, wrapper, pathKey) {
3935
+ const rawPrefill = ctx.prefill[element.key];
3936
+ const initialFiles = Array.isArray(rawPrefill) ? rawPrefill : [];
3937
+ renderMultiFileReadonly(initialFiles, ctx.state, wrapper, pathKey, "4px");
3938
+ }
3939
+
3940
+ // src/components/file.ts
3941
+ function renderFileElement(element, ctx, wrapper, pathKey) {
3942
+ if (isElementReadonly(element, ctx.state, ctx)) {
3943
+ renderFileElementReadonly(element, ctx, wrapper, pathKey);
3944
+ } else {
3945
+ renderFileElementEdit(element, ctx, wrapper, pathKey);
3946
+ }
3947
+ }
3948
+ function renderFilesElement(element, ctx, wrapper, pathKey) {
3949
+ if (isElementReadonly(element, ctx.state, ctx)) {
3950
+ renderFilesElementReadonly(element, ctx, wrapper, pathKey);
3951
+ } else {
3952
+ renderFilesElementEdit(element, ctx, wrapper, pathKey);
3953
+ }
3954
+ }
3955
+ function renderMultipleFileElement(element, ctx, wrapper, pathKey) {
3956
+ if (isElementReadonly(element, ctx.state, ctx)) {
3957
+ renderMultipleFileElementReadonly(element, ctx, wrapper, pathKey);
3958
+ } else {
3959
+ renderMultipleFileElementEdit(element, ctx, wrapper, pathKey);
3384
3960
  }
3385
3961
  }
3386
3962
  function updateFileField(element, fieldPath, value, context) {
3387
3963
  const { scopeRoot, state } = context;
3388
- if ("multiple" in element && element.multiple) {
3964
+ if (element.type === "files" || "multiple" in element && element.multiple) {
3389
3965
  if (!Array.isArray(value)) {
3390
3966
  console.warn(
3391
3967
  `updateFileField: Expected array for multiple file field "${fieldPath}", got ${typeof value}`
@@ -3395,29 +3971,20 @@ function updateFileField(element, fieldPath, value, context) {
3395
3971
  value.forEach((resourceId) => {
3396
3972
  if (resourceId && typeof resourceId === "string") {
3397
3973
  if (!state.resourceIndex.has(resourceId)) {
3398
- const filename = resourceId.split("/").pop() || "file";
3399
- const extension = filename.split(".").pop()?.toLowerCase();
3400
- let fileType = "application/octet-stream";
3401
- if (extension) {
3402
- if (["jpg", "jpeg", "png", "gif", "webp"].includes(extension)) {
3403
- fileType = `image/${extension === "jpg" ? "jpeg" : extension}`;
3404
- } else if (["mp4", "webm", "mov", "avi"].includes(extension)) {
3405
- fileType = `video/${extension === "mov" ? "quicktime" : extension}`;
3406
- }
3407
- }
3408
- state.resourceIndex.set(resourceId, {
3409
- name: filename,
3410
- type: fileType,
3411
- size: 0,
3412
- uploadedAt: /* @__PURE__ */ new Date(),
3413
- file: void 0
3414
- });
3974
+ addResourceToIndex(resourceId, state);
3415
3975
  }
3416
3976
  }
3417
3977
  });
3418
- console.info(
3419
- `updateFileField: Multiple file field "${fieldPath}" updated. Preview update requires re-render.`
3978
+ const filesWrapper = scopeRoot.querySelector(
3979
+ `[data-files-wrapper="${fieldPath}"]`
3420
3980
  );
3981
+ if (filesWrapper) {
3982
+ filesWrapper.dataset.resourceIds = JSON.stringify(value);
3983
+ } else {
3984
+ console.warn(
3985
+ `updateFileField: [data-files-wrapper="${fieldPath}"] not found in DOM; data-resource-ids not updated`
3986
+ );
3987
+ }
3421
3988
  } else {
3422
3989
  const hiddenInput = scopeRoot.querySelector(
3423
3990
  `input[name="${fieldPath}"][type="hidden"]`
@@ -3431,23 +3998,7 @@ function updateFileField(element, fieldPath, value, context) {
3431
3998
  hiddenInput.value = value != null ? String(value) : "";
3432
3999
  if (value && typeof value === "string") {
3433
4000
  if (!state.resourceIndex.has(value)) {
3434
- const filename = value.split("/").pop() || "file";
3435
- const extension = filename.split(".").pop()?.toLowerCase();
3436
- let fileType = "application/octet-stream";
3437
- if (extension) {
3438
- if (["jpg", "jpeg", "png", "gif", "webp"].includes(extension)) {
3439
- fileType = `image/${extension === "jpg" ? "jpeg" : extension}`;
3440
- } else if (["mp4", "webm", "mov", "avi"].includes(extension)) {
3441
- fileType = `video/${extension === "mov" ? "quicktime" : extension}`;
3442
- }
3443
- }
3444
- state.resourceIndex.set(value, {
3445
- name: filename,
3446
- type: fileType,
3447
- size: 0,
3448
- uploadedAt: /* @__PURE__ */ new Date(),
3449
- file: void 0
3450
- });
4001
+ addResourceToIndex(value, state);
3451
4002
  }
3452
4003
  console.info(
3453
4004
  `updateFileField: File field "${fieldPath}" updated. Preview update requires re-render.`
@@ -3455,6 +4006,25 @@ function updateFileField(element, fieldPath, value, context) {
3455
4006
  }
3456
4007
  }
3457
4008
  }
4009
+ function addResourceToIndex(resourceId, state) {
4010
+ const filename = resourceId.split("/").pop() || "file";
4011
+ const extension = filename.split(".").pop()?.toLowerCase();
4012
+ let fileType = "application/octet-stream";
4013
+ if (extension) {
4014
+ if (["jpg", "jpeg", "png", "gif", "webp"].includes(extension)) {
4015
+ fileType = `image/${extension === "jpg" ? "jpeg" : extension}`;
4016
+ } else if (["mp4", "webm", "mov", "avi"].includes(extension)) {
4017
+ fileType = `video/${extension === "mov" ? "quicktime" : extension}`;
4018
+ }
4019
+ }
4020
+ state.resourceIndex.set(resourceId, {
4021
+ name: filename,
4022
+ type: fileType,
4023
+ size: 0,
4024
+ uploadedAt: /* @__PURE__ */ new Date(),
4025
+ file: void 0
4026
+ });
4027
+ }
3458
4028
 
3459
4029
  // src/components/colour.ts
3460
4030
  function normalizeColourValue(value) {
@@ -8109,6 +8679,8 @@ var defaultConfig = {
8109
8679
  noFileSelected: "No file selected",
8110
8680
  noFilesSelected: "No files selected",
8111
8681
  downloadButton: "Download",
8682
+ downloadFile: "Download",
8683
+ openInNewTab: "Open in new tab",
8112
8684
  changeButton: "Change",
8113
8685
  placeholderText: "Enter text",
8114
8686
  previewAlt: "Preview",
@@ -8130,6 +8702,8 @@ var defaultConfig = {
8130
8702
  fileCountSingle: "{count} file",
8131
8703
  fileCountPlural: "{count} files",
8132
8704
  fileCountRange: "({min}-{max})",
8705
+ uploadingFile: "Uploading\u2026",
8706
+ filesCounter: "{count}/{max}",
8133
8707
  // Validation errors
8134
8708
  required: "Required",
8135
8709
  minItems: "Minimum {min} items required",
@@ -8171,6 +8745,8 @@ var defaultConfig = {
8171
8745
  noFileSelected: "\u0424\u0430\u0439\u043B \u043D\u0435 \u0432\u044B\u0431\u0440\u0430\u043D",
8172
8746
  noFilesSelected: "\u041D\u0435\u0442 \u0444\u0430\u0439\u043B\u043E\u0432",
8173
8747
  downloadButton: "\u0421\u043A\u0430\u0447\u0430\u0442\u044C",
8748
+ downloadFile: "\u0421\u043A\u0430\u0447\u0430\u0442\u044C",
8749
+ openInNewTab: "\u041E\u0442\u043A\u0440\u044B\u0442\u044C \u0432 \u043D\u043E\u0432\u043E\u0439 \u0432\u043A\u043B\u0430\u0434\u043A\u0435",
8174
8750
  changeButton: "\u0418\u0437\u043C\u0435\u043D\u0438\u0442\u044C",
8175
8751
  placeholderText: "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0442\u0435\u043A\u0441\u0442",
8176
8752
  previewAlt: "\u041F\u0440\u0435\u0434\u043F\u0440\u043E\u0441\u043C\u043E\u0442\u0440",
@@ -8192,6 +8768,8 @@ var defaultConfig = {
8192
8768
  fileCountSingle: "{count} \u0444\u0430\u0439\u043B",
8193
8769
  fileCountPlural: "{count} \u0444\u0430\u0439\u043B\u043E\u0432",
8194
8770
  fileCountRange: "({min}-{max})",
8771
+ uploadingFile: "\u0417\u0430\u0433\u0440\u0443\u0437\u043A\u0430\u2026",
8772
+ filesCounter: "{count}/{max}",
8195
8773
  // Validation errors
8196
8774
  required: "\u041E\u0431\u044F\u0437\u0430\u0442\u0435\u043B\u044C\u043D\u043E\u0435 \u043F\u043E\u043B\u0435",
8197
8775
  minItems: "\u041C\u0438\u043D\u0438\u043C\u0443\u043C {min} \u044D\u043B\u0435\u043C\u0435\u043D\u0442\u043E\u0432",