feat: recursive tree, drag & drop, sub-folders in sidebar workspace
- _load_workspace_pages now recursively loads children via _load_children()
- Sidebar: recursive Jinja2 macro render_workspace_tree()
- Drag & drop: HTML5 DnD — drag files into folders or reorder
- move API: PUT /api/local-workspace/items/{id}/move
- Folder hover shows 📄 (new file) + 📁 (sub-folder) buttons
- newPageInFolder/newFolderInFolder with parent_id for nesting
- Visual: dragging opacity + drop target highlight
- Items indented by depth (16px per level)
This commit is contained in:
+30
-3
@@ -141,7 +141,7 @@ def _build_page_tree(conn, parent_id: int | None, ws_key: str, depth: int = 0, m
|
|||||||
|
|
||||||
|
|
||||||
def _load_workspace_pages(ws_cookie: str) -> list:
|
def _load_workspace_pages(ws_cookie: str) -> list:
|
||||||
"""Load top-level pages for the active workspace."""
|
"""Load top-level pages with children for the active workspace."""
|
||||||
if not ws_cookie:
|
if not ws_cookie:
|
||||||
return []
|
return []
|
||||||
try:
|
try:
|
||||||
@@ -151,12 +151,39 @@ def _load_workspace_pages(ws_cookie: str) -> list:
|
|||||||
"SELECT id, title, parent_section FROM pages WHERE workspace_id=? AND parent_id IS NULL ORDER BY created_at DESC",
|
"SELECT id, title, parent_section FROM pages WHERE workspace_id=? AND parent_id IS NULL ORDER BY created_at DESC",
|
||||||
(ws_id,),
|
(ws_id,),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return [{"db_id": r["id"], "name": r["title"] or "Untitled", "id": f"page/{r['id']}",
|
items = []
|
||||||
"icon": "📁" if r["parent_section"] == "Workspace" else "📄"} for r in rows]
|
for r in rows:
|
||||||
|
items.append({
|
||||||
|
"db_id": r["id"], "name": r["title"] or "Untitled",
|
||||||
|
"id": f"page/{r['id']}",
|
||||||
|
"icon": "📁" if r["parent_section"] == "Workspace" else "📄",
|
||||||
|
"is_folder": r["parent_section"] == "Workspace",
|
||||||
|
"children": _load_children(r["id"]),
|
||||||
|
})
|
||||||
|
return items
|
||||||
except (ValueError, Exception):
|
except (ValueError, Exception):
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _load_children(parent_id: int) -> list:
|
||||||
|
"""Recursively load children of a page."""
|
||||||
|
with get_conn() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT id, title, parent_section FROM pages WHERE parent_id=? ORDER BY created_at",
|
||||||
|
(parent_id,),
|
||||||
|
).fetchall()
|
||||||
|
children = []
|
||||||
|
for r in rows:
|
||||||
|
children.append({
|
||||||
|
"db_id": r["id"], "name": r["title"] or "Untitled",
|
||||||
|
"id": f"page/{r['id']}",
|
||||||
|
"icon": "📁" if r["parent_section"] == "Workspace" else "📄",
|
||||||
|
"is_folder": r["parent_section"] == "Workspace",
|
||||||
|
"children": _load_children(r["id"]),
|
||||||
|
})
|
||||||
|
return children
|
||||||
|
|
||||||
|
|
||||||
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"
|
||||||
|
|||||||
@@ -581,6 +581,21 @@ async def delete_local_workspace_item(request: Request, item_id: int):
|
|||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/api/local-workspace/items/{item_id:int}/move")
|
||||||
|
async def move_local_workspace_item(request: Request, item_id: int):
|
||||||
|
"""Move an item to a new parent (drag & drop)."""
|
||||||
|
import json
|
||||||
|
body = await request.json()
|
||||||
|
new_parent_id = body.get("parent_id") # None = move to root
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE pages SET parent_id=? WHERE id=?",
|
||||||
|
(new_parent_id, item_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
# ═══════════ Workspaces CRUD ═══════════
|
# ═══════════ Workspaces CRUD ═══════════
|
||||||
|
|
||||||
WORKSPACE_COOKIE = "flowdeck_workspace"
|
WORKSPACE_COOKIE = "flowdeck_workspace"
|
||||||
|
|||||||
+87
-8
@@ -92,16 +92,38 @@
|
|||||||
<button class="section-action-btn" title="New Folder" @click.stop="newFolderInWorkspace()">📁</button>
|
<button class="section-action-btn" title="New Folder" @click.stop="newFolderInWorkspace()">📁</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% macro render_workspace_tree(pages, depth=0) %}
|
||||||
|
{% for page in pages %}
|
||||||
|
<li class="sidebar-item ws-tree-item"
|
||||||
|
data-id="{{ page.db_id }}"
|
||||||
|
data-is-folder="{{ 'true' if page.is_folder else 'false' }}"
|
||||||
|
draggable="true"
|
||||||
|
@dragstart="dragStart($event, {{ page.db_id }})"
|
||||||
|
@dragover.prevent="dragOver($event, {{ page.db_id }}, {{ 'true' if page.is_folder else 'false' }})"
|
||||||
|
@dragleave="dragLeave($event)"
|
||||||
|
@drop="drop($event, {{ page.db_id }})"
|
||||||
|
@dblclick="openWorkspacePage({{ page.db_id }})"
|
||||||
|
style="padding-left:{{ 8 + depth * 16 }}px;">
|
||||||
|
<span class="page-icon">{{ page.icon }}</span>
|
||||||
|
<a href="/pages/{{ page.db_id }}" class="page-name" draggable="false">{{ page.name }}</a>
|
||||||
|
<div class="sidebar-item-actions">
|
||||||
|
{% if page.is_folder %}
|
||||||
|
<button class="ws-act" @click.stop="newPageInFolder({{ page.db_id }})" title="New file in folder">📄</button>
|
||||||
|
<button class="ws-act" @click.stop="newFolderInFolder({{ page.db_id }})" title="New sub-folder">📁</button>
|
||||||
|
{% endif %}
|
||||||
|
<button class="ws-act danger" @click.stop="deleteWorkspacePage({{ page.db_id }})" title="Delete">×</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{% if page.children %}
|
||||||
|
{{ render_workspace_tree(page.children, depth + 1) }}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endmacro %}
|
||||||
<div class="sidebar-section-items" x-show="sectionsOpen.workspace" x-transition>
|
<div class="sidebar-section-items" x-show="sectionsOpen.workspace" x-transition>
|
||||||
<ul class="sidebar-items" data-section="workspace">
|
<ul class="sidebar-items" data-section="workspace">
|
||||||
{% for page in workspace_pages %}
|
{% if workspace_pages %}
|
||||||
<li class="sidebar-item" data-id="{{ page.db_id }}">
|
{{ render_workspace_tree(workspace_pages) }}
|
||||||
<span class="page-icon">{{ page.icon or '📄' }}</span>
|
{% else %}
|
||||||
<a href="/pages/{{ page.db_id }}" class="page-name">{{ page.name }}</a>
|
|
||||||
<button class="sidebar-item-delete" @click="deleteWorkspacePage({{ page.db_id }})" title="Delete">×</button>
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
{% if not workspace_pages %}
|
|
||||||
<li class="sidebar-item empty-hint"><span class="page-icon">📄</span><span class="page-name text-dim">No pages yet</span></li>
|
<li class="sidebar-item empty-hint"><span class="page-icon">📄</span><span class="page-name text-dim">No pages yet</span></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<li class="sidebar-item" style="margin-top:4px;border-top:1px solid var(--border);padding-top:8px;" onclick="window.location='/workspaces'">
|
<li class="sidebar-item" style="margin-top:4px;border-top:1px solid var(--border);padding-top:8px;" onclick="window.location='/workspaces'">
|
||||||
@@ -674,6 +696,63 @@
|
|||||||
.catch(err => { alert('Failed: ' + err.message); });
|
.catch(err => { alert('Failed: ' + err.message); });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Drag & Drop ──
|
||||||
|
dragOverId: null,
|
||||||
|
dragStart(ev, id) {
|
||||||
|
ev.dataTransfer.setData('text/plain', id);
|
||||||
|
ev.target.classList.add('dragging');
|
||||||
|
},
|
||||||
|
dragOver(ev, id, isFolder) {
|
||||||
|
this.dragOverId = id;
|
||||||
|
if (isFolder) ev.target.closest('.ws-tree-item')?.classList.add('drop-target');
|
||||||
|
},
|
||||||
|
dragLeave(ev) {
|
||||||
|
this.dragOverId = null;
|
||||||
|
ev.target.closest('.ws-tree-item')?.classList.remove('drop-target');
|
||||||
|
},
|
||||||
|
drop(ev, targetId) {
|
||||||
|
ev.target.closest('.ws-tree-item')?.classList.remove('drop-target');
|
||||||
|
const draggedId = ev.dataTransfer.getData('text/plain');
|
||||||
|
if (!draggedId || draggedId == targetId) return;
|
||||||
|
const isFolder = ev.target.closest('.ws-tree-item')?.dataset?.isFolder === 'true';
|
||||||
|
const parentId = isFolder && draggedId != targetId ? targetId : null;
|
||||||
|
fetch(`/api/local-workspace/items/${draggedId}/move`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() },
|
||||||
|
body: JSON.stringify({ parent_id: parentId })
|
||||||
|
})
|
||||||
|
.then(r => { if (!r.ok) throw new Error('Failed'); window.location.reload(); })
|
||||||
|
.catch(err => { alert('Move failed: ' + err.message); });
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Sub-item creation in folders ──
|
||||||
|
newPageInFolder(parentId) {
|
||||||
|
const name = prompt('File name:');
|
||||||
|
if (!name || !name.trim()) return;
|
||||||
|
fetch('/api/local-workspace/items', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() },
|
||||||
|
body: JSON.stringify({ name: name.trim(), type: 'page', parent_id: parentId })
|
||||||
|
})
|
||||||
|
.then(r => { if (!r.ok) throw new Error('Failed'); return r.json(); })
|
||||||
|
.then(data => { window.location.href = `/pages/${data.id}`; })
|
||||||
|
.catch(err => { alert('Failed: ' + err.message); });
|
||||||
|
},
|
||||||
|
newFolderInFolder(parentId) {
|
||||||
|
const name = prompt('Sub-folder name:');
|
||||||
|
if (!name || !name.trim()) return;
|
||||||
|
fetch('/api/local-workspace/items', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() },
|
||||||
|
body: JSON.stringify({ name: name.trim(), type: 'folder', parent_id: parentId })
|
||||||
|
})
|
||||||
|
.then(r => { if (!r.ok) throw new Error('Failed'); window.location.reload(); })
|
||||||
|
.catch(err => { alert('Failed: ' + err.message); });
|
||||||
|
},
|
||||||
|
openWorkspacePage(pageId) {
|
||||||
|
window.location = `/pages/${pageId}`;
|
||||||
|
},
|
||||||
|
|
||||||
createEmptyPage() {
|
createEmptyPage() {
|
||||||
this.showNewPageMenu = false;
|
this.showNewPageMenu = false;
|
||||||
this.newPage('Private');
|
this.newPage('Private');
|
||||||
|
|||||||
Reference in New Issue
Block a user