"""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="""

❓ FlowDeck Help

Everything you need to know about your Notion-style workspace with Gitea & GitHub integration.

🚀Getting Started

FlowDeck is your private, self-hosted workspace. Create pages, organize projects, and integrate with your Git forge.

  • Create a workspace from the Workspaces page
  • Click 📄 New Page in the sidebar to start writing
  • Use Ctrl+N anywhere to create a page

📝Pages & Editor

Notion-style block editor with slash commands, markdown shortcuts, and rich formatting.

  • Type / for the slash command menu
  • Drag & drop pages in the sidebar to reorganize
  • Right-click for context menu (duplicate, rename, delete)

{{ fd_icon("folder",16) }}Workspaces

Organize your work into separate workspaces. Each has its own pages and files.

  • Local Files stored on your server
  • Gitea Connect to browse & edit repos
  • GitHub Connect via Settings → Integrations

🦎Gitea Integration

Connect your Gitea account to access repositories directly from FlowDeck.

  • Go to Settings → Integrations to connect
  • Browse repo file trees in the sidebar
  • Create & edit files with commit messages
  • Sync labels as tags

🌐Sharing & Publishing

Share pages with collaborators or publish them to the web.

  • Click Share in the page editor top-right
  • Share with specific users or get a public link
  • Publish to make a page visible at /p/your-slug

📚Library, Trash & Tasks

Find all your content in one place with powerful filtering.

  • Library — Tabs for Recents, Favorites, Shared, Published
  • Trash — Soft-deleted pages (30-day retention)
  • My Tasks — Aggregated tasks from all collections

📶Offline & PWA

Install FlowDeck as an app and keep working without a connection.

  • Install — browser menu → Install app / Add to Home Screen
  • Edits made offline are queued locally and synced automatically
  • A ⟳ marker shows pages with pending changes

⌨️ Keyboard Shortcuts

Ctrl+N
Create new page
Ctrl+K
Quick find / command palette
Ctrl+S
Save current page
F2
Rename selected item
Delete
Move selected item to trash
Escape
Close modal / cancel editing
Enter
Open selected page
Ctrl+O
New AI chat (in footer)

🔐 Authentication

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).

📶 Offline mode (PWA)

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)”.

🔌 API publique v2

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).

💡 Tips

• 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.

""" )) @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 ═══════════