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:
@@ -147,6 +147,11 @@ def init_db():
|
|||||||
conn.execute("ALTER TABLE pages ADD COLUMN parent_id INTEGER REFERENCES pages(id)")
|
conn.execute("ALTER TABLE pages ADD COLUMN parent_id INTEGER REFERENCES pages(id)")
|
||||||
except sqlite3.OperationalError:
|
except sqlite3.OperationalError:
|
||||||
pass
|
pass
|
||||||
|
# Migration: add sort_order for drag & drop tree reordering (v1.1.0+)
|
||||||
|
try:
|
||||||
|
conn.execute("ALTER TABLE pages ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+83
-23
@@ -115,6 +115,31 @@ def _map_issue_to_card(issue: dict, owner: str = "", repo: str = "") -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_page_tree(conn, parent_id: int | None, ws_key: str, depth: int = 0, max_depth: int = 3) -> list[dict]:
|
||||||
|
"""Build nested page tree recursively. max_depth prevents infinite recursion."""
|
||||||
|
if depth >= max_depth:
|
||||||
|
return []
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT id, title, updated_at FROM pages WHERE workspace=? AND parent_id IS ? ORDER BY sort_order ASC, updated_at DESC",
|
||||||
|
(ws_key, parent_id),
|
||||||
|
).fetchall()
|
||||||
|
items = []
|
||||||
|
for row in rows:
|
||||||
|
children = _build_page_tree(conn, row["id"], ws_key, depth + 1, max_depth)
|
||||||
|
items.append({
|
||||||
|
"id": f"page/{row['id']}",
|
||||||
|
"db_id": row["id"],
|
||||||
|
"name": row["title"],
|
||||||
|
"icon": "📄",
|
||||||
|
"url": f"/pages/{row['id']}",
|
||||||
|
"active": False,
|
||||||
|
"depth": depth,
|
||||||
|
"has_children": len(children) > 0,
|
||||||
|
"children": children,
|
||||||
|
})
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
|
def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
|
||||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||||
ws_name = user.get("login", "Bruno") if user else "Bruno"
|
ws_name = user.get("login", "Bruno") if user else "Bruno"
|
||||||
@@ -134,32 +159,19 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
|
|||||||
"name": label, "icon": "📁" if first else "·",
|
"name": label, "icon": "📁" if first else "·",
|
||||||
"url": f"/board/{owner}/{repo}?view={view}",
|
"url": f"/board/{owner}/{repo}?view={view}",
|
||||||
"active": active, "indent": indent,
|
"active": active, "indent": indent,
|
||||||
|
"depth": indent, "has_children": False, "children": [],
|
||||||
})
|
})
|
||||||
first = False
|
first = False
|
||||||
# Load pages for this project workspace
|
# Load pages as nested tree for this project workspace
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
pages = conn.execute(
|
tree_pages = _build_page_tree(conn, None, ws_key)
|
||||||
"SELECT id, title, parent_id, updated_at FROM pages WHERE workspace=? AND parent_id IS NULL ORDER BY updated_at DESC LIMIT 10",
|
for p in tree_pages:
|
||||||
(ws_key,),
|
recent.append(p)
|
||||||
).fetchall()
|
# Private pages: same as recent but filtered for page/ items (non-board views)
|
||||||
for p in pages:
|
private_items = [r for r in recent if r.get("active") or r["id"].startswith("page/")]
|
||||||
recent.append({
|
|
||||||
"id": f"page/{p['id']}", "name": p["title"], "icon": "📄",
|
|
||||||
"url": f"/pages/{p['id']}", "active": False, "indent": 0,
|
|
||||||
})
|
|
||||||
# Load sub-pages
|
|
||||||
subs = conn.execute(
|
|
||||||
"SELECT id, title FROM pages WHERE parent_id=? ORDER BY updated_at DESC",
|
|
||||||
(p["id"],),
|
|
||||||
).fetchall()
|
|
||||||
for sp in subs:
|
|
||||||
recent.append({
|
|
||||||
"id": f"page/{sp['id']}", "name": sp["title"], "icon": "·",
|
|
||||||
"url": f"/pages/{sp['id']}", "active": False, "indent": 1,
|
|
||||||
})
|
|
||||||
return {"workspace_name": ws_name, "workspace_initial": ws_name[0].upper() if ws_name else "B",
|
return {"workspace_name": ws_name, "workspace_initial": ws_name[0].upper() if ws_name else "B",
|
||||||
"current_page": repo or "Dashboard", "last_edited": "now",
|
"current_page": repo or "Dashboard", "last_edited": "now",
|
||||||
"recent_pages": recent, "private_pages": [r for r in recent if r.get("active") or r["id"].startswith("page/")],
|
"recent_pages": recent, "private_pages": private_items,
|
||||||
"favorite_pages": [], "user": user,
|
"favorite_pages": [], "user": user,
|
||||||
"workspace_key": ws_key}
|
"workspace_key": ws_key}
|
||||||
|
|
||||||
@@ -455,9 +467,18 @@ async def create_page(request: Request, title: str = Query(default="New page"),
|
|||||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||||
ws_key = project if project else (user.get("login", "Bruno") if user else "Bruno")
|
ws_key = project if project else (user.get("login", "Bruno") if user else "Bruno")
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
|
# Compute next sort_order for this parent
|
||||||
|
next_order = 0
|
||||||
|
parent_val = parent_id if parent_id > 0 else None
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT COALESCE(MAX(sort_order), -1) + 1 FROM pages WHERE workspace=? AND parent_id IS ?",
|
||||||
|
(ws_key, parent_val),
|
||||||
|
).fetchone()
|
||||||
|
if row:
|
||||||
|
next_order = row[0]
|
||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
"INSERT INTO pages (workspace, title, parent_section, parent_id) VALUES (?,?,?,?)",
|
"INSERT INTO pages (workspace, title, parent_section, parent_id, sort_order) VALUES (?,?,?,?,?)",
|
||||||
(ws_key, title, section, parent_id if parent_id > 0 else None),
|
(ws_key, title, section, parent_val, next_order),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
page_id = cur.lastrowid
|
page_id = cur.lastrowid
|
||||||
@@ -487,6 +508,45 @@ async def update_page(page_id: int, title: str = Query(default=""),
|
|||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/api/pages/{page_id}/move")
|
||||||
|
async def move_page(request: Request, page_id: int,
|
||||||
|
new_parent_id: int = Query(default=0),
|
||||||
|
new_order: int = Query(default=0)):
|
||||||
|
"""Move/reorder a page in the tree (drag & drop).
|
||||||
|
new_parent_id=0 means move to root level.
|
||||||
|
new_order=0 means append to end (or set explicit position)."""
|
||||||
|
with get_conn() as conn:
|
||||||
|
# Verify page exists
|
||||||
|
row = conn.execute("SELECT id, workspace FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, "Page not found")
|
||||||
|
# Update parent (None for root level)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE pages SET parent_id=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||||
|
(new_parent_id if new_parent_id > 0 else None, page_id),
|
||||||
|
)
|
||||||
|
# Update sort order
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE pages SET sort_order=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||||
|
(new_order, page_id),
|
||||||
|
)
|
||||||
|
# Re-index siblings to ensure no gaps
|
||||||
|
siblings = conn.execute(
|
||||||
|
"SELECT id, sort_order FROM pages WHERE workspace=? AND parent_id IS ? AND id != ? ORDER BY sort_order ASC",
|
||||||
|
(row["workspace"], new_parent_id if new_parent_id > 0 else None, page_id),
|
||||||
|
).fetchall()
|
||||||
|
# Recompute sort_order for all siblings after the insertion point
|
||||||
|
insert_point = new_order
|
||||||
|
reorder = []
|
||||||
|
for sib in siblings:
|
||||||
|
if sib["sort_order"] >= insert_point:
|
||||||
|
reorder.append(sib["id"])
|
||||||
|
for i, sid in enumerate(sorted(reorder, key=lambda x: next(s["sort_order"] for s in siblings if s["id"] == x))):
|
||||||
|
conn.execute("UPDATE pages SET sort_order=? WHERE id=?", (insert_point + i + 1, sid))
|
||||||
|
conn.commit()
|
||||||
|
return {"status": "ok", "id": page_id, "parent_id": new_parent_id, "order": new_order}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/pages/{page_id}", response_class=HTMLResponse)
|
@router.get("/pages/{page_id}", response_class=HTMLResponse)
|
||||||
async def view_page(request: Request, page_id: int):
|
async def view_page(request: Request, page_id: int):
|
||||||
"""Render a page as HTML."""
|
"""Render a page as HTML."""
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ def _sidebar_data(request: Request, repos: list[dict]) -> dict:
|
|||||||
"url": f"/board/{full_name}",
|
"url": f"/board/{full_name}",
|
||||||
"active": False,
|
"active": False,
|
||||||
"indent": 0,
|
"indent": 0,
|
||||||
|
"depth": 0,
|
||||||
|
"has_children": False,
|
||||||
|
"children": [],
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
+111
-36
@@ -12,8 +12,45 @@
|
|||||||
<body hx-headers='{"X-CSRF-Token":"__CSRF_PLACEHOLDER__"}'>
|
<body hx-headers='{"X-CSRF-Token":"__CSRF_PLACEHOLDER__"}'>
|
||||||
<div class="app-layout" x-data="appState()">
|
<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 ═══════════ -->
|
<!-- ═══════════ SIDEBAR ═══════════ -->
|
||||||
<aside class="sidebar" :class="{ collapsed: sidebarCollapsed }" id="sidebar">
|
<aside class="sidebar" :class="{ collapsed: sidebarCollapsed }" id="sidebar"
|
||||||
|
x-init="$nextTick(() => initTreeSortable())">
|
||||||
|
|
||||||
<!-- Workspace header -->
|
<!-- Workspace header -->
|
||||||
<div class="sidebar-workspace">
|
<div class="sidebar-workspace">
|
||||||
@@ -52,22 +89,13 @@
|
|||||||
<button class="section-action-btn" title="Add page" @click="newPage('Recents')">+</button>
|
<button class="section-action-btn" title="Add page" @click="newPage('Recents')">+</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ul class="sidebar-items">
|
<ul class="sidebar-items" data-section="recents">
|
||||||
{% for page in recent_pages %}
|
{% for page in recent_pages %}
|
||||||
<li class="sidebar-item {% if page.active %}active{% endif %} {% if page.indent %}indent-{{ page.indent }}{% endif %}"
|
{{ render_tree_item(page) }}
|
||||||
@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>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% if not recent_pages %}
|
{% if not recent_pages %}
|
||||||
<li class="sidebar-item">
|
<li class="sidebar-item">
|
||||||
|
<span class="tree-toggle-placeholder"></span>
|
||||||
<span class="page-icon">📄</span>
|
<span class="page-icon">📄</span>
|
||||||
<span class="page-name text-dim">No recent pages</span>
|
<span class="page-name text-dim">No recent pages</span>
|
||||||
</li>
|
</li>
|
||||||
@@ -86,22 +114,13 @@
|
|||||||
<button class="section-action-btn" title="Add page" @click="newPage('Private')">+</button>
|
<button class="section-action-btn" title="Add page" @click="newPage('Private')">+</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ul class="sidebar-items">
|
<ul class="sidebar-items" data-section="private">
|
||||||
{% for page in private_pages %}
|
{% for page in private_pages %}
|
||||||
<li class="sidebar-item {% if page.active %}active{% endif %} {% if page.indent %}indent-{{ page.indent }}{% endif %}"
|
{{ render_tree_item(page) }}
|
||||||
@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>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% if not private_pages %}
|
{% if not private_pages %}
|
||||||
<li class="sidebar-item">
|
<li class="sidebar-item">
|
||||||
|
<span class="tree-toggle-placeholder"></span>
|
||||||
<span class="page-icon">📄</span>
|
<span class="page-icon">📄</span>
|
||||||
<span class="page-name text-dim">No pages yet</span>
|
<span class="page-name text-dim">No pages yet</span>
|
||||||
</li>
|
</li>
|
||||||
@@ -132,21 +151,13 @@
|
|||||||
<button class="section-action-btn" title="Add page" @click="newPage('Favorites')">+</button>
|
<button class="section-action-btn" title="Add page" @click="newPage('Favorites')">+</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ul class="sidebar-items">
|
<ul class="sidebar-items" data-section="favorites">
|
||||||
{% for page in favorite_pages %}
|
{% for page in favorite_pages %}
|
||||||
<li class="sidebar-item {% if page.active %}active{% endif %}"
|
{{ render_tree_item(page) }}
|
||||||
@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>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% if not favorite_pages %}
|
{% if not favorite_pages %}
|
||||||
<li class="sidebar-item">
|
<li class="sidebar-item">
|
||||||
|
<span class="tree-toggle-placeholder"></span>
|
||||||
<span class="page-icon">⭐</span>
|
<span class="page-icon">⭐</span>
|
||||||
<span class="page-name text-dim">No favorites yet</span>
|
<span class="page-name text-dim">No favorites yet</span>
|
||||||
</li>
|
</li>
|
||||||
@@ -351,6 +362,70 @@
|
|||||||
contextActions: {},
|
contextActions: {},
|
||||||
showNewPageMenu: false,
|
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) {
|
navigateTo(url) {
|
||||||
window.location.href = url;
|
window.location.href = url;
|
||||||
},
|
},
|
||||||
|
|||||||
+47
-4
@@ -311,11 +311,58 @@ input, select, textarea { font-family: inherit; color: inherit; }
|
|||||||
background: var(--bg-active);
|
background: var(--bg-active);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Tree toggle button (▶/▼ chevron) */
|
||||||
|
.tree-toggle {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 3px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 9px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: transform 0.15s ease, background var(--transition);
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-toggle:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-toggle.expanded {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-toggle-placeholder {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tree children wrapper */
|
||||||
|
.tree-children-wrapper {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-children-wrapper .sidebar-items {
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar-item .page-icon {
|
.sidebar-item .page-icon {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
width: 18px;
|
width: 18px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-item:active .page-icon {
|
||||||
|
cursor: grabbing;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-item .page-name {
|
.sidebar-item .page-name {
|
||||||
@@ -353,10 +400,6 @@ input, select, textarea { font-family: inherit; color: inherit; }
|
|||||||
background: var(--bg-tertiary);
|
background: var(--bg-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Indented sub-items */
|
|
||||||
.sidebar-item.indent-1 { padding-left: 24px; }
|
|
||||||
.sidebar-item.indent-2 { padding-left: 40px; }
|
|
||||||
|
|
||||||
/* Sidebar footer */
|
/* Sidebar footer */
|
||||||
.sidebar-footer {
|
.sidebar-footer {
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
|
|||||||
Reference in New Issue
Block a user