@dmitryvim/form-builder 0.2.26 → 0.2.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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,768 @@ 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
- function isFileSizeAllowed(file, maxSizeMB) {
2107
+ function isSizeWithinLimit(bytes, maxSizeMB) {
2108
2108
  if (maxSizeMB === Infinity) return true;
2109
- return file.size <= maxSizeMB * 1024 * 1024;
2109
+ return bytes <= maxSizeMB * 1024 * 1024;
2110
+ }
2111
+ function isFileSizeAllowed(file, maxSizeMB) {
2112
+ return isSizeWithinLimit(file.size, maxSizeMB);
2113
+ }
2114
+ function getAllowedMimes(accept) {
2115
+ if (!accept) return [];
2116
+ if (typeof accept === "string") return [];
2117
+ if (!Array.isArray(accept.mime)) return [];
2118
+ return accept.mime.map((m) => m.toLowerCase());
2119
+ }
2120
+ function isMimeAllowed(mimeType, allowedMimes) {
2121
+ if (allowedMimes.length === 0) return true;
2122
+ const normalizedType = mimeType.toLowerCase();
2123
+ return allowedMimes.some((allowed) => {
2124
+ if (allowed.endsWith("/*")) {
2125
+ const prefix = allowed.slice(0, -1);
2126
+ return normalizedType.startsWith(prefix);
2127
+ }
2128
+ return normalizedType === allowed;
2129
+ });
2130
+ }
2131
+ function inferMimeFromResourceId(resourceId) {
2132
+ const filename = resourceId.split("/").pop() || "file";
2133
+ const extension = filename.split(".").pop()?.toLowerCase();
2134
+ if (extension) {
2135
+ if (["jpg", "jpeg", "png", "gif", "webp"].includes(extension)) {
2136
+ return `image/${extension === "jpg" ? "jpeg" : extension}`;
2137
+ }
2138
+ if (["mp4", "webm", "mov", "avi"].includes(extension)) {
2139
+ return `video/${extension === "mov" ? "quicktime" : extension}`;
2140
+ }
2141
+ }
2142
+ return "application/octet-stream";
2143
+ }
2144
+ function seedInferredResource(resourceId, resourceIndex) {
2145
+ if (resourceIndex.has(resourceId)) return;
2146
+ const filename = resourceId.split("/").pop() || "file";
2147
+ resourceIndex.set(resourceId, {
2148
+ name: filename,
2149
+ type: inferMimeFromResourceId(resourceId),
2150
+ size: 0,
2151
+ uploadedAt: /* @__PURE__ */ new Date(),
2152
+ file: void 0,
2153
+ inferredFromExtension: true
2154
+ });
2155
+ }
2156
+ function addPrefillFilesToIndex(initialFiles, resourceIndex) {
2157
+ for (const resourceId of initialFiles) {
2158
+ seedInferredResource(resourceId, resourceIndex);
2159
+ }
2160
+ }
2161
+
2162
+ // src/components/file/styles.ts
2163
+ var STYLE_ID = "fb-file-styles";
2164
+ function ensureFileStyles() {
2165
+ if (typeof document === "undefined") return;
2166
+ if (document.getElementById(STYLE_ID)) return;
2167
+ const style = document.createElement("style");
2168
+ style.id = STYLE_ID;
2169
+ style.setAttribute("data-fb-file-styles", "true");
2170
+ style.textContent = `
2171
+ @keyframes fb-spin { to { transform: rotate(360deg); } }
2172
+
2173
+ /* Spinner used during single-file and multi-file upload */
2174
+ .fb-spinner {
2175
+ width: 36px;
2176
+ height: 36px;
2177
+ border: 3px solid rgba(0,0,0,0.12);
2178
+ border-top-color: var(--fb-text-secondary-color, #6b7280);
2179
+ border-radius: 50%;
2180
+ animation: fb-spin 0.7s linear infinite;
2181
+ flex-shrink: 0;
2182
+ }
2183
+
2184
+ /* Base tile: fixed 160\xD7160 square, theme-aware background */
2185
+ .fb-tile {
2186
+ width: var(--fb-tile-size, 160px);
2187
+ height: var(--fb-tile-size, 160px);
2188
+ flex-shrink: 0;
2189
+ position: relative;
2190
+ overflow: hidden;
2191
+ border-radius: var(--fb-border-radius, 0.5rem);
2192
+ background: var(--fb-file-upload-bg-color, #f3f4f6);
2193
+ }
2194
+
2195
+ /* Uploaded resource tile \u2014 adds a visible border */
2196
+ .fb-tile-resource {
2197
+ border: 1px solid var(--fb-file-upload-border-color, #d1d5db);
2198
+ }
2199
+
2200
+ /* Uploading placeholder tile \u2014 dashed border, uploading indicator */
2201
+ .fb-tile-uploading {
2202
+ border: 2px dashed var(--fb-file-upload-border-color, #d1d5db);
2203
+ }
2204
+
2205
+ /* "+" add-more tile */
2206
+ .fb-tile-add {
2207
+ border: 2px dashed var(--fb-file-upload-border-color, #d1d5db);
2208
+ display: flex;
2209
+ align-items: center;
2210
+ justify-content: center;
2211
+ cursor: pointer;
2212
+ font-size: 32px;
2213
+ color: var(--fb-file-upload-text-color, #9ca3af);
2214
+ transition:
2215
+ border-color var(--fb-transition-duration, 200ms),
2216
+ color var(--fb-transition-duration, 200ms);
2217
+ }
2218
+ .fb-tile-add:hover {
2219
+ border-color: var(--fb-file-upload-hover-border-color, #3b82f6);
2220
+ color: var(--fb-text-color, #1f2937);
2221
+ }
2222
+
2223
+ /* Count chip shown when at maxCount */
2224
+ .fb-tile-counter {
2225
+ font-size: 11px;
2226
+ color: var(--fb-text-secondary-color, #6b7280);
2227
+ background: var(--fb-file-upload-bg-color, #f3f4f6);
2228
+ border: 1px solid var(--fb-file-upload-border-color, #d1d5db);
2229
+ border-radius: 4px;
2230
+ padding: 2px 6px;
2231
+ align-self: flex-end;
2232
+ margin-bottom: 4px;
2233
+ }
2234
+
2235
+ /* Empty-state dropzone */
2236
+ .fb-file-dropzone {
2237
+ width: 100%;
2238
+ height: 128px;
2239
+ border: 2px dashed var(--fb-file-upload-border-color, #d1d5db);
2240
+ border-radius: var(--fb-border-radius, 0.5rem);
2241
+ display: flex;
2242
+ flex-direction: column;
2243
+ align-items: center;
2244
+ justify-content: center;
2245
+ gap: 4px;
2246
+ cursor: pointer;
2247
+ transition:
2248
+ border-color var(--fb-transition-duration, 200ms),
2249
+ background var(--fb-transition-duration, 200ms);
2250
+ }
2251
+ .fb-file-dropzone:hover {
2252
+ border-color: var(--fb-file-upload-hover-border-color, #3b82f6);
2253
+ background: var(--fb-background-hover-color, #f9fafb);
2254
+ }
2255
+
2256
+ /* Inline text inside tiles */
2257
+ .fb-tile-label {
2258
+ font-size: 9px;
2259
+ color: var(--fb-text-secondary-color, #6b7280);
2260
+ text-align: center;
2261
+ overflow: hidden;
2262
+ word-break: break-all;
2263
+ max-height: 28px;
2264
+ }
2265
+ .fb-tile-uploading-text {
2266
+ font-size: 8px;
2267
+ color: var(--fb-file-upload-text-color, #9ca3af);
2268
+ }
2269
+ .fb-tile-hint {
2270
+ font-size: 11px;
2271
+ color: var(--fb-file-upload-text-color, #9ca3af);
2272
+ margin-top: 4px;
2273
+ }
2274
+ .fb-tile-empty-text {
2275
+ font-size: 12px;
2276
+ color: var(--fb-text-secondary-color, #6b7280);
2277
+ padding: 4px 0;
2278
+ }
2279
+ .fb-dropzone-primary-text {
2280
+ font-size: 13px;
2281
+ color: var(--fb-text-secondary-color, #6b7280);
2282
+ }
2283
+ .fb-dropzone-hint-text {
2284
+ font-size: 11px;
2285
+ color: var(--fb-file-upload-text-color, #9ca3af);
2286
+ }
2287
+
2288
+ /* Hover overlay + X-button on resource tiles */
2289
+ .fb-tile-overlay {
2290
+ position: absolute;
2291
+ inset: 0;
2292
+ background: transparent;
2293
+ transition: background var(--fb-transition-duration, 200ms);
2294
+ display: flex;
2295
+ align-items: flex-start;
2296
+ justify-content: flex-end;
2297
+ }
2298
+ .fb-tile-resource:hover .fb-tile-overlay {
2299
+ background: var(--fb-tile-hover-overlay-color, rgba(0,0,0,0.4));
2300
+ }
2301
+ .fb-tile-x-btn {
2302
+ margin: 3px;
2303
+ width: 18px;
2304
+ height: 18px;
2305
+ background: var(--fb-error-color, #ef4444);
2306
+ color: var(--fb-file-bg-color, #fff);
2307
+ border: none;
2308
+ border-radius: 50%;
2309
+ font-size: 11px;
2310
+ line-height: 1;
2311
+ cursor: pointer;
2312
+ display: flex;
2313
+ align-items: center;
2314
+ justify-content: center;
2315
+ opacity: 0;
2316
+ transition: opacity var(--fb-transition-duration, 200ms);
2317
+ }
2318
+ .fb-tile-resource:hover .fb-tile-x-btn {
2319
+ opacity: 1;
2320
+ }
2321
+
2322
+ /* Video play button overlay (readonly tiles with video thumbnails) */
2323
+ .fb-video-overlay {
2324
+ position: absolute;
2325
+ inset: 0;
2326
+ display: flex;
2327
+ align-items: center;
2328
+ justify-content: center;
2329
+ background: var(--fb-tile-hover-overlay-color, rgba(0,0,0,0.25));
2330
+ }
2331
+ .fb-play-btn {
2332
+ background: var(--fb-file-bg-color, rgba(255,255,255,0.9));
2333
+ border-radius: 50%;
2334
+ display: flex;
2335
+ align-items: center;
2336
+ justify-content: center;
2337
+ }
2338
+
2339
+ /* Edit-mode local video preview wrapper */
2340
+ .fb-video-preview-wrap {
2341
+ position: relative;
2342
+ width: 100%;
2343
+ height: 100%;
2344
+ }
2345
+
2346
+ /* Hover overlay for edit-mode local video (Remove / Change buttons) */
2347
+ .fb-video-btn-overlay {
2348
+ position: absolute;
2349
+ top: 8px;
2350
+ right: 8px;
2351
+ z-index: 10;
2352
+ display: flex;
2353
+ gap: 4px;
2354
+ opacity: 0;
2355
+ transition: opacity var(--fb-transition-duration, 200ms);
2356
+ pointer-events: none;
2357
+ }
2358
+ .fb-video-preview-wrap:hover .fb-video-btn-overlay {
2359
+ opacity: 1;
2360
+ pointer-events: auto;
2361
+ }
2362
+ .fb-video-btn {
2363
+ border: none;
2364
+ border-radius: var(--fb-border-radius, 4px);
2365
+ font-size: 11px;
2366
+ padding: 4px 8px;
2367
+ cursor: pointer;
2368
+ color: #fff;
2369
+ line-height: 1.2;
2370
+ }
2371
+ .fb-video-btn-delete {
2372
+ background: rgba(220, 38, 38, 0.85);
2373
+ }
2374
+ .fb-video-btn-delete:hover {
2375
+ background: rgba(185, 28, 28, 0.95);
2376
+ }
2377
+ .fb-video-btn-change {
2378
+ background: rgba(31, 41, 55, 0.85);
2379
+ }
2380
+ .fb-video-btn-change:hover {
2381
+ background: rgba(17, 24, 39, 0.95);
2382
+ }
2383
+
2384
+ /* Tile action icon buttons (download / open / remove) \u2014 shown on tile hover */
2385
+ .fb-tile-actions {
2386
+ position: absolute;
2387
+ top: 3px;
2388
+ right: 3px;
2389
+ display: flex;
2390
+ flex-direction: row;
2391
+ gap: 3px;
2392
+ opacity: 0;
2393
+ transition: opacity var(--fb-transition-duration, 200ms);
2394
+ z-index: 10;
2395
+ }
2396
+ .fb-tile-resource:hover .fb-tile-actions {
2397
+ opacity: 1;
2398
+ }
2399
+ .fb-tile-action-btn {
2400
+ width: 28px;
2401
+ height: 28px;
2402
+ display: flex;
2403
+ align-items: center;
2404
+ justify-content: center;
2405
+ border: none;
2406
+ border-radius: 50%;
2407
+ cursor: pointer;
2408
+ background: rgba(31, 41, 55, 0.75);
2409
+ color: #fff;
2410
+ padding: 0;
2411
+ flex-shrink: 0;
2412
+ transition:
2413
+ background var(--fb-transition-duration, 200ms),
2414
+ opacity var(--fb-transition-duration, 200ms);
2415
+ }
2416
+ .fb-tile-action-btn:hover {
2417
+ background: rgba(17, 24, 39, 0.95);
2418
+ }
2419
+ .fb-tile-action-remove {
2420
+ background: rgba(220, 38, 38, 0.8);
2421
+ }
2422
+ .fb-tile-action-remove:hover {
2423
+ background: rgba(185, 28, 28, 0.95);
2424
+ }
2425
+
2426
+ /* Actions row inside zoom popup \u2014 always visible while popup is shown */
2427
+ .fb-tile-zoom-preview .fb-tile-actions {
2428
+ position: absolute;
2429
+ top: 6px;
2430
+ right: 6px;
2431
+ opacity: 1;
2432
+ z-index: 10000;
2433
+ }
2434
+
2435
+ /* Two-card empty-state layout (upload card + library card) */
2436
+ .fb-file-card-row {
2437
+ display: flex;
2438
+ gap: 8px;
2439
+ align-items: stretch;
2440
+ }
2441
+ .fb-file-card-row .fb-file-dropzone,
2442
+ .fb-file-card-row .fb-file-library-card {
2443
+ flex: 1;
2444
+ min-width: 0;
2445
+ }
2446
+
2447
+ /* Library picker card \u2014 mirrors .fb-file-dropzone styling */
2448
+ .fb-file-library-card {
2449
+ height: 128px;
2450
+ border: 2px dashed var(--fb-file-upload-border-color, #d1d5db);
2451
+ border-radius: var(--fb-border-radius, 0.5rem);
2452
+ display: flex;
2453
+ flex-direction: column;
2454
+ align-items: center;
2455
+ justify-content: center;
2456
+ gap: 4px;
2457
+ cursor: pointer;
2458
+ background: none;
2459
+ padding: 0;
2460
+ transition:
2461
+ border-color var(--fb-transition-duration, 200ms),
2462
+ background var(--fb-transition-duration, 200ms);
2463
+ width: 100%;
2464
+ }
2465
+ .fb-file-library-card:hover,
2466
+ .fb-file-library-card:focus-visible {
2467
+ border-color: var(--fb-file-upload-hover-border-color, #3b82f6);
2468
+ background: var(--fb-background-hover-color, #f9fafb);
2469
+ outline: none;
2470
+ }
2471
+ .fb-file-library-card-icon {
2472
+ font-size: 24px;
2473
+ line-height: 1;
2474
+ flex-shrink: 0;
2475
+ }
2476
+ .fb-file-library-card-label {
2477
+ font-size: 13px;
2478
+ color: var(--fb-text-secondary-color, #6b7280);
2479
+ }
2480
+ .fb-file-library-card-hint {
2481
+ font-size: 11px;
2482
+ color: var(--fb-file-upload-text-color, #9ca3af);
2483
+ }
2484
+
2485
+ /* Library "\u{1F4DA}" add-tile \u2014 same size/style as the "+" add tile */
2486
+ .fb-tile-add-library {
2487
+ border: 2px dashed var(--fb-file-upload-border-color, #d1d5db);
2488
+ display: flex;
2489
+ align-items: center;
2490
+ justify-content: center;
2491
+ cursor: pointer;
2492
+ font-size: 24px;
2493
+ color: var(--fb-file-upload-text-color, #9ca3af);
2494
+ transition:
2495
+ border-color var(--fb-transition-duration, 200ms),
2496
+ color var(--fb-transition-duration, 200ms);
2497
+ background: none;
2498
+ padding: 0;
2499
+ width: var(--fb-tile-size, 160px);
2500
+ height: var(--fb-tile-size, 160px);
2501
+ flex-shrink: 0;
2502
+ position: relative;
2503
+ overflow: hidden;
2504
+ border-radius: var(--fb-border-radius, 0.5rem);
2505
+ }
2506
+ .fb-tile-add-library:hover,
2507
+ .fb-tile-add-library:focus-visible {
2508
+ border-color: var(--fb-file-upload-hover-border-color, #3b82f6);
2509
+ color: var(--fb-text-color, #1f2937);
2510
+ outline: none;
2511
+ }
2512
+
2513
+ /* Hover zoom preview popup for image tiles \u2014 appended to document.body (fixed) */
2514
+ .fb-tile-zoom-preview {
2515
+ position: fixed;
2516
+ z-index: 9999;
2517
+ background: var(--fb-background-color, #fff);
2518
+ border: 1px solid var(--fb-file-upload-border-color, #d1d5db);
2519
+ border-radius: var(--fb-border-radius, 0.5rem);
2520
+ box-shadow: 0 4px 16px rgba(0,0,0,0.18);
2521
+ padding: 4px;
2522
+ width: 350px;
2523
+ height: 350px;
2524
+ pointer-events: none;
2525
+ opacity: 0;
2526
+ transition: opacity 150ms ease;
2527
+ }
2528
+ .fb-tile-zoom-preview.fb-tile-zoom-preview--visible {
2529
+ opacity: 1;
2530
+ }
2531
+ .fb-tile-zoom-preview-img {
2532
+ width: 100%;
2533
+ height: 100%;
2534
+ object-fit: contain;
2535
+ display: block;
2536
+ background: var(--fb-file-upload-bg-color, #f3f4f6);
2537
+ border-radius: calc(var(--fb-border-radius, 0.5rem) - 2px);
2538
+ }
2539
+ `;
2540
+ document.head.appendChild(style);
2541
+ }
2542
+
2543
+ // src/components/file/dom.ts
2544
+ var TILE_SIZE = "160px";
2545
+ function createFileTile() {
2546
+ ensureFileStyles();
2547
+ const tile = document.createElement("div");
2548
+ tile.className = "fb-tile";
2549
+ return tile;
2550
+ }
2551
+ function showFileError(container, message) {
2552
+ const existing = container.closest(".space-y-2")?.querySelector(".file-error-message");
2553
+ if (existing) existing.remove();
2554
+ const errorEl = document.createElement("div");
2555
+ errorEl.className = "file-error-message error-message";
2556
+ errorEl.style.cssText = `
2557
+ color: var(--fb-error-color);
2558
+ font-size: var(--fb-font-size-small);
2559
+ margin-top: 0.25rem;
2560
+ `;
2561
+ errorEl.textContent = message;
2562
+ container.closest(".space-y-2")?.appendChild(errorEl);
2563
+ }
2564
+ function clearFileError(container) {
2565
+ const existing = container.closest(".space-y-2")?.querySelector(".file-error-message");
2566
+ if (existing) existing.remove();
2567
+ }
2568
+ function addDeleteButton(container, state, onDelete) {
2569
+ const existingOverlay = container.querySelector(".delete-overlay");
2570
+ if (existingOverlay) existingOverlay.remove();
2571
+ const overlay = document.createElement("div");
2572
+ 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";
2573
+ const deleteBtn = document.createElement("button");
2574
+ deleteBtn.className = "bg-red-600 text-white px-3 py-1 rounded text-sm hover:bg-red-700 transition-colors";
2575
+ deleteBtn.textContent = t("removeElement", state);
2576
+ deleteBtn.onclick = (e) => {
2577
+ e.stopPropagation();
2578
+ onDelete();
2579
+ };
2580
+ overlay.appendChild(deleteBtn);
2581
+ container.appendChild(overlay);
2582
+ }
2583
+ function findFilePicker(container) {
2584
+ let el = container.parentElement;
2585
+ while (el && !el.dataset.filesWrapper) {
2586
+ el = el.parentElement;
2587
+ }
2588
+ return el?.querySelector('input[type="file"]') ?? null;
2589
+ }
2590
+ function createUploadingTile(fileName, state) {
2591
+ ensureFileStyles();
2592
+ const tile = createFileTile();
2593
+ tile.classList.add("fb-tile-uploading");
2594
+ tile.className += " fb-uploading-tile";
2595
+ const label = fileName.length > 10 ? fileName.substring(0, 8) + "\u2026" : fileName;
2596
+ tile.innerHTML = `
2597
+ <div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;gap:6px;padding:4px;">
2598
+ <div class="fb-spinner"></div>
2599
+ <div class="fb-tile-label">${escapeHtml(label)}</div>
2600
+ <div class="fb-tile-uploading-text">${escapeHtml(t("uploadingFile", state))}</div>
2601
+ </div>`;
2602
+ return tile;
2603
+ }
2604
+ function ensureTilesWrap(list) {
2605
+ const existing = list.querySelector(".fb-tiles-wrap");
2606
+ if (existing) return existing;
2607
+ const dropzone = list.querySelector(".fb-file-dropzone");
2608
+ if (dropzone) dropzone.remove();
2609
+ const tilesWrap = document.createElement("div");
2610
+ tilesWrap.className = "fb-tiles-wrap";
2611
+ tilesWrap.style.cssText = "display:flex;flex-wrap:wrap;gap:6px;align-items:flex-start;";
2612
+ const addTile = document.createElement("div");
2613
+ addTile.className = "fb-tile fb-tile-add";
2614
+ addTile.innerHTML = "+";
2615
+ tilesWrap.appendChild(addTile);
2616
+ list.appendChild(tilesWrap);
2617
+ return tilesWrap;
2618
+ }
2619
+ function setEmptyFileContainer(fileContainer, state, hint) {
2620
+ const hintHtml = hint ? `<div class="text-xs text-gray-500 mt-1">${escapeHtml(hint)}</div>` : "";
2621
+ fileContainer.innerHTML = `
2622
+ <div class="flex flex-col items-center justify-center h-full text-gray-400">
2623
+ <svg class="w-6 h-6 mb-2" fill="currentColor" viewBox="0 0 24 24">
2624
+ <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"/>
2625
+ </svg>
2626
+ <div class="text-sm text-center">${escapeHtml(t("clickDragText", state))}</div>
2627
+ ${hintHtml}
2628
+ </div>
2629
+ `;
2630
+ }
2631
+ function setupDragAndDrop(element, dropHandler) {
2632
+ element.addEventListener("dragover", (e) => {
2633
+ e.preventDefault();
2634
+ element.classList.add("border-blue-500", "bg-blue-50");
2635
+ });
2636
+ element.addEventListener("dragleave", (e) => {
2637
+ e.preventDefault();
2638
+ element.classList.remove("border-blue-500", "bg-blue-50");
2639
+ });
2640
+ element.addEventListener("drop", (e) => {
2641
+ e.preventDefault();
2642
+ element.classList.remove("border-blue-500", "bg-blue-50");
2643
+ if (e.dataTransfer?.files) {
2644
+ dropHandler(e.dataTransfer.files);
2645
+ }
2646
+ });
2647
+ }
2648
+
2649
+ // src/components/file/preview.ts
2650
+ 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>`;
2651
+ 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>`;
2652
+ 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>`;
2653
+ function canDownload(state, meta) {
2654
+ return Boolean(
2655
+ state.config.downloadFile || state.config.getDownloadUrl || state.config.getThumbnail || meta?.file
2656
+ );
2657
+ }
2658
+ function canOpenInTab(state, meta) {
2659
+ return Boolean(
2660
+ state.config.getDownloadUrl || state.config.getThumbnail || meta?.file
2661
+ );
2662
+ }
2663
+ function createTileActions(options) {
2664
+ const { canRemove, removeHandler, state, resourceId, fileName, meta } = options;
2665
+ const group = document.createElement("div");
2666
+ group.className = "fb-tile-actions";
2667
+ const makeBtn = (icon, label, cls) => {
2668
+ const btn = document.createElement("button");
2669
+ btn.type = "button";
2670
+ btn.className = `fb-tile-action-btn ${cls}`;
2671
+ btn.innerHTML = icon;
2672
+ btn.title = label;
2673
+ btn.setAttribute("aria-label", label);
2674
+ btn.addEventListener("click", (e) => {
2675
+ e.stopPropagation();
2676
+ });
2677
+ return btn;
2678
+ };
2679
+ if (canDownload(state, meta)) {
2680
+ const dlBtn = makeBtn(ICON_DOWNLOAD, t("downloadFile", state), "fb-tile-action-download");
2681
+ dlBtn.addEventListener("click", () => {
2682
+ triggerTileDownload(resourceId, fileName, state, meta);
2683
+ });
2684
+ group.appendChild(dlBtn);
2685
+ }
2686
+ if (canOpenInTab(state, meta)) {
2687
+ const openBtn = makeBtn(ICON_OPEN, t("openInNewTab", state), "fb-tile-action-open");
2688
+ openBtn.addEventListener("click", () => {
2689
+ triggerTileOpen(resourceId, state, meta).catch((err) => {
2690
+ console.error("Open failed:", err);
2691
+ });
2692
+ });
2693
+ group.appendChild(openBtn);
2694
+ }
2695
+ if (canRemove && removeHandler) {
2696
+ const rmBtn = makeBtn(ICON_REMOVE, t("removeElement", state), "fb-tile-action-remove");
2697
+ rmBtn.addEventListener("click", () => {
2698
+ removeHandler();
2699
+ });
2700
+ group.appendChild(rmBtn);
2701
+ }
2702
+ return group;
2703
+ }
2704
+ var localFileUrlCache = /* @__PURE__ */ new WeakMap();
2705
+ function getLocalFileUrl(file) {
2706
+ let url = localFileUrlCache.get(file);
2707
+ if (!url) {
2708
+ url = URL.createObjectURL(file);
2709
+ localFileUrlCache.set(file, url);
2710
+ }
2711
+ return url;
2712
+ }
2713
+ function releaseLocalFileUrl(file) {
2714
+ if (!file) return;
2715
+ const url = localFileUrlCache.get(file);
2716
+ if (url) {
2717
+ URL.revokeObjectURL(url);
2718
+ localFileUrlCache.delete(file);
2719
+ }
2720
+ }
2721
+ function triggerTileDownload(resourceId, fileName, state, meta) {
2722
+ if (state.config.downloadFile) {
2723
+ state.config.downloadFile(resourceId, fileName);
2724
+ return;
2725
+ }
2726
+ if (meta?.file instanceof File) {
2727
+ downloadBlob(meta.file, fileName || meta.file.name);
2728
+ return;
2729
+ }
2730
+ forceDownload(resourceId, fileName, state).catch((err) => {
2731
+ console.error("Download failed:", err);
2732
+ });
2733
+ }
2734
+ async function triggerTileOpen(resourceId, state, meta) {
2735
+ let url = null;
2736
+ if (state.config.getDownloadUrl) {
2737
+ url = state.config.getDownloadUrl(resourceId);
2738
+ } else if (state.config.getThumbnail) {
2739
+ url = await state.config.getThumbnail(resourceId);
2740
+ } else if (meta?.file instanceof File) {
2741
+ url = getLocalFileUrl(meta.file);
2742
+ }
2743
+ if (url) {
2744
+ window.open(url, "_blank");
2745
+ }
2746
+ }
2747
+ var sharedZoomPopup = null;
2748
+ var zoomTimer = null;
2749
+ var zoomHideTimer = null;
2750
+ var zoomOwner = null;
2751
+ function getOrCreateZoomPopup() {
2752
+ if (!sharedZoomPopup) {
2753
+ sharedZoomPopup = document.createElement("div");
2754
+ sharedZoomPopup.className = "fb-tile-zoom-preview";
2755
+ const img = document.createElement("img");
2756
+ img.className = "fb-tile-zoom-preview-img";
2757
+ sharedZoomPopup.appendChild(img);
2758
+ sharedZoomPopup.addEventListener("mouseenter", cancelHideZoomPopup);
2759
+ sharedZoomPopup.addEventListener("mouseleave", scheduleHideZoomPopup);
2760
+ }
2761
+ return sharedZoomPopup;
2762
+ }
2763
+ function positionZoomPopup(popup, tile) {
2764
+ const tileRect = tile.getBoundingClientRect();
2765
+ const popupSize = 350;
2766
+ const margin = 6;
2767
+ const padding = 8;
2768
+ let top;
2769
+ if (tileRect.top - popupSize - margin >= padding) {
2770
+ top = tileRect.top - popupSize - margin;
2771
+ } else if (tileRect.bottom + margin + popupSize + padding <= window.innerHeight) {
2772
+ top = tileRect.bottom + margin;
2773
+ } else {
2774
+ top = Math.max(padding, Math.min(window.innerHeight - popupSize - padding, tileRect.top));
2775
+ }
2776
+ const tileCenterX = tileRect.left + tileRect.width / 2;
2777
+ let left = tileCenterX - popupSize / 2;
2778
+ left = Math.max(padding, Math.min(window.innerWidth - popupSize - padding, left));
2779
+ popup.style.top = `${top}px`;
2780
+ popup.style.left = `${left}px`;
2781
+ }
2782
+ function scheduleHideZoomPopup() {
2783
+ if (zoomHideTimer !== null) {
2784
+ clearTimeout(zoomHideTimer);
2785
+ }
2786
+ zoomHideTimer = setTimeout(() => {
2787
+ zoomHideTimer = null;
2788
+ removeZoomPopupNow();
2789
+ }, 100);
2790
+ }
2791
+ function cancelHideZoomPopup() {
2792
+ if (zoomHideTimer !== null) {
2793
+ clearTimeout(zoomHideTimer);
2794
+ zoomHideTimer = null;
2795
+ }
2796
+ }
2797
+ function removeZoomPopupNow() {
2798
+ if (zoomTimer !== null) {
2799
+ clearTimeout(zoomTimer);
2800
+ zoomTimer = null;
2801
+ }
2802
+ if (sharedZoomPopup && sharedZoomPopup.parentNode) {
2803
+ sharedZoomPopup.classList.remove("fb-tile-zoom-preview--visible");
2804
+ sharedZoomPopup.parentNode.removeChild(sharedZoomPopup);
2805
+ }
2806
+ zoomOwner = null;
2807
+ }
2808
+ function attachZoomHover(tile, src, alt, actionsEl) {
2809
+ tile.dataset.zoomSrc = src;
2810
+ tile.dataset.zoomAlt = alt;
2811
+ tile.addEventListener("mouseenter", () => {
2812
+ cancelHideZoomPopup();
2813
+ if (zoomOwner !== tile) {
2814
+ removeZoomPopupNow();
2815
+ }
2816
+ zoomOwner = tile;
2817
+ zoomTimer = setTimeout(() => {
2818
+ zoomTimer = null;
2819
+ const popup = getOrCreateZoomPopup();
2820
+ const existingActions = popup.querySelector(".fb-tile-actions");
2821
+ if (existingActions) existingActions.remove();
2822
+ const img = popup.querySelector(".fb-tile-zoom-preview-img");
2823
+ img.src = src;
2824
+ img.alt = alt;
2825
+ if (actionsEl) {
2826
+ popup.appendChild(actionsEl.cloneNode(true));
2827
+ attachClonedActionListeners(
2828
+ popup.querySelector(".fb-tile-actions"),
2829
+ actionsEl
2830
+ );
2831
+ }
2832
+ popup.style.pointerEvents = "auto";
2833
+ positionZoomPopup(popup, tile);
2834
+ document.body.appendChild(popup);
2835
+ popup.getBoundingClientRect();
2836
+ popup.classList.add("fb-tile-zoom-preview--visible");
2837
+ }, 200);
2838
+ });
2839
+ tile.addEventListener("mouseleave", () => {
2840
+ if (zoomTimer !== null) {
2841
+ clearTimeout(zoomTimer);
2842
+ zoomTimer = null;
2843
+ zoomOwner = null;
2844
+ } else {
2845
+ scheduleHideZoomPopup();
2846
+ }
2847
+ });
2848
+ }
2849
+ function attachClonedActionListeners(cloned, original) {
2850
+ const originalBtns = Array.from(original.querySelectorAll(".fb-tile-action-btn"));
2851
+ const clonedBtns = Array.from(cloned.querySelectorAll(".fb-tile-action-btn"));
2852
+ clonedBtns.forEach((clonedBtn, i) => {
2853
+ const origBtn = originalBtns[i];
2854
+ if (origBtn) {
2855
+ clonedBtn.addEventListener("click", (e) => {
2856
+ e.stopPropagation();
2857
+ origBtn.click();
2858
+ });
2859
+ }
2860
+ });
2110
2861
  }
2111
2862
  function renderLocalImagePreview(container, file, fileName, state) {
2112
2863
  const img = document.createElement("img");
2113
2864
  img.className = "w-full h-full object-contain";
2865
+ img.style.background = "var(--fb-file-upload-bg-color,#f3f4f6)";
2114
2866
  img.alt = fileName || t("previewAlt", state);
2115
2867
  const reader = new FileReader();
2116
2868
  reader.onload = (e) => {
@@ -2119,23 +2871,27 @@ function renderLocalImagePreview(container, file, fileName, state) {
2119
2871
  reader.readAsDataURL(file);
2120
2872
  container.appendChild(img);
2121
2873
  }
2122
- function renderLocalVideoPreview(container, file, videoType, resourceId, state, deps) {
2123
- const videoUrl = URL.createObjectURL(file);
2874
+ function setupDragDropless(container, _deps) {
2124
2875
  container.onclick = null;
2125
2876
  const newContainer = container.cloneNode(false);
2126
2877
  if (container.parentNode) {
2127
2878
  container.parentNode.replaceChild(newContainer, container);
2128
2879
  }
2880
+ return newContainer;
2881
+ }
2882
+ function renderLocalVideoPreview(container, file, videoType, resourceId, state, deps) {
2883
+ const videoUrl = URL.createObjectURL(file);
2884
+ const newContainer = setupDragDropless(container);
2129
2885
  newContainer.innerHTML = `
2130
- <div class="relative group h-full">
2886
+ <div class="fb-video-preview-wrap">
2131
2887
  <video class="w-full h-full object-contain" controls preload="auto" muted src="${videoUrl}">
2132
2888
  ${escapeHtml(t("videoNotSupported", state))}
2133
2889
  </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">
2890
+ <div class="fb-video-btn-overlay">
2891
+ <button class="fb-video-btn fb-video-btn-delete delete-file-btn">
2136
2892
  ${escapeHtml(t("removeElement", state))}
2137
2893
  </button>
2138
- <button class="bg-gray-800 bg-opacity-75 hover:bg-opacity-90 text-white p-1 rounded text-xs change-file-btn">
2894
+ <button class="fb-video-btn fb-video-btn-change change-file-btn">
2139
2895
  ${escapeHtml(t("changeButton", state))}
2140
2896
  </button>
2141
2897
  </div>
@@ -2145,20 +2901,14 @@ function renderLocalVideoPreview(container, file, videoType, resourceId, state,
2145
2901
  return newContainer;
2146
2902
  }
2147
2903
  function attachVideoButtonHandlers(container, resourceId, state, deps) {
2148
- const changeBtn = container.querySelector(
2149
- ".change-file-btn"
2150
- );
2904
+ const changeBtn = container.querySelector(".change-file-btn");
2151
2905
  if (changeBtn) {
2152
2906
  changeBtn.onclick = (e) => {
2153
2907
  e.stopPropagation();
2154
- if (deps?.picker) {
2155
- deps.picker.click();
2156
- }
2908
+ deps?.picker?.click();
2157
2909
  };
2158
2910
  }
2159
- const deleteBtn = container.querySelector(
2160
- ".delete-file-btn"
2161
- );
2911
+ const deleteBtn = container.querySelector(".delete-file-btn");
2162
2912
  if (deleteBtn) {
2163
2913
  deleteBtn.onclick = (e) => {
2164
2914
  e.stopPropagation();
@@ -2177,9 +2927,6 @@ function handleVideoDelete(container, resourceId, state, deps) {
2177
2927
  if (deps?.fileUploadHandler) {
2178
2928
  container.onclick = deps.fileUploadHandler;
2179
2929
  }
2180
- if (deps?.dragHandler) {
2181
- setupDragAndDrop(container, deps.dragHandler);
2182
- }
2183
2930
  container.innerHTML = `
2184
2931
  <div class="flex flex-col items-center justify-center h-full text-gray-400">
2185
2932
  <svg class="w-6 h-6 mb-2" fill="currentColor" viewBox="0 0 24 24">
@@ -2188,16 +2935,9 @@ function handleVideoDelete(container, resourceId, state, deps) {
2188
2935
  <div class="text-sm text-center">${escapeHtml(t("clickDragText", state))}</div>
2189
2936
  </div>
2190
2937
  `;
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);
2938
+ if (deps?.setupDrop) {
2939
+ deps.setupDrop(container);
2940
+ }
2201
2941
  }
2202
2942
  function renderDeleteButton(container, resourceId, state) {
2203
2943
  addDeleteButton(container, state, () => {
@@ -2219,13 +2959,11 @@ function renderDeleteButton(container, resourceId, state) {
2219
2959
  });
2220
2960
  }
2221
2961
  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/")) {
2962
+ if (!meta.file || !(meta.file instanceof File)) return;
2963
+ if (meta.type?.startsWith("image/")) {
2226
2964
  renderLocalImagePreview(container, meta.file, fileName, state);
2227
- } else if (meta.type && meta.type.startsWith("video/")) {
2228
- const newContainer = renderLocalVideoPreview(
2965
+ } else if (meta.type?.startsWith("video/")) {
2966
+ container = renderLocalVideoPreview(
2229
2967
  container,
2230
2968
  meta.file,
2231
2969
  meta.type,
@@ -2233,14 +2971,23 @@ async function renderLocalFilePreview(container, meta, fileName, resourceId, isR
2233
2971
  state,
2234
2972
  deps
2235
2973
  );
2236
- container = newContainer;
2237
2974
  } 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>`;
2975
+ 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
2976
  }
2240
- if (!isReadonly && !(meta.type && meta.type.startsWith("video/"))) {
2977
+ if (!isReadonly && !meta.type?.startsWith("video/")) {
2241
2978
  renderDeleteButton(container, resourceId, state);
2242
2979
  }
2243
2980
  }
2981
+ function renderUploadedVideoPreview(container, thumbnailUrl, state) {
2982
+ const video = document.createElement("video");
2983
+ video.className = "w-full h-full object-contain";
2984
+ video.controls = true;
2985
+ video.preload = "metadata";
2986
+ video.muted = true;
2987
+ video.src = thumbnailUrl;
2988
+ video.appendChild(document.createTextNode(t("videoNotSupported", state)));
2989
+ container.appendChild(video);
2990
+ }
2244
2991
  async function renderUploadedFilePreview(container, resourceId, fileName, meta, state) {
2245
2992
  if (!state.config.getThumbnail) {
2246
2993
  setEmptyFileContainer(container, state);
@@ -2250,11 +2997,12 @@ async function renderUploadedFilePreview(container, resourceId, fileName, meta,
2250
2997
  const thumbnailUrl = await state.config.getThumbnail(resourceId);
2251
2998
  if (thumbnailUrl) {
2252
2999
  clear(container);
2253
- if (meta && meta.type && meta.type.startsWith("video/")) {
2254
- renderUploadedVideoPreview(container, thumbnailUrl, meta.type, state);
3000
+ if (meta?.type?.startsWith("video/")) {
3001
+ renderUploadedVideoPreview(container, thumbnailUrl, state);
2255
3002
  } else {
2256
3003
  const img = document.createElement("img");
2257
3004
  img.className = "w-full h-full object-contain";
3005
+ img.style.background = "var(--fb-file-upload-bg-color,#f3f4f6)";
2258
3006
  img.alt = fileName || t("previewAlt", state);
2259
3007
  img.src = thumbnailUrl;
2260
3008
  container.appendChild(img);
@@ -2282,9 +3030,6 @@ async function renderFilePreview(container, resourceId, state, options = {}) {
2282
3030
  );
2283
3031
  }
2284
3032
  clear(container);
2285
- if (isReadonly) {
2286
- container.classList.add("cursor-pointer");
2287
- }
2288
3033
  const meta = state.resourceIndex.get(resourceId);
2289
3034
  if (meta && meta.file && meta.file instanceof File) {
2290
3035
  await renderLocalFilePreview(
@@ -2297,366 +3042,304 @@ async function renderFilePreview(container, resourceId, state, options = {}) {
2297
3042
  deps
2298
3043
  );
2299
3044
  } else {
2300
- await renderUploadedFilePreview(
2301
- container,
2302
- resourceId,
2303
- fileName,
2304
- meta,
2305
- state
2306
- );
3045
+ await renderUploadedFilePreview(container, resourceId, fileName, meta, state);
2307
3046
  const isVideo = meta?.type?.startsWith("video/");
2308
3047
  if (!isReadonly && !isVideo) {
2309
3048
  renderDeleteButton(container, resourceId, state);
2310
3049
  }
2311
3050
  }
2312
3051
  }
2313
- async function renderFilePreviewReadonly(resourceId, state, fileName) {
3052
+ function resolveFileName(resourceId, meta, fileName) {
3053
+ if (fileName) return fileName;
3054
+ if (meta?.name?.includes(".")) return meta.name;
3055
+ const basename = resourceId.includes("/") ? resourceId.split("/").pop() : resourceId;
3056
+ return basename?.includes(".") ? basename : "";
3057
+ }
3058
+ async function renderFilePreviewReadonly(resourceId, state, fileName, options = {}) {
2314
3059
  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) {
3060
+ const actualFileName = resolveFileName(resourceId, meta, fileName);
3061
+ const { canRemove = false, removeHandler = null } = options;
3062
+ const isImage = meta?.type?.startsWith("image/") || Boolean(actualFileName.toLowerCase().match(/\.(jpg|jpeg|png|gif|webp)$/));
3063
+ const isVideo = meta?.type?.startsWith("video/") || Boolean(actualFileName.toLowerCase().match(/\.(mp4|webm|avi|mov)$/));
3064
+ const tile = createFileTile();
3065
+ tile.classList.add("fb-tile-resource");
3066
+ tile.style.cursor = "pointer";
3067
+ if (actualFileName) {
3068
+ tile.title = actualFileName;
3069
+ }
3070
+ const localFileUrl = meta?.file instanceof File ? getLocalFileUrl(meta.file) : null;
3071
+ const resolveOpenUrl = async () => {
3072
+ if (state.config.getDownloadUrl) return state.config.getDownloadUrl(resourceId);
3073
+ if (state.config.getThumbnail) return state.config.getThumbnail(resourceId);
3074
+ return localFileUrl;
3075
+ };
3076
+ tile.onclick = async () => {
3077
+ const url = await resolveOpenUrl();
3078
+ if (url) {
3079
+ window.open(url, "_blank");
3080
+ } else if (state.config.downloadFile) {
3081
+ state.config.downloadFile(resourceId, actualFileName);
3082
+ } else {
3083
+ forceDownload(resourceId, actualFileName, state).catch((err) => {
3084
+ console.error("Download failed:", err);
3085
+ });
3086
+ }
3087
+ };
3088
+ const actionsEl = createTileActions({
3089
+ canRemove,
3090
+ removeHandler,
3091
+ state,
3092
+ resourceId,
3093
+ fileName: actualFileName,
3094
+ meta
3095
+ });
3096
+ const resolveImageDisplayUrl = async () => {
2328
3097
  if (state.config.getThumbnail) {
2329
3098
  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>`;
3099
+ const url = await state.config.getThumbnail(resourceId);
3100
+ if (url) return url;
3101
+ } catch {
2339
3102
  }
3103
+ }
3104
+ return localFileUrl;
3105
+ };
3106
+ const renderImageFallback = () => {
3107
+ tile.innerHTML = `<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;font-size:36px;">\u{1F5BC}\uFE0F</div>`;
3108
+ tile.appendChild(actionsEl);
3109
+ };
3110
+ if (isImage) {
3111
+ const displayUrl = await resolveImageDisplayUrl();
3112
+ if (displayUrl) {
3113
+ const img = document.createElement("img");
3114
+ img.style.cssText = "width:100%;height:100%;object-fit:contain;background:var(--fb-file-upload-bg-color,#f3f4f6);";
3115
+ img.alt = actualFileName;
3116
+ img.src = displayUrl;
3117
+ tile.appendChild(img);
3118
+ tile.appendChild(actionsEl);
3119
+ attachZoomHover(tile, displayUrl, actualFileName, actionsEl);
2340
3120
  } 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>`;
3121
+ renderImageFallback();
2342
3122
  }
2343
3123
  } else if (isVideo) {
2344
3124
  if (state.config.getThumbnail) {
2345
3125
  try {
2346
3126
  const videoUrl = await state.config.getThumbnail(resourceId);
2347
3127
  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>
3128
+ tile.innerHTML = `
3129
+ <img style="width:100%;height:100%;object-fit:contain;background:var(--fb-file-upload-bg-color,#f3f4f6);" alt="${escapeHtml(actualFileName)}" src="${videoUrl}">
3130
+ <div class="fb-video-overlay">
3131
+ <div class="fb-play-btn" style="width:22px;height:22px;">
3132
+ <svg width="10" height="12" viewBox="0 0 10 12" fill="currentColor"><path d="M0 0l10 6-10 6z"/></svg>
2359
3133
  </div>
2360
- </div>
2361
- `;
3134
+ </div>`;
3135
+ tile.appendChild(actionsEl);
2362
3136
  } 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>`;
3137
+ tile.innerHTML = `<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;font-size:36px;">\u{1F3A5}</div>`;
3138
+ tile.appendChild(actionsEl);
2364
3139
  }
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>`;
3140
+ } catch {
3141
+ tile.innerHTML = `<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;font-size:36px;">\u{1F3A5}</div>`;
3142
+ tile.appendChild(actionsEl);
2368
3143
  }
2369
3144
  } 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>`;
3145
+ tile.innerHTML = `<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;font-size:36px;">\u{1F3A5}</div>`;
3146
+ tile.appendChild(actionsEl);
2371
3147
  }
