diff --git a/frontend/app.js b/frontend/app.js index f6a85c6..fa00d69 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -87,9 +87,10 @@ function human(bytes){ return (bytes/Math.pow(k,i)).toFixed(1)+' '+sizes[i]; } -function addItem(file){ +function addItem(file, relativePath){ const id = (crypto && crypto.randomUUID) ? crypto.randomUUID() : (Math.random().toString(36).slice(2)); - const it = { id, file, name: file.name, size: file.size, status: 'queued', progress: 0, relativePath: file.webkitRelativePath || '' }; + const resolvedPath = (relativePath !== undefined) ? relativePath : (file.webkitRelativePath || ''); + const it = { id, file, name: file.name, size: file.size, status: 'queued', progress: 0, relativePath: resolvedPath }; items.unshift(it); render(); } @@ -318,6 +319,7 @@ async function uploadChunked(next){ // --- DOM refs --- const dz = document.getElementById('dropzone'); const fi = document.getElementById('fileInput'); +const foi = document.getElementById('folderInput'); const btnMobilePick = document.getElementById('btnMobilePick'); const btnClearFinished = document.getElementById('btnClearFinished'); const btnClearAll = document.getElementById('btnClearAll'); @@ -380,10 +382,37 @@ if (btnPing) btnPing.onclick = async () => { // --- Drag & drop (no click-to-open on touch) --- ['dragenter','dragover'].forEach(ev => dz.addEventListener(ev, e=>{ e.preventDefault(); dz.classList.add('border-blue-500','bg-blue-50','dark:bg-blue-900','dark:bg-opacity-20'); })); ['dragleave','drop'].forEach(ev => dz.addEventListener(ev, e=>{ e.preventDefault(); dz.classList.remove('border-blue-500','bg-blue-50','dark:bg-blue-900','dark:bg-opacity-20'); })); -dz.addEventListener('drop', (e)=>{ +dz.addEventListener('drop', async (e)=>{ e.preventDefault(); - const files = Array.from(e.dataTransfer.files || []); - files.forEach(addItem); + const filesAndPaths = []; + + const traverseFileTree = async (entry) => { + if (!entry) return; + if (entry.isFile) { + return new Promise(resolve => { + entry.file(file => { + filesAndPaths.push({ file, path: entry.fullPath ? entry.fullPath.substring(1) : file.name }); + resolve(); + }); + }); + } else if (entry.isDirectory) { + const reader = entry.createReader(); + const entries = await new Promise(resolve => reader.readEntries(resolve)); + for (const subEntry of entries) { + await traverseFileTree(subEntry); + } + } + }; + + if (e.dataTransfer.items && e.dataTransfer.items.length > 0 && e.dataTransfer.items[0].webkitGetAsEntry) { + const promises = Array.from(e.dataTransfer.items).map(item => traverseFileTree(item.webkitGetAsEntry())); + await Promise.all(promises); + filesAndPaths.forEach(fp => addItem(fp.file, fp.path)); + } else { + // Fallback for browsers without directory drop support + Array.from(e.dataTransfer.files).forEach(file => addItem(file)); + } + render(); runQueue(); }); @@ -413,20 +442,27 @@ fi.addEventListener('click', (e) => { // prevent bubbling to parents (extra safety) e.stopPropagation(); }); +if (foi) { + foi.addEventListener('click', (e) => { e.stopPropagation(); }); +} -fi.onchange = () => { +const onFilesSelected = (inputEl) => { + if (!inputEl) return; // Suppress any stray clicks for a short window after the picker closes suppressClicksUntil = Date.now() + 800; - const files = Array.from(fi.files || []); - files.forEach(addItem); + const files = Array.from(inputEl.files || []); + files.forEach(file => addItem(file)); render(); runQueue(); // Reset a bit later so selecting the same items again still triggers 'change' - setTimeout(() => { try { fi.value = ''; } catch {} }, 500); + setTimeout(() => { try { inputEl.value = ''; } catch {} }, 500); }; +fi.onchange = () => onFilesSelected(fi); +if (foi) foi.onchange = () => onFilesSelected(foi); + // If you want the whole dropzone clickable on desktop only, enable this: if (!isTouch) { dz.addEventListener('click', () => { diff --git a/frontend/index.html b/frontend/index.html index 9f7d53d..e09e74a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -37,16 +37,26 @@ - -
- + +
+
+ +
+
+ +