feat: drag-and-drop upload depuis l'ordinateur (fichiers + dossiers récursifs)
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped

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:
2026-07-11 08:41:35 -04:00
parent e9a419b83f
commit 4810ff18eb
3 changed files with 417 additions and 3 deletions
+1 -1
View File
@@ -16,7 +16,7 @@ class CSRFMiddleware(BaseHTTPMiddleware):
"""
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
EXCLUDED_PATHS = {"/api/webhook", "/api/v1", "/auth/callback", "/auth/register", "/auth/local-login", "/api/user", "/board/api/pages", "/board/api/favorites", "/api/workspace", "/api/local-workspace", "/db/", "/workspace"}
EXCLUDED_PATHS = {"/api/webhook", "/api/v1", "/auth/callback", "/auth/register", "/auth/local-login", "/api/user", "/board/api/pages", "/board/api/favorites", "/api/workspace", "/api/local-workspace", "/db/", "/workspace"}
async def dispatch(self, request: Request, call_next):
# Webhook receiver, OAuth callback, and internal API are exempt
+183 -1
View File
@@ -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"
+233 -1
View File
@@ -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);
}
}
};