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:
+183
-1
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request, Query
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
|
||||
|
||||
from app.services.gitea_client import gitea
|
||||
from app.auth.session import SessionManager
|
||||
@@ -667,6 +667,188 @@ async def move_local_workspace_item(request: Request, item_id: int):
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/api/local-workspace/upload")
|
||||
async def upload_local_workspace_file(request: Request):
|
||||
"""Upload one or more files via drag-and-drop.
|
||||
|
||||
Accepts multipart form with 'files' field (one or multiple files).
|
||||
Optional: 'parent_id' to place files in a specific folder.
|
||||
Stores files on disk at /data/uploads/workspace_{id}/ and creates DB records.
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
ws = _get_active_workspace(request)
|
||||
ws_id = ws["id"] if ws else None
|
||||
if not ws_id:
|
||||
return JSONResponse({"error": "No active workspace"}, status_code=400)
|
||||
|
||||
try:
|
||||
form = await request.form()
|
||||
except Exception:
|
||||
return JSONResponse({"error": "Invalid form data"}, status_code=400)
|
||||
|
||||
parent_id_raw = form.get("parent_id")
|
||||
parent_id = int(parent_id_raw) if parent_id_raw else None
|
||||
files = form.getlist("files")
|
||||
|
||||
if not files:
|
||||
return JSONResponse({"error": "No files provided"}, status_code=400)
|
||||
|
||||
upload_dir = Path(f"/data/uploads/workspace_{ws_id}")
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
results = []
|
||||
with get_conn() as conn:
|
||||
for f in files:
|
||||
filename = f.filename or "untitled"
|
||||
# Sanitize filename: only keep basename, prevent path traversal
|
||||
safe_name = Path(filename).name
|
||||
if not safe_name:
|
||||
safe_name = "untitled"
|
||||
|
||||
# Unique filename on disk
|
||||
file_path = upload_dir / safe_name
|
||||
stem, suffix = file_path.stem, file_path.suffix
|
||||
counter = 1
|
||||
while file_path.exists():
|
||||
file_path = upload_dir / f"{stem} ({counter}){suffix}"
|
||||
counter += 1
|
||||
|
||||
content = await f.read()
|
||||
file_path.write_bytes(content)
|
||||
|
||||
# Determine if this is a folder marker or actual file
|
||||
rel_path = str(file_path.relative_to("/data"))
|
||||
size = len(content)
|
||||
mime = f.content_type or "application/octet-stream"
|
||||
|
||||
cursor = conn.execute(
|
||||
"""INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section, parent_id)
|
||||
VALUES ('', ?, ?, ?, 'file', 'Private', ?)""",
|
||||
(ws_id, file_path.name,
|
||||
json.dumps({"file_path": rel_path, "size": size, "mime_type": mime}),
|
||||
parent_id),
|
||||
)
|
||||
results.append({"id": cursor.lastrowid, "name": file_path.name, "type": "page", "size": size})
|
||||
|
||||
conn.commit()
|
||||
|
||||
return {"status": "ok", "items": results}
|
||||
|
||||
|
||||
@router.post("/api/local-workspace/upload-folder")
|
||||
async def upload_local_workspace_folder(request: Request):
|
||||
"""Handle recursive folder upload.
|
||||
|
||||
Frontend walks the directory tree with webkitGetAsEntry and sends:
|
||||
- 'structure': JSON array of {path: str, type: 'folder'|'file'}
|
||||
- 'files': multipart files (one per file in the structure)
|
||||
- 'parent_id': target folder (optional)
|
||||
|
||||
Creates folders first, then uploads files into their respective folders.
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
ws = _get_active_workspace(request)
|
||||
ws_id = ws["id"] if ws else None
|
||||
if not ws_id:
|
||||
return JSONResponse({"error": "No active workspace"}, status_code=400)
|
||||
|
||||
try:
|
||||
form = await request.form()
|
||||
except Exception:
|
||||
return JSONResponse({"error": "Invalid form data"}, status_code=400)
|
||||
|
||||
parent_id_raw = form.get("parent_id")
|
||||
root_parent_id = int(parent_id_raw) if parent_id_raw else None
|
||||
structure_raw = form.get("structure")
|
||||
|
||||
if not structure_raw:
|
||||
return JSONResponse({"error": "No structure provided"}, status_code=400)
|
||||
|
||||
try:
|
||||
structure = json.loads(structure_raw)
|
||||
except json.JSONDecodeError:
|
||||
return JSONResponse({"error": "Invalid structure JSON"}, status_code=400)
|
||||
|
||||
upload_dir = Path(f"/data/uploads/workspace_{ws_id}")
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
results = []
|
||||
created_folders = {} # relative_path -> db_id
|
||||
|
||||
with get_conn() as conn:
|
||||
# Phase 1: Create all folders
|
||||
for item in structure:
|
||||
if item.get("type") != "folder":
|
||||
continue
|
||||
path_parts = item["path"].strip("/").split("/")
|
||||
folder_name = path_parts[-1]
|
||||
# Determine parent: parent of this folder in the tree
|
||||
if len(path_parts) == 1:
|
||||
actual_parent = root_parent_id
|
||||
else:
|
||||
parent_path = "/".join(path_parts[:-1])
|
||||
actual_parent = created_folders.get(parent_path)
|
||||
|
||||
cursor = conn.execute(
|
||||
"""INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section, parent_id)
|
||||
VALUES ('', ?, ?, '', 'blocks', 'Workspace', ?)""",
|
||||
(ws_id, folder_name, actual_parent),
|
||||
)
|
||||
fid = cursor.lastrowid
|
||||
created_folders[item["path"].strip("/")] = fid
|
||||
results.append({"id": fid, "name": folder_name, "type": "folder"})
|
||||
|
||||
# Phase 2: Upload files into their respective folders
|
||||
for item in structure:
|
||||
if item.get("type") != "file":
|
||||
continue
|
||||
path_parts = item["path"].strip("/").split("/")
|
||||
file_name = path_parts[-1]
|
||||
if len(path_parts) == 1:
|
||||
file_parent = root_parent_id
|
||||
else:
|
||||
parent_path = "/".join(path_parts[:-1])
|
||||
file_parent = created_folders.get(parent_path)
|
||||
|
||||
# Find the matching file in multipart data
|
||||
matched = None
|
||||
for f in form.getlist("files"):
|
||||
if f.filename and (f.filename == item["path"] or f.filename.endswith("/" + file_name)):
|
||||
matched = f
|
||||
break
|
||||
if not matched:
|
||||
continue
|
||||
|
||||
safe_name = Path(file_name).name
|
||||
file_path = upload_dir / safe_name
|
||||
stem, suffix = file_path.stem, file_path.suffix
|
||||
counter = 1
|
||||
while file_path.exists():
|
||||
file_path = upload_dir / f"{stem} ({counter}){suffix}"
|
||||
counter += 1
|
||||
|
||||
content = await matched.read()
|
||||
file_path.write_bytes(content)
|
||||
|
||||
rel_path = str(file_path.relative_to("/data"))
|
||||
cursor = conn.execute(
|
||||
"""INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section, parent_id)
|
||||
VALUES ('', ?, ?, ?, 'file', 'Private', ?)""",
|
||||
(ws_id, file_path.name,
|
||||
json.dumps({"file_path": rel_path, "size": len(content), "mime_type": matched.content_type or "application/octet-stream"}),
|
||||
file_parent),
|
||||
)
|
||||
results.append({"id": cursor.lastrowid, "name": file_path.name, "type": "page", "size": len(content)})
|
||||
|
||||
conn.commit()
|
||||
|
||||
return {"status": "ok", "items": results}
|
||||
|
||||
|
||||
# ═══════════ Workspaces CRUD ═══════════
|
||||
|
||||
WORKSPACE_COOKIE = "flowdeck_workspace"
|
||||
|
||||
@@ -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