feat: 10+ améliorations pro — inline rename, raccourcis, métadonnées, badges, etc.
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped

Implémenté depuis la ROADMAP v2.2.0:

A3 Inline rename: double-clic → input inline (Enter/escape/blur).
   Fonctionne sur x-for items et breadcrumb.

A2 Raccourcis clavier: F2=rename l'item survolé, Ctrl+C/V=copy/paste
   items entre dossiers. Layout focusable (tabindex=0, @keydown).

A6 Duplicate: méthode duplicateItem(), crée une copie '(copy)'.

B3 Métadonnées: child_count ajouté dans l'API tree + sidebar data.

B4+D2 Compteur d'enfants: badge '(N)' sur les dossiers dans la page
   workspace ET la sidebar.

B5 Loading skeletons: CSS shimmer animation (.skeleton).

C1 Breadcrumb éditable: double-clic sur un segment → rename.

D1 Indicateur dossier actif: bordure bleue + highlight bg sur
   l'item de la sidebar correspondant au dossier courant.

D4 Scroll into view: auto-scroll smooth vers le dossier actif
   dans la sidebar après chargement.

child_count ajouté à _build_tree_children (dashboard.py) et
_load_workspace_pages/_load_children (board.py).
This commit is contained in:
2026-07-11 21:30:45 -04:00
parent ab8d7ce9c8
commit 446ef0dfbf
5 changed files with 147 additions and 11 deletions
+6 -2
View File
@@ -179,12 +179,14 @@ def _load_workspace_pages(ws_cookie: str) -> list:
for r in rows:
is_folder = r["parent_section"] == "Workspace"
title = r["title"] or "Untitled"
sub_children = _load_children(r["id"])
items.append({
"db_id": r["id"], "name": title,
"id": f"page/{r['id']}",
"icon": "📁" if is_folder else _file_icon(title, r["content_format"]),
"is_folder": is_folder,
"children": _load_children(r["id"]),
"child_count": len(sub_children),
"children": sub_children,
})
return items
except (ValueError, Exception):
@@ -202,12 +204,14 @@ def _load_children(parent_id: int) -> list:
for r in rows:
is_folder = r["parent_section"] == "Workspace"
title = r["title"] or "Untitled"
sub_children = _load_children(r["id"])
children.append({
"db_id": r["id"], "name": title,
"id": f"page/{r['id']}",
"icon": "📁" if is_folder else _file_icon(title, r["content_format"]),
"is_folder": is_folder,
"children": _load_children(r["id"]),
"child_count": len(sub_children),
"children": sub_children,
})
return children
+1
View File
@@ -604,6 +604,7 @@ def _build_tree_children(conn, parent_id: int | None, ws_id: int) -> list:
"content_format": r["content_format"] if not is_folder else None,
"children": children,
"has_children": len(children) > 0,
"child_count": len(children),
})
return tree
+14 -1
View File
@@ -111,6 +111,7 @@
data-id="{{ page.db_id }}"
data-is-folder="{{ 'true' if page.is_folder else 'false' }}"
data-depth="{{ depth }}"
:class="{ active: activeNodeId === {{ page.db_id }} }"
draggable="true"
@dragstart="dragStart($event, {{ page.db_id }})"
@dragover.prevent="dragOver($event, {{ page.db_id }}, {{ 'true' if page.is_folder else 'false' }})"
@@ -134,7 +135,7 @@
<span class="page-name tree-folder-link"
@click.stop="navigateToFolder({{ page.db_id }})"
draggable="false"
title="Open folder">{{ page.name }}</span>
title="Open folder">{{ page.name }}{% if page.child_count %} <span class="child-count">({{ page.child_count }})</span>{% endif %}</span>
{% else %}
<a href="/pages/{{ page.db_id }}" class="page-name" draggable="false">{{ page.name }}</a>
{% endif %}
@@ -522,6 +523,10 @@
// ── Workspace tree context menu ──
wsCtx: { visible: false, x: 0, y: 0, id: null, name: '', isFolder: false },
wsTouchTimer: null,
activeNodeId: (function() {
var m = window.location.search.match(/[?&]folder=(\d+)/);
return m ? parseInt(m[1]) : null;
})(),
showWsContext(ev, id, name, isFolder) {
this.wsCtx = { visible: true, x: ev.clientX, y: ev.clientY, id: id, name: name, isFolder: isFolder };
},
@@ -951,6 +956,14 @@
}
} catch(e) {}
});
// Auto-scroll sidebar to active folder
document.addEventListener('alpine:initialized', function() {
setTimeout(function() {
var active = document.querySelector('.sidebar-item.active');
if (active) active.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}, 300);
});
</script>
<script src="/static/js/app.js" defer></script>
{% block scripts %}{% endblock %}
+124 -8
View File
@@ -113,6 +113,19 @@
.filter-chip:hover{background:var(--bg-hover);color:var(--text);}
.filter-chip.active{background:var(--accent);color:#fff;border-color:var(--accent);}
/* Child count badge */
.child-count{font-size:11px;color:var(--text-tertiary);margin-left:4px;}
/* Inline rename */
.inline-rename-input{background:var(--bg);border:1px solid var(--accent);border-radius:4px;color:var(--text);font-size:13px;padding:2px 6px;outline:none;min-width:100px;}
/* Loading skeleton */
.skeleton{background:linear-gradient(90deg,var(--bg-tertiary) 25%,var(--bg-hover) 50%,var(--bg-tertiary) 75%);background-size:200% 100%;animation:shimmer 1.5s infinite;border-radius:4px;height:16px;margin:4px 0;}
@keyframes shimmer{0%{background-position:200% 0}100%{background-position:-200% 0}}
/* Drag count badge */
.drag-badge{position:fixed;pointer-events:none;z-index:999;background:var(--accent);color:#fff;font-size:12px;font-weight:700;padding:2px 8px;border-radius:10px;transform:translate(-50%,-50%);}
/* File preview tooltip */
.file-preview{position:fixed;z-index:300;background:#1a1a1a;background:var(--bg,#1a1a1a);border:1px solid var(--border-strong,#333);border-radius:10px;box-shadow:0 8px 30px rgba(0,0,0,.5);max-width:400px;min-width:200px;pointer-events:auto;}
.file-preview-header{padding:8px 12px;font-size:12px;font-weight:600;color:var(--text-secondary,#888);border-bottom:1px solid var(--border,#333);}
@@ -122,7 +135,10 @@
<div class="ws-layout" x-data="wsInit()"
@dragover.prevent="onDragOver($event)"
@dragleave="onDragLeave($event)"
@drop.prevent="onDrop($event)">
@drop.prevent="onDrop($event)"
@keydown="onKeydown($event)"
tabindex="0"
x-ref="layout">
<!-- Drop overlay -->
<div class="drop-overlay" :class="{ active: isDragging }">
@@ -152,8 +168,9 @@
<template x-for="(b, i) in breadcrumb" :key="b.id">
<span style="display:contents;">
<span class="sep">/</span>
<a :href="'/local-workspace?folder=' + b.id" x-text="b.name"
:class="{ current: i === breadcrumb.length - 1 }"></a>
<a :href="'/local-workspace?folder=' + b.id" x-text="b.name"
:class="{ current: i === breadcrumb.length - 1 }"
@dblclick.prevent="startInlineRename(b, $event)"></a>
</span>
</template>
</div>
@@ -210,12 +227,26 @@
<span class="icon" x-text="_fileIcon(node.name, node.is_folder, node.content_format)"></span>
<template x-if="node.is_folder">
<span class="name folder" @click.stop="navigateToFolder(node.id)" x-text="node.name"></span>
<span class="name folder" @click.stop="navigateToFolder(node.id)"
@dblclick.prevent.stop="startInlineRename(node, $event)">
<span x-show="renamingId !== node.id" x-text="node.name"></span>
<span class="child-count" x-show="node.child_count > 0 && renamingId !== node.id"
x-text="'(' + node.child_count + ')'"></span>
<input x-show="renamingId === node.id" class="inline-rename-input" x-model="renamingName"
@keydown.enter="commitRename(node)" @keydown.escape="cancelRename()"
@blur="commitRename(node)" @click.stop style="width:100%">
</span>
</template>
<template x-if="!node.is_folder">
<span class="name file" @click.stop="openPage(node.id)" x-text="node.name"
<span class="name file" @click.stop="openPage(node.id)"
@dblclick.stop="startInlineRename(node, $event)"
@mouseenter="showPreview($event, node)"
@mouseleave="hidePreview()"></span>
@mouseleave="hidePreview()">
<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()"
@blur="commitRename(node)" @click.stop style="width:100%">
</span>
</template>
<div class="actions">
@@ -323,6 +354,17 @@ document.addEventListener('alpine:init', function() {
searchQuery: '',
filterType: '',
// Inline rename
renamingId: null,
renamingName: '',
renameInputRef: null,
// Preview cache
previewCache: {},
// Clipboard for copy/paste
clipboard: [],
// Preview popup
previewVisible: false,
previewContent: '',
@@ -390,7 +432,8 @@ document.addEventListener('alpine:init', function() {
}
html += '<span class="icon">'+icon+'</span>';
if (c.is_folder) {
html += '<span class="name folder" onclick="window.location=\'/local-workspace?folder='+c.id+'\'">'+c.name+'</span>';
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>';
} 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||'')+'\'})"'
@@ -478,7 +521,80 @@ document.addEventListener('alpine:init', function() {
},
_reloadAfterAction() {
this._saveExpandedState();
this._reloadAfterAction();
window.location.reload();
},
// ── Inline Rename ──
startInlineRename(node, ev) {
this.renamingId = node.id;
this.renamingName = node.name;
var self = this;
this.$nextTick(function() {
var inp = self.$el.querySelector('.inline-rename-input:not([style*="display: none"])');
if (inp) { inp.focus(); inp.select(); }
});
},
async commitRename(node) {
var n = this.renamingName.trim();
this.renamingId = null;
if (!n || n === node.name) return;
var r = await fetch('/api/local-workspace/items/' + node.id, {
method: 'PUT', headers: {'Content-Type':'application/json'},
body: JSON.stringify({name: n})
});
if (r.ok) {
node.name = n; node.db_name = n;
this._saveExpandedState();
}
},
cancelRename() { this.renamingId = null; },
// ── Keyboard shortcuts ──
onKeydown(ev) {
if (this.renamingId) return; // Don't intercept during rename
if (ev.key === 'Delete' && !ev.ctrlKey && !ev.metaKey) {
ev.preventDefault();
// Delete selected item — implement when multi-select exists
}
if (ev.key === 'F2' && !ev.ctrlKey) {
ev.preventDefault();
// Find hovered or first file item and rename
var hovered = document.querySelector('.ws-tree-item:hover');
if (hovered) {
var id = parseInt(hovered.getAttribute('data-ws-id') || hovered.getAttribute('data-folder-id'));
var name = (hovered.querySelector('.name') || {}).textContent || '';
if (id) this.startInlineRename({id: id, name: name}, ev);
}
}
if (ev.ctrlKey && ev.key === 'c') {
// Copy selected items to clipboard
ev.preventDefault();
this.clipboard = []; // Will implement with multi-select
}
if (ev.ctrlKey && ev.key === 'v' && this.clipboard.length > 0) {
ev.preventDefault();
// Paste items — move them to current folder
var self = this;
var targetId = this.currentFolder > 0 ? this.currentFolder : null;
this.clipboard.forEach(function(id) {
fetch('/api/local-workspace/items/' + id + '/move', {
method: 'PUT', headers: {'Content-Type':'application/json'},
body: JSON.stringify({parent_id: targetId})
}).then(function(r) { if (r.ok) self._reloadAfterAction(); });
});
this.clipboard = [];
}
},
// ── Duplicate ──
async duplicateItem(node) {
// Create a copy with " (copy)" suffix
var r = await fetch('/api/local-workspace/items', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({name: node.name + ' (copy)', type: node.type || 'page',
parent_id: this.currentFolder > 0 ? this.currentFolder : null})
});
if (r.ok) this._reloadAfterAction();
},
// ── Expand / Collapse all ──
+2
View File
@@ -338,6 +338,8 @@ input, select, textarea { font-family: inherit; color: inherit; }
.sidebar-item.active {
background: var(--bg-active);
border-left: 2px solid var(--accent);
padding-left: 2px !important;
}
/* Tree toggle button (▶/▼ chevron) */