feat: drag-and-drop interne, file viewer multi-type, sidebar persistante
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped

1. Drag & drop interne: déplacer fichiers/dossiers existants vers d'autres
   dossiers ou racine via l'API /api/local-workspace/items/{id}/move.
   Items rendus draggable dans le template x-for et renderChildren.

2. File viewer pour fichiers uploadés: détection content_format='file',
   visualiseur intégré avec rendu adapté au type MIME:
   - Images: affichage direct
   - PDF: iframe
   - Texte/Code (.py .js .md .html .ps1 etc.): <pre> avec fetch du contenu
   - Autres: lien de téléchargement
   Route GET /api/files/{ws_id}/{filename:path} pour servir les fichiers.

3. Sidebar persistante: expandedFolders sauvegardé dans localStorage,
   restauré au chargement, préservé avant navigation vers un dossier.

4. Suppression: nettoie aussi le fichier disque pour content_format='file'
This commit is contained in:
2026-07-11 09:04:26 -04:00
parent 4810ff18eb
commit 4be4c3432b
4 changed files with 221 additions and 7 deletions
+35 -2
View File
@@ -182,15 +182,20 @@ async def library_page(request: Request, owner: str = Query(default=""), repo: s
@router.get("/pages/{page_id}", response_class=HTMLResponse)
async def view_page_root(request: Request, page_id: int):
"""Render a Markdown page at root level with workspace context."""
"""Render a Markdown page at root level with workspace context — or file viewer."""
from jinja2 import Environment, FileSystemLoader
from app.routers.board import _sidebar_data as board_sidebar
from app.routers.board import _render_file_viewer
env = Environment(loader=FileSystemLoader("app/templates"))
with get_conn() as conn:
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
if not row:
return HTMLResponse("<h2>Page not found</h2>", status_code=404)
page = dict(row)
# ── File viewer for uploaded files ──
if page.get("content_format") == "file":
return _render_file_viewer(page, request)
ws = page.get("workspace", "")
parts = ws.split("/") if "/" in ws else ["", ""]
owner, repo = parts[0], parts[1] if len(parts) > 1 else ""
@@ -645,13 +650,41 @@ async def rename_local_workspace_item(request: Request, item_id: int):
@router.delete("/api/local-workspace/items/{item_id:int}")
async def delete_local_workspace_item(request: Request, item_id: int):
"""Delete a file (and its children)."""
"""Delete a file (and its children). Also removes uploaded file from disk."""
import json
from pathlib import Path
with get_conn() as conn:
# Check if it's a file-type page with a disk file
row = conn.execute("SELECT content, content_format FROM pages WHERE id=?", (item_id,)).fetchone()
if row and row["content_format"] == "file":
try:
meta = json.loads(row["content"])
fp = Path(f"/data/{meta['file_path']}")
if fp.exists():
fp.unlink()
except Exception:
pass
conn.execute("DELETE FROM pages WHERE id=? OR parent_id=?", (item_id, item_id))
conn.commit()
return {"status": "ok"}
@router.get("/api/files/{ws_id:int}/{filename:path}")
async def serve_uploaded_file(ws_id: int, filename: str):
"""Serve an uploaded file from disk."""
from pathlib import Path
import mimetypes
fp = Path(f"/data/uploads/workspace_{ws_id}/{filename}").resolve()
if not str(fp).startswith(f"/data/uploads/workspace_{ws_id}/"):
return JSONResponse({"error": "Path traversal denied"}, status_code=403)
if not fp.exists():
return JSONResponse({"error": "File not found"}, status_code=404)
mime, _ = mimetypes.guess_type(str(fp))
content = fp.read_bytes()
from fastapi.responses import Response
return Response(content=content, media_type=mime or "application/octet-stream")
@router.put("/api/local-workspace/items/{item_id:int}/move")
async def move_local_workspace_item(request: Request, item_id: int):
"""Move an item to a new parent (drag & drop)."""