fix: drag-drop cible dossier, icônes seules, filtre recherche, preview hover
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped

1. Drag & drop: ajout data-folder-id sur les items x-for et renderChildren.
   onDrop lit cet attribut au lieu de parser onclick (cassé pour x-for).
   Corrige le bug où les fichiers droppés allaient toujours à la racine.

2. Boutons création: remplacés par .btn-icon (icône seule, 34px, tooltip
   natif). Style Notion épuré.

3. Barre de recherche: champ 🔍 qui filtre displayTree en temps réel.
   Matching récursif (un dossier match si lui ou un enfant match).

4. Preview hover: popup positionné au curseur montrant les 500 premiers
   caractères du fichier. Fonctionne sur x-for et renderChildren.
This commit is contained in:
2026-07-11 09:28:31 -04:00
parent 2314af9825
commit c59f38659e
2 changed files with 101 additions and 16 deletions
+1
View File
@@ -523,6 +523,7 @@ async def local_workspace_page(request: Request, folder: int = None):
"user": user,
"workspace_name": sidebar.get("active_ws_name", "My Workspace"),
"current_folder_id": current_folder_id or 0,
"workspace_id": ws_id or 0,
"breadcrumb": breadcrumb,
}
template = env.get_template("local_workspace.html")
+100 -16
View File
@@ -68,6 +68,9 @@
.btn-danger{background:rgba(255,80,80,.1);color:var(--danger);}
.btn-danger:hover{background:rgba(255,80,80,.2);}
.btn-icon{width:34px;height:34px;display:inline-flex;align-items:center;justify-content:center;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;cursor:pointer;font-size:16px;color:var(--text-secondary);transition:all 150ms;}
.btn-icon:hover{background:var(--bg-hover);color:var(--text);border-color:var(--border-strong);}
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.6);display:flex;align-items:center;justify-content:center;z-index:999;backdrop-filter:blur(4px);}
.modal-box{background:#1F1F1F;border:1px solid var(--border-strong);border-radius:16px;padding:28px;width:420px;max-width:90vw;box-shadow:0 20px 60px rgba(0,0,0,.5);}
.modal-box h3{font-size:16px;margin-bottom:6px;display:flex;align-items:center;gap:8px;}
@@ -94,6 +97,20 @@
.upload-progress{position:fixed;bottom:24px;right:24px;background:var(--bg);border:1px solid var(--border-strong);border-radius:12px;padding:16px 20px;z-index:200;box-shadow:0 8px 30px rgba(0,0,0,.4);min-width:200px;}
.upload-progress .up-bar{height:4px;background:var(--bg-tertiary);border-radius:2px;margin-top:8px;overflow:hidden;}
.upload-progress .up-bar-fill{height:100%;background:var(--accent);border-radius:2px;transition:width 200ms;}
/* Search bar */
.search-bar{display:flex;align-items:center;gap:8px;margin-bottom:12px;padding:8px 12px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;}
.search-bar:focus-within{border-color:var(--accent);}
.search-icon{font-size:14px;color:var(--text-tertiary);flex-shrink:0;}
.search-input{flex:1;background:none;border:none;color:var(--text);font-size:13px;outline:none;}
.search-input::placeholder{color:var(--text-tertiary);}
.search-clear{background:none;border:none;color:var(--text-tertiary);cursor:pointer;font-size:16px;padding:2px 4px;border-radius:3px;}
.search-clear:hover{color:var(--text);background:var(--bg-hover);}
/* File preview tooltip */
.file-preview{position:fixed;z-index:300;background:var(--bg);border:1px solid var(--border-strong);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);border-bottom:1px solid var(--border);}
.file-preview-body{padding:10px 12px;margin:0;font-size:12px;line-height:1.5;color:var(--text);max-height:200px;overflow:auto;font-family:'Fira Code','Cascadia Code',monospace;white-space:pre-wrap;word-wrap:break-word;}
</style>
<div class="ws-layout" x-data="wsInit()"
@@ -141,17 +158,26 @@
📁 <span x-text="breadcrumb.length > 0 ? breadcrumb[breadcrumb.length-1].name : ''"></span>
</h1>
<div class="ws-toolbar">
<button class="btn btn-primary" @click="openModal('file')">📄 New File</button>
<button class="btn btn-secondary" @click="openModal('folder')">📁 New Folder</button>
<button class="btn-icon" @click="openModal('file')" title="New file">📄</button>
<button class="btn-icon" @click="openModal('folder')" title="New folder">📁</button>
</div>
</div>
<!-- Tree -->
<ul class="ws-tree" x-show="tree.length>0">
<template x-for="node in tree" :key="node.id">
<!-- Search bar -->
<div class="search-bar" x-show="tree.length>0 || searchQuery">
<span class="search-icon">🔍</span>
<input class="search-input" type="text" placeholder="Filter files & folders..."
x-model="searchQuery" @input="doFilter" autocomplete="off">
<button class="search-clear" x-show="searchQuery" @click="searchQuery='';doFilter()" title="Clear">×</button>
</div>
<ul class="ws-tree" x-show="displayTree.length>0">
<template x-for="node in displayTree" :key="node.id">
<li>
<div class="ws-tree-item" :style="{paddingLeft:(node.depth||0)*20+'px'}"
draggable="true"
:data-folder-id="node.is_folder ? node.id : null"
@dragstart="onInternalDragStart($event, node.id)"
@dragend="draggedItemId = null; $el.style.opacity = ''">
<!-- chevron -->
@@ -170,7 +196,9 @@
<span class="name folder" @click.stop="navigateToFolder(node.id)" x-text="node.name"></span>
</template>
<template x-if="!node.is_folder">
<span class="name file" @click.stop="openPage(node.id)" x-text="node.name"></span>
<span class="name file" @click.stop="openPage(node.id)" x-text="node.name"
@mouseenter="showPreview($event, node)"
@mouseleave="hidePreview()"></span>
</template>
<div class="actions">
@@ -198,8 +226,8 @@
<h2>Empty folder</h2>
<p>Create a new file or folder to get started</p>
<div class="empty-actions">
<button class="btn btn-primary" @click="openModal('file')">📄 New File</button>
<button class="btn btn-secondary" @click="openModal('folder')">📁 New Folder</button>
<button class="btn-icon" @click="openModal('file')" title="New file">📄</button>
<button class="btn-icon" @click="openModal('folder')" title="New folder">📁</button>
</div>
</div>
@@ -245,6 +273,14 @@
</div>
</div>
<!-- Preview tooltip -->
<div class="file-preview" x-show="previewVisible"
:style="{ left: previewX + 'px', top: previewY + 'px' }"
@mouseenter="previewVisible = true" @mouseleave="previewVisible = false">
<div class="file-preview-header" x-text="previewName"></div>
<pre class="file-preview-body" x-text="previewContent"></pre>
</div>
</div>
<script>
@@ -252,6 +288,7 @@ document.addEventListener('alpine:init', function() {
Alpine.data('wsInit', function() {
return {
tree:[],
displayTree:[],
breadcrumb: [],
loaded:false,
showCreate:false,
@@ -263,6 +300,14 @@ document.addEventListener('alpine:init', function() {
target:null,
expanded: {},
currentFolder: {{ current_folder_id }},
searchQuery: '',
// Preview popup
previewVisible: false,
previewContent: '',
previewName: '',
previewX: 0,
previewY: 0,
// Drag & drop state
isDragging: false,
@@ -282,6 +327,7 @@ document.addEventListener('alpine:init', function() {
var r = await fetch(url);
var d = await r.json();
this.tree = this.addDepths(d.tree || [], 0);
this.displayTree = this.tree;
this.breadcrumb = d.breadcrumb || [];
this.loaded = true;
} catch(e) { console.error('Tree load error:', e); this.loaded = true; }
@@ -307,7 +353,7 @@ document.addEventListener('alpine:init', function() {
var c = children[i];
var hasKids = c.children && c.children.length > 0;
var icon = c.is_folder ? '📁' : '📄';
html += '<li><div class="ws-tree-item" style="padding-left:'+(depth*20)+'px" draggable="true" ondragstart="var d=document.querySelector(\'[x-data]\').__x.$data;d.onInternalDragStart(event,'+c.id+')" ondragend="event.target.style.opacity=\'\'">';
html += '<li><div class="ws-tree-item" style="padding-left:'+(depth*20)+'px" draggable="true" data-folder-id="'+(c.is_folder?c.id:'')+'" ondragstart="var d=document.querySelector(\'[x-data]\').__x.$data;d.onInternalDragStart(event,'+c.id+')" ondragend="event.target.style.opacity=\'\'">';
if (c.is_folder && hasKids) {
html += '<button class="ws-chevron" onclick="event.stopPropagation();document.querySelector(\'[x-data]\').__x.$data.toggleExpand('+c.id+')">▶</button>';
} else {
@@ -317,7 +363,7 @@ document.addEventListener('alpine:init', function() {
if (c.is_folder) {
html += '<span class="name folder" onclick="window.location=\'/local-workspace?folder='+c.id+'\'">'+c.name+'</span>';
} else {
html += '<span class="name file" onclick="window.location=\'/pages/'+c.id+'\'">'+c.name+'</span>';
html += '<span class="name file" onclick="window.location=\'/pages/'+c.id+'\'" onmouseenter="var d=document.querySelector(\'[x-data]\').__x.$data;d.showPreview(event,{name:\''+this.esc(c.name)+'\',is_folder:false})" onmouseleave="var d=document.querySelector(\'[x-data]\').__x.$data;d.hidePreview()">'+c.name+'</span>';
}
html += '<div class="actions">';
if (c.is_folder) {
@@ -351,6 +397,46 @@ document.addEventListener('alpine:init', function() {
window.location = '/local-workspace?folder=' + id;
},
// ── Search / Filter ──
doFilter() {
var q = (this.searchQuery || '').toLowerCase().trim();
if (!q) { this.displayTree = this.tree; return; }
var self = this;
this.displayTree = this.tree.filter(function(n) {
return self._itemMatches(n, q);
});
},
_itemMatches(node, query) {
if ((node.name || '').toLowerCase().indexOf(query) !== -1) return true;
if (node.children && node.children.length > 0) {
for (var i = 0; i < node.children.length; i++) {
if (this._itemMatches(node.children[i], query)) return true;
}
}
return false;
},
// ── Preview on hover ──
showPreview(ev, node) {
if (node.is_folder) return;
this.previewName = node.name;
this.previewX = ev.clientX + 12;
this.previewY = ev.clientY + 12;
this.previewContent = 'Loading...';
this.previewVisible = true;
var self = this;
var wsId = {{ workspace_id }} || 1;
fetch('/api/files/' + wsId + '/' + encodeURIComponent(node.name))
.then(function(r) { return r.text(); })
.then(function(t) {
self.previewContent = t.substring(0, 500);
})
.catch(function() { self.previewContent = '(preview not available)'; });
},
hidePreview() {
this.previewVisible = false;
},
openModal(type, parent) {
this.createType = type;
this.parentFolder = parent||null;
@@ -473,9 +559,8 @@ document.addEventListener('alpine:init', function() {
if (folderEl) {
var folderLink = folderEl.querySelector('.name.folder');
if (folderLink) {
var onclick = folderLink.getAttribute('onclick') || '';
var m = onclick.match(/folder=(\d+)/);
if (m) targetParentId = parseInt(m[1]);
var fid = folderEl.getAttribute('data-folder-id');
if (fid) targetParentId = parseInt(fid);
}
}
// Don't drop on self or same parent
@@ -493,10 +578,9 @@ document.addEventListener('alpine:init', function() {
var folderLink = folderEl.querySelector('.name.folder');
if (folderLink) {
parentName = folderLink.textContent.trim();
// Extract folder ID from the onclick handler in the HTML
var onclick = folderLink.getAttribute('onclick') || '';
var m = onclick.match(/folder=(\d+)/);
if (m) parentId = parseInt(m[1]);
// Use data-folder-id attribute (works for both x-for and renderChildren)
var fid = folderEl.getAttribute('data-folder-id');
if (fid) parentId = parseInt(fid);
}
}