Corrige deux régressions de /local-workspace :
- le chemin du header restait bloqué sur « Home / <workspace> » quel que
soit le dossier affiché : la route rend désormais breadcrumb_items
(Home / <workspace> / <dossier> / <sous-dossier>, niveaux cliquables,
collapse « … » au-delà de 4) et la navigation sans rechargement recalcule
le chemin via l'event flowdeck:breadcrumb-changed ;
- le clic sur un dossier du sidebar affichait TOUS les composants à la
fois : Alpine.data('wsInitData') retournait le même objet singleton, le
2e montage (navigation partielle) levait « Cannot redefine property:
\ » et initTree abandonnait, laissant tout le contenu au state
brut. La factory retourne désormais une enveloppe fraîche par montage
qui délègue à l'état réactif partagé. #lw-config est aussi relu à chaque
exécution (le 2e montage gardait le folder_id du 1er chargement).
Inclus également le travail en cours de l'arbre : Library (colonnes Last
visited/Source, ordre d'en-tête, favoris à icônes Workspace), Meeting
Notes (bloc, CSS, routes, docs), coloration de code hljs, badges
favori/publié dans l'arbre local-workspace, docs (DATA_MODEL,
architectures) et tests associés.
308 lines
13 KiB
Python
308 lines
13 KiB
Python
"""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)
|
|
# v7.69.6 : tracer la visite (colonne Library « Last visited », table recents).
|
|
try:
|
|
from datetime import UTC, datetime
|
|
_sess = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
if _sess and _sess.get("id"):
|
|
with get_conn() as _conn:
|
|
_conn.execute(
|
|
"""INSERT INTO recents (user_id, page_id, workspace, source_type, accessed_at)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
ON CONFLICT(user_id, page_id)
|
|
DO UPDATE SET workspace=excluded.workspace,
|
|
accessed_at=excluded.accessed_at""",
|
|
(_sess["id"], page_id, page.get("workspace", "") or "",
|
|
"local", datetime.now(UTC).replace(tzinfo=None).isoformat()),
|
|
)
|
|
_conn.commit()
|
|
except Exception:
|
|
pass
|
|
# 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("is_published", 0) or 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 ═══════════
|