2372
3148
  } 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>`;
2387
- }
2388
- }
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;
2408
- }
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;
3149
+ if (state.config.getThumbnail) {
3150
+ try {
3151
+ const thumbUrl = await state.config.getThumbnail(resourceId);
3152
+ if (thumbUrl) {
3153
+ const img = document.createElement("img");
3154
+ img.style.cssText = "width:100%;height:100%;object-fit:contain;background:var(--fb-file-upload-bg-color,#f3f4f6);";
3155
+ img.alt = actualFileName || resourceId;
3156
+ img.src = thumbUrl;
3157
+ tile.appendChild(img);
3158
+ tile.appendChild(actionsEl);
3159
+ return tile;
2445
3160
  }
2446
- const fileInput = filesWrapper?.querySelector(
2447
- 'input[type="file"]'
2448
- );
2449
- if (fileInput) fileInput.click();
2450
- };
2451
- gridContainer2.appendChild(slot);
3161
+ } catch {
3162
+ }
2452
3163
  }
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;
3164
+ const captionHtml = actualFileName ? `<div class="fb-tile-label">${escapeHtml(actualFileName.length > 10 ? actualFileName.substring(0, 8) + "\u2026" : actualFileName)}</div>
3165
+ <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>` : "";
3166
+ tile.innerHTML = `
3167
+ <div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;padding:6px;gap:4px;">
3168
+ <div style="font-size:36px;">\u{1F4C1}</div>
3169
+ ${captionHtml}
3170
+ </div>`;
3171
+ tile.appendChild(actionsEl);
2459
3172
  }
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);
3173
+ return tile;
2516
3174
  }
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>`;
3175
+ async function renderSingleFileEditTile(fileContainer, resourceId, state, deps) {
3176
+ const meta = state.resourceIndex.get(resourceId);
3177
+ const fileName = meta?.name ?? resourceId.split("/").pop() ?? "";
3178
+ const removeHandler = deps.onRemove ?? null;
3179
+ const tile = await renderFilePreviewReadonly(resourceId, state, fileName, {
3180
+ canRemove: true,
3181
+ removeHandler
3182
+ });
3183
+ fileContainer.className = "file-preview-container";
3184
+ fileContainer.removeAttribute("style");
3185
+ clear(fileContainer);
3186
+ fileContainer.appendChild(tile);
2524
3187
  }
