From 755404fbb0f32e84dea14ba11832d169bba7443f Mon Sep 17 00:00:00 2001 From: bruno Date: Wed, 8 Jul 2026 22:27:09 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20sidebar=20tree=20navigation=20=E2=80=94?= =?UTF-8?q?=20toggle=20chevrons=20+=20drag=20&=20drop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add sort_order column to pages table for drag & drop reordering - Rewrite _sidebar_data with recursive _build_page_tree for nested hierarchy - Add tree toggle buttons (โ–ถ collapsed / โ–ผ expanded) on parent items - Alpine.js expandedNodes state with toggleTreeNode() for expand/collapse - Jinja2 recursive macro render_tree_item for proper nesting + indentation - SortableJS integration on .sidebar-items for drag & drop repositioning - New PUT /board/api/pages/{id}/move API for persisting moves - Auto-set sort_order on page creation (max+1 for same parent) - CSS: .tree-toggle, .tree-toggle-placeholder, grab cursor on page-icon - 28/28 tests pass, all endpoints healthy --- app/db.py | 5 ++ app/routers/board.py | 106 ++++++++++++++++++++++------ app/routers/dashboard.py | 3 + app/templates/base.html | 147 +++++++++++++++++++++++++++++---------- static/css/app.css | 51 ++++++++++++-- 5 files changed, 249 insertions(+), 63 deletions(-) diff --git a/app/db.py b/app/db.py index 72accfc..ded9473 100644 --- a/app/db.py +++ b/app/db.py @@ -147,6 +147,11 @@ def init_db(): conn.execute("ALTER TABLE pages ADD COLUMN parent_id INTEGER REFERENCES pages(id)") except sqlite3.OperationalError: pass + # Migration: add sort_order for drag & drop tree reordering (v1.1.0+) + try: + conn.execute("ALTER TABLE pages ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0") + except sqlite3.OperationalError: + pass conn.commit() diff --git a/app/routers/board.py b/app/routers/board.py index 5540c5e..8175286 100644 --- a/app/routers/board.py +++ b/app/routers/board.py @@ -115,6 +115,31 @@ def _map_issue_to_card(issue: dict, owner: str = "", repo: str = "") -> dict: } +def _build_page_tree(conn, parent_id: int | None, ws_key: str, depth: int = 0, max_depth: int = 3) -> list[dict]: + """Build nested page tree recursively. max_depth prevents infinite recursion.""" + if depth >= max_depth: + return [] + rows = conn.execute( + "SELECT id, title, updated_at FROM pages WHERE workspace=? AND parent_id IS ? ORDER BY sort_order ASC, updated_at DESC", + (ws_key, parent_id), + ).fetchall() + items = [] + for row in rows: + children = _build_page_tree(conn, row["id"], ws_key, depth + 1, max_depth) + items.append({ + "id": f"page/{row['id']}", + "db_id": row["id"], + "name": row["title"], + "icon": "๐Ÿ“„", + "url": f"/pages/{row['id']}", + "active": False, + "depth": depth, + "has_children": len(children) > 0, + "children": children, + }) + return items + + def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict: user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) ws_name = user.get("login", "Bruno") if user else "Bruno" @@ -134,32 +159,19 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict: "name": label, "icon": "๐Ÿ“" if first else "ยท", "url": f"/board/{owner}/{repo}?view={view}", "active": active, "indent": indent, + "depth": indent, "has_children": False, "children": [], }) first = False - # Load pages for this project workspace + # Load pages as nested tree for this project workspace with get_conn() as conn: - pages = conn.execute( - "SELECT id, title, parent_id, updated_at FROM pages WHERE workspace=? AND parent_id IS NULL ORDER BY updated_at DESC LIMIT 10", - (ws_key,), - ).fetchall() - for p in pages: - recent.append({ - "id": f"page/{p['id']}", "name": p["title"], "icon": "๐Ÿ“„", - "url": f"/pages/{p['id']}", "active": False, "indent": 0, - }) - # Load sub-pages - subs = conn.execute( - "SELECT id, title FROM pages WHERE parent_id=? ORDER BY updated_at DESC", - (p["id"],), - ).fetchall() - for sp in subs: - recent.append({ - "id": f"page/{sp['id']}", "name": sp["title"], "icon": "ยท", - "url": f"/pages/{sp['id']}", "active": False, "indent": 1, - }) + tree_pages = _build_page_tree(conn, None, ws_key) + for p in tree_pages: + recent.append(p) + # Private pages: same as recent but filtered for page/ items (non-board views) + private_items = [r for r in recent if r.get("active") or r["id"].startswith("page/")] return {"workspace_name": ws_name, "workspace_initial": ws_name[0].upper() if ws_name else "B", "current_page": repo or "Dashboard", "last_edited": "now", - "recent_pages": recent, "private_pages": [r for r in recent if r.get("active") or r["id"].startswith("page/")], + "recent_pages": recent, "private_pages": private_items, "favorite_pages": [], "user": user, "workspace_key": ws_key} @@ -455,9 +467,18 @@ async def create_page(request: Request, title: str = Query(default="New page"), user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) ws_key = project if project else (user.get("login", "Bruno") if user else "Bruno") with get_conn() as conn: + # Compute next sort_order for this parent + next_order = 0 + parent_val = parent_id if parent_id > 0 else None + row = conn.execute( + "SELECT COALESCE(MAX(sort_order), -1) + 1 FROM pages WHERE workspace=? AND parent_id IS ?", + (ws_key, parent_val), + ).fetchone() + if row: + next_order = row[0] cur = conn.execute( - "INSERT INTO pages (workspace, title, parent_section, parent_id) VALUES (?,?,?,?)", - (ws_key, title, section, parent_id if parent_id > 0 else None), + "INSERT INTO pages (workspace, title, parent_section, parent_id, sort_order) VALUES (?,?,?,?,?)", + (ws_key, title, section, parent_val, next_order), ) conn.commit() page_id = cur.lastrowid @@ -487,6 +508,45 @@ async def update_page(page_id: int, title: str = Query(default=""), return {"status": "ok"} +@router.put("/api/pages/{page_id}/move") +async def move_page(request: Request, page_id: int, + new_parent_id: int = Query(default=0), + new_order: int = Query(default=0)): + """Move/reorder a page in the tree (drag & drop). + new_parent_id=0 means move to root level. + new_order=0 means append to end (or set explicit position).""" + with get_conn() as conn: + # Verify page exists + row = conn.execute("SELECT id, workspace FROM pages WHERE id=?", (page_id,)).fetchone() + if not row: + raise HTTPException(404, "Page not found") + # Update parent (None for root level) + conn.execute( + "UPDATE pages SET parent_id=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", + (new_parent_id if new_parent_id > 0 else None, page_id), + ) + # Update sort order + conn.execute( + "UPDATE pages SET sort_order=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", + (new_order, page_id), + ) + # Re-index siblings to ensure no gaps + siblings = conn.execute( + "SELECT id, sort_order FROM pages WHERE workspace=? AND parent_id IS ? AND id != ? ORDER BY sort_order ASC", + (row["workspace"], new_parent_id if new_parent_id > 0 else None, page_id), + ).fetchall() + # Recompute sort_order for all siblings after the insertion point + insert_point = new_order + reorder = [] + for sib in siblings: + if sib["sort_order"] >= insert_point: + reorder.append(sib["id"]) + for i, sid in enumerate(sorted(reorder, key=lambda x: next(s["sort_order"] for s in siblings if s["id"] == x))): + conn.execute("UPDATE pages SET sort_order=? WHERE id=?", (insert_point + i + 1, sid)) + conn.commit() + return {"status": "ok", "id": page_id, "parent_id": new_parent_id, "order": new_order} + + @router.get("/pages/{page_id}", response_class=HTMLResponse) async def view_page(request: Request, page_id: int): """Render a page as HTML.""" diff --git a/app/routers/dashboard.py b/app/routers/dashboard.py index cb6b296..1ae25de 100644 --- a/app/routers/dashboard.py +++ b/app/routers/dashboard.py @@ -29,6 +29,9 @@ def _sidebar_data(request: Request, repos: list[dict]) -> dict: "url": f"/board/{full_name}", "active": False, "indent": 0, + "depth": 0, + "has_children": False, + "children": [], }) return { diff --git a/app/templates/base.html b/app/templates/base.html index 17a8b70..d1366e5 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -12,8 +12,45 @@
+{# โ”€โ”€โ”€ Recursive tree macro โ”€โ”€โ”€ #} +{% macro render_tree_item(page, depth=0) %} + {% set indent_px = depth * 16 %} + + {% if page.has_children %} +
  • + +
  • + {% endif %} +{% endmacro %} + -
    -