feat: sidebar tree navigation — toggle chevrons + drag & drop
- Add sort_order column to pages table for drag & drop reordering
- Rewrite _sidebar_data with recursive _build_page_tree for nested hierarchy
- Add tree toggle buttons (▶ collapsed / ▼ expanded) on parent items
- Alpine.js expandedNodes state with toggleTreeNode() for expand/collapse
- Jinja2 recursive macro render_tree_item for proper nesting + indentation
- SortableJS integration on .sidebar-items for drag & drop repositioning
- New PUT /board/api/pages/{id}/move API for persisting moves
- Auto-set sort_order on page creation (max+1 for same parent)
- CSS: .tree-toggle, .tree-toggle-placeholder, grab cursor on page-icon
- 28/28 tests pass, all endpoints healthy
This commit is contained in:
+111
-36
@@ -12,8 +12,45 @@
|
||||
<body hx-headers='{"X-CSRF-Token":"__CSRF_PLACEHOLDER__"}'>
|
||||
<div class="app-layout" x-data="appState()">
|
||||
|
||||
{# ─── Recursive tree macro ─── #}
|
||||
{% macro render_tree_item(page, depth=0) %}
|
||||
{% set indent_px = depth * 16 %}
|
||||
<li class="sidebar-item {% if page.active %}active{% endif %}"
|
||||
style="padding-left: {{ 8 + indent_px }}px;"
|
||||
@click="navigateTo('{{ page.url }}')"
|
||||
@contextmenu.prevent="showContextMenu($event, '{{ page.id }}', '{{ page.name }}')"
|
||||
data-page-id="{{ page.id }}"
|
||||
data-page-depth="{{ depth }}"
|
||||
data-has-children="{{ 'true' if page.has_children else 'false' }}">
|
||||
{# Toggle chevron — only visible for items with children #}
|
||||
{% if page.has_children %}
|
||||
<button class="tree-toggle"
|
||||
@click.stop="toggleTreeNode('{{ page.id }}')"
|
||||
:class="{ expanded: expandedNodes['{{ page.id }}'] }">▶</button>
|
||||
{% else %}
|
||||
<span class="tree-toggle-placeholder"></span>
|
||||
{% endif %}
|
||||
<span class="page-icon">{{ page.icon }}</span>
|
||||
<span class="page-name">{{ page.name }}</span>
|
||||
<span class="item-actions">
|
||||
<button class="item-action-btn" @click.stop="openLibrary('{{ page.id }}')">📚</button>
|
||||
<button class="item-action-btn" @click.stop="newSubPage('{{ page.id }}')">+</button>
|
||||
</span>
|
||||
</li>
|
||||
{% if page.has_children %}
|
||||
<li class="tree-children-wrapper" x-show="expandedNodes['{{ page.id }}']" x-transition>
|
||||
<ul class="sidebar-items" data-parent="{{ page.id }}">
|
||||
{% for child in page.children %}
|
||||
{{ render_tree_item(child, depth + 1) }}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
<!-- ═══════════ SIDEBAR ═══════════ -->
|
||||
<aside class="sidebar" :class="{ collapsed: sidebarCollapsed }" id="sidebar">
|
||||
<aside class="sidebar" :class="{ collapsed: sidebarCollapsed }" id="sidebar"
|
||||
x-init="$nextTick(() => initTreeSortable())">
|
||||
|
||||
<!-- Workspace header -->
|
||||
<div class="sidebar-workspace">
|
||||
@@ -52,22 +89,13 @@
|
||||
<button class="section-action-btn" title="Add page" @click="newPage('Recents')">+</button>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="sidebar-items">
|
||||
<ul class="sidebar-items" data-section="recents">
|
||||
{% for page in recent_pages %}
|
||||
<li class="sidebar-item {% if page.active %}active{% endif %} {% if page.indent %}indent-{{ page.indent }}{% endif %}"
|
||||
@click="navigateTo('{{ page.url }}')"
|
||||
@contextmenu.prevent="showContextMenu($event, '{{ page.id }}', '{{ page.name }}')"
|
||||
data-page-id="{{ page.id }}">
|
||||
<span class="page-icon">{{ page.icon }}</span>
|
||||
<span class="page-name">{{ page.name }}</span>
|
||||
<span class="item-actions">
|
||||
<button class="item-action-btn" @click.stop="openLibrary('{{ page.id }}')">📚</button>
|
||||
<button class="item-action-btn" @click.stop="newSubPage('{{ page.id }}')">+</button>
|
||||
</span>
|
||||
</li>
|
||||
{{ render_tree_item(page) }}
|
||||
{% endfor %}
|
||||
{% if not recent_pages %}
|
||||
<li class="sidebar-item">
|
||||
<span class="tree-toggle-placeholder"></span>
|
||||
<span class="page-icon">📄</span>
|
||||
<span class="page-name text-dim">No recent pages</span>
|
||||
</li>
|
||||
@@ -86,22 +114,13 @@
|
||||
<button class="section-action-btn" title="Add page" @click="newPage('Private')">+</button>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="sidebar-items">
|
||||
<ul class="sidebar-items" data-section="private">
|
||||
{% for page in private_pages %}
|
||||
<li class="sidebar-item {% if page.active %}active{% endif %} {% if page.indent %}indent-{{ page.indent }}{% endif %}"
|
||||
@click="navigateTo('{{ page.url }}')"
|
||||
@contextmenu.prevent="showContextMenu($event, '{{ page.id }}', '{{ page.name }}')"
|
||||
data-page-id="{{ page.id }}">
|
||||
<span class="page-icon">{{ page.icon }}</span>
|
||||
<span class="page-name">{{ page.name }}</span>
|
||||
<span class="item-actions">
|
||||
<button class="item-action-btn" @click.stop="openLibrary('{{ page.id }}')">📚</button>
|
||||
<button class="item-action-btn" @click.stop="newSubPage('{{ page.id }}')">+</button>
|
||||
</span>
|
||||
</li>
|
||||
{{ render_tree_item(page) }}
|
||||
{% endfor %}
|
||||
{% if not private_pages %}
|
||||
<li class="sidebar-item">
|
||||
<span class="tree-toggle-placeholder"></span>
|
||||
<span class="page-icon">📄</span>
|
||||
<span class="page-name text-dim">No pages yet</span>
|
||||
</li>
|
||||
@@ -132,21 +151,13 @@
|
||||
<button class="section-action-btn" title="Add page" @click="newPage('Favorites')">+</button>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="sidebar-items">
|
||||
<ul class="sidebar-items" data-section="favorites">
|
||||
{% for page in favorite_pages %}
|
||||
<li class="sidebar-item {% if page.active %}active{% endif %}"
|
||||
@click="navigateTo('{{ page.url }}')"
|
||||
@contextmenu.prevent="showContextMenu($event, '{{ page.id }}', '{{ page.name }}')"
|
||||
data-page-id="{{ page.id }}">
|
||||
<span class="page-icon">{{ page.icon }}</span>
|
||||
<span class="page-name">{{ page.name }}</span>
|
||||
<span class="item-actions">
|
||||
<button class="item-action-btn" @click.stop="newSubPage('{{ page.id }}')">+</button>
|
||||
</span>
|
||||
</li>
|
||||
{{ render_tree_item(page) }}
|
||||
{% endfor %}
|
||||
{% if not favorite_pages %}
|
||||
<li class="sidebar-item">
|
||||
<span class="tree-toggle-placeholder"></span>
|
||||
<span class="page-icon">⭐</span>
|
||||
<span class="page-name text-dim">No favorites yet</span>
|
||||
</li>
|
||||
@@ -351,6 +362,70 @@
|
||||
contextActions: {},
|
||||
showNewPageMenu: false,
|
||||
|
||||
// ── Tree state ──
|
||||
expandedNodes: {},
|
||||
|
||||
isExpanded(id) {
|
||||
return this.expandedNodes[id] || false;
|
||||
},
|
||||
toggleTreeNode(id) {
|
||||
const current = this.expandedNodes[id] || false;
|
||||
this.expandedNodes[id] = !current;
|
||||
// Trigger Alpine.js reactivity by reassigning
|
||||
this.expandedNodes = Object.assign({}, this.expandedNodes);
|
||||
// If expanding, init SortableJS on the newly visible children
|
||||
if (!current) {
|
||||
this.$nextTick(() => this.initTreeSortable());
|
||||
}
|
||||
},
|
||||
expandAll() {
|
||||
document.querySelectorAll('[data-has-children="true"]').forEach(el => {
|
||||
this.expandedNodes[el.dataset.pageId] = true;
|
||||
});
|
||||
this.expandedNodes = Object.assign({}, this.expandedNodes);
|
||||
this.$nextTick(() => this.initTreeSortable());
|
||||
},
|
||||
|
||||
// ── Drag & drop tree ──
|
||||
treeSortables: [],
|
||||
initTreeSortable() {
|
||||
// Initialize SortableJS on every .sidebar-items list
|
||||
const self = this;
|
||||
document.querySelectorAll('.sidebar-items').forEach(function(el) {
|
||||
if (el._sortableTree) return; // Already initialized
|
||||
el._sortableTree = new Sortable(el, {
|
||||
group: 'pages-tree',
|
||||
handle: '.page-icon',
|
||||
animation: 150,
|
||||
ghostClass: 'sortable-ghost',
|
||||
dragClass: 'sortable-drag',
|
||||
fallbackOnBody: true,
|
||||
swapThreshold: 0.5,
|
||||
onEnd: function(evt) {
|
||||
// Get the dragged item's page ID
|
||||
const item = evt.item;
|
||||
const pageId = item.dataset.pageId;
|
||||
if (!pageId || !pageId.startsWith('page/')) return;
|
||||
const dbId = pageId.replace('page/', '');
|
||||
// Determine new parent from the target list's data-parent
|
||||
const targetList = evt.to;
|
||||
let newParentId = 0;
|
||||
const parentAttr = targetList.dataset.parent;
|
||||
if (parentAttr && parentAttr.startsWith('page/')) {
|
||||
newParentId = parentAttr.replace('page/', '');
|
||||
}
|
||||
const newIndex = evt.newIndex;
|
||||
// Call API to persist the move
|
||||
const csrf = self.getCsrfToken();
|
||||
fetch(`/board/api/pages/${dbId}/move?new_parent_id=${newParentId}&new_order=${newIndex}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'X-CSRF-Token': csrf }
|
||||
}).catch(e => console.error('Move failed:', e));
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
navigateTo(url) {
|
||||
window.location.href = url;
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user