/** * File upload — card-based dropzone with drag-and-drop, current file preview, and pending file preview. * * Markup contract: *
*
* *
* * * * * *
*
* *
* * * * * * * *
*
*/ const MAX_PREVIEW_SIZE = 10 * 1024 * 1024; // 10 MB const isImageFile = (file) => file.type.startsWith('image/'); const formatFileSize = (bytes) => { if (bytes < 1024) { return `${bytes} B`; } if (bytes < 1024 * 1024) { return `${(bytes / 1024).toFixed(1)} KB`; } return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; }; export function initFileUpload(root) { if (!(root instanceof HTMLElement)) { return { destroy: () => {} }; } if (root.dataset.fileUploadBound === '1') { return { destroy: () => {} }; } root.dataset.fileUploadBound = '1'; const input = root.querySelector('input[type="file"]'); if (!input) { return { destroy: () => {} }; } const thumbnail = root.querySelector('.app-file-upload-thumbnail'); const fileIcon = root.querySelector('.app-file-upload-file-icon'); const filenameEl = root.querySelector('.app-file-upload-filename'); const filesizeEl = root.querySelector('.app-file-upload-filesize'); const clearButton = root.querySelector('.app-file-upload-clear'); const replaceButton = root.querySelector('.app-file-upload-replace-button'); const currentImage = root.querySelector('.app-file-upload-current-image'); const cleanupFns = []; let dragCounter = 0; // Server rendered the initial state (img src + label + has-current class). // `data-current-src` is kept so clearFile() can restore the server file // after a user previewed a different selection. const currentSrc = (root.dataset.currentSrc || '').trim(); // ── Show pending file (new selection) ── const showFile = (file) => { root.classList.add('has-file'); root.classList.remove('has-current'); if (filenameEl) { filenameEl.textContent = file.name; } if (filesizeEl) { filesizeEl.textContent = formatFileSize(file.size); } if (thumbnail) { thumbnail.hidden = true; } if (fileIcon) { fileIcon.hidden = true; } if (isImageFile(file) && file.size <= MAX_PREVIEW_SIZE && thumbnail) { const reader = new FileReader(); reader.onload = (e) => { thumbnail.src = /** @type {string} */ (e.target?.result ?? ''); thumbnail.hidden = false; if (fileIcon) { fileIcon.hidden = true; } }; reader.onerror = () => { if (fileIcon) { fileIcon.hidden = false; } }; reader.readAsDataURL(file); } else if (fileIcon) { fileIcon.hidden = false; } }; // ── Clear pending file → go back to current (if exists) or dropzone ── const clearFile = () => { root.classList.remove('has-file'); input.value = ''; if (thumbnail) { thumbnail.src = ''; thumbnail.hidden = true; } if (fileIcon) { fileIcon.hidden = true; } if (filenameEl) { filenameEl.textContent = ''; } if (filesizeEl) { filesizeEl.textContent = ''; } // Restore current state if a server file exists if (currentSrc) { root.classList.add('has-current'); } }; // ── Input change ── const onInputChange = () => { const file = input.files?.[0]; if (file) { showFile(file); } else { clearFile(); } }; input.addEventListener('change', onInputChange); cleanupFns.push(() => input.removeEventListener('change', onInputChange)); // ── Clear button ── if (clearButton) { const onClear = (e) => { e.preventDefault(); e.stopPropagation(); clearFile(); }; clearButton.addEventListener('click', onClear); cleanupFns.push(() => clearButton.removeEventListener('click', onClear)); } // ── Replace button + click on current preview → open file dialog ── const openPicker = (e) => { e.preventDefault(); e.stopPropagation(); input.click(); }; if (replaceButton) { replaceButton.addEventListener('click', openPicker); cleanupFns.push(() => replaceButton.removeEventListener('click', openPicker)); } if (currentImage) { currentImage.addEventListener('click', openPicker); cleanupFns.push(() => currentImage.removeEventListener('click', openPicker)); } // ── Drag and drop ── const onDragEnter = (e) => { e.preventDefault(); dragCounter++; root.classList.add('is-dragover'); }; const onDragOver = (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; }; const onDragLeave = (e) => { e.preventDefault(); dragCounter--; if (dragCounter <= 0) { dragCounter = 0; root.classList.remove('is-dragover'); } }; const onDrop = (e) => { e.preventDefault(); dragCounter = 0; root.classList.remove('is-dragover'); const file = e.dataTransfer?.files?.[0]; if (!file) { return; } // Validate against accept attribute const accept = input.accept; if (accept && !fileMatchesAccept(file, accept)) { root.classList.add('is-rejected'); setTimeout(() => root.classList.remove('is-rejected'), 600); return; } // Transfer file to the native input via DataTransfer const dt = new DataTransfer(); dt.items.add(file); input.files = dt.files; showFile(file); }; root.addEventListener('dragenter', onDragEnter); root.addEventListener('dragover', onDragOver); root.addEventListener('dragleave', onDragLeave); root.addEventListener('drop', onDrop); cleanupFns.push(() => { root.removeEventListener('dragenter', onDragEnter); root.removeEventListener('dragover', onDragOver); root.removeEventListener('dragleave', onDragLeave); root.removeEventListener('drop', onDrop); }); const destroy = () => { cleanupFns.forEach((fn) => fn()); cleanupFns.length = 0; delete root.dataset.fileUploadBound; }; return { destroy }; } /** * Check if a File matches an accept attribute value. * Supports MIME types (image/*), extensions (.csv), and exact types (image/png). */ function fileMatchesAccept(file, accept) { const tokens = accept.split(',').map((t) => t.trim().toLowerCase()); const fileName = file.name.toLowerCase(); const fileType = file.type.toLowerCase(); return tokens.some((token) => { if (token.startsWith('.')) { return fileName.endsWith(token); } if (token.endsWith('/*')) { return fileType.startsWith(token.slice(0, -1)); } return fileType === token; }); }