diff --git a/app/routers/board.py b/app/routers/board.py index 45f3c20..e108bfd 100644 --- a/app/routers/board.py +++ b/app/routers/board.py @@ -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"}) diff --git a/app/routers/dashboard.py b/app/routers/dashboard.py index 623e257..44f8a22 100644 --- a/app/routers/dashboard.py +++ b/app/routers/dashboard.py @@ -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.""" diff --git a/app/templates/_header.html b/app/templates/_header.html index 2ac9cc5..b6eede6 100644 --- a/app/templates/_header.html +++ b/app/templates/_header.html @@ -1,11 +1,38 @@ -{# ── Unified Header (Notion-inspired) ── +{# ── Unified Header (Notion-inspired breadcrumb navigation) ── Parameters available in calling context: - breadcrumb_items: list of {label: str, url: str|None} — last item = current page + breadcrumb_items: list of {label, url, id, icon, menu} — last item = current page page_icon: str — emoji icon + page_title: str — fallback current-page label when no breadcrumb_items right_actions: str — raw HTML for right-side action buttons - hamburger_click: str — Alpine expression for hamburger button (default: opens sidebar) + hamburger_click: str — Alpine expression for hamburger button hide_hamburger: bool — if truthy, hides the hamburger button + hide_home: bool — if truthy, do not prepend the "Home" crumb + nav_workspace_id: int — workspace id used to fetch the navigation menu + nav_page_id: int — current page id (enables the sibling nav menu) #} +{% set ns = namespace(items=[]) %} +{% if not hide_home %} + {% set ns.items = ns.items + [{"label": "Home", "url": "/workspaces", "id": none, "icon": "🏠", "menu": false, "home": true}] %} +{% endif %} +{% if breadcrumb_items %} + {% for item in breadcrumb_items %} + {% set ns.items = ns.items + [{ + "label": item.get('label') or item.get('name') or 'Untitled', + "url": item.get('url'), + "id": item.get('id'), + "icon": item.get('icon'), + "menu": (item.get('id') is not none) + }] %} + {% endfor %} +{% elif page_title %} + {% set ns.items = ns.items + [{ + "label": page_title, + "url": none, + "id": nav_page_id if nav_page_id is defined else none, + "icon": page_icon if page_icon is defined else none, + "menu": (nav_page_id is defined and nav_page_id) + }] %} +{% endif %}
{% if not hide_hamburger %}
+ + diff --git a/app/templates/gitea_workspace.html b/app/templates/gitea_workspace.html index f6455e1..2a7e383 100644 --- a/app/templates/gitea_workspace.html +++ b/app/templates/gitea_workspace.html @@ -4,7 +4,7 @@ {% block page_icon %}🔗{% endblock %} {% block topbar %} -{% set breadcrumb_items = [{"label": "Home", "url": "/workspaces"}, {"label": owner ~ "/" ~ repo, "url": None}] %} +{% set breadcrumb_items = [{"label": owner ~ "/" ~ repo, "url": None}] %} {% set page_icon = "🔗" %} {% set right_actions = '⚙' %} {% include '_header.html' %} diff --git a/app/templates/workspaces.html b/app/templates/workspaces.html index f49ce71..f793541 100644 --- a/app/templates/workspaces.html +++ b/app/templates/workspaces.html @@ -4,7 +4,7 @@ {% block page_icon %}🏠{% endblock %} {% block topbar %} -{% set breadcrumb_items = [{"label": "Home", "url": "/workspaces"}, {"label": "Workspaces", "url": None}] %} +{% set breadcrumb_items = [{"label": "Workspaces", "url": None}] %} {% set page_icon = "🏠" %} {% set right_actions = '🔑⚙' %} {% include '_header.html' %} diff --git a/flowdeck.db b/flowdeck.db index 4263a52..5a0d357 100644 Binary files a/flowdeck.db and b/flowdeck.db differ diff --git a/images/menu-path/menu-path-01.png b/images/menu-path/menu-path-01.png new file mode 100644 index 0000000..07bca6c Binary files /dev/null and b/images/menu-path/menu-path-01.png differ diff --git a/images/menu-path/menu-path-02.png b/images/menu-path/menu-path-02.png new file mode 100644 index 0000000..e130e19 Binary files /dev/null and b/images/menu-path/menu-path-02.png differ diff --git a/images/menu-path/menu-path-03.png b/images/menu-path/menu-path-03.png new file mode 100644 index 0000000..e60232f Binary files /dev/null and b/images/menu-path/menu-path-03.png differ diff --git a/images/menu-path/menu-path-04.png b/images/menu-path/menu-path-04.png new file mode 100644 index 0000000..0a23a04 Binary files /dev/null and b/images/menu-path/menu-path-04.png differ diff --git a/images/menu-path/menu-path-05.png b/images/menu-path/menu-path-05.png new file mode 100644 index 0000000..3e6ead1 Binary files /dev/null and b/images/menu-path/menu-path-05.png differ diff --git a/images/menu-path/menu-path-06.png b/images/menu-path/menu-path-06.png new file mode 100644 index 0000000..5ca967d Binary files /dev/null and b/images/menu-path/menu-path-06.png differ diff --git a/static/css/app.css b/static/css/app.css index dde004c..7a77c53 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -843,13 +843,25 @@ input, select, textarea { font-family: inherit; color: inherit; } .header-breadcrumb { display: flex; align-items: center; - gap: 4px; + gap: 2px; font-size: 14px; - overflow: hidden; + overflow: visible; flex: 1; min-width: 0; } +.crumb-cell { + display: inline-flex; + align-items: center; + min-width: 0; +} + +.crumb-anchor { + position: relative; + display: inline-flex; + align-items: center; +} + .header-page-icon { font-size: 16px; flex-shrink: 0; @@ -861,7 +873,16 @@ input, select, textarea { font-family: inherit; color: inherit; } text-decoration: none; white-space: nowrap; flex-shrink: 0; - padding: 2px 4px; + max-width: 220px; + overflow: hidden; + text-overflow: ellipsis; + padding: 2px 6px; + border: none; + background: transparent; + font: inherit; + font-size: 14px; + line-height: 1.4; + cursor: pointer; border-radius: 4px; transition: background 100ms, color 100ms; } @@ -876,11 +897,17 @@ input, select, textarea { font-family: inherit; color: inherit; } opacity: 0.6; } +.crumb-ellipsis { + font-weight: 600; + letter-spacing: 1px; +} + .breadcrumb-sep { color: var(--text-tertiary); flex-shrink: 0; - font-size: 12px; + font-size: 13px; margin: 0 1px; + user-select: none; } .breadcrumb-current { @@ -891,6 +918,8 @@ input, select, textarea { font-family: inherit; color: inherit; } text-overflow: ellipsis; } +button.breadcrumb-current { max-width: 260px; } + .header-actions { display: flex; align-items: center; @@ -898,6 +927,80 @@ input, select, textarea { font-family: inherit; color: inherit; } flex-shrink: 0; } +/* ── Breadcrumb navigation dropdown (Notion-style) ── */ +.fd-nav-menu { + position: absolute; + top: calc(100% + 4px); + left: 0; + min-width: 240px; + max-width: 320px; + overflow: visible; + background: var(--bg-modal, #fff); + border: 1px solid var(--border); + border-radius: var(--radius-lg, 8px); + box-shadow: var(--shadow-popover, 0 6px 24px rgba(0,0,0,0.25)); + z-index: 2000; + padding: 6px; +} + +.fd-nav-submenu { + top: -6px; + left: calc(100% + 2px); +} + +.fd-nav-section { + padding: 4px 8px 6px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.02em; + color: var(--text-tertiary); + text-transform: none; +} + +.fd-nav-empty { + padding: 8px 10px; + font-size: 13px; + color: var(--text-tertiary); +} + +.fd-nav-item { + position: relative; + display: flex; + align-items: center; + gap: 8px; + padding: 6px 8px; + border-radius: 6px; + font-size: 14px; + color: var(--text-primary); + cursor: pointer; + white-space: nowrap; + transition: background 100ms; +} + +.fd-nav-item:hover { + background: var(--bg-hover); +} + +.fd-nav-ico { + font-size: 16px; + width: 20px; + text-align: center; + flex-shrink: 0; +} + +.fd-nav-label { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; +} + +.fd-nav-arrow { + color: var(--text-tertiary); + font-size: 16px; + flex-shrink: 0; + margin-left: 4px; +} + /* ── Dropdown panel (reusable) ── */ .dropdown-panel { position: absolute; diff --git a/tests/test_app.py b/tests/test_app.py index d487117..547596a 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1847,3 +1847,70 @@ def test_gitea_private_pages_create_no_auth(client): """POST private-pages — 401 without session.""" resp = client.post("/api/gitea/projects/owner/repo/private-pages", json={"title": "Test"}) assert resp.status_code == 401 + + +# ── v2.6.0: Notion-style breadcrumb navigation ── + +def test_nav_menu_endpoint_responds(client): + """GET /api/nav/menu — always returns an items list (200).""" + resp = client.get("/api/nav/menu") + assert resp.status_code == 200 + assert "items" in resp.json() + + +def test_nav_menu_workspace_pages(client): + """/api/nav/menu returns root pages and nested children for a workspace.""" + from app.db import get_conn + with get_conn() as conn: + u = conn.execute("INSERT OR IGNORE INTO users (login) VALUES ('navuser')") + uid = conn.execute("SELECT id FROM users WHERE login='navuser'").fetchone()["id"] + cur = conn.execute("INSERT INTO workspaces (name, owner_id) VALUES ('NavWS', ?)", (uid,)) + ws_id = cur.lastrowid + p = conn.execute( + "INSERT INTO pages (workspace, workspace_id, title, content, content_format, " + "parent_section, parent_id) VALUES ('NavWS', ?, 'Parent Page', '', 'blocks', 'Private', NULL)", + (ws_id,), + ) + parent_id = p.lastrowid + conn.execute( + "INSERT INTO pages (workspace, workspace_id, title, content, content_format, " + "parent_section, parent_id) VALUES ('NavWS', ?, 'Child Page', '', 'blocks', 'Private', ?)", + (ws_id, parent_id), + ) + conn.commit() + + # Root level should list the parent and flag it as having children + resp = client.get(f"/api/nav/menu?workspace_id={ws_id}") + assert resp.status_code == 200 + items = resp.json()["items"] + names = {i["name"]: i for i in items} + assert "Parent Page" in names + assert names["Parent Page"]["has_children"] is True + + # Children of the parent should include the child page + resp = client.get(f"/api/nav/menu?workspace_id={ws_id}&parent_id={parent_id}") + child_names = {i["name"] for i in resp.json()["items"]} + assert "Child Page" in child_names + + +def test_page_renders_breadcrumb_data(client): + """Rendered page includes the breadcrumb JSON payload with a Home crumb.""" + from app.db import get_conn + with get_conn() as conn: + conn.execute("INSERT OR IGNORE INTO users (login) VALUES ('crumbuser')") + uid = conn.execute("SELECT id FROM users WHERE login='crumbuser'").fetchone()["id"] + cur = conn.execute("INSERT INTO workspaces (name, owner_id) VALUES ('CrumbWS', ?)", (uid,)) + ws_id = cur.lastrowid + p = conn.execute( + "INSERT INTO pages (workspace, workspace_id, title, content, content_format, " + "parent_section, parent_id) VALUES ('CrumbWS', ?, 'Crumb Page', '', 'blocks', 'Private', NULL)", + (ws_id,), + ) + page_id = p.lastrowid + conn.commit() + + resp = client.get(f"/pages/{page_id}") + assert resp.status_code == 200 + assert 'id="fd-breadcrumb-data"' in resp.text + assert '"Home"' in resp.text + assert "fdBreadcrumb" in resp.text