feat: add Notion-style breadcrumb navigation with dropdown menus
FlowDeck CI / test (push) Failing after 5s
FlowDeck CI / docker (push) Has been skipped

Backend:
- Added _nav_breadcrumb() to build page hierarchy chain (root → current)
- New /api/nav/menu endpoint returns workspace/folder children with has_children flag
- view_page() and view_page_root() now pass breadcrumb_items, nav_workspace_id, nav_page_id to template

Header template (_header.html):
- Replaced static breadcrumb with Alpine.js fdBreadcrumb() component
- Breadcrumb items now clickable with dropdown menus showing
This commit is contained in:
2026-07-17 23:30:59 -04:00
parent 6f1eabf91d
commit d751415308
14 changed files with 471 additions and 27 deletions
+7 -1
View File
@@ -965,10 +965,16 @@ async def view_page(request: Request, page_id: int):
page_data["file_size"] = file_size
page_data["file_name"] = filename
from app.routers.dashboard import _nav_breadcrumb
with get_conn() as conn:
nav_crumbs = _nav_breadcrumb(conn, page_id)
ctx = {**sidebar, "page": page, "page_favorited": fav is not None,
"page_share_mode": page.get("share_mode", "private"),
"page_published": bool(page.get("published", 0)),
"page_data": page_data}
"page_data": page_data,
"breadcrumb_items": nav_crumbs,
"nav_workspace_id": page.get("workspace_id") or 0,
"nav_page_id": page_id}
template = env.get_template("page_editor.html")
response = template.render(**ctx)
return HTMLResponse(content=response, headers={"Cache-Control": "no-store, max-age=0"})
+99 -1
View File
@@ -264,11 +264,16 @@ async def view_page_root(request: Request, page_id: int):
page_data["file_size"] = file_size
page_data["file_name"] = filename
with get_conn() as conn:
nav_crumbs = _nav_breadcrumb(conn, page_id)
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_data": page_data}
"page_data": page_data,
"breadcrumb_items": nav_crumbs,
"nav_workspace_id": page.get("workspace_id") or 0,
"nav_page_id": page_id}
template = env.get_template("page_editor.html")
response = template.render(**ctx)
return HTMLResponse(content=response, headers={"Cache-Control": "no-store, max-age=0"})
@@ -827,6 +832,99 @@ def _build_breadcrumb(conn, folder_id: int) -> list:
return breadcrumb
def _nav_breadcrumb(conn, page_id: int) -> list:
"""Build a Notion-style breadcrumb chain (root -> page) for the header.
Returns a list of dicts: {id, label, url, icon, menu}. The last item is the
current page (url = None). Every item has ``menu: True`` so the header can
open a sibling-navigation dropdown for it.
"""
from app.routers.board import _file_icon
chain = []
current = page_id
seen = set()
while current and current not in seen:
seen.add(current)
row = conn.execute(
"SELECT id, title, parent_id, parent_section, content_format "
"FROM pages WHERE id=? AND deleted_at IS NULL",
(current,),
).fetchone()
if not row:
break
is_folder = row["parent_section"] == "Workspace"
title = row["title"] or "Untitled"
chain.insert(0, {
"id": row["id"],
"label": title,
"url": None,
"icon": "\U0001F4C1" if is_folder else _file_icon(title, row["content_format"]),
"menu": True,
})
current = row["parent_id"]
# All items except the current page are navigable links.
for i, item in enumerate(chain):
if i < len(chain) - 1:
item["url"] = f"/pages/{item['id']}"
return chain
@router.get("/api/nav/menu")
async def nav_menu(request: Request, workspace_id: int = None, parent_id: int = None):
"""Return the pages at one level for the header breadcrumb navigation menu.
If ``parent_id`` is given, returns that page's children; otherwise the
workspace's root pages. Each item includes ``has_children`` so the frontend
can render an expandable sub-menu.
"""
from app.routers.board import _file_icon
ws_id = workspace_id
if not ws_id:
ws = _get_active_workspace(request, user_id=_get_user_id(request))
ws_id = ws["id"] if ws else None
if not ws_id:
return {"items": []}
with get_conn() as conn:
if parent_id:
rows = conn.execute(
"SELECT id, title, parent_section, content_format FROM pages "
"WHERE parent_id=? AND workspace_id=? AND deleted_at IS NULL "
"ORDER BY sort_order ASC, created_at DESC",
(parent_id, ws_id),
).fetchall()
else:
rows = conn.execute(
"SELECT id, title, parent_section, content_format FROM pages "
"WHERE parent_id IS NULL AND workspace_id=? AND deleted_at IS NULL "
"ORDER BY sort_order ASC, created_at DESC",
(ws_id,),
).fetchall()
ids = [r["id"] for r in rows]
child_counts = {}
if ids:
placeholders = ",".join("?" for _ in ids)
cc_rows = conn.execute(
f"SELECT parent_id, COUNT(*) AS c FROM pages "
f"WHERE parent_id IN ({placeholders}) AND deleted_at IS NULL "
f"GROUP BY parent_id",
ids,
).fetchall()
for cr in cc_rows:
child_counts[cr["parent_id"]] = cr["c"]
items = []
for r in rows:
is_folder = r["parent_section"] == "Workspace"
title = r["title"] or "Untitled"
items.append({
"id": r["id"],
"name": title,
"icon": "\U0001F4C1" if is_folder else _file_icon(title, r["content_format"]),
"has_children": child_counts.get(r["id"], 0) > 0,
"url": f"/pages/{r['id']}",
})
return {"items": items}
@router.post("/api/local-workspace/items")
async def create_local_workspace_item(request: Request):
"""Create a new file in the active workspace."""