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:
2026-01-20 22:11:53 -07:00
parent cc95608364
commit fb8567a4a9
2 changed files with 65 additions and 19 deletions

View File

@@ -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', () => {

View File

@@ -37,16 +37,26 @@
<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>
<!-- Mobile-safe choose control: label wraps the hidden input -->
<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 files or a folder">
Choose files or a folder
<input id="fileInput"
type="file"
multiple
webkitdirectory
class="absolute inset-0 opacity-0 cursor-pointer" />
</label>
<!-- Mobile-safe choose controls -->
<div class="flex flex-col sm:flex-row items-center justify-center gap-4">
<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 files">
Choose files
<input id="fileInput"
type="file"
multiple
class="absolute inset-0 opacity-0 cursor-pointer" />
</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>
</section>