/**
* 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;
});
}