feat: Add multiple file/folder upload and enhanced drag-and-drop support
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
This commit is contained in:
@@ -87,9 +87,10 @@ function human(bytes){
|
|||||||
return (bytes/Math.pow(k,i)).toFixed(1)+' '+sizes[i];
|
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 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);
|
items.unshift(it);
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
@@ -318,6 +319,7 @@ async function uploadChunked(next){
|
|||||||
// --- DOM refs ---
|
// --- DOM refs ---
|
||||||
const dz = document.getElementById('dropzone');
|
const dz = document.getElementById('dropzone');
|
||||||
const fi = document.getElementById('fileInput');
|
const fi = document.getElementById('fileInput');
|
||||||
|
const foi = document.getElementById('folderInput');
|
||||||
const btnMobilePick = document.getElementById('btnMobilePick');
|
const btnMobilePick = document.getElementById('btnMobilePick');
|
||||||
const btnClearFinished = document.getElementById('btnClearFinished');
|
const btnClearFinished = document.getElementById('btnClearFinished');
|
||||||
const btnClearAll = document.getElementById('btnClearAll');
|
const btnClearAll = document.getElementById('btnClearAll');
|
||||||
@@ -380,10 +382,37 @@ if (btnPing) btnPing.onclick = async () => {
|
|||||||
// --- Drag & drop (no click-to-open on touch) ---
|
// --- 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'); }));
|
['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'); }));
|
['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();
|
e.preventDefault();
|
||||||
const files = Array.from(e.dataTransfer.files || []);
|
const filesAndPaths = [];
|
||||||
files.forEach(addItem);
|
|
||||||
|
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();
|
render();
|
||||||
runQueue();
|
runQueue();
|
||||||
});
|
});
|
||||||
@@ -413,20 +442,27 @@ fi.addEventListener('click', (e) => {
|
|||||||
// prevent bubbling to parents (extra safety)
|
// prevent bubbling to parents (extra safety)
|
||||||
e.stopPropagation();
|
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
|
// Suppress any stray clicks for a short window after the picker closes
|
||||||
suppressClicksUntil = Date.now() + 800;
|
suppressClicksUntil = Date.now() + 800;
|
||||||
|
|
||||||
const files = Array.from(fi.files || []);
|
const files = Array.from(inputEl.files || []);
|
||||||
files.forEach(addItem);
|
files.forEach(file => addItem(file));
|
||||||
render();
|
render();
|
||||||
runQueue();
|
runQueue();
|
||||||
|
|
||||||
// Reset a bit later so selecting the same items again still triggers 'change'
|
// 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 you want the whole dropzone clickable on desktop only, enable this:
|
||||||
if (!isTouch) {
|
if (!isTouch) {
|
||||||
dz.addEventListener('click', () => {
|
dz.addEventListener('click', () => {
|
||||||
|
|||||||
@@ -37,16 +37,26 @@
|
|||||||
<p class="mt-3 font-medium hidden md:block">Drop files or a folder here</p>
|
<p class="mt-3 font-medium hidden md:block">Drop files or a folder here</p>
|
||||||
<p class="mb-5 text-sm text-gray-600 dark:text-gray-400 hidden md:block">...or</p>
|
<p class="mb-5 text-sm text-gray-600 dark:text-gray-400 hidden md:block">...or</p>
|
||||||
|
|
||||||
<!-- Mobile-safe choose control: label wraps the hidden input -->
|
<!-- Mobile-safe choose controls -->
|
||||||
<div class="relative inline-block">
|
<div class="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||||
<label class="rounded-2xl bg-black text-white dark:bg-white dark:text-black px-5 py-3 hover:opacity-90 cursor-pointer select-none transition-colors" aria-label="Choose files or a folder">
|
<div class="relative inline-block">
|
||||||
Choose files or a folder
|
<label class="rounded-2xl bg-black text-white dark:bg-white dark:text-black px-5 py-3 hover:opacity-90 cursor-pointer select-none transition-colors" aria-label="Choose files">
|
||||||
<input id="fileInput"
|
Choose files
|
||||||
type="file"
|
<input id="fileInput"
|
||||||
multiple
|
type="file"
|
||||||
webkitdirectory
|
multiple
|
||||||
class="absolute inset-0 opacity-0 cursor-pointer" />
|
class="absolute inset-0 opacity-0 cursor-pointer" />
|
||||||
</label>
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="relative inline-block">
|
||||||
|
<label class="rounded-2xl bg-black text-white dark:bg-white dark:text-black px-5 py-3 hover:opacity-90 cursor-pointer select-none transition-colors" aria-label="Choose a folder">
|
||||||
|
Choose a folder
|
||||||
|
<input id="folderInput"
|
||||||
|
type="file"
|
||||||
|
webkitdirectory
|
||||||
|
class="absolute inset-0 opacity-0 cursor-pointer" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user