From 4810ff18eb381ba2c6bd3e34b4a4be2b9e12d1b1 Mon Sep 17 00:00:00 2001 From: bruno Date: Sat, 11 Jul 2026 08:41:35 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20drag-and-drop=20upload=20depuis=20l'ord?= =?UTF-8?q?inateur=20(fichiers=20+=20dossiers=20r=C3=A9cursifs)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/middleware/csrf.py | 2 +- app/routers/dashboard.py | 184 ++++++++++++++++++++++- app/templates/local_workspace.html | 234 ++++++++++++++++++++++++++++- 3 files changed, 417 insertions(+), 3 deletions(-) diff --git a/app/middleware/csrf.py b/app/middleware/csrf.py index 17617ec..72036be 100644 --- a/app/middleware/csrf.py +++ b/app/middleware/csrf.py @@ -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 diff --git a/app/routers/dashboard.py b/app/routers/dashboard.py index 9372f35..6ab831e 100644 --- a/app/routers/dashboard.py +++ b/app/routers/dashboard.py @@ -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" diff --git a/app/templates/local_workspace.html b/app/templates/local_workspace.html index 976c851..f330cec 100644 --- a/app/templates/local_workspace.html +++ b/app/templates/local_workspace.html @@ -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;} -
+
+ + +
+
+ 📥 +
Drop files here
+
+ Into: +
+
+
+ + +
+
+ + +
+
+
+
+