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
+
+
+
+"""
+
+ if is_image:
+ html += f'

'
+ 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 @@
-
-
+