diff --git a/app/routers/board.py b/app/routers/board.py index f12f303..a4a1242 100644 --- a/app/routers/board.py +++ b/app/routers/board.py @@ -811,7 +811,7 @@ async def delete_page(request: Request, page_id: int): @router.get("/pages/{page_id}", response_class=HTMLResponse) async def view_page(request: Request, page_id: int): - """Render a page as HTML.""" + """Render a page as HTML, or a file viewer for uploaded files.""" from jinja2 import Environment, FileSystemLoader env = Environment(loader=FileSystemLoader("app/templates")) with get_conn() as conn: @@ -819,6 +819,11 @@ async def view_page(request: Request, page_id: int): if not row: return HTMLResponse("

Page not found

", 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 "" @@ -838,6 +843,121 @@ async def view_page(request: Request, page_id: int): response = template.render(**ctx) return HTMLResponse(content=response, headers={"Cache-Control": "no-store, max-age=0"}) + +def _render_file_viewer(page: dict, request: Request) -> HTMLResponse: + """Render a file viewer for uploaded files (content_format='file').""" + import json + page_id = page["id"] + title = page.get("title", "File") + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + ws_name = user.get("login", "Bruno") if user else "Bruno" + initial = ws_name[0].upper() if ws_name else "B" + + try: + meta = json.loads(page.get("content", "{}")) + except json.JSONDecodeError: + meta = {} + file_path = meta.get("file_path", "") + mime_type = meta.get("mime_type", "application/octet-stream") + size = meta.get("size", 0) + + # Build workspace_id from file_path + parts = file_path.split("/") + ws_id = "" + if len(parts) >= 3 and parts[2].startswith("workspace_"): + ws_id = parts[2].replace("workspace_", "") + filename = parts[-1] if parts else title + + file_url = f"/api/files/{ws_id}/{filename}" if ws_id else "" + + # Determine viewer type + is_image = mime_type.startswith("image/") + is_pdf = mime_type == "application/pdf" + is_text = mime_type.startswith("text/") or mime_type in ( + "application/json", "application/javascript", "application/xml", + "application/x-python", "application/x-sh" + ) + is_code = any(ext in (filename or "").lower() for ext in ( + ".py", ".js", ".ts", ".jsx", ".tsx", ".html", ".htm", ".css", + ".json", ".xml", ".yaml", ".yml", ".md", ".sql", ".sh", ".bash", + ".ps1", ".bat", ".cmd", ".rb", ".go", ".rs", ".java", ".c", ".cpp", + ".h", ".hpp", ".php", ".swift", ".kt", ".scala", ".r", ".toml", + ".ini", ".cfg", ".conf", ".env", ".dockerfile", ".makefile" + )) + if is_code: + is_text = True + + # Determine language class for syntax highlighting + ext_map = { + ".py": "python", ".js": "javascript", ".ts": "typescript", + ".html": "html", ".css": "css", ".json": "json", ".md": "markdown", + ".sql": "sql", ".sh": "bash", ".ps1": "powershell", ".rs": "rust", + ".go": "go", ".java": "java", ".rb": "ruby", ".xml": "xml", + ".yaml": "yaml", ".yml": "yaml", ".toml": "toml", + } + lang = "plaintext" + for ext, name in ext_map.items(): + if (filename or "").lower().endswith(ext): + lang = name + break + + size_str = f"{size:,} bytes" if size < 1024 else f"{size/1024:.1f} KB" if size < 1024*1024 else f"{size/1024/1024:.1f} MB" + + html = f""" + + +{title} — FlowDeck + + +
+ ← Workspace + 📄 {title} + {size_str} · {mime_type} + ⬇ Download +
+
""" + + if is_image: + html += f'{title}' + elif is_pdf: + html += f'' + elif is_text: + # Fetch and embed the content as text + html += f'
Loading...
' + html += f'''''' + else: + html += f'''
+
📎
+

{title}

+

{size_str} · {mime_type}
This file type cannot be previewed.

+⬇ Download +
''' + + html += "
" + return HTMLResponse(content=html) + @router.post("/api/sync/{owner}/{repo}") async def sync_project(owner: str, repo: str): """Full bidirectional sync: fetch Gitea issues → update local DB.""" diff --git a/app/routers/dashboard.py b/app/routers/dashboard.py index 6ab831e..696f86e 100644 --- a/app/routers/dashboard.py +++ b/app/routers/dashboard.py @@ -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("

Page not found

", 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).""" diff --git a/app/templates/base.html b/app/templates/base.html index 9796ff4..3676f31 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -803,12 +803,20 @@ }, // ── Tree expand/collapse + folder navigation ── - expandedFolders: {}, + expandedFolders: (function() { + try { return JSON.parse(localStorage.getItem('fd_expanded_folders') || '{}'); } + catch(e) { return {}; } + })(), toggleTreeFolder(id) { this.expandedFolders[id] = !(this.expandedFolders[id] || false); this.expandedFolders = Object.assign({}, this.expandedFolders); + try { localStorage.setItem('fd_expanded_folders', JSON.stringify(this.expandedFolders)); } + catch(e) {} }, navigateToFolder(id) { + // Save expansion state before navigating + try { localStorage.setItem('fd_expanded_folders', JSON.stringify(this.expandedFolders)); } + catch(e) {} window.location = `/local-workspace?folder=${id}`; }, diff --git a/app/templates/local_workspace.html b/app/templates/local_workspace.html index f330cec..15994ef 100644 --- a/app/templates/local_workspace.html +++ b/app/templates/local_workspace.html @@ -150,7 +150,10 @@