feat: breadcrumb hover Notion-style + nav_workspace_id sur toutes les pages
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped

- _header.html: remplace click par hover (mouseenter/mouseleave)
  avec timer 200ms anti-flicker, cascade sous-menus au hover
- dashboard.py: ajoute nav_workspace_id à trash, library, workspace,
  gitea-workspace, local-workspace, workspaces, settings, pages/{id}
- La section du popover affiche 'Workspaces' pour Home, 'Private' pour le reste
- Les clics dans les popovers ferment le menu + naviguent
This commit is contained in:
2026-07-17 23:43:16 -04:00
parent d751415308
commit 25386bc79f
2 changed files with 80 additions and 21 deletions
+23
View File
@@ -192,6 +192,9 @@ async def trash_page(request: Request, owner: str = Query(default=""), repo: str
"SELECT id, title, workspace FROM pages WHERE parent_section='Trash' ORDER BY updated_at DESC", "SELECT id, title, workspace FROM pages WHERE parent_section='Trash' ORDER BY updated_at DESC",
).fetchall() ).fetchall()
sidebar["trash_items"] = [{"id": r["id"], "name": r["title"] or "Untitled", "workspace": r["workspace"]} for r in rows] 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") template = env.get_template("trash.html")
return template.render(**sidebar) return template.render(**sidebar)
@@ -206,6 +209,15 @@ async def library_page(request: Request, owner: str = Query(default=""), repo: s
from app.routers.board import _sidebar_data as board_sidebar from app.routers.board import _sidebar_data as board_sidebar
env = Environment(loader=FileSystemLoader("app/templates")) env = Environment(loader=FileSystemLoader("app/templates"))
sidebar = board_sidebar(request, owner, repo) 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") template = env.get_template("library.html")
return template.render(**sidebar) return template.render(**sidebar)
@@ -447,6 +459,9 @@ async def workspace_page(request: Request):
if not user: if not user:
return RedirectResponse("/auth/login?provider=local", status_code=302) return RedirectResponse("/auth/login?provider=local", status_code=302)
ctx = {**sidebar, "user": user} ctx = {**sidebar, "user": user}
# Pass active workspace for breadcrumb nav menu
ws = _get_active_workspace(request, user_id=_get_user_id(request))
ctx["nav_workspace_id"] = ws["id"] if ws else 0
template = env.get_template("workspace.html") template = env.get_template("workspace.html")
return template.render(**ctx) return template.render(**ctx)
@@ -498,6 +513,7 @@ async def gitea_workspace_page(request: Request):
"workspace_initial": repo[0].upper() if repo else "G", "workspace_initial": repo[0].upper() if repo else "G",
"owner": owner, "owner": owner,
"repo": repo, "repo": repo,
"nav_workspace_id": local_ws_id or 0, # for breadcrumb nav menu
} }
template = env.get_template("gitea_workspace.html") template = env.get_template("gitea_workspace.html")
resp = HTMLResponse(content=template.render(**ctx)) resp = HTMLResponse(content=template.render(**ctx))
@@ -661,6 +677,7 @@ async def local_workspace_page(request: Request, folder: int = None):
"workspace_name": sidebar.get("active_ws_name", "My Workspace"), "workspace_name": sidebar.get("active_ws_name", "My Workspace"),
"current_folder_id": current_folder_id or 0, "current_folder_id": current_folder_id or 0,
"workspace_id": ws_id or 0, "workspace_id": ws_id or 0,
"nav_workspace_id": ws_id or 0,
"breadcrumb": breadcrumb, "breadcrumb": breadcrumb,
} }
template = env.get_template("local_workspace.html") template = env.get_template("local_workspace.html")
@@ -1244,6 +1261,9 @@ async def workspaces_page(request: Request):
if not user: if not user:
return RedirectResponse("/auth/login?provider=local", status_code=302) return RedirectResponse("/auth/login?provider=local", status_code=302)
ctx = {**sidebar, "user": user} ctx = {**sidebar, "user": user}
# Pass active workspace for breadcrumb nav menu (null on workspaces home)
ws = _get_active_workspace(request, user_id=_get_user_id(request))
ctx["nav_workspace_id"] = ws["id"] if ws else 0
template = env.get_template("workspaces.html") template = env.get_template("workspaces.html")
return template.render(**ctx) return template.render(**ctx)
@@ -1346,6 +1366,9 @@ async def app_settings_page(request: Request):
if not user: if not user:
return RedirectResponse("/auth/login?provider=local", status_code=302) return RedirectResponse("/auth/login?provider=local", status_code=302)
ctx = {**sidebar, "user": user} ctx = {**sidebar, "user": user}
# Pass active workspace for breadcrumb nav menu
ws = _get_active_workspace(request, user_id=_get_user_id(request))
ctx["nav_workspace_id"] = ws["id"] if ws else 0
template = env.get_template("settings.html") template = env.get_template("settings.html")
return template.render(**ctx) return template.render(**ctx)
+57 -21
View File
@@ -48,7 +48,8 @@
<script type="application/json" id="fd-breadcrumb-data">{{ {"workspace_id": nav_workspace_id|default(0), "crumbs": ns.items}|tojson }}</script> <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()"> <div class="topbar-left header-breadcrumb" x-data="fdBreadcrumb()" x-init="init()"
@mouseleave="dragCloseTimer()">
{% if page_icon %} {% if page_icon %}
<span class="header-page-icon">{{ page_icon }}</span> <span class="header-page-icon">{{ page_icon }}</span>
{% endif %} {% endif %}
@@ -57,11 +58,15 @@
<span class="crumb-cell"> <span class="crumb-cell">
{# ── Collapsed "…" segment ── #} {# ── Collapsed "…" segment ── #}
<template x-if="crumb.ellipsis"> <template x-if="crumb.ellipsis">
<span class="crumb-anchor"> <span class="crumb-anchor"
<button type="button" class="breadcrumb-link crumb-ellipsis" @click.stop="toggleEllipsis()">&hellip;</button> @mouseenter="cancelCloseTimer(); ellipsisOpen = true"
<div class="fd-nav-menu" x-show="ellipsisOpen" x-cloak x-transition.opacity> @mouseleave="dragCloseTimer()">
<button type="button" class="breadcrumb-link crumb-ellipsis">&hellip;</button>
<div class="fd-nav-menu" x-show="ellipsisOpen" x-cloak x-transition.opacity
@mouseenter="cancelCloseTimer()"
@mouseleave="dragCloseTimer()">
<template x-for="h in crumb.hidden" :key="h.id"> <template x-for="h in crumb.hidden" :key="h.id">
<div class="fd-nav-item" @click.stop="go(h.url)"> <div class="fd-nav-item" @click.stop="go(h.url); closeAll()">
<span class="fd-nav-ico" x-text="h.icon || '📄'"></span> <span class="fd-nav-ico" x-text="h.icon || '📄'"></span>
<span class="fd-nav-label" x-text="h.label"></span> <span class="fd-nav-label" x-text="h.label"></span>
</div> </div>
@@ -72,23 +77,29 @@
{# ── Plain link crumb (Home / non-menu with url) ── #} {# ── Plain link crumb (Home / non-menu with url) ── #}
<template x-if="!crumb.ellipsis && !crumb.menu && crumb.url"> <template x-if="!crumb.ellipsis && !crumb.menu && crumb.url">
<a class="breadcrumb-link" :href="crumb.url" x-text="crumb.label"></a> <a class="breadcrumb-link" :href="crumb.url" x-text="crumb.label"
@mouseenter="closeAll()"></a>
</template> </template>
{# ── Plain current crumb (no menu, no url) ── #} {# ── Plain current crumb (no menu, no url) ── #}
<template x-if="!crumb.ellipsis && !crumb.menu && !crumb.url"> <template x-if="!crumb.ellipsis && !crumb.menu && !crumb.url">
<span class="breadcrumb-current" x-text="crumb.label"></span> <span class="breadcrumb-current" x-text="crumb.label"
@mouseenter="closeAll()"></span>
</template> </template>
{# ── Interactive crumb with navigation dropdown ── #} {# ── Interactive crumb with navigation dropdown (Notion-style hover) ── #}
<template x-if="!crumb.ellipsis && crumb.menu"> <template x-if="!crumb.ellipsis && crumb.menu">
<span class="crumb-anchor"> <span class="crumb-anchor"
@mouseenter="cancelCloseTimer(); openMenu(crumb.origIndex)"
@mouseleave="dragCloseTimer()">
<button type="button" class="breadcrumb-link" <button type="button" class="breadcrumb-link"
:class="{ 'breadcrumb-current': crumb.origIndex === crumbs.length - 1 }" :class="{ 'breadcrumb-current': crumb.origIndex === crumbs.length - 1 }"
@click.stop="openMenu(crumb.origIndex)"
x-text="crumb.label"></button> x-text="crumb.label"></button>
<div class="fd-nav-menu" x-show="openIdx === crumb.origIndex" x-cloak x-transition.opacity> <div class="fd-nav-menu"
<div class="fd-nav-section">Private</div> x-show="openIdx === crumb.origIndex" x-cloak x-transition.opacity
@mouseenter="cancelCloseTimer()"
@mouseleave="dragCloseTimer()">
<div class="fd-nav-section" x-text="crumb.home ? 'Workspaces' : 'Private'"></div>
<template x-if="loading"> <template x-if="loading">
<div class="fd-nav-empty">Loading&hellip;</div> <div class="fd-nav-empty">Loading&hellip;</div>
</template> </template>
@@ -98,13 +109,15 @@
<template x-for="item in activeItems" :key="item.id"> <template x-for="item in activeItems" :key="item.id">
<div class="fd-nav-item" <div class="fd-nav-item"
@mouseenter="openSub(item)" @mouseenter="openSub(item)"
@click.stop="go(item.url)"> @click.stop="go(item.url); closeAll()">
<span class="fd-nav-ico" x-text="item.icon || '📄'"></span> <span class="fd-nav-ico" x-text="item.icon || '📄'"></span>
<span class="fd-nav-label" x-text="item.name"></span> <span class="fd-nav-label" x-text="item.name"></span>
<span class="fd-nav-arrow" x-show="item.has_children">&rsaquo;</span> <span class="fd-nav-arrow" x-show="item.has_children">&rsaquo;</span>
<div class="fd-nav-menu fd-nav-submenu" x-show="subOpenId === item.id" x-cloak> <div class="fd-nav-menu fd-nav-submenu" x-show="subOpenId === item.id" x-cloak
@mouseenter="cancelCloseTimer()"
@mouseleave="dragCloseTimer()">
<template x-for="s in subItems" :key="s.id"> <template x-for="s in subItems" :key="s.id">
<div class="fd-nav-item" @click.stop="go(s.url)"> <div class="fd-nav-item" @click.stop="go(s.url); closeAll()">
<span class="fd-nav-ico" x-text="s.icon || '📄'"></span> <span class="fd-nav-ico" x-text="s.icon || '📄'"></span>
<span class="fd-nav-label" x-text="s.name"></span> <span class="fd-nav-label" x-text="s.name"></span>
<span class="fd-nav-arrow" x-show="s.has_children">&rsaquo;</span> <span class="fd-nav-arrow" x-show="s.has_children">&rsaquo;</span>
@@ -142,6 +155,8 @@ document.addEventListener('alpine:init', function () {
activeItems: [], activeItems: [],
subOpenId: null, subOpenId: null,
subItems: [], subItems: [],
closeTimer: null,
init: function () { init: function () {
var el = document.getElementById('fd-breadcrumb-data'); var el = document.getElementById('fd-breadcrumb-data');
if (el) { if (el) {
@@ -152,6 +167,7 @@ document.addEventListener('alpine:init', function () {
} catch (e) { this.crumbs = []; } } catch (e) { this.crumbs = []; }
} }
}, },
get display() { get display() {
var c = this.crumbs; var c = this.crumbs;
if (c.length <= 4) return c; if (c.length <= 4) return c;
@@ -160,11 +176,13 @@ document.addEventListener('alpine:init', function () {
var tail = [c[c.length - 2], c[c.length - 1]]; var tail = [c[c.length - 2], c[c.length - 1]];
return head.concat([{ ellipsis: true, hidden: hidden }], tail); return head.concat([{ ellipsis: true, hidden: hidden }], tail);
}, },
parentIdFor: function (origIndex) { parentIdFor: function (origIndex) {
if (origIndex <= 0) return null; if (origIndex <= 0) return null;
var prev = this.crumbs[origIndex - 1]; var prev = this.crumbs[origIndex - 1];
return prev && prev.id ? prev.id : null; return prev && prev.id ? prev.id : null;
}, },
fetchMenu: function (parentId) { fetchMenu: function (parentId) {
var key = parentId == null ? 'root' : String(parentId); var key = parentId == null ? 'root' : String(parentId);
var self = this; var self = this;
@@ -177,10 +195,13 @@ document.addEventListener('alpine:init', function () {
.then(function (d) { self.cache[key] = d.items || []; return self.cache[key]; }) .then(function (d) { self.cache[key] = d.items || []; return self.cache[key]; })
.catch(function () { return []; }); .catch(function () { return []; });
}, },
openMenu: function (origIndex) { openMenu: function (origIndex) {
if (this.openIdx === origIndex) return; // already open
var self = this; var self = this;
if (this.openIdx === origIndex) { this.closeAll(); return; } // Close sub first
this.closeAll(); this.subOpenId = null;
this.subItems = [];
this.openIdx = origIndex; this.openIdx = origIndex;
this.loading = true; this.loading = true;
this.fetchMenu(this.parentIdFor(origIndex)).then(function (items) { this.fetchMenu(this.parentIdFor(origIndex)).then(function (items) {
@@ -188,6 +209,7 @@ document.addEventListener('alpine:init', function () {
self.loading = false; self.loading = false;
}); });
}, },
openSub: function (item) { openSub: function (item) {
var self = this; var self = this;
if (!item.has_children) { this.subOpenId = null; this.subItems = []; return; } if (!item.has_children) { this.subOpenId = null; this.subItems = []; return; }
@@ -198,18 +220,32 @@ document.addEventListener('alpine:init', function () {
if (self.subOpenId === item.id) { self.subItems = items; } if (self.subOpenId === item.id) { self.subItems = items; }
}); });
}, },
toggleEllipsis: function () {
var wasOpen = this.ellipsisOpen; // ── Hover close timer ──
this.closeAll(); dragCloseTimer: function () {
this.ellipsisOpen = !wasOpen; var self = this;
this.cancelCloseTimer();
this.closeTimer = setTimeout(function () {
self.closeAll();
}, 200);
}, },
cancelCloseTimer: function () {
if (this.closeTimer) {
clearTimeout(this.closeTimer);
this.closeTimer = null;
}
},
closeAll: function () { closeAll: function () {
this.cancelCloseTimer();
this.openIdx = null; this.openIdx = null;
this.ellipsisOpen = false; this.ellipsisOpen = false;
this.subOpenId = null; this.subOpenId = null;
this.subItems = []; this.subItems = [];
this.activeItems = []; this.activeItems = [];
}, },
go: function (url) { if (url) window.location.href = url; } go: function (url) { if (url) window.location.href = url; }
}; };
}); });