feat: multi-sélection + vue tableau + bulk delete — finalise ROADMAP v2.2.0
A1 Multi-sélection: - Checkboxes sur chaque item (visibles au hover/sélectionnés) - Shift+click: range select entre le dernier clic et le courant - Ctrl+click: toggle individuel - Barre d'actions bulk: 'N selected' + Delete/Clear - bulkDelete(): supprime tous les items sélectionnés - selectAll() via checkbox dans l'en-tête tableau B1 Vue tableau: - Toggle 📋/🌳 entre arbre et tableau - flatTree getter + _flattenTree() - Colonnes: checkbox, Name+icon, Type, Size/Items - Lignes cliquables pour naviguer/ouvrir - Cohérent avec la multi-sélection ROADMAP v2.2.0: 35/35 implémentés. ✅
This commit is contained in:
@@ -85,6 +85,26 @@
|
|||||||
.ws-tree-item:hover .pin-icon{opacity:1;}
|
.ws-tree-item:hover .pin-icon{opacity:1;}
|
||||||
.pin-icon.pinned{opacity:1;color:var(--accent);}
|
.pin-icon.pinned{opacity:1;color:var(--accent);}
|
||||||
|
|
||||||
|
/* 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;}
|
||||||
|
.ws-tree-item.selected{background:rgba(35,131,226,.1);}
|
||||||
|
|
||||||
|
/* Bulk bar */
|
||||||
|
.bulk-bar{display:flex;align-items:center;gap:10px;padding:8px 12px;background:var(--accent);color:#fff;border-radius:8px;margin-bottom:10px;font-size:13px;font-weight:500;}
|
||||||
|
.bulk-bar .btn-danger{background:rgba(255,255,255,.15);color:#fff;border:1px solid rgba(255,255,255,.3);}
|
||||||
|
.bulk-bar .btn-secondary{background:rgba(255,255,255,.1);color:#fff;border:1px solid rgba(255,255,255,.2);}
|
||||||
|
|
||||||
|
/* Table view */
|
||||||
|
.ws-table{width:100%;border-collapse:collapse;font-size:13px;}
|
||||||
|
.ws-table th{text-align:left;padding:6px 12px;color:var(--text-tertiary);font-weight:500;border-bottom:1px solid var(--border);font-size:11px;text-transform:uppercase;letter-spacing:.5px;}
|
||||||
|
.ws-table td{padding:8px 12px;border-bottom:1px solid var(--border);}
|
||||||
|
.ws-table tr:hover td{background:var(--bg-hover);}
|
||||||
|
.ws-table .table-icon{font-size:16px;width:24px;}
|
||||||
|
.ws-table .table-name{font-weight:500;cursor:pointer;}
|
||||||
|
.ws-table .table-name:hover{color:var(--accent);}
|
||||||
|
.ws-table .table-meta{color:var(--text-tertiary);font-size:12px;}
|
||||||
|
|
||||||
.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;}
|
||||||
@@ -240,6 +260,9 @@
|
|||||||
<div class="ws-toolbar">
|
<div class="ws-toolbar">
|
||||||
<button class="btn-icon" @click="goBack()" title="Back" :disabled="!canGoBack">←</button>
|
<button class="btn-icon" @click="goBack()" title="Back" :disabled="!canGoBack">←</button>
|
||||||
<button class="btn-icon" @click="goForward()" title="Forward" :disabled="!canGoForward">→</button>
|
<button class="btn-icon" @click="goForward()" title="Forward" :disabled="!canGoForward">→</button>
|
||||||
|
<button class="btn-icon" @click="viewMode = (viewMode==='tree'?'table':'tree')" :title="viewMode==='tree'?'Table view':'Tree view'">
|
||||||
|
<span x-text="viewMode==='tree'?'📋':'🌳'"></span>
|
||||||
|
</button>
|
||||||
<select class="sort-select" x-model="sortBy" @change="doSort()">
|
<select class="sort-select" x-model="sortBy" @change="doSort()">
|
||||||
<option value="">Sort by...</option>
|
<option value="">Sort by...</option>
|
||||||
<option value="name-asc">Name A→Z</option>
|
<option value="name-asc">Name A→Z</option>
|
||||||
@@ -253,6 +276,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Bulk actions bar -->
|
||||||
|
<div class="bulk-bar" x-show="selectedIds.size > 0" x-transition>
|
||||||
|
<span x-text="selectedIds.size + ' selected'"></span>
|
||||||
|
<button class="btn btn-danger" @click="bulkDelete()">🗑 Delete</button>
|
||||||
|
<button class="btn btn-secondary" @click="selectedIds.clear(); selectedIds=new Set()">✕ Clear</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Tree -->
|
<!-- Tree -->
|
||||||
<!-- Search bar -->
|
<!-- Search bar -->
|
||||||
<div class="search-bar" x-show="tree.length>0 || searchQuery">
|
<div class="search-bar" x-show="tree.length>0 || searchQuery">
|
||||||
@@ -271,7 +301,7 @@
|
|||||||
<button class="filter-chip" :class="{ active: filterType === 'text' }" @click="filterByType('text')" title="Text">📄 Text</button>
|
<button class="filter-chip" :class="{ active: filterType === 'text' }" @click="filterByType('text')" title="Text">📄 Text</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul class="ws-tree" x-show="displayTree.length>0 || (currentFolder > 0)" @click="handleTreeAction($event)">
|
<ul class="ws-tree" x-show="viewMode==='tree' && (displayTree.length>0 || (currentFolder > 0))" @click="handleTreeAction($event)">
|
||||||
<!-- Parent folder ".." -->
|
<!-- Parent folder ".." -->
|
||||||
<li x-show="currentFolder > 0">
|
<li x-show="currentFolder > 0">
|
||||||
<div class="ws-tree-item" @click="goToParent()" style="padding-left:4px;">
|
<div class="ws-tree-item" @click="goToParent()" style="padding-left:4px;">
|
||||||
@@ -286,7 +316,12 @@
|
|||||||
draggable="true"
|
draggable="true"
|
||||||
:data-folder-id="node.is_folder ? node.id : null"
|
: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 = ''"
|
||||||
|
:class="{ selected: selectedIds.has(node.id) }">
|
||||||
|
<input type="checkbox" class="item-checkbox"
|
||||||
|
:checked="selectedIds.has(node.id)"
|
||||||
|
@click.stop="toggleSelect(node, $event)"
|
||||||
|
title="Select item">
|
||||||
<!-- chevron -->
|
<!-- chevron -->
|
||||||
<template x-if="node.is_folder && node.children && node.children.length > 0">
|
<template x-if="node.is_folder && node.children && node.children.length > 0">
|
||||||
<button class="ws-chevron"
|
<button class="ws-chevron"
|
||||||
@@ -341,6 +376,27 @@
|
|||||||
</template>
|
</template>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
<!-- Table view -->
|
||||||
|
<table class="ws-table" x-show="viewMode==='table' && displayTree.length>0">
|
||||||
|
<thead><tr>
|
||||||
|
<th style="width:30px"><input type="checkbox" @click="selectAll($event)"></th>
|
||||||
|
<th>Name</th><th>Type</th><th style="width:100px">Size</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<template x-for="node in flatTree" :key="'t'+node.id">
|
||||||
|
<tr :class="{ selected: selectedIds.has(node.id) }">
|
||||||
|
<td><input type="checkbox" :checked="selectedIds.has(node.id)" @click.stop="toggleSelect(node, $event)"></td>
|
||||||
|
<td>
|
||||||
|
<span class="table-icon" x-text="_fileIcon(node.name, node.is_folder, node.content_format)"></span>
|
||||||
|
<span class="table-name" @click="node.is_folder ? navigateToFolder(node.id) : openPage(node.id)" x-text="node.name"></span>
|
||||||
|
</td>
|
||||||
|
<td x-text="node.is_folder ? 'Folder' : 'File'"></td>
|
||||||
|
<td class="table-meta" x-text="node.is_folder ? (node.child_count ? node.child_count+' items' : 'Empty') : '—'"></td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
<!-- Empty -->
|
<!-- Empty -->
|
||||||
<div class="empty-state" x-show="tree.length===0&&loaded">
|
<div class="empty-state" x-show="tree.length===0&&loaded">
|
||||||
<div class="empty-icon">📁</div>
|
<div class="empty-icon">📁</div>
|
||||||
@@ -445,6 +501,11 @@ document.addEventListener('alpine:init', function() {
|
|||||||
|
|
||||||
// Sort
|
// Sort
|
||||||
sortBy: '',
|
sortBy: '',
|
||||||
|
viewMode: 'tree',
|
||||||
|
|
||||||
|
// Multi-select
|
||||||
|
selectedIds: new Set(),
|
||||||
|
lastSelectedId: null,
|
||||||
|
|
||||||
// Undo
|
// Undo
|
||||||
undoVisible: false,
|
undoVisible: false,
|
||||||
@@ -534,6 +595,73 @@ document.addEventListener('alpine:init', function() {
|
|||||||
return this.pinnedIds.indexOf(id) !== -1;
|
return this.pinnedIds.indexOf(id) !== -1;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Multi-select ──
|
||||||
|
toggleSelect(node, ev) {
|
||||||
|
if (ev.shiftKey && this.lastSelectedId) {
|
||||||
|
// Range select
|
||||||
|
var allIds = this._collectVisibleIds(this.displayTree);
|
||||||
|
var start = allIds.indexOf(this.lastSelectedId);
|
||||||
|
var end = allIds.indexOf(node.id);
|
||||||
|
if (start !== -1 && end !== -1) {
|
||||||
|
var from = Math.min(start, end), to = Math.max(start, end);
|
||||||
|
for (var i = from; i <= to; i++) this.selectedIds.add(allIds[i]);
|
||||||
|
}
|
||||||
|
} else if (ev.ctrlKey || ev.metaKey) {
|
||||||
|
// Toggle single
|
||||||
|
if (this.selectedIds.has(node.id)) this.selectedIds.delete(node.id);
|
||||||
|
else this.selectedIds.add(node.id);
|
||||||
|
} else {
|
||||||
|
// Replace selection
|
||||||
|
this.selectedIds.clear();
|
||||||
|
this.selectedIds.add(node.id);
|
||||||
|
}
|
||||||
|
this.lastSelectedId = node.id;
|
||||||
|
// Force reactivity
|
||||||
|
this.selectedIds = new Set(this.selectedIds);
|
||||||
|
},
|
||||||
|
_collectVisibleIds(nodes) {
|
||||||
|
var ids = [];
|
||||||
|
for (var i=0; i<nodes.length; i++) {
|
||||||
|
ids.push(nodes[i].id);
|
||||||
|
if (nodes[i].children) ids = ids.concat(this._collectVisibleIds(nodes[i].children));
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
},
|
||||||
|
async bulkDelete() {
|
||||||
|
var ids = Array.from(this.selectedIds);
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
if (!confirm('Delete ' + ids.length + ' selected items?')) return;
|
||||||
|
var self = this;
|
||||||
|
for (var i=0; i<ids.length; i++) {
|
||||||
|
await fetch('/api/local-workspace/items/' + ids[i], { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
this.selectedIds.clear();
|
||||||
|
this.selectedIds = new Set();
|
||||||
|
this._reloadAfterAction();
|
||||||
|
},
|
||||||
|
selectAll(ev) {
|
||||||
|
if (ev.target.checked) {
|
||||||
|
var all = this._collectVisibleIds(this.displayTree);
|
||||||
|
for (var i=0; i<all.length; i++) this.selectedIds.add(all[i]);
|
||||||
|
} else {
|
||||||
|
this.selectedIds.clear();
|
||||||
|
}
|
||||||
|
this.selectedIds = new Set(this.selectedIds);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Table view data ──
|
||||||
|
get flatTree() {
|
||||||
|
var result = [];
|
||||||
|
this._flattenTree(this.tree, result);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
_flattenTree(nodes, out) {
|
||||||
|
for (var i=0; i<nodes.length; i++) {
|
||||||
|
out.push(nodes[i]);
|
||||||
|
if (nodes[i].children) this._flattenTree(nodes[i].children, out);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
_markTree(nodes, depth) {
|
_markTree(nodes, depth) {
|
||||||
for (var i=0; i<nodes.length; i++) {
|
for (var i=0; i<nodes.length; i++) {
|
||||||
var n = nodes[i];
|
var n = nodes[i];
|
||||||
|
|||||||
Reference in New Issue
Block a user