"""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 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] # 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 if page.get("content_format") == "collection" and not embed: 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): """Comprehensive help & documentation page.""" from app.templating import ENV env = ENV sidebar = _sidebar_data(request, []) # Render via a block-based template so content_html lands in {% block content %} block_tpl = env.from_string( '{% extends "base.html" %}{% block content %}{{ content_html|safe }}{% endblock %}' ) return HTMLResponse(block_tpl.render( **sidebar, request=request, page_title="Help", title_prefix="Help", page_icon="❓", content_html="""
Everything you need to know about your Notion-style workspace with Gitea & GitHub integration.
FlowDeck is your private, self-hosted workspace. Create pages, organize projects, and integrate with your Git forge.
Notion-style block editor with slash commands, markdown shortcuts, and rich formatting.
Organize your work into separate workspaces. Each has its own pages and files.
Connect your Gitea account to access repositories directly from FlowDeck.
Share pages with collaborators or publish them to the web.
/p/your-slugFind all your content in one place with powerful filtering.
Install FlowDeck as an app and keep working without a connection.
FlowDeck supports three authentication methods:
Local Email + password — create an account on the login page.
Gitea OAuth Login with your Gitea account. Your repos appear as workspaces.
GitHub OAuth Login or link your GitHub account in Settings → Integrations.
Tip: You can connect Gitea/GitHub to an existing local account — your identity stays as your local user.
SSO Enterprise SSO (v6.7.0) — sign in with your organization account.
For administrators: open Settings → Admin → SSO / Enterprise and pick a provider:
• SAML 2.0 — paste the IdP Entity ID, SSO URL and signing certificate, then give the IdP this
/auth/saml/metadata link (it contains the SP Entity ID, ACS URL and certificate).
• OpenID Connect — paste the Issuer URL, Client ID and Client Secret (PKCE is used, scopes default to openid profile email).
• Provisioning — accounts are created automatically on first login, groups from the IdP map to workspace roles,
and SSO only disables local login (admins keep their local door). Every attempt is audited in
Settings → Admin → SSO / Enterprise (login history).
FlowDeck is a Progressive Web App: pages you visited stay available offline and your
edits are saved locally, then synchronised when the connection returns.
Install: open your browser menu and choose Install app (Chrome/Edge) or
Add to Home Screen (Safari/iOS). FlowDeck then opens in its own window.
Offline editing: while offline, the editor stores changes in the browser
(IndexedDB) and shows an offline banner with the number of pending changes. A
⟳ icon appears next to pages that have unsynced edits.
Reconnection: the queue is replayed automatically (and via Background Sync).
A spinner badge appears while syncing, followed by a confirmation toast.
Conflicts: if a page changed on the server, the latest edit wins and a notice is
shown. If a page was deleted server-side, your offline copy is recreated as an orphan
page. If a page with the same title already exists, the offline copy is renamed
“Title (copie offline)”.
FlowDeck exposes a full REST API under /api/v2 for third-party integrations.
Auth: create a token in Settings → API tokens, then send it as
Authorization: Bearer <token>. Tokens carry scopes
read, write or admin (a higher scope implies the lower ones).
Features: CRUD on collections, pages, properties, views, comments, notifications,
favorites, tags, sharing, sprints and templates; pagination (?limit=&offset= +
X-Total-Count), filters (filter[prop]=value), sorting, full-text search
(/api/v2/search), idempotency (Idempotency-Key) and RFC 7807 error bodies.
Reference: interactive OpenAPI docs at /docs
(also /redoc, docs/openapi-v2.json).
• Toggle the sidebar with the « button in the top-left corner.
• Switch between workspaces using the dropdown menu in the sidebar header.
• The Private section appears when a remote workspace is active — files here stay local.
• Hover over any sidebar item to see action buttons (favorite, share, delete).
• Use Ctrl+Click or Shift+Click to multi-select items in the sidebar.