/** * file-upload-queue.js * * Powers two UI features: * * 1. TFE multi-file upload queue (#tfe-file-queue) * - Renders each selected file as a sortable row with icon, name, size * and an optional label input. * - Drag-to-reorder via SortableJS. * - Injects hidden `file_labels[]` and `file_orders[]` inputs so PHP * receives per-file label and intended sort-order data. * - Works for both the add/partage form (pure new uploads) and the edit * form (new uploads only; existing-file sort is handled server-side). * * 2. Legacy single-file previews (data-preview="CONTAINER_ID") * - Backward-compatible with cover-image and banner inputs. */ (() => { /* ── Helpers ──────────────────────────────────────────────────────────── */ const ICONS = { pdf: '📄', video: '🎬', audio: '🔊', zip: '🗜️', vtt: '💬', image: '🖼️', other: '📎', }; function iconFor(file) { const t = file.type || ''; const n = file.name.toLowerCase(); if (t.startsWith('image/')) return ICONS.image; if (t === 'application/pdf' || n.endsWith('.pdf')) return ICONS.pdf; if (t.startsWith('video/') || /\.(mp4|webm|mov|ogv)$/.test(n)) return ICONS.video; if (t.startsWith('audio/') || /\.(mp3|ogg|oga|wav|flac|aac|m4a)$/.test(n)) return ICONS.audio; if (t === 'application/zip' || /\.(zip|tar|gz|tgz)$/.test(n)) return ICONS.zip; if (n.endsWith('.vtt')) return ICONS.vtt; return ICONS.other; } function humanSize(bytes) { if (bytes >= 1073741824) return `${(bytes / 1073741824).toFixed(2)} GB`; if (bytes >= 1048576) return `${(bytes / 1048576).toFixed(2)} MB`; if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${bytes} B`; } function esc(str) { return str.replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } /* ── DataTransfer-backed file list ────────────────────────────────────── */ // We keep a parallel array so we can freely re-order and remove files // then reconstruct a proper FileList via DataTransfer when needed. function syncInputFiles(input, fileArray) { try { const dt = new DataTransfer(); for (const f of fileArray) dt.items.add(f); input.files = dt.files; } catch { // DataTransfer not available in older browsers — graceful degradation. } } /* ── TFE file queue ───────────────────────────────────────────────────── */ function initFileQueue() { const picker = document.getElementById('tfe-files-input'); const queue = document.getElementById('tfe-file-queue'); const empty = document.getElementById('tfe-file-queue-empty'); if (!picker || !queue) return; // Array parallel to the visual queue let fileArray = []; // Keep SortableJS instance reference let _sortable = null; if (typeof Sortable !== 'undefined') { _sortable = Sortable.create(queue, { animation: 150, handle: '.fq-drag-handle', ghostClass: 'fq-ghost', onEnd: () => reorderFiles(), }); } picker.addEventListener('change', () => { const newFiles = Array.from(picker.files); fileArray = fileArray.concat(newFiles); renderQueue(); // Reset input so the same file can be selected again if needed picker.value = ''; }); function renderQueue() { queue.innerHTML = ''; if (fileArray.length === 0) { empty.style.display = ''; syncInputFiles(picker, []); return; } empty.style.display = 'none'; fileArray.forEach((file, idx) => { const li = document.createElement('li'); li.className = 'fq-item'; li.setAttribute('data-idx', idx); li.innerHTML = '' + '' + iconFor(file) + '' + '' + '' + esc(file.name) + '' + '' + humanSize(file.size) + '' + '' + '' + ''; // Remove button li.querySelector('.fq-remove').addEventListener('click', () => { fileArray.splice(idx, 1); renderQueue(); }); queue.appendChild(li); }); syncInputFiles(picker, fileArray); injectHiddenFields(); } function reorderFiles() { // Re-sync fileArray to match current DOM order const items = Array.from(queue.querySelectorAll('.fq-item')); const newArr = items.map(li => fileArray[parseInt(li.getAttribute('data-idx'), 10)]); fileArray = newArr; // Re-render to update data-idx attributes renderQueue(); } function injectHiddenFields() { // Remove previous hidden fields const form = picker.closest('form'); if (!form) return; for (const el of form.querySelectorAll('.fq-hidden-label, .fq-hidden-order')) el.remove(); // Inject current labels and order indices // We use the queue DOM (post-sort) as the source of truth. const items = Array.from(queue.querySelectorAll('.fq-item')); items.forEach((li, sortedIdx) => { const labelVal = li.querySelector('.fq-label').value; const lInput = document.createElement('input'); lInput.type = 'hidden'; lInput.name = 'file_labels[]'; lInput.value = labelVal; lInput.className = 'fq-hidden-label'; form.appendChild(lInput); const oInput = document.createElement('input'); oInput.type = 'hidden'; oInput.name = 'file_orders[]'; oInput.value = sortedIdx + 1; oInput.className = 'fq-hidden-order'; form.appendChild(oInput); }); } // Before form submit, inject hidden fields so labels are up-to-date const form = picker.closest('form'); if (form) { form.addEventListener('submit', () => { syncInputFiles(picker, fileArray); injectHiddenFields(); }); } } /* ── Existing-files sortable (edit form only) ─────────────────────────── */ function initExistingFilesSortable() { const list = document.getElementById('existing-files-sortable'); if (!list || typeof Sortable === 'undefined') return; Sortable.create(list, { animation: 150, handle: '.admin-file-drag-handle', ghostClass: 'fq-ghost', onEnd: () => { // Update the hidden file_sort_order[] inputs to reflect new order const items = list.querySelectorAll('.admin-file-list-item[data-file-id]'); for (const el of list.querySelectorAll('input[name="file_sort_order[]"]')) el.remove(); items.forEach((li) => { const inp = document.createElement('input'); inp.type = 'hidden'; inp.name = 'file_sort_order[]'; inp.value = li.getAttribute('data-file-id'); li.prepend(inp); }); }, }); } /* ── Legacy single-file preview (data-preview="CONTAINER_ID") ─────────── */ function initLegacyPreviews() { document.querySelectorAll('input[type="file"][data-preview]').forEach((input) => { // Skip the TFE multi-file picker (handled by queue above) if (input.id === 'tfe-files-input') return; const containerId = input.getAttribute('data-preview'); const container = document.getElementById(containerId); if (!container) return; input.addEventListener('change', () => { renderLegacyPreview(input, container); }); }); } function renderLegacyPreview(input, container) { container.innerHTML = ''; const files = Array.from(input.files); if (!files.length) return; files.forEach((file) => { const item = document.createElement('div'); item.className = 'fp-item'; if (file.type.startsWith('image/')) { const img = document.createElement('img'); img.className = 'fp-thumb'; img.alt = file.name; const reader = new FileReader(); reader.onload = (e) => { img.src = e.target.result; }; reader.readAsDataURL(file); item.appendChild(img); } else { const icon = document.createElement('span'); icon.className = 'fp-icon'; icon.textContent = iconFor(file); item.appendChild(icon); } const meta = document.createElement('span'); meta.className = 'fp-meta'; meta.innerHTML = '' + esc(file.name) + '' + '' + humanSize(file.size) + ''; item.appendChild(meta); container.appendChild(item); }); } /* ── Bootstrap ────────────────────────────────────────────────────────── */ function init() { initFileQueue(); initExistingFilesSortable(); initLegacyPreviews(); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();