feat: drag-and-drop upload depuis l'ordinateur (fichiers + dossiers récursifs)
Backend:
- POST /api/local-workspace/upload: upload fichiers via multipart, stockage
disque (/data/uploads/workspace_{id}/), page DB avec content_format='file'
- POST /api/local-workspace/upload-folder: upload récursif de dossiers
(structure JSON + fichiers), crée l'arborescence complète
Frontend:
- Drop zones sur la page workspace (racine + dossiers ciblés)
- Visual: overlay 'Drop files here', highlight du dossier cible
- webkitGetAsEntry pour walk récursif des dossiers
- Progress bar en bas à droite pendant l'upload
- Auto-reload après upload réussi
- Déduplication automatique des noms de fichiers
This commit is contained in:
@@ -83,9 +83,45 @@
|
||||
.breadcrumb-bar a:hover{color:var(--text);background:var(--bg-tertiary);}
|
||||
.breadcrumb-bar .sep{color:var(--text-tertiary);}
|
||||
.breadcrumb-bar .current{color:var(--text);font-weight:500;}
|
||||
|
||||
/* Drag & drop */
|
||||
.ws-layout.drop-active { outline: 2px dashed var(--accent); outline-offset: -4px; border-radius: 8px; }
|
||||
.drop-overlay{display:none;position:fixed;inset:0;background:rgba(35,131,226,.08);z-index:100;pointer-events:none;align-items:center;justify-content:center;}
|
||||
.drop-overlay.active{display:flex;}
|
||||
.drop-overlay .drop-hint{background:var(--bg);border:2px dashed var(--accent);border-radius:16px;padding:40px 60px;text-align:center;color:var(--text);font-size:16px;}
|
||||
.drop-overlay .drop-hint .icon{font-size:48px;display:block;margin-bottom:12px;}
|
||||
.drop-target-highlight{outline:2px solid var(--accent)!important;outline-offset:-2px;border-radius:6px;background:rgba(35,131,226,.1)!important;}
|
||||
.upload-progress{position:fixed;bottom:24px;right:24px;background:var(--bg);border:1px solid var(--border-strong);border-radius:12px;padding:16px 20px;z-index:200;box-shadow:0 8px 30px rgba(0,0,0,.4);min-width:200px;}
|
||||
.upload-progress .up-bar{height:4px;background:var(--bg-tertiary);border-radius:2px;margin-top:8px;overflow:hidden;}
|
||||
.upload-progress .up-bar-fill{height:100%;background:var(--accent);border-radius:2px;transition:width 200ms;}
|
||||
</style>
|
||||
|
||||
<div class="ws-layout" x-data="wsInit()">
|
||||
<div class="ws-layout" x-data="wsInit()"
|
||||
@dragover.prevent="onDragOver($event)"
|
||||
@dragleave="onDragLeave($event)"
|
||||
@drop.prevent="onDrop($event)">
|
||||
|
||||
<!-- Drop overlay -->
|
||||
<div class="drop-overlay" :class="{ active: isDragging }">
|
||||
<div class="drop-hint">
|
||||
<span class="icon">📥</span>
|
||||
<div>Drop files here</div>
|
||||
<div style="font-size:12px;color:var(--text-tertiary);margin-top:6px;" x-show="dropTarget">
|
||||
Into: <strong x-text="dropTargetName"></strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload progress -->
|
||||
<div class="upload-progress" x-show="uploading" x-transition>
|
||||
<div style="display:flex;align-items:center;gap:8px;">
|
||||
<span x-text="uploading ? '⏳' : ''"></span>
|
||||
<span x-text="uploadStatus"></span>
|
||||
</div>
|
||||
<div class="up-bar">
|
||||
<div class="up-bar-fill" :style="{ width: uploadProgress + '%' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Breadcrumb -->
|
||||
<div class="breadcrumb-bar" x-show="breadcrumb.length > 0">
|
||||
@@ -225,6 +261,14 @@ document.addEventListener('alpine:init', function() {
|
||||
expanded: {},
|
||||
currentFolder: {{ current_folder_id }},
|
||||
|
||||
// Drag & drop state
|
||||
isDragging: false,
|
||||
dropTarget: null,
|
||||
dropTargetName: '',
|
||||
uploading: false,
|
||||
uploadStatus: '',
|
||||
uploadProgress: 0,
|
||||
|
||||
async init() {
|
||||
try {
|
||||
var url = '/api/local-workspace/tree';
|
||||
@@ -366,6 +410,194 @@ document.addEventListener('alpine:init', function() {
|
||||
if (!this.target) return;
|
||||
var r = await fetch('/api/local-workspace/items/'+this.target.db_id, { method:'DELETE' });
|
||||
if (r.ok) { window.location.reload(); }
|
||||
},
|
||||
|
||||
// ── Drag & Drop ──
|
||||
onDragOver(ev) {
|
||||
this.isDragging = true;
|
||||
// Detect if hovering over a folder item
|
||||
var folderEl = ev.target.closest('.ws-tree-item');
|
||||
var parentFolder = folderEl ? folderEl.querySelector('.name.folder') : null;
|
||||
// Clear previous highlight
|
||||
document.querySelectorAll('.drop-target-highlight').forEach(function(el){ el.classList.remove('drop-target-highlight'); });
|
||||
if (parentFolder) {
|
||||
var name = parentFolder.textContent.trim();
|
||||
this.dropTargetName = name;
|
||||
// Find parent node id from the onclick attribute or data
|
||||
var li = parentFolder.closest('.ws-tree-item');
|
||||
if (li) {
|
||||
var clickEl = li.querySelector('.name.folder');
|
||||
// Try to extract ID from onclick or the onclick handler
|
||||
// Use data attributes if available, otherwise fall back
|
||||
}
|
||||
folderEl.classList.add('drop-target-highlight');
|
||||
} else {
|
||||
this.dropTargetName = 'root';
|
||||
}
|
||||
},
|
||||
|
||||
onDragLeave(ev) {
|
||||
// Only reset if leaving the layout entirely
|
||||
if (!ev.currentTarget.contains(ev.relatedTarget)) {
|
||||
this.isDragging = false;
|
||||
this.dropTargetName = '';
|
||||
document.querySelectorAll('.drop-target-highlight').forEach(function(el){ el.classList.remove('drop-target-highlight'); });
|
||||
}
|
||||
},
|
||||
|
||||
onDrop(ev) {
|
||||
this.isDragging = false;
|
||||
document.querySelectorAll('.drop-target-highlight').forEach(function(el){ el.classList.remove('drop-target-highlight'); });
|
||||
|
||||
// Determine target parent
|
||||
var folderEl = ev.target.closest('.ws-tree-item');
|
||||
var parentId = this.currentFolder > 0 ? this.currentFolder : null;
|
||||
var parentName = 'root';
|
||||
|
||||
if (folderEl) {
|
||||
var folderLink = folderEl.querySelector('.name.folder');
|
||||
if (folderLink) {
|
||||
parentName = folderLink.textContent.trim();
|
||||
// Extract folder ID from the onclick handler in the HTML
|
||||
var onclick = folderLink.getAttribute('onclick') || '';
|
||||
var m = onclick.match(/folder=(\d+)/);
|
||||
if (m) parentId = parseInt(m[1]);
|
||||
}
|
||||
}
|
||||
|
||||
var items = ev.dataTransfer.items;
|
||||
if (!items) return;
|
||||
|
||||
var self = this;
|
||||
var dtFiles = ev.dataTransfer.files;
|
||||
|
||||
// Check if any item is a directory (WebKit only)
|
||||
var hasDirectory = false;
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
var entry = items[i].webkitGetAsEntry ? items[i].webkitGetAsEntry() : null;
|
||||
if (entry && entry.isDirectory) { hasDirectory = true; break; }
|
||||
}
|
||||
|
||||
if (hasDirectory) {
|
||||
// Walk directory tree and upload structure
|
||||
this._processDirectoryDrop(items, parentId, parentName);
|
||||
} else if (dtFiles.length > 0) {
|
||||
// Simple file upload
|
||||
this._uploadFiles(dtFiles, parentId, parentName);
|
||||
}
|
||||
},
|
||||
|
||||
_processDirectoryDrop(items, parentId, parentName) {
|
||||
var self = this;
|
||||
var allItems = []; // {path: string, type: 'file'|'folder', entry: FileSystemEntry}
|
||||
var pending = items.length;
|
||||
|
||||
function walkEntry(entry, basePath) {
|
||||
var path = basePath ? basePath + '/' + entry.name : entry.name;
|
||||
if (entry.isFile) {
|
||||
allItems.push({path: path, type: 'file', entry: entry});
|
||||
pending--;
|
||||
if (pending === 0) doUpload();
|
||||
} else if (entry.isDirectory) {
|
||||
allItems.push({path: path, type: 'folder', entry: entry});
|
||||
var reader = entry.createReader();
|
||||
reader.readEntries(function(entries) {
|
||||
pending += entries.length;
|
||||
for (var i = 0; i < entries.length; i++) {
|
||||
walkEntry(entries[i], path);
|
||||
}
|
||||
pending--;
|
||||
if (pending === 0) doUpload();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
var entry = items[i].webkitGetAsEntry ? items[i].webkitGetAsEntry() : null;
|
||||
if (entry) walkEntry(entry, '');
|
||||
else pending--;
|
||||
}
|
||||
if (pending === 0) doUpload();
|
||||
|
||||
function doUpload() {
|
||||
// Build structure
|
||||
var structure = [];
|
||||
var files = [];
|
||||
for (var i = 0; i < allItems.length; i++) {
|
||||
structure.push({path: allItems[i].path, type: allItems[i].type});
|
||||
}
|
||||
|
||||
// Resolve file entries to actual File objects
|
||||
var fileEntries = [];
|
||||
for (var i = 0; i < allItems.length; i++) {
|
||||
if (allItems[i].type === 'file') fileEntries.push(allItems[i]);
|
||||
}
|
||||
|
||||
var resolved = 0;
|
||||
var formData = new FormData();
|
||||
formData.append('structure', JSON.stringify(structure));
|
||||
if (parentId) formData.append('parent_id', parentId);
|
||||
|
||||
function addFile(idx) {
|
||||
if (idx >= fileEntries.length) {
|
||||
// All resolved, upload
|
||||
self._doUpload(formData, parentId, parentName);
|
||||
return;
|
||||
}
|
||||
var fe = fileEntries[idx];
|
||||
fe.entry.file(function(file) {
|
||||
formData.append('files', file, fe.path);
|
||||
resolved++;
|
||||
addFile(idx + 1);
|
||||
}, function() {
|
||||
resolved++;
|
||||
addFile(idx + 1);
|
||||
});
|
||||
}
|
||||
addFile(0);
|
||||
}
|
||||
},
|
||||
|
||||
_uploadFiles(fileList, parentId, parentName) {
|
||||
var formData = new FormData();
|
||||
for (var i = 0; i < fileList.length; i++) {
|
||||
formData.append('files', fileList[i]);
|
||||
}
|
||||
if (parentId) formData.append('parent_id', parentId);
|
||||
this._doUpload(formData, parentId, parentName);
|
||||
},
|
||||
|
||||
async _doUpload(formData, parentId, parentName) {
|
||||
var self = this;
|
||||
this.uploading = true;
|
||||
this.uploadStatus = 'Uploading to ' + parentName + '...';
|
||||
this.uploadProgress = 10;
|
||||
|
||||
var folderHandled = formData.has('structure');
|
||||
var url = folderHandled ? '/api/local-workspace/upload-folder' : '/api/local-workspace/upload';
|
||||
|
||||
try {
|
||||
var r = await fetch(url, { method: 'POST', body: formData });
|
||||
this.uploadProgress = 80;
|
||||
var d = await r.json();
|
||||
if (r.ok) {
|
||||
this.uploadStatus = '✓ ' + d.items.length + ' item(s) uploaded';
|
||||
this.uploadProgress = 100;
|
||||
var items = d.items || [];
|
||||
setTimeout(function() {
|
||||
self.uploading = false;
|
||||
window.location.reload();
|
||||
}, 800);
|
||||
} else {
|
||||
this.uploadStatus = '✗ ' + (d.error || 'Upload failed');
|
||||
this.uploadProgress = 0;
|
||||
setTimeout(function() { self.uploading = false; }, 2000);
|
||||
}
|
||||
} catch(e) {
|
||||
this.uploadStatus = '✗ Network error';
|
||||
this.uploadProgress = 0;
|
||||
setTimeout(function() { self.uploading = false; }, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user