feat: add Notion-style breadcrumb navigation with dropdown menus
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:
@@ -965,10 +965,16 @@ async def view_page(request: Request, page_id: int):
|
|||||||
page_data["file_size"] = file_size
|
page_data["file_size"] = file_size
|
||||||
page_data["file_name"] = filename
|
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,
|
ctx = {**sidebar, "page": page, "page_favorited": fav is not None,
|
||||||
"page_share_mode": page.get("share_mode", "private"),
|
"page_share_mode": page.get("share_mode", "private"),
|
||||||
"page_published": bool(page.get("published", 0)),
|
"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")
|
template = env.get_template("page_editor.html")
|
||||||
response = template.render(**ctx)
|
response = template.render(**ctx)
|
||||||
return HTMLResponse(content=response, headers={"Cache-Control": "no-store, max-age=0"})
|
return HTMLResponse(content=response, headers={"Cache-Control": "no-store, max-age=0"})
|
||||||
|
|||||||
@@ -264,11 +264,16 @@ async def view_page_root(request: Request, page_id: int):
|
|||||||
page_data["file_size"] = file_size
|
page_data["file_size"] = file_size
|
||||||
page_data["file_name"] = filename
|
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],
|
ctx = {**sidebar, "page": page, "sub_pages": [dict(s) for s in subs],
|
||||||
"page_favorited": fav is not None,
|
"page_favorited": fav is not None,
|
||||||
"page_share_mode": page.get("share_mode", "private"),
|
"page_share_mode": page.get("share_mode", "private"),
|
||||||
"page_published": bool(page.get("published", 0)),
|
"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")
|
template = env.get_template("page_editor.html")
|
||||||
response = template.render(**ctx)
|
response = template.render(**ctx)
|
||||||
return HTMLResponse(content=response, headers={"Cache-Control": "no-store, max-age=0"})
|
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
|
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")
|
@router.post("/api/local-workspace/items")
|
||||||
async def create_local_workspace_item(request: Request):
|
async def create_local_workspace_item(request: Request):
|
||||||
"""Create a new file in the active workspace."""
|
"""Create a new file in the active workspace."""
|
||||||
|
|||||||
+189
-19
@@ -1,11 +1,38 @@
|
|||||||
{# ── Unified Header (Notion-inspired) ──
|
{# ── Unified Header (Notion-inspired breadcrumb navigation) ──
|
||||||
Parameters available in calling context:
|
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_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
|
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_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 %}
|
||||||
<header class="topbar unified-header">
|
<header class="topbar unified-header">
|
||||||
{% if not hide_hamburger %}
|
{% if not hide_hamburger %}
|
||||||
<button class="hamburger-btn"
|
<button class="hamburger-btn"
|
||||||
@@ -19,29 +46,172 @@
|
|||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="topbar-left header-breadcrumb">
|
<script type="application/json" id="fd-breadcrumb-data">{{ {"workspace_id": nav_workspace_id|default(0), "crumbs": ns.items}|tojson }}</script>
|
||||||
|
|
||||||
|
<div class="topbar-left header-breadcrumb" x-data="fdBreadcrumb()" x-init="init()" @click.outside="closeAll()">
|
||||||
{% if page_icon %}
|
{% if page_icon %}
|
||||||
<span class="header-page-icon">{{ page_icon }}</span>
|
<span class="header-page-icon">{{ page_icon }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if breadcrumb_items %}
|
<template x-for="(crumb, di) in display" :key="di">
|
||||||
{% for item in breadcrumb_items %}
|
<span class="crumb-cell">
|
||||||
{% if item.url and not loop.last %}
|
{# ── Collapsed "…" segment ── #}
|
||||||
<a href="{{ item.url }}" class="breadcrumb-link">{{ item.label }}</a>
|
<template x-if="crumb.ellipsis">
|
||||||
<span class="breadcrumb-sep">/</span>
|
<span class="crumb-anchor">
|
||||||
{% elif loop.last %}
|
<button type="button" class="breadcrumb-link crumb-ellipsis" @click.stop="toggleEllipsis()">…</button>
|
||||||
<span class="breadcrumb-current">{{ item.label }}</span>
|
<div class="fd-nav-menu" x-show="ellipsisOpen" x-cloak x-transition.opacity>
|
||||||
{% else %}
|
<template x-for="h in crumb.hidden" :key="h.id">
|
||||||
<span class="breadcrumb-link disabled">{{ item.label }}</span>
|
<div class="fd-nav-item" @click.stop="go(h.url)">
|
||||||
<span class="breadcrumb-sep">/</span>
|
<span class="fd-nav-ico" x-text="h.icon || '📄'"></span>
|
||||||
{% endif %}
|
<span class="fd-nav-label" x-text="h.label"></span>
|
||||||
{% endfor %}
|
</div>
|
||||||
{% else %}
|
</template>
|
||||||
<span class="breadcrumb-current">{{ page_title|default('FlowDeck') }}</span>
|
</div>
|
||||||
{% endif %}
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
{# ── Plain link crumb (Home / non-menu with url) ── #}
|
||||||
|
<template x-if="!crumb.ellipsis && !crumb.menu && crumb.url">
|
||||||
|
<a class="breadcrumb-link" :href="crumb.url" x-text="crumb.label"></a>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
{# ── Plain current crumb (no menu, no url) ── #}
|
||||||
|
<template x-if="!crumb.ellipsis && !crumb.menu && !crumb.url">
|
||||||
|
<span class="breadcrumb-current" x-text="crumb.label"></span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
{# ── Interactive crumb with navigation dropdown ── #}
|
||||||
|
<template x-if="!crumb.ellipsis && crumb.menu">
|
||||||
|
<span class="crumb-anchor">
|
||||||
|
<button type="button" class="breadcrumb-link"
|
||||||
|
:class="{ 'breadcrumb-current': crumb.origIndex === crumbs.length - 1 }"
|
||||||
|
@click.stop="openMenu(crumb.origIndex)"
|
||||||
|
x-text="crumb.label"></button>
|
||||||
|
<div class="fd-nav-menu" x-show="openIdx === crumb.origIndex" x-cloak x-transition.opacity>
|
||||||
|
<div class="fd-nav-section">Private</div>
|
||||||
|
<template x-if="loading">
|
||||||
|
<div class="fd-nav-empty">Loading…</div>
|
||||||
|
</template>
|
||||||
|
<template x-if="!loading && activeItems.length === 0">
|
||||||
|
<div class="fd-nav-empty">No pages</div>
|
||||||
|
</template>
|
||||||
|
<template x-for="item in activeItems" :key="item.id">
|
||||||
|
<div class="fd-nav-item"
|
||||||
|
@mouseenter="openSub(item)"
|
||||||
|
@click.stop="go(item.url)">
|
||||||
|
<span class="fd-nav-ico" x-text="item.icon || '📄'"></span>
|
||||||
|
<span class="fd-nav-label" x-text="item.name"></span>
|
||||||
|
<span class="fd-nav-arrow" x-show="item.has_children">›</span>
|
||||||
|
<div class="fd-nav-menu fd-nav-submenu" x-show="subOpenId === item.id" x-cloak>
|
||||||
|
<template x-for="s in subItems" :key="s.id">
|
||||||
|
<div class="fd-nav-item" @click.stop="go(s.url)">
|
||||||
|
<span class="fd-nav-ico" x-text="s.icon || '📄'"></span>
|
||||||
|
<span class="fd-nav-label" x-text="s.name"></span>
|
||||||
|
<span class="fd-nav-arrow" x-show="s.has_children">›</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<span class="breadcrumb-sep" x-show="di < display.length - 1">/</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="topbar-right header-actions">
|
<div class="topbar-right header-actions">
|
||||||
{{ right_actions|safe if right_actions else '' }}
|
{{ right_actions|safe if right_actions else '' }}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('alpine:init', function () {
|
||||||
|
if (window.Alpine && window.Alpine.__fdBreadcrumbRegistered) return;
|
||||||
|
if (window.Alpine) window.Alpine.__fdBreadcrumbRegistered = true;
|
||||||
|
Alpine.data('fdBreadcrumb', function () {
|
||||||
|
return {
|
||||||
|
crumbs: [],
|
||||||
|
wsId: 0,
|
||||||
|
openIdx: null,
|
||||||
|
ellipsisOpen: false,
|
||||||
|
loading: false,
|
||||||
|
cache: {},
|
||||||
|
activeItems: [],
|
||||||
|
subOpenId: null,
|
||||||
|
subItems: [],
|
||||||
|
init: function () {
|
||||||
|
var el = document.getElementById('fd-breadcrumb-data');
|
||||||
|
if (el) {
|
||||||
|
try {
|
||||||
|
var d = JSON.parse(el.textContent);
|
||||||
|
this.crumbs = (d.crumbs || []).map(function (c, i) { c.origIndex = i; return c; });
|
||||||
|
this.wsId = d.workspace_id || 0;
|
||||||
|
} catch (e) { this.crumbs = []; }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
get display() {
|
||||||
|
var c = this.crumbs;
|
||||||
|
if (c.length <= 4) return c;
|
||||||
|
var head = [c[0], c[1]];
|
||||||
|
var hidden = c.slice(2, c.length - 2);
|
||||||
|
var tail = [c[c.length - 2], c[c.length - 1]];
|
||||||
|
return head.concat([{ ellipsis: true, hidden: hidden }], tail);
|
||||||
|
},
|
||||||
|
parentIdFor: function (origIndex) {
|
||||||
|
if (origIndex <= 0) return null;
|
||||||
|
var prev = this.crumbs[origIndex - 1];
|
||||||
|
return prev && prev.id ? prev.id : null;
|
||||||
|
},
|
||||||
|
fetchMenu: function (parentId) {
|
||||||
|
var key = parentId == null ? 'root' : String(parentId);
|
||||||
|
var self = this;
|
||||||
|
if (this.cache[key]) return Promise.resolve(this.cache[key]);
|
||||||
|
var url = '/api/nav/menu?';
|
||||||
|
if (this.wsId) url += 'workspace_id=' + this.wsId + '&';
|
||||||
|
if (parentId != null) url += 'parent_id=' + parentId;
|
||||||
|
return fetch(url, { credentials: 'same-origin' })
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (d) { self.cache[key] = d.items || []; return self.cache[key]; })
|
||||||
|
.catch(function () { return []; });
|
||||||
|
},
|
||||||
|
openMenu: function (origIndex) {
|
||||||
|
var self = this;
|
||||||
|
if (this.openIdx === origIndex) { this.closeAll(); return; }
|
||||||
|
this.closeAll();
|
||||||
|
this.openIdx = origIndex;
|
||||||
|
this.loading = true;
|
||||||
|
this.fetchMenu(this.parentIdFor(origIndex)).then(function (items) {
|
||||||
|
if (self.openIdx === origIndex) { self.activeItems = items; }
|
||||||
|
self.loading = false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
openSub: function (item) {
|
||||||
|
var self = this;
|
||||||
|
if (!item.has_children) { this.subOpenId = null; this.subItems = []; return; }
|
||||||
|
if (this.subOpenId === item.id) return;
|
||||||
|
this.subOpenId = item.id;
|
||||||
|
this.subItems = [];
|
||||||
|
this.fetchMenu(item.id).then(function (items) {
|
||||||
|
if (self.subOpenId === item.id) { self.subItems = items; }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
toggleEllipsis: function () {
|
||||||
|
var wasOpen = this.ellipsisOpen;
|
||||||
|
this.closeAll();
|
||||||
|
this.ellipsisOpen = !wasOpen;
|
||||||
|
},
|
||||||
|
closeAll: function () {
|
||||||
|
this.openIdx = null;
|
||||||
|
this.ellipsisOpen = false;
|
||||||
|
this.subOpenId = null;
|
||||||
|
this.subItems = [];
|
||||||
|
this.activeItems = [];
|
||||||
|
},
|
||||||
|
go: function (url) { if (url) window.location.href = url; }
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
{% block page_icon %}🔗{% endblock %}
|
{% block page_icon %}🔗{% endblock %}
|
||||||
|
|
||||||
{% block topbar %}
|
{% 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 page_icon = "🔗" %}
|
||||||
{% set right_actions = '<button class="page-action-btn" @click="showNewFile=!showNewFile" title="New file">📄+</button><button class="page-action-btn" @click="triggerUpload()" title="Upload file">📤</button><input type="file" id="gitea-upload-input" style="display:none" @change="doUpload($event)" multiple><button class="page-action-btn" @click="refreshTree()" title="Refresh">🔄</button><a href="/accounts/settings" class="topbar-btn" title="Settings">⚙</a>' %}
|
{% set right_actions = '<button class="page-action-btn" @click="showNewFile=!showNewFile" title="New file">📄+</button><button class="page-action-btn" @click="triggerUpload()" title="Upload file">📤</button><input type="file" id="gitea-upload-input" style="display:none" @change="doUpload($event)" multiple><button class="page-action-btn" @click="refreshTree()" title="Refresh">🔄</button><a href="/accounts/settings" class="topbar-btn" title="Settings">⚙</a>' %}
|
||||||
{% include '_header.html' %}
|
{% include '_header.html' %}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
{% block page_icon %}🏠{% endblock %}
|
{% block page_icon %}🏠{% endblock %}
|
||||||
|
|
||||||
{% block topbar %}
|
{% block topbar %}
|
||||||
{% set breadcrumb_items = [{"label": "Home", "url": "/workspaces"}, {"label": "Workspaces", "url": None}] %}
|
{% set breadcrumb_items = [{"label": "Workspaces", "url": None}] %}
|
||||||
{% set page_icon = "🏠" %}
|
{% set page_icon = "🏠" %}
|
||||||
{% set right_actions = '<a href="/auth/login?provider=local" class="topbar-btn" title="Login">🔑</a><a href="/accounts/settings" class="topbar-btn" title="Settings">⚙</a>' %}
|
{% set right_actions = '<a href="/auth/login?provider=local" class="topbar-btn" title="Login">🔑</a><a href="/accounts/settings" class="topbar-btn" title="Settings">⚙</a>' %}
|
||||||
{% include '_header.html' %}
|
{% include '_header.html' %}
|
||||||
|
|||||||
BIN
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
+107
-4
@@ -843,13 +843,25 @@ input, select, textarea { font-family: inherit; color: inherit; }
|
|||||||
.header-breadcrumb {
|
.header-breadcrumb {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 2px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
overflow: hidden;
|
overflow: visible;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
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 {
|
.header-page-icon {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -861,7 +873,16 @@ input, select, textarea { font-family: inherit; color: inherit; }
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
flex-shrink: 0;
|
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;
|
border-radius: 4px;
|
||||||
transition: background 100ms, color 100ms;
|
transition: background 100ms, color 100ms;
|
||||||
}
|
}
|
||||||
@@ -876,11 +897,17 @@ input, select, textarea { font-family: inherit; color: inherit; }
|
|||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.crumb-ellipsis {
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
.breadcrumb-sep {
|
.breadcrumb-sep {
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
margin: 0 1px;
|
margin: 0 1px;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.breadcrumb-current {
|
.breadcrumb-current {
|
||||||
@@ -891,6 +918,8 @@ input, select, textarea { font-family: inherit; color: inherit; }
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button.breadcrumb-current { max-width: 260px; }
|
||||||
|
|
||||||
.header-actions {
|
.header-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -898,6 +927,80 @@ input, select, textarea { font-family: inherit; color: inherit; }
|
|||||||
flex-shrink: 0;
|
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 (reusable) ── */
|
||||||
.dropdown-panel {
|
.dropdown-panel {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
@@ -1847,3 +1847,70 @@ def test_gitea_private_pages_create_no_auth(client):
|
|||||||
"""POST private-pages — 401 without session."""
|
"""POST private-pages — 401 without session."""
|
||||||
resp = client.post("/api/gitea/projects/owner/repo/private-pages", json={"title": "Test"})
|
resp = client.post("/api/gitea/projects/owner/repo/private-pages", json={"title": "Test"})
|
||||||
assert resp.status_code == 401
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user