fix: drag-drop cible dossier, icônes seules, filtre recherche, preview hover
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:
@@ -523,6 +523,7 @@ async def local_workspace_page(request: Request, folder: int = None):
|
|||||||
"user": user,
|
"user": user,
|
||||||
"workspace_name": sidebar.get("active_ws_name", "My Workspace"),
|
"workspace_name": sidebar.get("active_ws_name", "My Workspace"),
|
||||||
"current_folder_id": current_folder_id or 0,
|
"current_folder_id": current_folder_id or 0,
|
||||||
|
"workspace_id": ws_id or 0,
|
||||||
"breadcrumb": breadcrumb,
|
"breadcrumb": breadcrumb,
|
||||||
}
|
}
|
||||||
template = env.get_template("local_workspace.html")
|
template = env.get_template("local_workspace.html")
|
||||||
|
|||||||
@@ -68,6 +68,9 @@
|
|||||||
.btn-danger{background:rgba(255,80,80,.1);color:var(--danger);}
|
.btn-danger{background:rgba(255,80,80,.1);color:var(--danger);}
|
||||||
.btn-danger:hover{background:rgba(255,80,80,.2);}
|
.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-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{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;}
|
.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{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{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;}
|
.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>
|
</style>
|
||||||
|
|
||||||
<div class="ws-layout" x-data="wsInit()"
|
<div class="ws-layout" x-data="wsInit()"
|
||||||
@@ -141,17 +158,26 @@
|
|||||||
📁 <span x-text="breadcrumb.length > 0 ? breadcrumb[breadcrumb.length-1].name : ''"></span>
|
📁 <span x-text="breadcrumb.length > 0 ? breadcrumb[breadcrumb.length-1].name : ''"></span>
|
||||||
</h1>
|
</h1>
|
||||||
<div class="ws-toolbar">
|
<div class="ws-toolbar">
|
||||||
<button class="btn btn-primary" @click="openModal('file')">📄 New File</button>
|
<button class="btn-icon" @click="openModal('file')" title="New file">📄</button>
|
||||||
<button class="btn btn-secondary" @click="openModal('folder')">📁 New Folder</button>
|
<button class="btn-icon" @click="openModal('folder')" title="New folder">📁</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tree -->
|
<!-- Tree -->
|
||||||
<ul class="ws-tree" x-show="tree.length>0">
|
<!-- Search bar -->
|
||||||
<template x-for="node in tree" :key="node.id">
|
<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>
|
<li>
|
||||||
<div class="ws-tree-item" :style="{paddingLeft:(node.depth||0)*20+'px'}"
|
<div class="ws-tree-item" :style="{paddingLeft:(node.depth||0)*20+'px'}"
|
||||||
draggable="true"
|
draggable="true"
|
||||||
|
:data-folder-id="node.is_folder ? node.id : null"
|
||||||
@dragstart="onInternalDragStart($event, node.id)"
|
@dragstart="onInternalDragStart($event, node.id)"
|
||||||
@dragend="draggedItemId = null; $el.style.opacity = ''">
|
@dragend="draggedItemId = null; $el.style.opacity = ''">
|
||||||
<!-- chevron -->
|
<!-- chevron -->
|
||||||
@@ -170,7 +196,9 @@
|
|||||||
<span class="name folder" @click.stop="navigateToFolder(node.id)" x-text="node.name"></span>
|
<span class="name folder" @click.stop="navigateToFolder(node.id)" x-text="node.name"></span>
|
||||||
</template>
|
</template>
|
||||||
<template x-if="!node.is_folder">
|
<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>
|
</template>
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
@@ -198,8 +226,8 @@
|
|||||||
<h2>Empty folder</h2>
|
<h2>Empty folder</h2>
|
||||||
<p>Create a new file or folder to get started</p>
|
<p>Create a new file or folder to get started</p>
|
||||||
<div class="empty-actions">
|
<div class="empty-actions">
|
||||||
<button class="btn btn-primary" @click="openModal('file')">📄 New File</button>
|
<button class="btn-icon" @click="openModal('file')" title="New file">📄</button>
|
||||||
<button class="btn btn-secondary" @click="openModal('folder')">📁 New Folder</button>
|
<button class="btn-icon" @click="openModal('folder')" title="New folder">📁</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -245,6 +273,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -252,6 +288,7 @@ document.addEventListener('alpine:init', function() {
|
|||||||
Alpine.data('wsInit', function() {
|
Alpine.data('wsInit', function() {
|
||||||
return {
|
return {
|
||||||
tree:[],
|
tree:[],
|
||||||
|
displayTree:[],
|
||||||
breadcrumb: [],
|
breadcrumb: [],
|
||||||
loaded:false,
|
loaded:false,
|
||||||
showCreate:false,
|
showCreate:false,
|
||||||
@@ -263,6 +300,14 @@ document.addEventListener('alpine:init', function() {
|
|||||||
target:null,
|
target:null,
|
||||||
expanded: {},
|
expanded: {},
|
||||||
currentFolder: {{ current_folder_id }},
|
currentFolder: {{ current_folder_id }},
|
||||||
|
searchQuery: '',
|
||||||
|
|
||||||
|
// Preview popup
|
||||||
|
previewVisible: false,
|
||||||
|
previewContent: '',
|
||||||
|
previewName: '',
|
||||||
|
previewX: 0,
|
||||||
|
previewY: 0,
|
||||||
|
|
||||||
// Drag & drop state
|
// Drag & drop state
|
||||||
isDragging: false,
|
isDragging: false,
|
||||||
@@ -282,6 +327,7 @@ document.addEventListener('alpine:init', function() {
|
|||||||
var r = await fetch(url);
|
var r = await fetch(url);
|
||||||
var d = await r.json();
|
var d = await r.json();
|
||||||
this.tree = this.addDepths(d.tree || [], 0);
|
this.tree = this.addDepths(d.tree || [], 0);
|
||||||
|
this.displayTree = this.tree;
|
||||||
this.breadcrumb = d.breadcrumb || [];
|
this.breadcrumb = d.breadcrumb || [];
|
||||||
this.loaded = true;
|
this.loaded = true;
|
||||||
} catch(e) { console.error('Tree load error:', e); 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 c = children[i];
|
||||||
var hasKids = c.children && c.children.length > 0;
|
var hasKids = c.children && c.children.length > 0;
|
||||||
var icon = c.is_folder ? '📁' : '📄';
|
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) {
|
if (c.is_folder && hasKids) {
|
||||||
html += '<button class="ws-chevron" onclick="event.stopPropagation();document.querySelector(\'[x-data]\').__x.$data.toggleExpand('+c.id+')">▶</button>';
|
html += '<button class="ws-chevron" onclick="event.stopPropagation();document.querySelector(\'[x-data]\').__x.$data.toggleExpand('+c.id+')">▶</button>';
|
||||||
} else {
|
} else {
|
||||||
@@ -317,7 +363,7 @@ document.addEventListener('alpine:init', function() {
|
|||||||
if (c.is_folder) {
|
if (c.is_folder) {
|
||||||
html += '<span class="name folder" onclick="window.location=\'/local-workspace?folder='+c.id+'\'">'+c.name+'</span>';
|
html += '<span class="name folder" onclick="window.location=\'/local-workspace?folder='+c.id+'\'">'+c.name+'</span>';
|
||||||
} else {
|
} 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">';
|
html += '<div class="actions">';
|
||||||
if (c.is_folder) {
|
if (c.is_folder) {
|
||||||
@@ -351,6 +397,46 @@ document.addEventListener('alpine:init', function() {
|
|||||||
window.location = '/local-workspace?folder=' + id;
|
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) {
|
openModal(type, parent) {
|
||||||
this.createType = type;
|
this.createType = type;
|
||||||
this.parentFolder = parent||null;
|
this.parentFolder = parent||null;
|
||||||
@@ -473,9 +559,8 @@ document.addEventListener('alpine:init', function() {
|
|||||||
if (folderEl) {
|
if (folderEl) {
|
||||||
var folderLink = folderEl.querySelector('.name.folder');
|
var folderLink = folderEl.querySelector('.name.folder');
|
||||||
if (folderLink) {
|
if (folderLink) {
|
||||||
var onclick = folderLink.getAttribute('onclick') || '';
|
var fid = folderEl.getAttribute('data-folder-id');
|
||||||
var m = onclick.match(/folder=(\d+)/);
|
if (fid) targetParentId = parseInt(fid);
|
||||||
if (m) targetParentId = parseInt(m[1]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Don't drop on self or same parent
|
// Don't drop on self or same parent
|
||||||
@@ -493,10 +578,9 @@ document.addEventListener('alpine:init', function() {
|
|||||||
var folderLink = folderEl.querySelector('.name.folder');
|
var folderLink = folderEl.querySelector('.name.folder');
|
||||||
if (folderLink) {
|
if (folderLink) {
|
||||||
parentName = folderLink.textContent.trim();
|
parentName = folderLink.textContent.trim();
|
||||||
// Extract folder ID from the onclick handler in the HTML
|
// Use data-folder-id attribute (works for both x-for and renderChildren)
|
||||||
var onclick = folderLink.getAttribute('onclick') || '';
|
var fid = folderEl.getAttribute('data-folder-id');
|
||||||
var m = onclick.match(/folder=(\d+)/);
|
if (fid) parentId = parseInt(fid);
|
||||||
if (m) parentId = parseInt(m[1]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user