fix(library+workspace): arborescence dans Workspace tab, icônes drag+multi-select visibles, inline rename au clic
- Library: ajout /api/library/children/{id} pour charger les enfants à la demande
- Library: drag handle (.drag-handle) et checkbox (.lib-checkbox) toujours visibles
- Library: SortableJS init pour drag-and-drop réordonner les rows
- Library: toggleExpand() async avec chargement des enfants depuis l'API
- Local workspace: drag handle 6-dots + checkbox toujours visible dans les tree items
- Local workspace: renderChildren() inclut checkbox + drag handle + inline rename
- Local workspace: clic sur nom de fichier = inline rename (plus navigation directe)
- Local workspace: bouton Open pour ouvrir le fichier (double-clic aussi)
- CSS: .item-checkbox toujours visible (plus opacity:0)
- CSS: .drag-handle avec opacité .45 par défaut, 1.0 au hover
This commit is contained in:
@@ -70,6 +70,7 @@ def _build_item(db_row: dict, uid: int = 1) -> dict:
|
||||
"id": page_id,
|
||||
"title": title,
|
||||
"icon": icon,
|
||||
"is_folder": bool(db_row.get("is_folder", 0)),
|
||||
"source_type": source_type,
|
||||
"source_label": _source_label(source_type, workspace),
|
||||
"workspace": workspace,
|
||||
@@ -254,6 +255,22 @@ async def library_private(
|
||||
return {"items": items}
|
||||
|
||||
|
||||
@router.get("/children/{page_id:int}")
|
||||
async def library_children(page_id: int, request: Request):
|
||||
"""Return child pages for a given parent page (for tree expansion in Library)."""
|
||||
uid = _get_user_id(request)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
f"{BASE_SELECT}, p.is_folder, (SELECT COUNT(*) FROM pages c WHERE c.parent_id = p.id AND c.deleted_at IS NULL) as child_count "
|
||||
f"FROM pages p WHERE p.parent_id = ? AND p.deleted_at IS NULL "
|
||||
f"ORDER BY p.is_folder DESC, p.title COLLATE NOCASE",
|
||||
[page_id],
|
||||
).fetchall()
|
||||
items = _rows_to_items(rows, uid)
|
||||
_enrich_children(items)
|
||||
return {"items": items}
|
||||
|
||||
|
||||
@router.get("/workspace")
|
||||
async def library_workspace(
|
||||
request: Request,
|
||||
|
||||
+79
-10
@@ -58,17 +58,17 @@
|
||||
.lib-row .row-cell{display:flex;align-items:center;padding:0 8px;height:100%;overflow:hidden;}
|
||||
|
||||
/* Hover-only elements */
|
||||
.lib-row .hover-only{opacity:0;transition:opacity 0.1s;}
|
||||
.lib-row .hover-only{opacity:0.35;transition:opacity 0.15s;}
|
||||
.lib-row:hover .hover-only{opacity:1;}
|
||||
|
||||
/* Drag handle — 6 dots */
|
||||
.drag-handle{display:flex;flex-wrap:wrap;width:14px;height:14px;cursor:grab;align-content:space-between;justify-content:space-between;flex-shrink:0;}
|
||||
.drag-handle:hover{opacity:1!important;}
|
||||
.drag-handle{display:flex;flex-wrap:wrap;width:16px;height:16px;cursor:grab;align-content:space-between;justify-content:space-between;flex-shrink:0;opacity:0.45;}
|
||||
.lib-row:hover .drag-handle{opacity:1;}
|
||||
.drag-handle .dot{width:3px;height:3px;background:var(--text-tertiary);border-radius:50%;}
|
||||
|
||||
/* Checkbox */
|
||||
.lib-checkbox{width:14px;height:14px;border:1.5px solid var(--text-tertiary);border-radius:3px;cursor:pointer;flex-shrink:0;display:flex;align-items:center;justify-content:center;transition:all 0.1s;}
|
||||
.lib-checkbox:hover{border-color:var(--text-primary);}
|
||||
.lib-checkbox{width:15px;height:15px;border:1.5px solid var(--text-tertiary);border-radius:3px;cursor:pointer;flex-shrink:0;display:flex;align-items:center;justify-content:center;transition:all 0.15s;}
|
||||
.lib-row:hover .lib-checkbox{border-color:var(--text-secondary);}
|
||||
.lib-checkbox.checked{background:var(--accent);border-color:var(--accent);}
|
||||
.lib-checkbox.checked::after{content:'✓';color:#fff;font-size:10px;font-weight:700;}
|
||||
|
||||
@@ -204,7 +204,7 @@
|
||||
</div>
|
||||
|
||||
<!-- ── Table ── -->
|
||||
<div class="lib-table" x-show="!loading && flatItems.length > 0">
|
||||
<div class="lib-table" x-show="!loading && flatItems.length > 0" id="lib-table">
|
||||
<div class="lib-table-header">
|
||||
<div class="th lib-col-drag"></div>
|
||||
<div class="th lib-col-select">
|
||||
@@ -217,9 +217,9 @@
|
||||
<div class="th lib-col-visited sortable" @click="sortBy='visited';doSort()">Last visited</div>
|
||||
<div class="th lib-col-open"></div>
|
||||
</div>
|
||||
<div class="lib-table-body">
|
||||
<div class="lib-table-body" id="lib-table-body">
|
||||
<template x-for="(item, idx) in flatItems" :key="item.id">
|
||||
<div class="lib-row" :class="{ selected: !!selected[item.id] }">
|
||||
<div class="lib-row" :class="{ selected: !!selected[item.id] }" :data-lib-id="item.id">
|
||||
<div class="row-cell lib-col-drag">
|
||||
<div class="drag-handle hover-only" style="margin:auto;" title="Drag to move">
|
||||
<span class="dot"></span><span class="dot"></span>
|
||||
@@ -484,9 +484,39 @@ function libraryPage() {
|
||||
},
|
||||
|
||||
// ── Tree expand ──
|
||||
toggleExpand(item) {
|
||||
this.expanded[item.id] = !this.expanded[item.id];
|
||||
async toggleExpand(item) {
|
||||
if (this.expanded[item.id]) {
|
||||
// Collapse
|
||||
delete this.expanded[item.id];
|
||||
this.expanded = Object.assign({}, this.expanded);
|
||||
return;
|
||||
}
|
||||
// Expand — load children if not loaded yet
|
||||
this.expanded[item.id] = true;
|
||||
this.expanded = Object.assign({}, this.expanded);
|
||||
if (!item.children || !item.children.length) {
|
||||
try {
|
||||
var r = await fetch('/api/library/children/' + item.id);
|
||||
var d = await r.json();
|
||||
item.children = d.items || [];
|
||||
// Mark the tree for depth calculation
|
||||
if (typeof this._markChildren === 'function') {
|
||||
this._markChildren(item.children, (item.depth || 0) + 1);
|
||||
}
|
||||
} catch(e) {
|
||||
item.children = [];
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
_markChildren(nodes, depth) {
|
||||
if (!nodes) return;
|
||||
for (var i = 0; i < nodes.length; i++) {
|
||||
nodes[i].depth = depth;
|
||||
if (nodes[i].children && nodes[i].children.length > 0) {
|
||||
this._markChildren(nodes[i].children, depth + 1);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ── Selection ──
|
||||
@@ -712,5 +742,44 @@ function libraryPage() {
|
||||
}
|
||||
};
|
||||
}
|
||||
// SortableJS — drag-and-drop reorder in library table
|
||||
document.addEventListener('alpine:init', function() {
|
||||
var initSortable = function() {
|
||||
var body = document.getElementById('lib-table-body');
|
||||
if (!body || typeof Sortable === 'undefined') return;
|
||||
// Clean up any existing instance
|
||||
if (body._sortable) body._sortable.destroy();
|
||||
body._sortable = new Sortable(body, {
|
||||
handle: '.drag-handle',
|
||||
animation: 150,
|
||||
ghostClass: 'sortable-ghost',
|
||||
onEnd: async function(evt) {
|
||||
var itemEl = evt.item;
|
||||
var itemId = itemEl.getAttribute('data-lib-id');
|
||||
var prevEl = evt.item.previousElementSibling;
|
||||
var nextEl = evt.item.nextElementSibling;
|
||||
var prevId = prevEl ? prevEl.getAttribute('data-lib-id') : null;
|
||||
var nextId = nextEl ? nextEl.getAttribute('data-lib-id') : null;
|
||||
if (!itemId) return;
|
||||
try {
|
||||
await fetch('/api/pages/' + itemId + '/move', {
|
||||
method: 'PUT',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({before_id: nextId || null, after_id: prevId || null})
|
||||
});
|
||||
} catch(e) {}
|
||||
}
|
||||
});
|
||||
};
|
||||
// Watch for table body updates with MutationObserver
|
||||
var target = document.getElementById('lib-table');
|
||||
if (target) {
|
||||
var observer = new MutationObserver(function() {
|
||||
setTimeout(initSortable, 200);
|
||||
});
|
||||
observer.observe(target, {childList: true, subtree: true});
|
||||
}
|
||||
initSortable();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -79,8 +79,7 @@
|
||||
.ws-more-item:hover{opacity:1;background:var(--bg-tertiary);}
|
||||
|
||||
/* Multi-select */
|
||||
.item-checkbox{width:14px;height:14px;flex-shrink:0;cursor:pointer;accent-color:var(--accent);opacity:0;transition:opacity 150ms;}
|
||||
.ws-tree-item:hover .item-checkbox,.ws-tree-item.selected .item-checkbox{opacity:1;}
|
||||
.item-checkbox{width:15px;height:15px;flex-shrink:0;cursor:pointer;accent-color:var(--accent);margin:0 4px;}
|
||||
.ws-tree-item.selected{background:rgba(35,131,226,.1);}
|
||||
|
||||
/* Bulk bar */
|
||||
@@ -118,6 +117,10 @@
|
||||
|
||||
/* Drag & drop */
|
||||
.ws-layout.drop-active { outline: 2px dashed var(--accent); outline-offset: -4px; border-radius: 8px; }
|
||||
/* Drag handle — 6 dots */
|
||||
.drag-handle{display:flex;flex-wrap:wrap;width:16px;height:16px;cursor:grab;align-content:space-between;justify-content:space-between;flex-shrink:0;opacity:0.45;margin:0 4px;}
|
||||
.ws-tree-item:hover .drag-handle{opacity:1;}
|
||||
.drag-handle .dot{width:3px;height:3px;background:var(--text-tertiary);border-radius:50%;}
|
||||
.drop-overlay{display:none;position:fixed;inset:0;background:rgba(35,131,226,.08);z-index:100;pointer-events:none;align-items:center;justify-content:center;}
|
||||
.drop-overlay.active{display:flex;}
|
||||
.drop-overlay .drop-hint{background:var(--bg);border:2px dashed var(--accent);border-radius:16px;padding:40px 60px;text-align:center;color:var(--text);font-size:16px;}
|
||||
@@ -942,34 +945,60 @@ window._wsInitData = (function() {
|
||||
var hasKids = c.children && c.children.length > 0;
|
||||
var icon = this._fileIcon(c.name, c.is_folder, c.content_format);
|
||||
var escName = this.esc(c.name);
|
||||
var escId = c.id;
|
||||
html += '<li><div class="ws-tree-item" style="padding-left:'+(depth*20)+'px" draggable="true"'
|
||||
+ ' data-folder-id="'+(c.is_folder?c.id:'')+'"'
|
||||
+ ' data-ws-id="'+c.id+'"'
|
||||
+ ' ondragstart="var d=window._wsData;if(d)d.onInternalDragStart(event,'+c.id+')"'
|
||||
+ ' ondragend="event.target.style.opacity=\'\'">';
|
||||
+ ' data-ws-id="'+escId+'"'
|
||||
+ ' ondragstart="var d=window._wsData;if(d)d.onInternalDragStart(event,'+escId+')"'
|
||||
+ ' ondragend="event.target.style.opacity=\\'\\'">';
|
||||
// Checkbox
|
||||
html += '<input type="checkbox" class="item-checkbox"'
|
||||
+ ' data-ws-id="'+escId+'"'
|
||||
+ ' onclick="event.stopPropagation();var d=window._wsData;if(d)d.toggleSelect({id:'+escId+',name:\\''+escName+'\\'},event)"'
|
||||
+ ' title="Select item">';
|
||||
// Drag handle
|
||||
html += '<div class="drag-handle" title="Drag to move">'
|
||||
+ '<span class="dot"></span><span class="dot"></span>'
|
||||
+ '<span class="dot"></span><span class="dot"></span>'
|
||||
+ '<span class="dot"></span><span class="dot"></span></div>';
|
||||
if (c.is_folder && hasKids) {
|
||||
html += '<button class="ws-chevron ws-expand-btn" data-ws-id="'+c.id+'" onclick="event.stopPropagation();var d=window._wsData;if(d)d.toggleExpand('+c.id+',this)">▶</button>';
|
||||
html += '<button class="ws-chevron ws-expand-btn" data-ws-id="'+escId+'" onclick="event.stopPropagation();var d=window._wsData;if(d)d.toggleExpand('+escId+',this)">▶</button>';
|
||||
} else {
|
||||
html += '<span style="width:16px;flex-shrink:0;"></span>';
|
||||
}
|
||||
html += '<span class="icon">'+icon+'</span>';
|
||||
if (c.is_folder) {
|
||||
var countBadge = (c.child_count > 0) ? ' <span class="child-count">('+c.child_count+')</span>' : '';
|
||||
html += '<span class="name folder" onclick="window.location=\'/local-workspace?folder='+c.id+'\'">'+c.name+countBadge+'</span>';
|
||||
html += '<span class="name folder" onclick="window._wsData.navigateToFolder('+escId+')">'+c.name+countBadge+'</span>';
|
||||
} else {
|
||||
html += '<span class="name file" onclick="window.location=\'/pages/'+c.id+'\'"'
|
||||
+ ' onmouseenter="var d=window._wsData;if(d)d.showPreview(event,{id:'+c.id+',name:\''+escName+'\',is_folder:false,content_format:\''+(c.content_format||'')+'\'})"'
|
||||
+ ' onmouseleave="var d=window._wsData;if(d)d.hidePreview()">'+c.name+'</span>';
|
||||
// Click to rename, double-click or button to open
|
||||
html += '<span class="name file" onclick="event.stopPropagation();window._wsData.startRenderChildRename('+escId+',\''+escName+'\')"'
|
||||
+ ' ondblclick="window.location=\\'/pages/'+escId+'\\'"'
|
||||
+ ' title="Click to rename, double-click to open"'
|
||||
+ ' onmouseenter="var d=window._wsData;if(d)d.showPreview(event,{id:'+escId+',name:\\''+escName+'\\',is_folder:false,content_format:\\''+(c.content_format||'')+'\\'})"'
|
||||
+ ' onmouseleave="var d=window._wsData;if(d)d.hidePreview()">'
|
||||
+ '<span id="rc-name-'+escId+'">'+c.name+'</span>'
|
||||
+ '<input id="rc-input-'+escId+'" class="inline-rename-input" style="display:none;width:100%"'
|
||||
+ ' onkeydown="if(event.key===\\'Enter\\')window._wsData.commitRenderChildRename('+escId+')"'
|
||||
+ ' onblur="window._wsData.commitRenderChildRename('+escId+')"'
|
||||
+ ' onclick="event.stopPropagation()">'
|
||||
+ '</span>';
|
||||
}
|
||||
html += '<div class="actions">';
|
||||
if (c.is_folder) {
|
||||
html += '<span style="display:flex;gap:2px">';
|
||||
html += '<button class="ws-act" data-ws-action="new-file" data-ws-id="'+c.id+'" data-ws-name="'+escName+'">📄</button>';
|
||||
html += '<button class="ws-act" data-ws-action="new-folder" data-ws-id="'+c.id+'" data-ws-name="'+escName+'">📁</button>';
|
||||
html += '<button class="ws-act" data-ws-action="new-file" data-ws-id="'+escId+'" data-ws-name="'+escName+'">📄</button>';
|
||||
html += '<button class="ws-act" data-ws-action="new-folder" data-ws-id="'+escId+'" data-ws-name="'+escName+'">📁</button>';
|
||||
html += '</span>';
|
||||
}
|
||||
html += '<button class="ws-act" data-ws-action="rename" data-ws-id="'+c.id+'" data-ws-name="'+escName+'">✏️</button>';
|
||||
html += '<button class="ws-act danger" data-ws-action="delete" data-ws-id="'+c.id+'" data-ws-name="'+escName+'">🗑</button>';
|
||||
html += '<button class="ws-act" data-ws-action="rename" data-ws-id="'+escId+'" data-ws-name="'+escName+'">✏️</button>';
|
||||
html += '<button class="ws-act danger" data-ws-action="delete" data-ws-id="'+escId+'" data-ws-name="'+escName+'">🗑</button>';
|
||||
// Open button for files
|
||||
if (!c.is_folder) {
|
||||
html += '<a href="/pages/'+escId+'" class="btn-open" style="display:inline-flex;align-items:center;gap:4px;padding:3px 10px;border:1px solid var(--border);border-radius:4px;font-size:12px;color:var(--text-secondary);background:var(--bg-tertiary);cursor:pointer;text-decoration:none;margin-left:4px;opacity:0;"'
|
||||
+ ' onmouseover="this.style.opacity=\\'1\\'" onmouseout="this.style.opacity=\\'0\\'"'
|
||||
+ ' onclick="event.stopPropagation();return true">⤢ Open</a>';
|
||||
}
|
||||
// Tags
|
||||
if (c.tags && c.tags.length > 0) {
|
||||
html += '<span style="display:flex;gap:2px;margin-left:4px;">';
|
||||
@@ -990,6 +1019,41 @@ window._wsInitData = (function() {
|
||||
return html;
|
||||
},
|
||||
|
||||
// Inline rename for renderChildren (static HTML)
|
||||
startRenderChildRename(id, currentName) {
|
||||
var nameSpan = document.getElementById('rc-name-' + id);
|
||||
var input = document.getElementById('rc-input-' + id);
|
||||
if (!nameSpan || !input) return;
|
||||
nameSpan.style.display = 'none';
|
||||
input.style.display = '';
|
||||
input.value = currentName;
|
||||
input.focus();
|
||||
input.select();
|
||||
},
|
||||
|
||||
async commitRenderChildRename(id) {
|
||||
var nameSpan = document.getElementById('rc-name-' + id);
|
||||
var input = document.getElementById('rc-input-' + id);
|
||||
if (!input) return;
|
||||
var newName = (input.value || '').trim();
|
||||
input.style.display = 'none';
|
||||
if (nameSpan) nameSpan.style.display = '';
|
||||
if (!newName) return;
|
||||
try {
|
||||
var r = await fetch('/api/local-workspace/items/' + id, {
|
||||
method: 'PUT', headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify({name: newName})
|
||||
});
|
||||
if (r.ok) {
|
||||
if (nameSpan) nameSpan.textContent = newName;
|
||||
// Update tree node
|
||||
var node = this._findNodeById(this.tree, id);
|
||||
if (node) { node.name = newName; }
|
||||
this._reloadAfterAction();
|
||||
}
|
||||
} catch(e) {}
|
||||
},
|
||||
|
||||
esc(s) {
|
||||
if (!s) return '';
|
||||
return s.replace(/\\/g,'\\\\').replace(/'/g,"\\'").replace(/"/g,'"');
|
||||
@@ -2166,6 +2230,12 @@ console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(wind
|
||||
:checked="!!selectedIds[node.id]"
|
||||
@click.stop="toggleSelect(node, $event)"
|
||||
title="Select item">
|
||||
<!-- drag handle -->
|
||||
<div class="drag-handle" style="margin-right:2px;" title="Drag to move">
|
||||
<span class="dot"></span><span class="dot"></span>
|
||||
<span class="dot"></span><span class="dot"></span>
|
||||
<span class="dot"></span><span class="dot"></span>
|
||||
</div>
|
||||
<!-- chevron -->
|
||||
<template x-if="node.is_folder && node.children && node.children.length > 0">
|
||||
<button class="ws-chevron"
|
||||
@@ -2190,10 +2260,10 @@ console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(wind
|
||||
</span>
|
||||
</template>
|
||||
<template x-if="!node.is_folder">
|
||||
<span class="name file" @click.stop="openPage(node.id)"
|
||||
@dblclick.stop="startInlineRename(node, $event)"
|
||||
<span class="name file" @click.stop="startInlineRename(node, $event)"
|
||||
@mouseenter="showPreview($event, node)"
|
||||
@mouseleave="hidePreview()">
|
||||
@mouseleave="hidePreview()"
|
||||
title="Click to rename, double-click to open">
|
||||
<span x-show="renamingId !== node.id" x-text="node.name"></span>
|
||||
<input x-show="renamingId === node.id" class="inline-rename-input" x-model="renamingName"
|
||||
@keydown.enter="commitRename(node)" @keydown.escape="cancelRename()"
|
||||
|
||||
Reference in New Issue
Block a user