feat: recursive tree, drag & drop, sub-folders in sidebar workspace
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped

- _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:
2026-07-10 21:22:11 -04:00
parent 373153de69
commit 1ab5b1271f
3 changed files with 132 additions and 11 deletions
+87 -8
View File
@@ -92,16 +92,38 @@
<button class="section-action-btn" title="New Folder" @click.stop="newFolderInWorkspace()">📁</button>
</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>
<ul class="sidebar-items" data-section="workspace">
{% for page in workspace_pages %}
<li class="sidebar-item" data-id="{{ page.db_id }}">
<span class="page-icon">{{ page.icon or '📄' }}</span>
<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 %}
{% if workspace_pages %}
{{ render_workspace_tree(workspace_pages) }}
{% else %}
<li class="sidebar-item empty-hint"><span class="page-icon">📄</span><span class="page-name text-dim">No pages yet</span></li>
{% endif %}
<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); });
},
// ── 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() {
this.showNewPageMenu = false;
this.newPage('Private');