"""FlowDeck — Dashboard : pages_html. Découpe A28 de l'ancien app/routers/dashboard.py (2 735 lignes, 63 routes) — un module par concern, contrat inchangé. """ from __future__ import annotations import logging from fastapi import APIRouter, Query, Request from fastapi.responses import HTMLResponse, RedirectResponse from app.auth.session import SessionManager from app.db import get_conn from ._common import _get_active_workspace, _get_user_id, _nav_breadcrumb, _sidebar_data logger = logging.getLogger(__name__) router = APIRouter(tags=["dashboard"]) @router.get("/trash", response_class=HTMLResponse) def trash_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")): """Trash page — scoped to workspace if owner/repo provided.""" from app.routers.board import _sidebar_data as board_sidebar from app.templating import ENV env = ENV 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 deleted_at (parent_section n'est plus # touché par le trash → source de vérité unique, v7.45.5) if ws_key: rows = conn.execute( "SELECT id, title, workspace FROM pages WHERE deleted_at IS NOT NULL AND workspace=? ORDER BY updated_at DESC", (ws_key,), ).fetchall() else: rows = conn.execute( "SELECT id, title, workspace FROM pages WHERE deleted_at IS NOT NULL ORDER BY updated_at DESC", ).fetchall() sidebar["trash_items"] = [{"id": r["id"], "name": r["title"] or "Untitled", "workspace": r["workspace"]} for r in rows] # Pass active workspace for breadcrumb nav menu ws = _get_active_workspace(request, user_id=_get_user_id(request)) sidebar["nav_workspace_id"] = ws["id"] if ws else 0 template = env.get_template("trash.html") return template.render(**sidebar) @router.get("/library", response_class=HTMLResponse) def library_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")): """Library page — tabbed view (recents, favorites, shared, published, private, workspace). Sidebar data is kept intact. Tab content is loaded client-side via /api/library/* endpoints. """ from app.routers.board import _sidebar_data as board_sidebar from app.templating import ENV env = ENV sidebar = board_sidebar(request, owner, repo) # Pass active workspace for breadcrumb nav menu ws_key_ws = f"{owner}/{repo}" if owner and repo else "" if ws_key_ws: with get_conn() as conn: ws_row = conn.execute("SELECT id FROM workspaces WHERE name=? AND owner_id=?", (ws_key_ws, _get_user_id(request))).fetchone() sidebar["nav_workspace_id"] = ws_row["id"] if ws_row else 0 else: ws = _get_active_workspace(request, user_id=_get_user_id(request)) sidebar["nav_workspace_id"] = ws["id"] if ws else 0 template = env.get_template("library.html") sidebar["active_workspace_id"] = sidebar.get("nav_workspace_id", 0) # Gitea workspace context for Repository tab sidebar["is_gitea_workspace"] = bool(owner and repo) sidebar["gitea_workspace_owner"] = owner sidebar["gitea_workspace_repo"] = repo return template.render(**sidebar) @router.get("/pages/{page_id}", response_class=HTMLResponse) def view_page_root(request: Request, page_id: int): """Render a Markdown page at root level with workspace context — or file viewer. ?embed=1 — minimal mode for side peek (editor only, no header).""" embed = request.query_params.get("embed") == "1" from app.routers.board import _sidebar_data as board_sidebar from app.templating import ENV env = ENV with get_conn() as conn: row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone() if not row: return RedirectResponse("/workspaces", status_code=302) page = dict(row) # v6.5.0: synced blocks resolve server-side at read time. from app.services.synced_blocks import resolve_content_json page["content"] = resolve_content_json(page.get("content", ""), page.get("content_format")) 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() fav = conn.execute( "SELECT id FROM favorites WHERE user_id=? AND page_id=?", (1, page_id), ).fetchone() # Build page_data, including file metadata for uploaded files _locked = bool(page.get("is_locked", 0)) _locked_by = page.get("locked_by") if "locked_by" in page else None _sess_user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) _uid = _sess_user.get("id") if _sess_user and _sess_user.get("id") else None _can_edit = (not _locked) or bool(_sess_user and _sess_user.get("is_admin")) or (_locked_by and _uid and _locked_by == _uid) page_data = {"id": page_id, "title": page.get("title"), "content_format": page.get("content_format", "blocks"), "content": page.get("content", ""), "favorited": fav is not None, "share_mode": page.get("share_mode", "private"), "published": bool(page.get("published", 0)), "is_locked": _locked, "locked_by": _locked_by, "can_edit": _can_edit, "full_width": bool(page.get("full_width", 0)) if "full_width" in page else False, "font_small": bool(page.get("font_small", 0)) if "font_small" in page else False} # For file pages, extract file metadata and add to page_data if page.get("content_format") == "file": import json as _json try: meta = _json.loads(page.get("content", "{}")) except _json.JSONDecodeError: meta = {} file_path = meta.get("file_path", "").replace("\\", "/") mime_type = meta.get("mime_type", "application/octet-stream") file_size = meta.get("size", 0) fp_parts = file_path.split("/") ws_id = "" for p in fp_parts: if p.startswith("workspace_"): ws_id = p.replace("workspace_", "") break filename = fp_parts[-1] if fp_parts else page.get("title", "File") from urllib.parse import quote safe_name = quote(filename, safe='') file_url = f"/api/files/{ws_id}/{safe_name}" if ws_id else "" page_data["file_url"] = file_url page_data["file_mime"] = mime_type page_data["file_size"] = file_size page_data["file_name"] = filename # For collection (database) pages, load collection + properties + pages collection_data = None if page.get("content_format") == "collection" and page.get("collection_id"): with get_conn() as conn: col = conn.execute( "SELECT * FROM collections WHERE id=?", (page["collection_id"],) ).fetchone() if col: props = [ dict(r) for r in conn.execute( "SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position", (page["collection_id"],), ).fetchall() ] cpages = [ dict(r) for r in conn.execute( "SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position", (page["collection_id"],), ).fetchall() ] cviews = [ dict(r) for r in conn.execute( "SELECT * FROM collection_views WHERE collection_id=? ORDER BY position", (page["collection_id"],), ).fetchall() ] collection_data = { "collection": dict(col), "properties": props, "pages": cpages, "views": cviews, } page_data["collection_id"] = page["collection_id"] with get_conn() as conn: nav_crumbs = _nav_breadcrumb(conn, page_id) # dérivé calculé UNE fois : ctx ET page_data (JSON) l'utilisent (A27) page_is_shared = ( bool(page.get("is_shared", 0)) or page.get("share_mode", "private") != "private" or bool(page.get("published", 0)) ) ctx = {**sidebar, "page": page, "sub_pages": [dict(s) for s in subs], "page_favorited": fav is not None, "page_share_mode": page.get("share_mode", "private"), "page_published": bool(page.get("published", 0)), "page_is_shared": page_is_shared, "page_data": page_data, "collection_data": collection_data, "breadcrumb_items": nav_crumbs, "nav_workspace_id": page.get("workspace_id") or 0, "nav_page_id": page_id, "embed_mode": embed} # A27 phase 2 : le JS de l'éditeur lit ces valeurs dans page-data (JSON) # au lieu des interpolations Jinja — une seule source, même calculs que le # ctx ci-dessus. from app.templating import ENV as _ENV27 page_data.update( updated_at=page.get("updated_at", ""), created_at=page.get("created_at", ""), user_id=_uid or 0, is_shared=page_is_shared, clip_icon=_ENV27.from_string( "{% from '_icons.html' import fd_icon %}{{ fd_icon('paperclip', 14) }}" ).render(), ) # Select template: collection pages use database table view (aussi en # mode embed : `page_editor_collection.html` + body.embed-mode = le # tableau SANS sidebar ni barre — le peek d'une base reste le tableau). if page.get("content_format") == "collection": template = env.get_template("page_editor_collection.html") else: template = env.get_template("page_editor_embed.html" if embed else "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) def accounts_page(request: Request): """Account management panel.""" from app.templating import ENV env = ENV sidebar = _sidebar_data(request, []) user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) with get_conn() as conn: users = conn.execute( "SELECT id, login, full_name, email, avatar_url, avatar_color, " "is_admin, is_active, created_at 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("/help", response_class=HTMLResponse) def help_page(request: Request): """Help & documentation page — settings-style panel with side navigation.""" from app.templating import ENV sidebar = _sidebar_data(request, []) return ENV.get_template("help.html").render(**sidebar, request=request) @router.get("/accounts/settings", response_class=HTMLResponse) def settings_page(request: Request): """User settings page — profile, forges, tokens.""" from app.templating import ENV env = ENV 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 ═══════════