2525
- async function renderThumbnailForResource(slot, rid, meta, state) {
2526
- if (meta && meta.type?.startsWith("image/")) {
3188
+ async function fillTileContent(tile, rid, meta, state, actionsEl) {
3189
+ if (meta?.type?.startsWith("image/")) {
2527
3190
  if (meta.file && meta.file instanceof File) {
2528
3191
  const img = document.createElement("img");
2529
- img.className = "w-full h-full object-contain";
3192
+ img.style.cssText = "width:100%;height:100%;object-fit:contain;background:var(--fb-file-upload-bg-color,#f3f4f6);";
2530
3193
  img.alt = meta.name;
2531
3194
  const reader = new FileReader();
2532
3195
  reader.onload = (e) => {
2533
3196
  img.src = e.target?.result || "";
3197
+ attachZoomHover(tile, img.src, meta.name, actionsEl ?? null);
2534
3198
  };
2535
3199
  reader.readAsDataURL(meta.file);
2536
- slot.appendChild(img);
3200
+ tile.appendChild(img);
2537
3201
  } else if (state.config.getThumbnail) {
2538
3202
  try {
2539
3203
  const url = await state.config.getThumbnail(rid);
2540
3204
  if (url) {
2541
3205
  const img = document.createElement("img");
2542
- img.className = "w-full h-full object-contain";
3206
+ img.style.cssText = "width:100%;height:100%;object-fit:contain;background:var(--fb-file-upload-bg-color,#f3f4f6);";
2543
3207
  img.alt = meta.name;
2544
3208
  img.src = url;
2545
- slot.appendChild(img);
3209
+ tile.appendChild(img);
3210
+ attachZoomHover(tile, url, meta.name, actionsEl ?? null);
2546
3211
  } 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>`;
3212
+ tile.innerHTML = `<div style="display:flex;align-items:center;justify-content:center;height:100%;font-size:36px;">\u{1F5BC}\uFE0F</div>`;
2552
3213
  }
2553
3214
  } catch (error) {
2554
3215
  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);
3216
+ if (state.config.onThumbnailError) state.config.onThumbnailError(err, rid);
3217
+ 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
3218
  }
2560
3219
  } 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>`;
3220
+ tile.innerHTML = `<div style="display:flex;align-items:center;justify-content:center;height:100%;font-size:36px;">\u{1F5BC}\uFE0F</div>`;
2566
3221
  }
2567
- } else if (meta && meta.type?.startsWith("video/")) {
3222
+ if (actionsEl) tile.appendChild(actionsEl);
3223
+ } else if (meta?.type?.startsWith("video/")) {
2568
3224
  if (meta.file && meta.file instanceof File) {
2569
3225
  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>
3226
+ tile.innerHTML = `
3227
+ <video style="width:100%;height:100%;" preload="metadata" muted src="${videoUrl}"></video>
3228
+ <div class="fb-video-overlay">
3229
+ <div class="fb-play-btn" style="width:20px;height:20px;">
3230
+ <svg width="8" height="10" viewBox="0 0 8 10" fill="currentColor"><path d="M0 0l8 5-8 5z"/></svg>
2580
3231
  </div>
2581
- </div>
2582
- `;
3232
+ </div>`;
2583
3233
  } else if (state.config.getThumbnail) {
2584
3234
  try {
2585
3235
  const videoUrl = await state.config.getThumbnail(rid);
2586
3236
  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>
3237
+ tile.innerHTML = `
3238
+ <img style="width:100%;height:100%;object-fit:contain;background:var(--fb-file-upload-bg-color,#f3f4f6);" alt="${escapeHtml(meta.name)}" src="${videoUrl}">
3239
+ <div class="fb-video-overlay">
3240
+ <div class="fb-play-btn" style="width:20px;height:20px;">
3241
+ <svg width="8" height="10" viewBox="0 0 8 10" fill="currentColor"><path d="M0 0l8 5-8 5z"/></svg>
2597
3242
  </div>
2598
- </div>
2599
- `;
3243
+ </div>`;
2600
3244
  } 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>`;
3245
+ tile.innerHTML = `<div style="display:flex;align-items:center;justify-content:center;height:100%;font-size:36px;">\u{1F3A5}</div>`;
2607
3246
  }
2608
3247
  } catch (error) {
2609
3248
  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");
3249
+ if (state.config.onThumbnailError) state.config.onThumbnailError(err, rid);
3250
+ 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
3251
  }
2615
3252
  } 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>`;
3253
+ tile.innerHTML = `<div style="display:flex;align-items:center;justify-content:center;height:100%;font-size:36px;">\u{1F3A5}</div>`;
2622
3254
  }
3255
+ if (actionsEl) tile.appendChild(actionsEl);
2623
3256
  } 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>`;
3257
+ const name = meta?.name ?? "";
3258
+ const hasExtension = name.includes(".");
3259
+ const captionHtml = hasExtension ? `<div class="fb-tile-label">${escapeHtml(name.length > 10 ? name.substring(0, 8) + "\u2026" : name)}</div>` : "";
3260
+ tile.innerHTML = `
3261
+ <div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;padding:6px;gap:4px;">
3262
+ <div style="font-size:36px;">\u{1F4C1}</div>
3263
+ ${captionHtml}
3264
+ </div>`;
3265
+ if (actionsEl) tile.appendChild(actionsEl);
2628
3266
  }
2629
3267
  }
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
- `;
2641
- }
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);
3268
+ async function forceDownload(resourceId, fileName, state) {
3269
+ try {
3270
+ let fileUrl = null;
3271
+ if (state.config.getDownloadUrl) {
3272
+ fileUrl = state.config.getDownloadUrl(resourceId);
3273
+ } else if (state.config.getThumbnail) {
3274
+ fileUrl = await state.config.getThumbnail(resourceId);
3275
+ }
3276
+ if (fileUrl) {
3277
+ const finalUrl = fileUrl.startsWith("http") ? fileUrl : new URL(fileUrl, window.location.href).href;
3278
+ const response = await fetch(finalUrl);
3279
+ if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
3280
+ const blob = await response.blob();
3281
+ downloadBlob(blob, fileName);
3282
+ } else {
3283
+ throw new Error("No download URL available for resource");
3284
+ }
3285
+ } catch (error) {
3286
+ const err = error instanceof Error ? error : new Error(String(error));
3287
+ if (state.config.onDownloadError) {
3288
+ state.config.onDownloadError(err, resourceId, fileName);
3289
+ }
3290
+ console.error(`File download failed for ${fileName}:`, err);
3291
+ throw err;
3292
+ }
2654
3293
  }
2655
- function clearFileError(container) {
2656
- const existing = container.closest(".space-y-2")?.querySelector(".file-error-message");
2657
- if (existing) existing.remove();
3294
+ function downloadBlob(blob, fileName) {
3295
+ try {
3296
+ const blobUrl = URL.createObjectURL(blob);
3297
+ const link = document.createElement("a");
3298
+ link.href = blobUrl;
3299
+ link.download = fileName;
3300
+ link.style.display = "none";
3301
+ document.body.appendChild(link);
3302
+ link.click();
3303
+ document.body.removeChild(link);
3304
+ setTimeout(() => {
3305
+ URL.revokeObjectURL(blobUrl);
3306
+ }, 100);
3307
+ } catch (error) {
3308
+ throw new Error(`Blob download failed: ${error.message}`);
3309
+ }
2658
3310
  }
2659
- async function handleFileSelect(file, container, fieldName, state, deps = null, instance, allowedExtensions = [], maxSizeMB = Infinity) {
3311
+
3312
+ // src/components/file/upload.ts
3313
+ async function uploadSingleFile(file, state) {
3314
+ if (!state.config.uploadFile) {
3315
+ throw new Error(
3316
+ "No upload handler configured. Set uploadHandler via FormBuilder.setUploadHandler()"
3317
+ );
3318
+ }
3319
+ try {
3320
+ const rid = await state.config.uploadFile(file);
3321
+ if (typeof rid !== "string") {
3322
+ throw new Error("Upload handler must return a string resource ID");
3323
+ }
3324
+ return rid;
3325
+ } catch (error) {
3326
+ const err = error instanceof Error ? error : new Error(String(error));
3327
+ if (state.config.onUploadError) state.config.onUploadError(err, file);
3328
+ throw new Error(`File upload failed: ${err.message}`);
3329
+ }
3330
+ }
3331
+ async function handleFileSelect(opts) {
3332
+ const {
3333
+ file,
3334
+ container,
3335
+ fieldName,
3336
+ state,
3337
+ deps = null,
3338
+ instance = null,
3339
+ allowedExtensions = [],
3340
+ allowedMimes = [],
3341
+ maxSizeMB = Infinity
3342
+ } = opts;
2660
3343
  if (!isFileExtensionAllowed(file.name, allowedExtensions)) {
2661
3344
  const formats = allowedExtensions.join(", ");
2662
3345
  showFileError(
@@ -2665,6 +3348,14 @@ async function handleFileSelect(file, container, fieldName, state, deps = null,
2665
3348
  );
2666
3349
  return;
2667
3350
  }
3351
+ if (!isMimeAllowed(file.type, allowedMimes)) {
3352
+ const mimes = allowedMimes.join(", ");
3353
+ showFileError(
3354
+ container,
3355
+ t("invalidFileMime", state, { name: file.name, type: file.type, mimes })
3356
+ );
3357
+ return;
3358
+ }
2668
3359
  if (!isFileSizeAllowed(file, maxSizeMB)) {
2669
3360
  showFileError(
2670
3361
  container,
@@ -2673,24 +3364,18 @@ async function handleFileSelect(file, container, fieldName, state, deps = null,
2673
3364
  return;
2674
3365
  }
2675
3366
  clearFileError(container);
3367
+ ensureFileStyles();
3368
+ container.innerHTML = `
3369
+ <div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;gap:6px;padding:6px;">
3370
+ <div class="fb-spinner"></div>
3371
+ <div style="font-size:11px;color:var(--fb-text-secondary-color,#6b7280);text-align:center;">${escapeHtml(t("uploadingFile", state))}</div>
3372
+ </div>`;
2676
3373
  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
- );
3374
+ try {
3375
+ rid = await uploadSingleFile(file, state);
3376
+ } catch (error) {
3377
+ setEmptyFileContainer(container, state);
3378
+ throw error;
2694
3379
  }
2695
3380
  state.resourceIndex.set(rid, {
2696
3381
  name: file.name,
@@ -2698,7 +3383,6 @@ async function handleFileSelect(file, container, fieldName, state, deps = null,
2698
3383
  size: file.size,
2699
3384
  uploadedAt: /* @__PURE__ */ new Date(),
2700
3385
  file
2701
- // Store the file object for local preview
2702
3386
  });
2703
3387
  let hiddenInput = container.parentElement?.querySelector(
2704
3388
  'input[type="hidden"]'
@@ -2710,682 +3394,992 @@ async function handleFileSelect(file, container, fieldName, state, deps = null,
2710
3394
  container.parentElement?.appendChild(hiddenInput);
2711
3395
  }
2712
3396
  hiddenInput.value = rid;
2713
- renderFilePreview(container, rid, state, {
2714
- fileName: file.name,
2715
- isReadonly: false,
2716
- deps
2717
- }).catch(console.error);
3397
+ const isVideo = file.type.startsWith("video/");
3398
+ if (!isVideo && deps) {
3399
+ renderSingleFileEditTile(container, rid, state, deps).catch(console.error);
3400
+ } else {
3401
+ renderFilePreview(container, rid, state, {
3402
+ fileName: file.name,
3403
+ isReadonly: false,
3404
+ deps
3405
+ }).catch(console.error);
3406
+ }
2718
3407
  if (instance && !state.config.readonly) {
2719
3408
  instance.triggerOnChange(fieldName, rid);
2720
3409
  }
2721
3410
  }
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()"
3411
+ function filterAndSlice(allFiles, currentCount, constraints, state) {
3412
+ const rejectedByExt = allFiles.filter(
3413
+ (f) => !isFileExtensionAllowed(f.name, constraints.allowedExtensions)
3414
+ );
3415
+ const afterExt = allFiles.filter(
3416
+ (f) => isFileExtensionAllowed(f.name, constraints.allowedExtensions)
3417
+ );
3418
+ const rejectedByMime = afterExt.filter(
3419
+ (f) => !isMimeAllowed(f.type, constraints.allowedMimes)
3420
+ );
3421
+ const afterMime = afterExt.filter(
3422
+ (f) => isMimeAllowed(f.type, constraints.allowedMimes)
3423
+ );
3424
+ const rejectedBySize = afterMime.filter(
3425
+ (f) => !isFileSizeAllowed(f, constraints.maxSize)
3426
+ );
3427
+ const valid = afterMime.filter((f) => isFileSizeAllowed(f, constraints.maxSize));
3428
+ const remaining = constraints.maxCount === Infinity ? valid.length : Math.max(0, constraints.maxCount - currentCount);
3429
+ const accepted = valid.slice(0, remaining);
3430
+ const skippedByCount = valid.length - accepted.length;
3431
+ const errorParts = [];
3432
+ if (rejectedByExt.length > 0) {
3433
+ const formats = constraints.allowedExtensions.join(", ");
3434
+ const names = rejectedByExt.map((f) => f.name).join(", ");
3435
+ errorParts.push(t("invalidFileExtension", state, { name: names, formats }));
3436
+ }
3437
+ if (rejectedByMime.length > 0) {
3438
+ const mimes = constraints.allowedMimes.join(", ");
3439
+ const names = rejectedByMime.map((f) => f.name).join(", ");
3440
+ errorParts.push(t("invalidFileMime", state, { name: names, type: rejectedByMime.map((f) => f.type).join(", "), mimes }));
3441
+ }
3442
+ if (rejectedBySize.length > 0) {
3443
+ const names = rejectedBySize.map((f) => f.name).join(", ");
3444
+ errorParts.push(
3445
+ t("fileTooLarge", state, { name: names, maxSize: constraints.maxSize })
2774
3446
  );
2775
3447
  }
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}`);
3448
+ if (skippedByCount > 0) {
3449
+ errorParts.push(
3450
+ t("filesLimitExceeded", state, {
3451
+ skipped: skippedByCount,
3452
+ max: constraints.maxCount
3453
+ })
3454
+ );
2820
3455
  }
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
- }
3456
+ return { accepted, errorMessage: errorParts.join(" \u2022 ") };
3457
+ }
3458
+ async function uploadBatch(accepted, resourceIds, listEl, state) {
3459
+ await Promise.all(
3460
+ accepted.map(async (file) => {
3461
+ const placeholder = createUploadingTile(file.name, state);
3462
+ if (listEl) {
3463
+ const tilesWrap = ensureTilesWrap(listEl);
3464
+ const addTile = tilesWrap.querySelector(".fb-tile-add");
3465
+ if (addTile) {
3466
+ tilesWrap.insertBefore(placeholder, addTile);
3467
+ } else {
3468
+ tilesWrap.appendChild(placeholder);
2835
3469
  }
2836
- state.resourceIndex.set(resourceId, {
2837
- name: filename,
2838
- type: fileType,
2839
- size: 0,
3470
+ }
3471
+ try {
3472
+ const rid = await uploadSingleFile(file, state);
3473
+ state.resourceIndex.set(rid, {
3474
+ name: file.name,
3475
+ type: file.type,
3476
+ size: file.size,
2840
3477
  uploadedAt: /* @__PURE__ */ new Date(),
2841
3478
  file: void 0
2842
3479
  });
3480
+ resourceIds.push(rid);
3481
+ } finally {
3482
+ placeholder.remove();
2843
3483
  }
2844
- });
2845
- }
2846
- }
2847
- function handleInitialFileData(initial, fileContainer, pathKey, fileWrapper, state, deps) {
2848
- if (!state.resourceIndex.has(initial)) {
2849
- const filename = initial.split("/").pop() || "file";
2850
- const extension = filename.split(".").pop()?.toLowerCase();
2851
- let fileType = "application/octet-stream";
2852
- if (extension) {
2853
- if (["jpg", "jpeg", "png", "gif", "webp"].includes(extension)) {
2854
- fileType = `image/${extension === "jpg" ? "jpeg" : extension}`;
2855
- } else if (["mp4", "webm", "mov", "avi"].includes(extension)) {
2856
- fileType = `video/${extension === "mov" ? "quicktime" : extension}`;
2857
- }
2858
- }
2859
- state.resourceIndex.set(initial, {
2860
- name: filename,
2861
- type: fileType,
2862
- size: 0,
2863
- uploadedAt: /* @__PURE__ */ new Date(),
2864
- file: void 0
2865
- });
2866
- }
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";
2874
- hiddenInput.name = pathKey;
2875
- hiddenInput.value = initial;
2876
- fileWrapper.appendChild(hiddenInput);
3484
+ })
3485
+ );
2877
3486
  }
2878
- function setupFilesDropHandler(filesContainer, initialFiles, state, updateCallback, constraints, pathKey, instance) {
3487
+ function setupFilesDropHandler(filesContainer, resourceIds, state, updateCallback, constraints, pathKey, instance) {
2879
3488
  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)
3489
+ const { accepted, errorMessage } = filterAndSlice(
3490
+ Array.from(files),
3491
+ resourceIds.length,
3492
+ constraints,
3493
+ state
2892
3494
  );
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 "));
3495
+ if (errorMessage) {
3496
+ showFileError(filesContainer, errorMessage);
2920
3497
  } else {
2921
3498
  clearFileError(filesContainer);
2922
3499
  }
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
- }
3500
+ const list = filesContainer.querySelector(".files-list") ?? filesContainer;
3501
+ await uploadBatch(accepted, resourceIds, list, state);
2934
3502
  updateCallback();
2935
3503
  if (instance && pathKey && !state.config.readonly) {
2936
- instance.triggerOnChange(pathKey, initialFiles);
3504
+ instance.triggerOnChange(pathKey, resourceIds);
2937
3505
  }
2938
3506
  });
2939
3507
  }
2940
- function setupFilesPickerHandler(filesPicker, initialFiles, state, updateCallback, constraints, pathKey, instance) {
3508
+ function setupFilesPickerHandler(filesPicker, resourceIds, state, updateCallback, constraints, pathKey, instance) {
2941
3509
  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
- }
3510
+ if (!filesPicker.files) return;
3511
+ const wrapperEl = filesPicker.closest(".space-y-2") || filesPicker.parentElement;
3512
+ const { accepted, errorMessage } = filterAndSlice(
3513
+ Array.from(filesPicker.files),
3514
+ resourceIds.length,
3515
+ constraints,
3516
+ state
3517
+ );
3518
+ if (errorMessage && wrapperEl) {
3519
+ showFileError(wrapperEl, errorMessage);
3520
+ } else if (wrapperEl) {
3521
+ clearFileError(wrapperEl);
3001
3522
  }
3523
+ const listEl = wrapperEl?.querySelector(".files-list");
3524
+ await uploadBatch(accepted, resourceIds, listEl ?? null, state);
3002
3525
  updateCallback();
3003
3526
  filesPicker.value = "";
3004
3527
  if (instance && pathKey && !state.config.readonly) {
3005
- instance.triggerOnChange(pathKey, initialFiles);
3528
+ instance.triggerOnChange(pathKey, resourceIds);
3006
3529
  }
3007
3530
  };
3008
3531
  }
3009
- function renderFileElement(element, ctx, wrapper, pathKey) {
3010
- 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
- });
3532
+
3533
+ // src/components/file/library.ts
3534
+ function buildAcceptContext(element) {
3535
+ if (!element.accept) return void 0;
3536
+ if (typeof element.accept === "string") {
3537
+ const exts2 = getAllowedExtensions(element.accept);
3538
+ return exts2.length > 0 ? { extensions: exts2 } : void 0;
3539
+ }
3540
+ const exts = element.accept.extensions ?? [];
3541
+ const mime = element.accept.mime ?? [];
3542
+ const normalizedExts = exts.map((e) => e.toLowerCase());
3543
+ if (normalizedExts.length === 0 && mime.length === 0) return void 0;
3544
+ const result = {};
3545
+ if (normalizedExts.length > 0) result.extensions = normalizedExts;
3546
+ if (mime.length > 0) result.mime = mime;
3547
+ return result;
3548
+ }
3549
+ function validatePickedResource(resource, allowedExtensions, allowedMimes, maxSizeMB, state) {
3550
+ if (!isFileExtensionAllowed(resource.name, allowedExtensions)) {
3551
+ const formats = allowedExtensions.join(", ");
3552
+ return t("invalidFileExtension", state, { name: resource.name, formats });
3553
+ }
3554
+ if (!isMimeAllowed(resource.type, allowedMimes)) {
3555
+ const mimes = allowedMimes.join(", ");
3556
+ return t("invalidFileMime", state, { name: resource.name, type: resource.type, mimes });
3557
+ }
3558
+ if (!isSizeWithinLimit(resource.size, maxSizeMB)) {
3559
+ return t("fileTooLarge", state, { name: resource.name, maxSize: maxSizeMB });
3560
+ }
3561
+ return null;
3562
+ }
3563
+ function readCurrentResourceIds(wrapper) {
3564
+ const raw = wrapper.dataset.resourceIds;
3565
+ if (!raw) return [];
3566
+ try {
3567
+ const parsed = JSON.parse(raw);
3568
+ return Array.isArray(parsed) ? parsed : [];
3569
+ } catch {
3570
+ return [];
3571
+ }
3572
+ }
3573
+ function registerPickedResource(resource, state) {
3574
+ const existing = state.resourceIndex.get(resource.resourceId);
3575
+ state.resourceIndex.set(resource.resourceId, {
3576
+ name: resource.name,
3577
+ type: resource.type,
3578
+ size: resource.size,
3579
+ uploadedAt: existing?.uploadedAt ?? /* @__PURE__ */ new Date(),
3580
+ file: existing?.file
3581
+ });
3582
+ }
3583
+ function extractPickerError(error, state) {
3584
+ if (error instanceof Error && error.message) return error.message;
3585
+ return t("pickerError", state);
3586
+ }
3587
+ async function handleLibraryPickMulti(state, element, wrapper, fieldPath, resourceIds, maxCount, updateCallback, instance) {
3588
+ if (!state.config.pickExistingFiles) return;
3589
+ const allowedExtensions = getAllowedExtensions(element.accept);
3590
+ const allowedMimes = getAllowedMimes(element.accept);
3591
+ const maxSizeMB = element.maxSize ?? Infinity;
3592
+ const currentIds = readCurrentResourceIds(wrapper);
3593
+ const remaining = maxCount === Infinity ? Infinity : Math.max(0, maxCount - currentIds.length);
3594
+ let picked;
3595
+ try {
3596
+ picked = await state.config.pickExistingFiles({
3597
+ fieldPath,
3598
+ mode: "multiple",
3599
+ accept: buildAcceptContext(element),
3600
+ maxSizeMB: maxSizeMB === Infinity ? void 0 : maxSizeMB,
3601
+ remainingSlots: remaining === Infinity ? void 0 : remaining,
3602
+ selectedResourceIds: [...currentIds]
3603
+ });
3604
+ } catch (error) {
3605
+ showFileError(wrapper, extractPickerError(error, state));
3606
+ return;
3607
+ }
3608
+ if (picked.length === 0) return;
3609
+ const existingSet = new Set(currentIds);
3610
+ const seen = /* @__PURE__ */ new Set();
3611
+ const deduped = picked.filter((r) => {
3612
+ if (existingSet.has(r.resourceId)) return false;
3613
+ if (seen.has(r.resourceId)) return false;
3614
+ seen.add(r.resourceId);
3615
+ return true;
3616
+ });
3617
+ const validItems = deduped.filter((r) => {
3618
+ const err = validatePickedResource(r, allowedExtensions, allowedMimes, maxSizeMB, state);
3619
+ return err === null;
3620
+ });
3621
+ const freshRemaining = maxCount === Infinity ? validItems.length : Math.max(0, maxCount - resourceIds.length);
3622
+ const accepted = validItems.slice(0, freshRemaining);
3623
+ const skipped = validItems.length - accepted.length;
3624
+ if (accepted.length === 0) return;
3625
+ clearFileError(wrapper);
3626
+ if (skipped > 0) {
3627
+ showFileError(
3628
+ wrapper,
3629
+ t("filesLimitExceeded", state, { skipped, max: maxCount })
3630
+ );
3631
+ }
3632
+ for (const resource of accepted) {
3633
+ registerPickedResource(resource, state);
3634
+ resourceIds.push(resource.resourceId);
3635
+ }
3636
+ wrapper.dataset.resourceIds = JSON.stringify(resourceIds);
3637
+ updateCallback();
3638
+ if (!state.config.readonly) {
3639
+ instance.triggerOnChange(fieldPath, resourceIds);
3640
+ }
3641
+ }
3642
+ async function handleLibraryPickSingle(state, element, container, fileWrapper, pathKey, fieldPath, renderCallback, instance) {
3643
+ if (!state.config.pickExistingFiles) return;
3644
+ const allowedExtensions = getAllowedExtensions(element.accept);
3645
+ const allowedMimes = getAllowedMimes(element.accept);
3646
+ const maxSizeMB = element.maxSize ?? Infinity;
3647
+ let picked;
3648
+ try {
3649
+ picked = await state.config.pickExistingFiles({
3650
+ fieldPath,
3651
+ mode: "single",
3652
+ accept: buildAcceptContext(element),
3653
+ maxSizeMB: maxSizeMB === Infinity ? void 0 : maxSizeMB,
3654
+ selectedResourceIds: []
3655
+ });
3656
+ } catch (error) {
3657
+ showFileError(container, extractPickerError(error, state));
3658
+ return;
3659
+ }
3660
+ if (picked.length === 0) return;
3661
+ const first = picked[0];
3662
+ const validationError = validatePickedResource(first, allowedExtensions, allowedMimes, maxSizeMB, state);
3663
+ if (validationError !== null) {
3664
+ showFileError(container, validationError);
3665
+ return;
3666
+ }
3667
+ clearFileError(container);
3668
+ registerPickedResource(first, state);
3669
+ let hiddenInput = fileWrapper.querySelector('input[type="hidden"]');
3670
+ if (!hiddenInput) {
3671
+ hiddenInput = document.createElement("input");
3672
+ hiddenInput.type = "hidden";
3673
+ hiddenInput.name = pathKey;
3674
+ fileWrapper.appendChild(hiddenInput);
3675
+ }
3676
+ hiddenInput.value = first.resourceId;
3677
+ await renderCallback(first.resourceId);
3678
+ if (!state.config.readonly) {
3679
+ instance.triggerOnChange(fieldPath, first.resourceId);
3680
+ }
3681
+ }
3682
+
3683
+ // src/components/file/render-edit.ts
3684
+ function handleInitialFileData(initial, fileContainer, pathKey, fileWrapper, state, deps) {
3685
+ seedInferredResource(initial, state.resourceIndex);
3686
+ const meta = state.resourceIndex.get(initial);
3687
+ const isVideo = meta?.type?.startsWith("video/");
3688
+ if (isVideo) {
3689
+ renderFilePreview(fileContainer, initial, state, {
3690
+ fileName: initial,
3691
+ isReadonly: false,
3692
+ deps
3693
+ }).catch(console.error);
3694
+ } else {
3695
+ renderSingleFileEditTile(fileContainer, initial, state, deps).catch(console.error);
3696
+ }
3697
+ const hiddenInput = document.createElement("input");
3698
+ hiddenInput.type = "hidden";
3699
+ hiddenInput.name = pathKey;
3700
+ hiddenInput.value = initial;
3701
+ fileWrapper.appendChild(hiddenInput);
3702
+ }
3703
+ var UPLOAD_SVG = `<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" style="flex-shrink:0;color:var(--fb-file-upload-text-color,#9ca3af);">
3704
+ <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"/>
3705
+ </svg>`;
3706
+ function buildEmptyDropzone(state, primaryText, subHint, openPicker) {
3707
+ const dropzone = document.createElement("div");
3708
+ dropzone.className = "fb-file-dropzone";
3709
+ dropzone.innerHTML = `
3710
+ ${UPLOAD_SVG}
3711
+ <div class="fb-dropzone-primary-text">${escapeHtml(primaryText)}</div>
3712
+ ${subHint ? `<div class="fb-dropzone-hint-text">${escapeHtml(subHint)}</div>` : ""}
3713
+ `;
3714
+ dropzone.onclick = openPicker;
3715
+ return dropzone;
3716
+ }
3717
+ function buildLibraryButton(variant, state, onClick) {
3718
+ const btn = document.createElement("button");
3719
+ btn.type = "button";
3720
+ btn.className = variant === "card" ? "fb-file-library-card" : "fb-tile fb-tile-add-library";
3721
+ if (variant === "card") {
3722
+ btn.innerHTML = `
3723
+ <span class="fb-file-library-card-icon" aria-hidden="true">\u{1F4DA}</span>
3724
+ <span class="fb-file-library-card-label">${escapeHtml(t("fromLibrary", state))}</span>
3725
+ <span class="fb-file-library-card-hint">${escapeHtml(t("libraryHint", state))}</span>
3726
+ `;
3727
+ } else {
3728
+ btn.innerHTML = `<span aria-hidden="true">\u{1F4DA}</span>`;
3729
+ btn.title = t("fromLibrary", state);
3730
+ btn.setAttribute("aria-label", t("fromLibrary", state));
3731
+ }
3732
+ btn.addEventListener("click", onClick);
3733
+ return btn;
3734
+ }
3735
+ function renderResourcePills(opts) {
3736
+ const {
3737
+ container,
3738
+ rids,
3739
+ state,
3740
+ onRemove,
3741
+ hint,
3742
+ countInfo,
3743
+ maxCount,
3744
+ isReadonly = false,
3745
+ onLibraryPick
3746
+ } = opts;
3747
+ ensureFileStyles();
3748
+ const wrapper = container.closest("[data-files-wrapper]");
3749
+ if (wrapper) {
3750
+ wrapper.dataset.resourceIds = JSON.stringify(rids ?? []);
3751
+ }
3752
+ while (container.firstChild) container.removeChild(container.firstChild);
3753
+ const ridList = rids ?? [];
3754
+ const atMax = maxCount !== void 0 && ridList.length >= maxCount;
3755
+ const hasLibrary = !isReadonly && typeof onLibraryPick === "function";
3756
+ const buildSubHint = () => {
3757
+ const parts = [];
3758
+ if (hint) parts.push(hint);
3759
+ if (countInfo) parts.push(countInfo);
3760
+ return parts.join(" \u2022 ");
3761
+ };
3762
+ const openPicker = () => {
3763
+ const picker = findFilePicker(container);
3764
+ if (picker) picker.click();
3765
+ };
3766
+ if (ridList.length === 0) {
3767
+ if (isReadonly) {
3768
+ const emptyEl = document.createElement("div");
3769
+ emptyEl.className = "fb-tile-empty-text";
3770
+ emptyEl.textContent = t("noFilesSelected", state);
3771
+ container.appendChild(emptyEl);
3772
+ } else if (hasLibrary) {
3773
+ const row = document.createElement("div");
3774
+ row.className = "fb-file-card-row";
3775
+ const dropzone = buildEmptyDropzone(
3776
+ state,
3777
+ t("clickDragTextMultiple", state),
3778
+ buildSubHint(),
3779
+ openPicker
3780
+ );
3781
+ const libraryBtn = buildLibraryButton("card", state, onLibraryPick);
3782
+ row.appendChild(dropzone);
3783
+ row.appendChild(libraryBtn);
3784
+ container.appendChild(row);
3029
3785
  } 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);
3786
+ const dropzone = buildEmptyDropzone(
3787
+ state,
3788
+ t("clickDragTextMultiple", state),
3789
+ buildSubHint(),
3790
+ openPicker
3791
+ );
3792
+ container.appendChild(dropzone);
3034
3793
  }
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) => {
3794
+ return;
3795
+ }
3796
+ const tilesWrap = document.createElement("div");
3797
+ tilesWrap.className = "fb-tiles-wrap";
3798
+ tilesWrap.style.cssText = "display:flex;flex-wrap:wrap;gap:6px;align-items:flex-start;";
3799
+ for (const rid of ridList) {
3800
+ const meta = state.resourceIndex.get(rid);
3801
+ const tile = createFileTile();
3802
+ tile.classList.add("fb-tile-resource", "resource-pill");
3803
+ tile.dataset.resourceId = rid;
3804
+ const actionsEl = createTileActions({
3805
+ canRemove: !isReadonly && onRemove !== null,
3806
+ removeHandler: onRemove ? () => onRemove(rid) : null,
3807
+ state,
3808
+ resourceId: rid,
3809
+ fileName: meta?.name ?? ""
3810
+ });
3811
+ fillTileContent(tile, rid, meta, state, actionsEl).catch((err) => {
3812
+ console.error("Failed to render tile:", err);
3813
+ });
3814
+ tilesWrap.appendChild(tile);
3815
+ }
3816
+ if (!isReadonly && !atMax) {
3817
+ const addTile = document.createElement("div");
3818
+ addTile.className = "fb-tile fb-tile-add";
3819
+ addTile.innerHTML = "+";
3820
+ addTile.onclick = openPicker;
3821
+ tilesWrap.appendChild(addTile);
3822
+ if (hasLibrary) {
3823
+ const libraryTile = buildLibraryButton("tile", state, onLibraryPick);
3824
+ tilesWrap.appendChild(libraryTile);
3825
+ }
3826
+ } else if (!isReadonly && atMax) {
3827
+ const chip = document.createElement("div");
3828
+ chip.className = "fb-tile-counter";
3829
+ chip.textContent = t("filesCounter", state, {
3830
+ count: ridList.length,
3831
+ max: maxCount
3832
+ });
3833
+ tilesWrap.appendChild(chip);
3834
+ }
3835
+ container.appendChild(tilesWrap);
3836
+ const subHint = buildSubHint();
3837
+ if (subHint) {
3838
+ const hintEl = document.createElement("div");
3839
+ hintEl.className = "fb-tile-hint";
3840
+ hintEl.textContent = subHint;
3841
+ container.appendChild(hintEl);
3842
+ }
3843
+ }
3844
+ function renderFileElementEdit(element, ctx, wrapper, pathKey) {
3845
+ const state = ctx.state;
3846
+ const fileWrapper = document.createElement("div");
3847
+ fileWrapper.className = "space-y-2";
3848
+ const picker = document.createElement("input");
3849
+ picker.type = "file";
3850
+ picker.name = pathKey;
3851
+ picker.style.display = "none";
3852
+ if (element.accept) {
3853
+ picker.accept = typeof element.accept === "string" ? element.accept : [
3854
+ ...element.accept.extensions?.map((ext) => `.${ext}`) ?? [],
3855
+ ...element.accept.mime ?? []
3856
+ ].join(",") || "";
3857
+ }
3858
+ const fileContainer = document.createElement("div");
3859
+ fileContainer.className = "file-preview-container";
3860
+ const initial = ctx.prefill[element.key];
3861
+ const allowedExts = getAllowedExtensions(element.accept);
3862
+ const allowedMimes = getAllowedMimes(element.accept);
3863
+ const maxSizeMB = element.maxSize ?? Infinity;
3864
+ const handlers = {
3865
+ fileUploadHandler() {
3866
+ picker.click();
3867
+ },
3868
+ dragHandler(files) {
3052
3869
  if (files.length > 0) {
3053
- const deps = { picker, fileUploadHandler, dragHandler };
3054
- handleFileSelect(
3055
- files[0],
3056
- fileContainer,
3057
- pathKey,
3870
+ handleFileSelect({
3871
+ file: files[0],
3872
+ container: fileContainer,
3873
+ fieldName: pathKey,
3058
3874
  state,
3059
- deps,
3060
- ctx.instance,
3061
- allowedExts,
3875
+ deps: buildDeps(),
3876
+ instance: ctx.instance,
3877
+ allowedExtensions: allowedExts,
3878
+ allowedMimes,
3062
3879
  maxSizeMB
3063
- );
3880
+ });
3064
3881
  }
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 {
3882
+ },
3883
+ setupDrop(container) {
3884
+ setupDragAndDrop(container, handlers.dragHandler);
3885
+ },
3886
+ restoreDropzone() {
3080
3887
  const hint = makeFieldHint(element, state);
3888
+ fileContainer.className = "file-preview-container w-full max-w-md bg-gray-100 rounded-lg overflow-hidden relative group cursor-pointer";
3889
+ fileContainer.style.height = "128px";
3081
3890
  setEmptyFileContainer(fileContainer, state, hint);
3891
+ fileContainer.onclick = handlers.fileUploadHandler;
3892
+ setupDragAndDrop(fileContainer, handlers.dragHandler);
3893
+ },
3894
+ onRemove() {
3895
+ const hiddenInput = fileWrapper.querySelector('input[type="hidden"]');
3896
+ const currentRid = hiddenInput?.value;
3897
+ if (currentRid) {
3898
+ releaseLocalFileUrl(state.resourceIndex.get(currentRid)?.file);
3899
+ }
3900
+ if (hiddenInput) hiddenInput.value = "";
3901
+ handlers.restoreDropzone();
3082
3902
  }
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],
3903
+ };
3904
+ const buildDeps = () => ({
3905
+ picker,
3906
+ fileUploadHandler: handlers.fileUploadHandler,
3907
+ dragHandler: handlers.dragHandler,
3908
+ setupDrop: handlers.setupDrop,
3909
+ onRemove: handlers.onRemove
3910
+ });
3911
+ const renderEmptySingleState = () => {
3912
+ if (state.config.pickExistingFiles && !element.disableLibrary) {
3913
+ fileContainer.className = "file-preview-container";
3914
+ fileContainer.removeAttribute("style");
3915
+ fileContainer.onclick = null;
3916
+ const row = document.createElement("div");
3917
+ row.className = "fb-file-card-row";
3918
+ row.style.cssText = "display:flex;gap:8px;align-items:stretch;";
3919
+ const hint = makeFieldHint(element, state);
3920
+ const uploadCard = buildEmptyDropzone(
3921
+ state,
3922
+ t("clickDragText", state),
3923
+ hint,
3924
+ handlers.fileUploadHandler
3925
+ );
3926
+ uploadCard.style.cssText = "flex:1;min-width:0;height:128px;";
3927
+ setupDragAndDrop(uploadCard, handlers.dragHandler);
3928
+ const libraryBtn = buildLibraryButton("card", state, () => {
3929
+ handleLibraryPickSingle(
3930
+ state,
3931
+ element,
3090
3932
  fileContainer,
3933
+ fileWrapper,
3091
3934
  pathKey,
3092
- state,
3093
- deps,
3094
- ctx.instance,
3095
- allowedExts,
3096
- maxSizeMB
3097
- );
3098
- }
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);
3935
+ pathKey,
3936
+ async (rid) => {
3937
+ await renderSingleFileEditTile(fileContainer, rid, state, buildDeps());
3938
+ },
3939
+ ctx.instance
3940
+ ).catch((err) => {
3941
+ console.error("Library pick failed:", err);
3131
3942
  });
3132
3943
  });
3944
+ libraryBtn.style.cssText = "flex:1;min-width:0;";
3945
+ row.appendChild(uploadCard);
3946
+ row.appendChild(libraryBtn);
3947
+ fileContainer.appendChild(row);
3133
3948
  } 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>`;
3949
+ handlers.restoreDropzone();
3950
+ }
3951
+ };
3952
+ if (initial) {
3953
+ handleInitialFileData(
3954
+ initial,
3955
+ fileContainer,
3956
+ pathKey,
3957
+ fileWrapper,
3958
+ state,
3959
+ buildDeps()
3960
+ );
3961
+ const prefillMeta = state.resourceIndex.get(initial);
3962
+ if (prefillMeta?.type?.startsWith("video/")) {
3963
+ fileContainer.onclick = handlers.fileUploadHandler;
3964
+ setupDragAndDrop(fileContainer, handlers.dragHandler);
3135
3965
  }
3136
- wrapper.appendChild(resultsWrapper);
3137
3966
  } else {
3138
- let updateFilesList2 = function() {
3139
- renderResourcePills(
3140
- list,
3141
- initialFiles,
3967
+ renderEmptySingleState();
3968
+ }
3969
+ picker.onchange = () => {
3970
+ if (picker.files && picker.files.length > 0) {
3971
+ handleFileSelect({
3972
+ file: picker.files[0],
3973
+ container: fileContainer,
3974
+ fieldName: pathKey,
3142
3975
  state,
3143
- (ridToRemove) => {
3144
- const index = initialFiles.indexOf(ridToRemove);
3145
- if (index > -1) {
3146
- initialFiles.splice(index, 1);
3147
- }
3148
- updateFilesList2();
3149
- },
3150
- filesFieldHint
3151
- );
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,
3976
+ deps: buildDeps(),
3977
+ instance: ctx.instance,
3978
+ allowedExtensions: allowedExts,
3979
+ allowedMimes,
3980
+ maxSizeMB
3981
+ });
3982
+ }
3983
+ };
3984
+ fileWrapper.appendChild(fileContainer);
3985
+ fileWrapper.appendChild(picker);
3986
+ wrapper.appendChild(fileWrapper);
3987
+ }
3988
+ function renderFilesElementEdit(element, ctx, wrapper, pathKey) {
3989
+ const state = ctx.state;
3990
+ const filesWrapper = document.createElement("div");
3991
+ filesWrapper.className = "space-y-2";
3992
+ filesWrapper.dataset.filesWrapper = pathKey;
3993
+ const filesPicker = document.createElement("input");
3994
+ filesPicker.type = "file";
3995
+ filesPicker.name = pathKey;
3996
+ filesPicker.multiple = true;
3997
+ filesPicker.style.display = "none";
3998
+ if (element.accept) {
3999
+ filesPicker.accept = typeof element.accept === "string" ? element.accept : [
4000
+ ...element.accept.extensions?.map((ext) => `.${ext}`) ?? [],
4001
+ ...element.accept.mime ?? []
4002
+ ].join(",") || "";
4003
+ }
4004
+ const filesContainer = document.createElement("div");
4005
+ filesContainer.className = "files-list-wrapper";
4006
+ 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);";
4007
+ const list = document.createElement("div");
4008
+ list.className = "files-list";
4009
+ const initialFiles = ctx.prefill[element.key] || [];
4010
+ addPrefillFilesToIndex(initialFiles, state.resourceIndex);
4011
+ filesWrapper.dataset.resourceIds = JSON.stringify(initialFiles);
4012
+ const filesFieldHint = makeFieldHint(element, state);
4013
+ const filesConstraints = {
4014
+ maxCount: Infinity,
4015
+ allowedExtensions: getAllowedExtensions(element.accept),
4016
+ allowedMimes: getAllowedMimes(element.accept),
4017
+ maxSize: element.maxSize ?? Infinity
4018
+ };
4019
+ filesContainer.appendChild(list);
4020
+ filesWrapper.appendChild(filesPicker);
4021
+ filesWrapper.appendChild(filesContainer);
4022
+ wrapper.appendChild(filesWrapper);
4023
+ const onLibraryPickFiles = state.config.pickExistingFiles && !element.disableLibrary ? () => {
4024
+ handleLibraryPickMulti(
3180
4025
  state,
3181
- updateFilesList2,
3182
- filesConstraints,
4026
+ element,
4027
+ filesWrapper,
3183
4028
  pathKey,
3184
- ctx.instance
3185
- );
3186
- setupFilesPickerHandler(
3187
- filesPicker,
3188
4029
  initialFiles,
3189
- state,
3190
- updateFilesList2,
3191
- filesConstraints,
3192
- pathKey,
4030
+ Infinity,
4031
+ updateFilesList,
3193
4032
  ctx.instance
3194
- );
3195
- filesContainer.appendChild(list);
3196
- filesWrapper.appendChild(filesContainer);
3197
- filesWrapper.appendChild(filesPicker);
3198
- wrapper.appendChild(filesWrapper);
4033
+ ).catch((err) => {
4034
+ console.error("Library pick failed:", err);
4035
+ });
4036
+ } : null;
4037
+ function updateFilesList() {
4038
+ const currentlyReadonly = isElementReadonly(element, state);
4039
+ renderResourcePills({
4040
+ container: list,
4041
+ rids: initialFiles,
4042
+ state,
4043
+ onRemove: currentlyReadonly ? null : (ridToRemove) => {
4044
+ releaseLocalFileUrl(state.resourceIndex.get(ridToRemove)?.file);
4045
+ const index = initialFiles.indexOf(ridToRemove);
4046
+ if (index > -1) initialFiles.splice(index, 1);
4047
+ updateFilesList();
4048
+ },
4049
+ hint: filesFieldHint,
4050
+ isReadonly: currentlyReadonly,
4051
+ onLibraryPick: currentlyReadonly ? null : onLibraryPickFiles
4052
+ });
3199
4053
  }
4054
+ updateFilesList();
4055
+ setupFilesDropHandler(
4056
+ filesContainer,
4057
+ initialFiles,
4058
+ state,
4059
+ updateFilesList,
4060
+ filesConstraints,
4061
+ pathKey,
4062
+ ctx.instance
4063
+ );
4064
+ setupFilesPickerHandler(
4065
+ filesPicker,
4066
+ initialFiles,
4067
+ state,
4068
+ updateFilesList,
4069
+ filesConstraints,
4070
+ pathKey,
4071
+ ctx.instance
4072
+ );
3200
4073
  }
3201
- function renderMultipleFileElement(element, ctx, wrapper, pathKey) {
4074
+ function renderMultipleFileElementEdit(element, ctx, wrapper, pathKey) {
3202
4075
  const state = ctx.state;
3203
4076
  const minFiles = element.minCount ?? 0;
3204
4077
  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,
3279
- initialFiles,
4078
+ const filesWrapper = document.createElement("div");
4079
+ filesWrapper.className = "space-y-2";
4080
+ filesWrapper.dataset.filesWrapper = pathKey;
4081
+ const filesPicker = document.createElement("input");
4082
+ filesPicker.type = "file";
4083
+ filesPicker.name = pathKey;
4084
+ filesPicker.multiple = true;
4085
+ filesPicker.style.display = "none";
4086
+ if (element.accept) {
4087
+ filesPicker.accept = typeof element.accept === "string" ? element.accept : [
4088
+ ...element.accept.extensions?.map((ext) => `.${ext}`) ?? [],
4089
+ ...element.accept.mime ?? []
4090
+ ].join(",") || "";
4091
+ }
4092
+ const filesContainer = document.createElement("div");
4093
+ filesContainer.className = "files-list-wrapper";
4094
+ 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);";
4095
+ const list = document.createElement("div");
4096
+ list.className = "files-list";
4097
+ filesWrapper.appendChild(filesPicker);
4098
+ filesWrapper.appendChild(filesContainer);
4099
+ filesContainer.appendChild(list);
4100
+ const initialFiles = Array.isArray(ctx.prefill[element.key]) ? [...ctx.prefill[element.key]] : [];
4101
+ addPrefillFilesToIndex(initialFiles, state.resourceIndex);
4102
+ filesWrapper.dataset.resourceIds = JSON.stringify(initialFiles);
4103
+ const multipleFilesHint = makeFieldHint(element, state);
4104
+ const multipleConstraints = {
4105
+ maxCount: maxFiles,
4106
+ allowedExtensions: getAllowedExtensions(element.accept),
4107
+ allowedMimes: getAllowedMimes(element.accept),
4108
+ maxSize: element.maxSize ?? Infinity
4109
+ };
4110
+ const buildCountInfo = () => {
4111
+ const countText = initialFiles.length === 1 ? t("fileCountSingle", state, { count: initialFiles.length }) : t("fileCountPlural", state, { count: initialFiles.length });
4112
+ const minMaxText = minFiles > 0 || maxFiles < Infinity ? ` ${t("fileCountRange", state, { min: minFiles, max: maxFiles })}` : "";
4113
+ return countText + minMaxText;
4114
+ };
4115
+ const onLibraryPickMultiple = state.config.pickExistingFiles && !element.disableLibrary ? () => {
4116
+ handleLibraryPickMulti(
3280
4117
  state,
3281
- updateFilesDisplay,
3282
- multipleConstraints,
4118
+ element,
4119
+ filesWrapper,
3283
4120
  pathKey,
3284
- ctx.instance
3285
- );
3286
- setupFilesPickerHandler(
3287
- filesPicker,
3288
4121
  initialFiles,
3289
- state,
4122
+ maxFiles,
3290
4123
  updateFilesDisplay,
3291
- multipleConstraints,
3292
- pathKey,
3293
4124
  ctx.instance
4125
+ ).catch((err) => {
4126
+ console.error("Library pick failed:", err);
4127
+ });
4128
+ } : null;
4129
+ const updateFilesDisplay = () => {
4130
+ const currentlyReadonly = isElementReadonly(element, state);
4131
+ renderResourcePills({
4132
+ container: list,
4133
+ rids: initialFiles,
4134
+ state,
4135
+ onRemove: currentlyReadonly ? null : (index) => {
4136
+ releaseLocalFileUrl(state.resourceIndex.get(index)?.file);
4137
+ initialFiles.splice(initialFiles.indexOf(index), 1);
4138
+ updateFilesDisplay();
4139
+ },
4140
+ hint: multipleFilesHint,
4141
+ countInfo: buildCountInfo(),
4142
+ maxCount: maxFiles < Infinity ? maxFiles : void 0,
4143
+ isReadonly: currentlyReadonly,
4144
+ onLibraryPick: currentlyReadonly ? null : onLibraryPickMultiple
4145
+ });
4146
+ };
4147
+ setupFilesDropHandler(
4148
+ filesContainer,
4149
+ initialFiles,
4150
+ state,
4151
+ updateFilesDisplay,
4152
+ multipleConstraints,
4153
+ pathKey,
4154
+ ctx.instance
4155
+ );
4156
+ setupFilesPickerHandler(
4157
+ filesPicker,
4158
+ initialFiles,
4159
+ state,
4160
+ updateFilesDisplay,
4161
+ multipleConstraints,
4162
+ pathKey,
4163
+ ctx.instance
4164
+ );
4165
+ updateFilesDisplay();
4166
+ wrapper.appendChild(filesWrapper);
4167
+ }
4168
+
4169
+ // src/components/file/validate.ts
4170
+ function readMultiFileResourceIds(scopeRoot, fullKey) {
4171
+ const wrapper = scopeRoot.querySelector(
4172
+ `[data-files-wrapper="${fullKey}"]`
4173
+ );
4174
+ if (!wrapper) return [];
4175
+ const encoded = wrapper.dataset.resourceIds;
4176
+ if (encoded === void 0) {
4177
+ throw new Error(
4178
+ `readMultiFileResourceIds: [data-files-wrapper="${fullKey}"] is missing data-resource-ids attribute. This is a render bug.`
4179
+ );
4180
+ }
4181
+ const parsed = JSON.parse(encoded);
4182
+ if (!Array.isArray(parsed)) {
4183
+ throw new Error(
4184
+ `readMultiFileResourceIds: data-resource-ids on [data-files-wrapper="${fullKey}"] is not a JSON array. Got: ${encoded}`
3294
4185
  );
3295
- updateFilesDisplay();
3296
- wrapper.appendChild(filesWrapper);
3297
4186
  }
4187
+ return parsed;
3298
4188
  }
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 })}`);
4189
+ function validateFileCount(key, resourceIds, element, state, errors) {
4190
+ const minFiles = "minCount" in element ? element.minCount ?? 0 : 0;
4191
+ const maxFiles = "maxCount" in element ? element.maxCount ?? Infinity : Infinity;
4192
+ if (element.required && resourceIds.length === 0) {
4193
+ errors.push(`${key}: ${t("required", state)}`);
4194
+ }
4195
+ if (resourceIds.length < minFiles) {
4196
+ errors.push(`${key}: ${t("minFiles", state, { min: minFiles })}`);
4197
+ }
4198
+ if (resourceIds.length > maxFiles) {
4199
+ errors.push(`${key}: ${t("maxFiles", state, { max: maxFiles })}`);
4200
+ }
4201
+ }
4202
+ function validateFileTypes(key, resourceIds, element, state, errors) {
4203
+ const acceptField = "accept" in element ? element.accept : void 0;
4204
+ const allowedExtensions = getAllowedExtensions(acceptField);
4205
+ const allowedMimes = getAllowedMimes(acceptField);
4206
+ if (allowedExtensions.length === 0 && allowedMimes.length === 0) return;
4207
+ const formats = allowedExtensions.join(", ");
4208
+ const mimes = allowedMimes.join(", ");
4209
+ for (const rid of resourceIds) {
4210
+ const meta = state.resourceIndex.get(rid);
4211
+ const fileName = meta?.name ?? rid;
4212
+ if (allowedExtensions.length > 0 && !isFileExtensionAllowed(fileName, allowedExtensions)) {
4213
+ errors.push(
4214
+ `${key}: ${t("invalidFileExtension", state, { name: fileName, formats })}`
4215
+ );
4216
+ continue;
3316
4217
  }
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)) {
4218
+ if (allowedMimes.length > 0 && !meta?.inferredFromExtension) {
4219
+ const mimeType = meta?.type ?? "";
4220
+ if (!isMimeAllowed(mimeType, allowedMimes)) {
3329
4221
  errors.push(
3330
- `${key2}: ${t("invalidFileExtension", state, { name: fileName, formats })}`
4222
+ `${key}: ${t("invalidFileMime", state, { name: fileName, type: mimeType, mimes })}`
3331
4223
  );
3332
4224
  }
3333
4225
  }
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
- }
4226
+ }
4227
+ }
4228
+ function validateFileSizes(key, resourceIds, element, state, errors) {
4229
+ const maxSizeMB = "maxSize" in element ? element.maxSize ?? Infinity : Infinity;
4230
+ if (maxSizeMB === Infinity) return;
4231
+ for (const rid of resourceIds) {
4232
+ const meta = state.resourceIndex.get(rid);
4233
+ if (!meta) continue;
4234
+ if (meta.size > maxSizeMB * 1024 * 1024) {
4235
+ errors.push(
4236
+ `${key}: ${t("fileTooLarge", state, { name: meta.name, maxSize: maxSizeMB })}`
4237
+ );
3348
4238
  }
3349
- };
4239
+ }
4240
+ }
4241
+ function validateMultiFile(element, key, context) {
4242
+ const { scopeRoot, skipValidation, path, state } = context;
4243
+ const errors = [];
4244
+ const fullKey = pathJoin(path, key);
4245
+ const resourceIds = readMultiFileResourceIds(scopeRoot, fullKey);
4246
+ if (!skipValidation) {
4247
+ validateFileCount(key, resourceIds, element, state, errors);
4248
+ validateFileTypes(key, resourceIds, element, state, errors);
4249
+ validateFileSizes(key, resourceIds, element, state, errors);
4250
+ }
4251
+ return { value: resourceIds, errors };
4252
+ }
4253
+ function validateSingleFile(element, key, context) {
4254
+ const { scopeRoot, skipValidation, state } = context;
4255
+ const errors = [];
4256
+ const input = scopeRoot.querySelector(
4257
+ `input[name$="${key}"][type="hidden"]`
4258
+ );
4259
+ const rid = input?.value ?? "";
4260
+ if (!skipValidation && element.required && rid === "") {
4261
+ errors.push(`${key}: ${t("required", state)}`);
4262
+ return { value: null, errors };
4263
+ }
4264
+ if (!skipValidation && rid !== "") {
4265
+ validateFileTypes(key, [rid], element, state, errors);
4266
+ validateFileSizes(key, [rid], element, state, errors);
4267
+ }
4268
+ return { value: rid || null, errors };
4269
+ }
4270
+ function validateFileElement(element, key, context) {
4271
+ const isMultipleField = element.type === "files" || "multiple" in element && Boolean(element.multiple);
3350
4272
  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 };
4273
+ return validateMultiFile(element, key, context);
4274
+ }
4275
+ return validateSingleFile(element, key, context);
4276
+ }
4277
+
4278
+ // src/components/file/render-readonly.ts
4279
+ function renderFileElementReadonly(element, ctx, wrapper, pathKey) {
4280
+ const state = ctx.state;
4281
+ const rawInitial = ctx.prefill[element.key];
4282
+ const initial = typeof rawInitial === "string" ? rawInitial : "";
4283
+ if (initial) {
4284
+ addPrefillFilesToIndex([initial], state.resourceIndex);
4285
+ const hiddenInput = document.createElement("input");
4286
+ hiddenInput.type = "hidden";
4287
+ hiddenInput.name = pathKey;
4288
+ hiddenInput.value = initial;
4289
+ wrapper.appendChild(hiddenInput);
4290
+ renderFilePreviewReadonly(initial, state).then((filePreview) => {
4291
+ wrapper.appendChild(filePreview);
4292
+ }).catch((err) => {
4293
+ console.error("Failed to render file preview:", err);
4294
+ wrapper.appendChild(buildEmptyReadonlyTile(state));
4295
+ });
3370
4296
  } 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 };
4297
+ wrapper.appendChild(buildEmptyReadonlyTile(state));
4298
+ }
4299
+ }
4300
+ function buildEmptyReadonlyTile(state) {
4301
+ const emptyState = document.createElement("div");
4302
+ emptyState.style.cssText = `
4303
+ width:${TILE_SIZE};
4304
+ height:${TILE_SIZE};
4305
+ display:flex;
4306
+ align-items:center;
4307
+ justify-content:center;
4308
+ background:var(--fb-file-upload-bg-color,#f3f4f6);
4309
+ border-radius:var(--fb-border-radius,0.5rem);
4310
+ border:1px solid var(--fb-file-upload-border-color,#d1d5db);
4311
+ `;
4312
+ emptyState.innerHTML = `<div style="font-size:11px;text-align:center;color:var(--fb-text-secondary-color,#6b7280);">${escapeHtml(t("noFileSelected", state))}</div>`;
4313
+ return emptyState;
4314
+ }
4315
+ function renderMultiFileReadonly(rids, state, wrapper, pathKey, marginTop) {
4316
+ addPrefillFilesToIndex(rids, state.resourceIndex);
4317
+ const filesWrapper = document.createElement("div");
4318
+ filesWrapper.dataset.filesWrapper = pathKey;
4319
+ filesWrapper.dataset.resourceIds = JSON.stringify(rids);
4320
+ wrapper.appendChild(filesWrapper);
4321
+ if (rids.length === 0) {
4322
+ const emptyEl = document.createElement("div");
4323
+ emptyEl.className = "fb-tile-empty-text";
4324
+ emptyEl.textContent = t("noFilesSelected", state);
4325
+ filesWrapper.appendChild(emptyEl);
4326
+ return;
4327
+ }
4328
+ const tilesWrap = document.createElement("div");
4329
+ tilesWrap.style.cssText = `display:flex;flex-wrap:wrap;gap:6px;${marginTop ? `margin-top:${marginTop};` : ""}`;
4330
+ filesWrapper.appendChild(tilesWrap);
4331
+ const placeholders = rids.map(() => {
4332
+ const placeholder = document.createElement("div");
4333
+ placeholder.style.cssText = `width:${TILE_SIZE};height:${TILE_SIZE};`;
4334
+ tilesWrap.appendChild(placeholder);
4335
+ return placeholder;
4336
+ });
4337
+ for (let i = 0; i < rids.length; i++) {
4338
+ const resourceId = rids[i];
4339
+ const placeholder = placeholders[i];
4340
+ renderFilePreviewReadonly(resourceId, state).then((tileEl) => {
4341
+ placeholder.replaceWith(tileEl);
4342
+ }).catch((err) => {
4343
+ console.error("Failed to render readonly tile:", err);
4344
+ });
4345
+ }
4346
+ }
4347
+ function renderFilesElementReadonly(element, ctx, wrapper, pathKey) {
4348
+ const rawPrefill = ctx.prefill[element.key];
4349
+ const initialFiles = Array.isArray(rawPrefill) ? rawPrefill : [];
4350
+ renderMultiFileReadonly(initialFiles, ctx.state, wrapper, pathKey);
4351
+ }
4352
+ function renderMultipleFileElementReadonly(element, ctx, wrapper, pathKey) {
4353
+ const rawPrefill = ctx.prefill[element.key];
4354
+ const initialFiles = Array.isArray(rawPrefill) ? rawPrefill : [];
4355
+ renderMultiFileReadonly(initialFiles, ctx.state, wrapper, pathKey, "4px");
4356
+ }
4357
+
4358
+ // src/components/file.ts
4359
+ function renderFileElement(element, ctx, wrapper, pathKey) {
4360
+ if (isElementReadonly(element, ctx.state, ctx)) {
4361
+ renderFileElementReadonly(element, ctx, wrapper, pathKey);
4362
+ } else {
4363
+ renderFileElementEdit(element, ctx, wrapper, pathKey);
4364
+ }
4365
+ }
4366
+ function renderFilesElement(element, ctx, wrapper, pathKey) {
4367
+ if (isElementReadonly(element, ctx.state, ctx)) {
4368
+ renderFilesElementReadonly(element, ctx, wrapper, pathKey);
4369
+ } else {
4370
+ renderFilesElementEdit(element, ctx, wrapper, pathKey);
4371
+ }
4372
+ }
4373
+ function renderMultipleFileElement(element, ctx, wrapper, pathKey) {
4374
+ if (isElementReadonly(element, ctx.state, ctx)) {
4375
+ renderMultipleFileElementReadonly(element, ctx, wrapper, pathKey);
4376
+ } else {
4377
+ renderMultipleFileElementEdit(element, ctx, wrapper, pathKey);
3384
4378
  }
3385
4379
  }
3386
4380
  function updateFileField(element, fieldPath, value, context) {
3387
4381
  const { scopeRoot, state } = context;
3388
- if ("multiple" in element && element.multiple) {
4382
+ if (element.type === "files" || "multiple" in element && element.multiple) {
3389
4383
  if (!Array.isArray(value)) {
3390
4384
  console.warn(
3391
4385
  `updateFileField: Expected array for multiple file field "${fieldPath}", got ${typeof value}`
@@ -3394,30 +4388,19 @@ function updateFileField(element, fieldPath, value, context) {
3394
4388
  }
3395
4389
  value.forEach((resourceId) => {
3396
4390
  if (resourceId && typeof resourceId === "string") {
3397
- 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
- });
3415
- }
4391
+ seedInferredResource(resourceId, state.resourceIndex);
3416
4392
  }
3417
4393
  });
3418
- console.info(
3419
- `updateFileField: Multiple file field "${fieldPath}" updated. Preview update requires re-render.`
4394
+ const filesWrapper = scopeRoot.querySelector(
4395
+ `[data-files-wrapper="${fieldPath}"]`
3420
4396
  );
4397
+ if (filesWrapper) {
4398
+ filesWrapper.dataset.resourceIds = JSON.stringify(value);
4399
+ } else {
4400
+ console.warn(
4401
+ `updateFileField: [data-files-wrapper="${fieldPath}"] not found in DOM; data-resource-ids not updated`
4402
+ );
4403
+ }
3421
4404
  } else {
3422
4405
  const hiddenInput = scopeRoot.querySelector(
3423
4406
  `input[name="${fieldPath}"][type="hidden"]`
@@ -3430,25 +4413,7 @@ function updateFileField(element, fieldPath, value, context) {
3430
4413
  }
3431
4414
  hiddenInput.value = value != null ? String(value) : "";
3432
4415
  if (value && typeof value === "string") {
3433
- 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
- });
3451
- }
4416
+ seedInferredResource(value, state.resourceIndex);
3452
4417
  console.info(
3453
4418
  `updateFileField: File field "${fieldPath}" updated. Preview update requires re-render.`
3454
4419
  );
@@ -8098,6 +9063,7 @@ var defaultConfig = {
8098
9063
  enableFilePreview: true,
8099
9064
  maxPreviewSize: "200px",
8100
9065
  readonly: false,
9066
+ pickExistingFiles: null,
8101
9067
  parseTableFile: null,
8102
9068
  locale: "en",
8103
9069
  translations: {
@@ -8109,6 +9075,8 @@ var defaultConfig = {
8109
9075
  noFileSelected: "No file selected",
8110
9076
  noFilesSelected: "No files selected",
8111
9077
  downloadButton: "Download",
9078
+ downloadFile: "Download",
9079
+ openInNewTab: "Open in new tab",
8112
9080
  changeButton: "Change",
8113
9081
  placeholderText: "Enter text",
8114
9082
  previewAlt: "Preview",
@@ -8130,6 +9098,12 @@ var defaultConfig = {
8130
9098
  fileCountSingle: "{count} file",
8131
9099
  fileCountPlural: "{count} files",
8132
9100
  fileCountRange: "({min}-{max})",
9101
+ uploadingFile: "Uploading\u2026",
9102
+ filesCounter: "{count}/{max}",
9103
+ fromLibrary: "From library",
9104
+ libraryEmpty: "Library is empty",
9105
+ libraryHint: "Choose from previously uploaded files",
9106
+ pickerError: "Failed to load files from library",
8133
9107
  // Validation errors
8134
9108
  required: "Required",
8135
9109
  minItems: "Minimum {min} items required",
@@ -8145,6 +9119,7 @@ var defaultConfig = {
8145
9119
  minFiles: "Minimum {min} files required",
8146
9120
  maxFiles: "Maximum {max} files allowed",
8147
9121
  invalidFileExtension: 'File "{name}" has unsupported format. Allowed: {formats}',
9122
+ invalidFileMime: 'File "{name}": file type {type} not allowed (allowed: {mimes})',
8148
9123
  fileTooLarge: 'File "{name}" exceeds maximum size of {maxSize}MB',
8149
9124
  filesLimitExceeded: "{skipped} file(s) skipped: maximum {max} files allowed",
8150
9125
  unsupportedFieldType: "Unsupported field type: {type}",
@@ -8171,6 +9146,8 @@ var defaultConfig = {
8171
9146
  noFileSelected: "\u0424\u0430\u0439\u043B \u043D\u0435 \u0432\u044B\u0431\u0440\u0430\u043D",
8172
9147
  noFilesSelected: "\u041D\u0435\u0442 \u0444\u0430\u0439\u043B\u043E\u0432",
8173
9148
  downloadButton: "\u0421\u043A\u0430\u0447\u0430\u0442\u044C",
9149
+ downloadFile: "\u0421\u043A\u0430\u0447\u0430\u0442\u044C",
9150
+ openInNewTab: "\u041E\u0442\u043A\u0440\u044B\u0442\u044C \u0432 \u043D\u043E\u0432\u043E\u0439 \u0432\u043A\u043B\u0430\u0434\u043A\u0435",
8174
9151
  changeButton: "\u0418\u0437\u043C\u0435\u043D\u0438\u0442\u044C",
8175
9152
  placeholderText: "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0442\u0435\u043A\u0441\u0442",
8176
9153
  previewAlt: "\u041F\u0440\u0435\u0434\u043F\u0440\u043E\u0441\u043C\u043E\u0442\u0440",
@@ -8192,6 +9169,12 @@ var defaultConfig = {
8192
9169
  fileCountSingle: "{count} \u0444\u0430\u0439\u043B",
8193
9170
  fileCountPlural: "{count} \u0444\u0430\u0439\u043B\u043E\u0432",
8194
9171
  fileCountRange: "({min}-{max})",
9172
+ uploadingFile: "\u0417\u0430\u0433\u0440\u0443\u0437\u043A\u0430\u2026",
9173
+ filesCounter: "{count}/{max}",
9174
+ fromLibrary: "\u0418\u0437 \u0431\u0438\u0431\u043B\u0438\u043E\u0442\u0435\u043A\u0438",
9175
+ libraryEmpty: "\u0411\u0438\u0431\u043B\u0438\u043E\u0442\u0435\u043A\u0430 \u043F\u0443\u0441\u0442\u0430",
9176
+ libraryHint: "\u0412\u044B\u0431\u0435\u0440\u0438\u0442\u0435 \u0438\u0437 \u0440\u0430\u043D\u0435\u0435 \u0437\u0430\u0433\u0440\u0443\u0436\u0435\u043D\u043D\u044B\u0445 \u0444\u0430\u0439\u043B\u043E\u0432",
9177
+ pickerError: "\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u0437\u0430\u0433\u0440\u0443\u0437\u0438\u0442\u044C \u0444\u0430\u0439\u043B\u044B \u0438\u0437 \u0431\u0438\u0431\u043B\u0438\u043E\u0442\u0435\u043A\u0438",
8195
9178
  // Validation errors
8196
9179
  required: "\u041E\u0431\u044F\u0437\u0430\u0442\u0435\u043B\u044C\u043D\u043E\u0435 \u043F\u043E\u043B\u0435",
8197
9180
  minItems: "\u041C\u0438\u043D\u0438\u043C\u0443\u043C {min} \u044D\u043B\u0435\u043C\u0435\u043D\u0442\u043E\u0432",
@@ -8207,6 +9190,7 @@ var defaultConfig = {
8207
9190
  minFiles: "\u041C\u0438\u043D\u0438\u043C\u0443\u043C {min} \u0444\u0430\u0439\u043B\u043E\u0432",
8208
9191
  maxFiles: "\u041C\u0430\u043A\u0441\u0438\u043C\u0443\u043C {max} \u0444\u0430\u0439\u043B\u043E\u0432",
8209
9192
  invalidFileExtension: '\u0424\u0430\u0439\u043B "{name}" \u0438\u043C\u0435\u0435\u0442 \u043D\u0435\u043F\u043E\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u043C\u044B\u0439 \u0444\u043E\u0440\u043C\u0430\u0442. \u0414\u043E\u043F\u0443\u0441\u0442\u0438\u043C\u044B\u0435: {formats}',
9193
+ invalidFileMime: '\u0424\u0430\u0439\u043B "{name}": \u0442\u0438\u043F \u0444\u0430\u0439\u043B\u0430 {type} \u043D\u0435 \u0440\u0430\u0437\u0440\u0435\u0448\u0451\u043D (\u0440\u0430\u0437\u0440\u0435\u0448\u0435\u043D\u044B: {mimes})',
8210
9194
  fileTooLarge: '\u0424\u0430\u0439\u043B "{name}" \u043F\u0440\u0435\u0432\u044B\u0448\u0430\u0435\u0442 \u043C\u0430\u043A\u0441\u0438\u043C\u0430\u043B\u044C\u043D\u044B\u0439 \u0440\u0430\u0437\u043C\u0435\u0440 {maxSize}\u041C\u0411',
8211
9195
  filesLimitExceeded: "{skipped} \u0444\u0430\u0439\u043B(\u043E\u0432) \u043F\u0440\u043E\u043F\u0443\u0449\u0435\u043D\u043E: \u043C\u0430\u043A\u0441\u0438\u043C\u0443\u043C {max} \u0444\u0430\u0439\u043B\u043E\u0432",
8212
9196
  unsupportedFieldType: "\u041D\u0435\u043F\u043E\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u043C\u044B\u0439 \u0442\u0438\u043F \u043F\u043E\u043B\u044F: {type}",