255 lines
10 KiB
HTML
255 lines
10 KiB
HTML
{# ── Unified Header (Notion-inspired breadcrumb navigation) ──
|
|
Parameters available in calling context:
|
|
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
|
|
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": "home", "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">
|
|
{% if not hide_hamburger %}
|
|
<button class="hamburger-btn"
|
|
@click="{{ hamburger_click|default('sidebarCollapsed ? (sidebarCollapsed = false) : (mobileSidebarOpen = true, sidebarCollapsed = false)') }}"
|
|
title="Toggle sidebar">
|
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<line x1="3" y1="6" x2="21" y2="6"/>
|
|
<line x1="3" y1="12" x2="21" y2="12"/>
|
|
<line x1="3" y1="18" x2="21" y2="18"/>
|
|
</svg>
|
|
</button>
|
|
{% endif %}
|
|
|
|
<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()"
|
|
@mouseleave="dragCloseTimer()">
|
|
{% if page_icon %}
|
|
<span class="header-page-icon">{% if page_icon in ("folder","file","star","link","trash","book","home","settings","lock","globe","image","edit","calendar","users","user","search","tag","bar-chart","grid","list","align-left","zap","paperclip","key","refresh","upload") %}{{ fd_icon(page_icon,18) }}{% else %}{{ page_icon }}{% endif %}</span>
|
|
{% endif %}
|
|
|
|
<template x-for="(crumb, di) in display" :key="di">
|
|
<span class="crumb-cell">
|
|
{# ── Collapsed "…" segment ── #}
|
|
<template x-if="crumb.ellipsis">
|
|
<span class="crumb-anchor"
|
|
@mouseenter="cancelCloseTimer(); ellipsisOpen = true"
|
|
@mouseleave="dragCloseTimer()">
|
|
<button type="button" class="breadcrumb-link crumb-ellipsis">…</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">
|
|
<div class="fd-nav-item" @click.stop="go(h.url); closeAll()">
|
|
<span class="fd-nav-ico" :title="h.icon || 'file'"></span>
|
|
<span class="fd-nav-label" x-text="h.label"></span>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</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"
|
|
@mouseenter="closeAll()"></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"
|
|
@mouseenter="closeAll()"></span>
|
|
</template>
|
|
|
|
{# ── Interactive crumb with navigation dropdown (Notion-style hover) ── #}
|
|
<template x-if="!crumb.ellipsis && crumb.menu">
|
|
<span class="crumb-anchor"
|
|
@mouseenter="cancelCloseTimer(); openMenu(crumb.origIndex)"
|
|
@mouseleave="dragCloseTimer()">
|
|
<button type="button" class="breadcrumb-link"
|
|
:class="{ 'breadcrumb-current': crumb.origIndex === crumbs.length - 1 }"
|
|
x-text="crumb.label"></button>
|
|
<div class="fd-nav-menu"
|
|
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">
|
|
<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); closeAll()">
|
|
<span class="fd-nav-ico" :title="item.icon || 'file'"></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
|
|
@mouseenter="cancelCloseTimer()"
|
|
@mouseleave="dragCloseTimer()">
|
|
<template x-for="s in subItems" :key="s.id">
|
|
<div class="fd-nav-item" @click.stop="go(s.url); closeAll()">
|
|
<span class="fd-nav-ico" :title="s.icon || 'file'"></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 class="topbar-right header-actions">
|
|
{% include '_notification_bell.html' %}
|
|
{{ right_actions|safe if right_actions else '' }}
|
|
</div>
|
|
</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: [],
|
|
closeTimer: null,
|
|
|
|
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) {
|
|
if (this.openIdx === origIndex) return; // already open
|
|
var self = this;
|
|
// Close sub first
|
|
this.subOpenId = null;
|
|
this.subItems = [];
|
|
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; }
|
|
});
|
|
},
|
|
|
|
// ── Hover close timer ──
|
|
dragCloseTimer: function () {
|
|
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 () {
|
|
this.cancelCloseTimer();
|
|
this.openIdx = null;
|
|
this.ellipsisOpen = false;
|
|
this.subOpenId = null;
|
|
this.subItems = [];
|
|
this.activeItems = [];
|
|
},
|
|
|
|
go: function (url) { if (url) window.location.href = url; }
|
|
};
|
|
});
|
|
});
|
|
</script>
|