"""FlowDeck β€” Dashboard: liste des projets Gitea + sidebar data.""" from __future__ import annotations import logging from fastapi import APIRouter, Request, Query from fastapi.responses import HTMLResponse from app.services.gitea_client import gitea from app.auth.session import SessionManager from app.db import get_conn logger = logging.getLogger(__name__) router = APIRouter(tags=["dashboard"]) def _sidebar_data(request: Request, repos: list[dict]) -> dict: user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) ws = user.get("login", "Bruno") if user else "Bruno" initial = ws[0].upper() if ws else "B" recent_pages = [] for repo in repos[:10]: full_name = repo.get("full_name", "") recent_pages.append({ "id": full_name, "name": repo.get("name", full_name), "icon": "πŸ“", "url": f"/board/{full_name}", "active": False, "indent": 0, "depth": 0, "has_children": False, "children": [], }) return { "workspace_name": ws, "workspace_initial": initial, "current_page": "Dashboard", "last_edited": "now", "recent_pages": recent_pages, "private_pages": [], "favorite_pages": [], "user": user, "workspace_key": "", } @router.get("/trash", response_class=HTMLResponse) async def trash_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")): """Trash page β€” scoped to workspace if owner/repo provided.""" from jinja2 import Environment, FileSystemLoader from app.routers.board import _sidebar_data as board_sidebar env = Environment(loader=FileSystemLoader("app/templates")) sidebar = board_sidebar(request, owner, repo) with get_conn() as conn: ws_key = f"{owner}/{repo}" if owner and repo else "" # Pages are soft-deleted via parent_section='Trash' if ws_key: rows = conn.execute( "SELECT id, title, workspace FROM pages WHERE parent_section='Trash' AND workspace=? ORDER BY updated_at DESC", (ws_key,), ).fetchall() else: rows = conn.execute( "SELECT id, title, workspace FROM pages WHERE parent_section='Trash' ORDER BY updated_at DESC", ).fetchall() sidebar["trash_items"] = [{"id": r["id"], "name": r["title"] or "Untitled", "workspace": r["workspace"]} for r in rows] template = env.get_template("trash.html") return template.render(**sidebar) @router.get("/library", response_class=HTMLResponse) async def library_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")): """Library page avec onglets Recents, Favorites, Shared, Private β€” scopΓ© au workspace. La sidebar garde exactement la mΓͺme arborescence que dans le workspace. Seul le contenu principal change pour afficher les pages triΓ©es par catΓ©gorie. """ from jinja2 import Environment, FileSystemLoader from app.routers.board import _sidebar_data as board_sidebar env = Environment(loader=FileSystemLoader("app/templates")) sidebar = board_sidebar(request, owner, repo) # Sidebar intacte, inchangΓ©e ws_key = f"{owner}/{repo}" if owner and repo else "" with get_conn() as conn: base_query = ( "SELECT id, title, workspace, parent_section, updated_at FROM pages " "WHERE parent_section != 'Trash'" ) params = [] if ws_key: base_query += " AND workspace=?" params.append(ws_key) base_query += " ORDER BY updated_at DESC" rows = conn.execute(base_query, params).fetchall() def to_page(r): r = dict(r) return { "id": f"page/{r['id']}", "name": r["title"] or "Untitled", "icon": "πŸ“„", "url": f"/pages/{r['id']}", "created_by": "You", "source": r.get("workspace") or "πŸ”’ Private", "last_edited": r.get("updated_at", "now"), "last_visited": r.get("updated_at", "now"), "section": r.get("parent_section", "Private"), } all_pages = [to_page(r) for r in rows] # Load favorites from DB for Library user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) uid = user["id"] if user and user.get("id") else 1 with get_conn() as conn: fav_rows = conn.execute( "SELECT p.id, p.title, p.workspace, p.updated_at FROM favorites f " "JOIN pages p ON p.id = f.page_id " "WHERE f.user_id=? ORDER BY f.position", (uid,) ).fetchall() lib_favs = [] for r in fav_rows: r = dict(r) lib_favs.append({ "id": f"page/{r['id']}", "name": r["title"] or "Untitled", "icon": "πŸ“„", "url": f"/pages/{r['id']}", "created_by": "You", "source": r.get("workspace") or "πŸ”’ Private", "last_edited": r.get("updated_at", "now"), "last_visited": r.get("updated_at", "now"), }) # CatΓ©goriser par parent_section β€” sidebar data intacte sidebar["lib_recent"] = all_pages sidebar["lib_favorites"] = lib_favs sidebar["lib_shared"] = [] sidebar["lib_private"] = [p for p in all_pages if p.get("section") == "Private"] sidebar["lib_has_favorites"] = len(lib_favs) > 0 sidebar["lib_has_shared"] = False template = env.get_template("library.html") return template.render(**sidebar) @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.""" from jinja2 import Environment, FileSystemLoader from app.routers.board import _sidebar_data as board_sidebar 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) ws = page.get("workspace", "") parts = ws.split("/") if "/" in ws else ["", ""] owner, repo = parts[0], parts[1] if len(parts) > 1 else "" sidebar = board_sidebar(request, owner, repo) # Load sub-pages with get_conn() as conn: subs = conn.execute( "SELECT id, title FROM pages WHERE parent_id=? ORDER BY updated_at DESC", (page_id,), ).fetchall() ctx = {**sidebar, "page": page, "sub_pages": [dict(s) for s in subs], "page_favorited": False, "page_share_mode": page.get("share_mode", "private"), "page_published": bool(page.get("published", 0)), "page_data": {"id": page_id, "title": page.get("title"), "content_format": page.get("content_format", "blocks"), "content": page.get("content", ""), "favorited": False, "share_mode": page.get("share_mode", "private"), "published": bool(page.get("published", 0))}} template = env.get_template("page_editor.html") response = template.render(**ctx) return HTMLResponse(content=response, headers={"Cache-Control": "no-store, max-age=0"}) @router.get("/accounts", response_class=HTMLResponse) async def accounts_page(request: Request): """Account management panel.""" from jinja2 import Environment, FileSystemLoader env = Environment(loader=FileSystemLoader("app/templates")) sidebar = _sidebar_data(request, []) user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) with get_conn() as conn: users = conn.execute("SELECT * FROM users ORDER BY created_at DESC").fetchall() ctx = {**sidebar, "user": user, "users": [dict(u) for u in users]} template = env.get_template("accounts.html") return template.render(**ctx) @router.get("/accounts/settings", response_class=HTMLResponse) async def settings_page(request: Request): """User settings page β€” profile, forges, tokens.""" from jinja2 import Environment, FileSystemLoader env = Environment(loader=FileSystemLoader("app/templates")) sidebar = _sidebar_data(request, []) user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) if not user: from fastapi.responses import RedirectResponse return RedirectResponse("/auth/login?provider=local", status_code=302) # Check forge connections gitea_connected = False github_connected = False if user.get("id"): with get_conn() as conn: tokens = conn.execute( "SELECT provider FROM user_oauth_tokens WHERE user_id=?", (user["id"],) ).fetchall() for t in tokens: if t["provider"] == "gitea": gitea_connected = True elif t["provider"] == "github": github_connected = True ctx = {**sidebar, "user": user, "gitea_connected": gitea_connected, "github_connected": github_connected} template = env.get_template("settings.html") response = template.render(**ctx) return HTMLResponse(content=response) # ═══════════ User API endpoints ═══════════ def _get_user_id(request: Request) -> int: user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) return user["id"] if user and user.get("id") else 1 @router.put("/api/user/profile") async def update_profile(request: Request): import json body = await request.json() full_name = body.get("full_name", "").strip() uid = _get_user_id(request) with get_conn() as conn: conn.execute("UPDATE users SET full_name=? WHERE id=?", (full_name, uid)) conn.commit() return {"status": "ok"} @router.put("/api/user/password") async def update_password(request: Request): import json from app.password_utils import hash_password body = await request.json() password = body.get("password", "").strip() if len(password) < 6: return {"error": "Password must be at least 6 characters"} uid = _get_user_id(request) with get_conn() as conn: conn.execute("UPDATE users SET password_hash=? WHERE id=?", (hash_password(password), uid)) conn.commit() return {"status": "ok"} @router.post("/api/user/token") async def generate_token(request: Request): import secrets uid = _get_user_id(request) token = secrets.token_hex(32) with get_conn() as conn: conn.execute( "INSERT OR REPLACE INTO user_tokens (gitea_user_id, gitea_token, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)", (uid, token), ) conn.commit() return {"token": f"fd_{token}"} @router.delete("/api/user/forge/{provider}") async def disconnect_forge(request: Request, provider: str): uid = _get_user_id(request) with get_conn() as conn: conn.execute( "DELETE FROM user_oauth_tokens WHERE user_id=? AND provider=?", (uid, provider) ) conn.commit() return {"status": "ok"} @router.get("/", response_class=HTMLResponse) async def dashboard( request: Request, search: str = Query(default=""), show_archived: bool = Query(default=False), ): try: repos = await gitea.get_user_repos(page=1, limit=50) if search: q = search.lower() repos = [r for r in repos if q in r.get("full_name", "").lower() or q in (r.get("description") or "").lower()] if not show_archived: repos = [r for r in repos if not r.get("archived", False)] repos.sort(key=lambda r: r.get("updated_at", ""), reverse=True) except Exception as e: logger.error("Dashboard error: %s", e) repos = [] from jinja2 import Environment, FileSystemLoader env = Environment(loader=FileSystemLoader("app/templates")) sidebar = _sidebar_data(request, repos) template = env.get_template("dashboard.html") return template.render(request=request, repos=repos, search=search, show_archived=show_archived, **sidebar) # ═══════════ Workspace ═══════════ @router.get("/workspace", response_class=HTMLResponse) async def workspace_page(request: Request): """Unified workspace showing all projects.""" from jinja2 import Environment, FileSystemLoader env = Environment(loader=FileSystemLoader("app/templates")) sidebar = _sidebar_data(request, []) user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) if not user: return RedirectResponse("/auth/login?provider=local", status_code=302) ctx = {**sidebar, "user": user} template = env.get_template("workspace.html") return template.render(**ctx) @router.get("/api/workspace/projects") async def list_workspace_projects(request: Request): """List all projects: built-in + Gitea + GitHub.""" builtin = [] with get_conn() as conn: rows = conn.execute( "SELECT id, title FROM pages WHERE parent_id IS NULL ORDER BY updated_at DESC LIMIT 20" ).fetchall() for r in rows: count = conn.execute("SELECT COUNT(*) FROM pages WHERE parent_id=?", (r["id"],)).fetchone()[0] builtin.append({"id": str(r["id"]), "name": r["title"] or "Untitled", "pageCount": count, "forge": "builtin"}) gitea_repos = [] try: repos = await gitea.get_user_repos(page=1, limit=50) for repo in repos: gitea_repos.append({ "id": str(repo.get("id", "")), "name": repo.get("name", ""), "full_name": repo.get("full_name", ""), "description": repo.get("description", ""), "html_url": repo.get("html_url", ""), "language": repo.get("language", ""), "forge": "gitea", }) except Exception: pass return {"builtin": builtin, "gitea": gitea_repos, "github": []} @router.post("/api/workspace/projects") async def create_workspace_project(request: Request): import json body = await request.json() name = body.get("name", "").strip() if not name: return {"error": "Name required"} with get_conn() as conn: cursor = conn.execute( "INSERT INTO pages (workspace, title, content, content_format, parent_section) VALUES ('', ?, '', 'blocks', 'Private')", (name,), ) conn.commit() pid = cursor.lastrowid return {"id": pid, "name": name, "forge": "builtin"}