feat: multi-select for batch file/folder actions (#100)
- Add checkboxes to list view items (grid view already had them) - Add 'select all' checkbox in list view header - Add batch action bar with Delete, Move, and Download buttons - Batch delete: moves all selected items to trash in one operation - Batch move: reuses existing move dialog in batch mode - Batch download: downloads each selected item - Keyboard shortcuts: Ctrl+A (select all), Escape (deselect), Delete key - Shift+click for range selection in both grid and list views - Selection state synced between grid and list views - New multiSelect.js module manages selection state and batch operations
This commit is contained in:
+138
-2
@@ -1783,17 +1783,18 @@ select:focus {
|
|||||||
|
|
||||||
.list-header {
|
.list-header {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(200px, 2fr) 1fr 1fr 120px;
|
grid-template-columns: 36px minmax(200px, 2fr) 1fr 1fr 120px;
|
||||||
padding: 15px;
|
padding: 15px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #2d3748;
|
color: #2d3748;
|
||||||
background-color: #f8f9fa;
|
background-color: #f8f9fa;
|
||||||
border-bottom: 1px solid #e0e6ed;
|
border-bottom: 1px solid #e0e6ed;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-item {
|
.file-item {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(200px, 2fr) 1fr 1fr 120px;
|
grid-template-columns: 36px minmax(200px, 2fr) 1fr 1fr 120px;
|
||||||
padding: 12px 15px;
|
padding: 12px 15px;
|
||||||
border-bottom: 1px solid #f0f0f0;
|
border-bottom: 1px solid #f0f0f0;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1802,6 +1803,14 @@ select:focus {
|
|||||||
background-color: white;
|
background-color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.file-item.selected {
|
||||||
|
background-color: #fff8f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item.selected:hover {
|
||||||
|
background-color: #fff0ec;
|
||||||
|
}
|
||||||
|
|
||||||
/* For trash mode, adjust columns */
|
/* For trash mode, adjust columns */
|
||||||
.trash-item.file-item {
|
.trash-item.file-item {
|
||||||
grid-template-columns: minmax(180px, 1.5fr) 0.5fr 1fr 120px 100px;
|
grid-template-columns: minmax(180px, 1.5fr) 0.5fr 1fr 120px 100px;
|
||||||
@@ -3601,4 +3610,131 @@ html[dir='rtl'] .fa-sign-out-alt {
|
|||||||
.upload-toast-stats {
|
.upload-toast-stats {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #888;
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════════════
|
||||||
|
Multi-Select – checkboxes & batch action bar
|
||||||
|
═══════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
/* -- List-view checkbox column -- */
|
||||||
|
.list-header-checkbox,
|
||||||
|
.list-item-checkbox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-header-checkbox input[type="checkbox"],
|
||||||
|
.list-item-checkbox input[type="checkbox"] {
|
||||||
|
width: 17px;
|
||||||
|
height: 17px;
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: #ff5e3a;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sync checkbox state visually with .selected */
|
||||||
|
.file-item.selected .item-checkbox {
|
||||||
|
/* Checked via JS */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -- Batch action bar -- */
|
||||||
|
.batch-action-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
background: #1e293b;
|
||||||
|
color: #fff;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
opacity: 0;
|
||||||
|
max-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
transform: translateY(-8px);
|
||||||
|
transition: opacity 0.2s, max-height 0.25s, transform 0.2s, margin 0.2s, padding 0.2s;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-action-bar.visible {
|
||||||
|
opacity: 1;
|
||||||
|
max-height: 60px;
|
||||||
|
transform: translateY(0);
|
||||||
|
pointer-events: auto;
|
||||||
|
padding: 10px 20px;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-bar-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-bar-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: rgba(255,255,255,0.7);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-bar-close:hover {
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-bar-count {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-bar-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 7px 14px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-btn:hover {
|
||||||
|
background: rgba(255,255,255,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-btn-danger {
|
||||||
|
background: rgba(239,68,68,0.25);
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-btn-danger:hover {
|
||||||
|
background: rgba(239,68,68,0.4);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive: hide button text on small screens */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.batch-btn span {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.batch-btn {
|
||||||
|
padding: 7px 10px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
<script src="/js/ui.js"></script>
|
<script src="/js/ui.js"></script>
|
||||||
<script src="/js/contextMenus.js"></script>
|
<script src="/js/contextMenus.js"></script>
|
||||||
<script src="/js/fileOperations.js"></script>
|
<script src="/js/fileOperations.js"></script>
|
||||||
|
<script src="/js/multiSelect.js"></script>
|
||||||
<script src="/js/search.js"></script>
|
<script src="/js/search.js"></script>
|
||||||
<script src="/js/favorites.js"></script>
|
<script src="/js/favorites.js"></script>
|
||||||
<script src="/js/recent.js"></script>
|
<script src="/js/recent.js"></script>
|
||||||
@@ -222,6 +223,7 @@
|
|||||||
<!-- List View (hidden by default) -->
|
<!-- List View (hidden by default) -->
|
||||||
<div class="files-list-view" id="files-list-view" style="display: none;">
|
<div class="files-list-view" id="files-list-view" style="display: none;">
|
||||||
<div class="list-header">
|
<div class="list-header">
|
||||||
|
<div class="list-header-checkbox"><input type="checkbox" id="select-all-checkbox" title="Select all"></div>
|
||||||
<div data-i18n="files.name">Name</div>
|
<div data-i18n="files.name">Name</div>
|
||||||
<div data-i18n="files.type">Type</div>
|
<div data-i18n="files.type">Type</div>
|
||||||
<div data-i18n="files.size">Size</div>
|
<div data-i18n="files.size">Size</div>
|
||||||
|
|||||||
+20
-1
@@ -91,6 +91,12 @@ function initApp() {
|
|||||||
console.warn('Recent files module not available or not initializable');
|
console.warn('Recent files module not available or not initializable');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize multi-select / batch actions
|
||||||
|
if (window.multiSelect && window.multiSelect.init) {
|
||||||
|
console.log('Initializing multi-select module');
|
||||||
|
window.multiSelect.init();
|
||||||
|
}
|
||||||
|
|
||||||
// Wait for translations to load before checking authentication
|
// Wait for translations to load before checking authentication
|
||||||
if (window.i18n && window.i18n.isLoaded && window.i18n.isLoaded()) {
|
if (window.i18n && window.i18n.isLoaded && window.i18n.isLoaded()) {
|
||||||
// Translations already loaded, proceed with authentication
|
// Translations already loaded, proceed with authentication
|
||||||
@@ -617,8 +623,10 @@ function setupEventListeners() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Deselect all cards when clicking empty area (not on a card, menu, or modal)
|
// Deselect all cards when clicking empty area (not on a card, menu, or modal)
|
||||||
if (!e.target.closest('.file-card') && !e.target.closest('.context-menu') && !e.target.closest('.about-modal')) {
|
if (!e.target.closest('.file-card') && !e.target.closest('.file-item') && !e.target.closest('.context-menu') && !e.target.closest('.about-modal') && !e.target.closest('.batch-action-bar')) {
|
||||||
document.querySelectorAll('.file-card.selected').forEach(c => c.classList.remove('selected'));
|
document.querySelectorAll('.file-card.selected').forEach(c => c.classList.remove('selected'));
|
||||||
|
document.querySelectorAll('.file-item.selected').forEach(c => c.classList.remove('selected'));
|
||||||
|
if (window.multiSelect) window.multiSelect.clear();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -714,6 +722,7 @@ async function loadFiles(options = {}) {
|
|||||||
elements.filesGrid.innerHTML = '<div class="empty-state"><p>Could not load files</p></div>';
|
elements.filesGrid.innerHTML = '<div class="empty-state"><p>Could not load files</p></div>';
|
||||||
elements.filesListView.innerHTML = `
|
elements.filesListView.innerHTML = `
|
||||||
<div class="list-header">
|
<div class="list-header">
|
||||||
|
<div class="list-header-checkbox"><input type="checkbox" id="select-all-checkbox" title="Select all"></div>
|
||||||
<div>Name</div>
|
<div>Name</div>
|
||||||
<div>Type</div>
|
<div>Type</div>
|
||||||
<div>Size</div>
|
<div>Size</div>
|
||||||
@@ -729,15 +738,23 @@ async function loadFiles(options = {}) {
|
|||||||
const folders = await response.json();
|
const folders = await response.json();
|
||||||
|
|
||||||
// Clear existing files in both views
|
// Clear existing files in both views
|
||||||
|
if (window.multiSelect) window.multiSelect.clear();
|
||||||
elements.filesGrid.innerHTML = '';
|
elements.filesGrid.innerHTML = '';
|
||||||
elements.filesListView.innerHTML = `
|
elements.filesListView.innerHTML = `
|
||||||
<div class="list-header">
|
<div class="list-header">
|
||||||
|
<div class="list-header-checkbox"><input type="checkbox" id="select-all-checkbox" title="Select all"></div>
|
||||||
<div data-i18n="files.name">Name</div>
|
<div data-i18n="files.name">Name</div>
|
||||||
<div data-i18n="files.type">Type</div>
|
<div data-i18n="files.type">Type</div>
|
||||||
<div data-i18n="files.size">Size</div>
|
<div data-i18n="files.size">Size</div>
|
||||||
<div data-i18n="files.modified">Modified</div>
|
<div data-i18n="files.modified">Modified</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// Re-wire select-all checkbox after DOM rebuild
|
||||||
|
const selectAllCb = document.getElementById('select-all-checkbox');
|
||||||
|
if (selectAllCb && window.multiSelect) {
|
||||||
|
selectAllCb.addEventListener('change', () => window.multiSelect.toggleAll());
|
||||||
|
}
|
||||||
|
|
||||||
// Translate the header if i18n is available
|
// Translate the header if i18n is available
|
||||||
if (window.i18n && window.i18n.translatePage) {
|
if (window.i18n && window.i18n.translatePage) {
|
||||||
@@ -844,9 +861,11 @@ function formatFileSize(bytes) {
|
|||||||
async function loadTrashItems() {
|
async function loadTrashItems() {
|
||||||
try {
|
try {
|
||||||
// Clear existing content
|
// Clear existing content
|
||||||
|
if (window.multiSelect) window.multiSelect.clear();
|
||||||
elements.filesGrid.innerHTML = '';
|
elements.filesGrid.innerHTML = '';
|
||||||
elements.filesListView.innerHTML = `
|
elements.filesListView.innerHTML = `
|
||||||
<div class="list-header">
|
<div class="list-header">
|
||||||
|
<div class="list-header-checkbox"><input type="checkbox" id="select-all-checkbox" title="Select all"></div>
|
||||||
<div data-i18n="files.name">Name</div>
|
<div data-i18n="files.name">Name</div>
|
||||||
<div data-i18n="files.type">Type</div>
|
<div data-i18n="files.type">Type</div>
|
||||||
<div data-i18n="trash.original_location">Original location</div>
|
<div data-i18n="trash.original_location">Original location</div>
|
||||||
|
|||||||
@@ -202,6 +202,41 @@ const contextMenus = {
|
|||||||
|
|
||||||
moveCancelBtn.addEventListener('click', this.closeMoveDialog);
|
moveCancelBtn.addEventListener('click', this.closeMoveDialog);
|
||||||
moveConfirmBtn.addEventListener('click', async () => {
|
moveConfirmBtn.addEventListener('click', async () => {
|
||||||
|
// Batch move mode (from multiSelect)
|
||||||
|
if (window.app.moveDialogMode === 'batch' && window.multiSelect) {
|
||||||
|
const targetId = window.app.selectedTargetFolderId;
|
||||||
|
const items = window.app.batchMoveItems || [];
|
||||||
|
let success = 0, errors = 0;
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
try {
|
||||||
|
if (item.type === 'folder') {
|
||||||
|
if (item.id === targetId) continue;
|
||||||
|
const ok = await window.fileOps.moveFolder(item.id, targetId);
|
||||||
|
if (ok) success++; else errors++;
|
||||||
|
} else {
|
||||||
|
const ok = await window.fileOps.moveFile(item.id, targetId);
|
||||||
|
if (ok) success++; else errors++;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error moving item:', item, err);
|
||||||
|
errors++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.closeMoveDialog();
|
||||||
|
window.multiSelect.clear();
|
||||||
|
window.loadFiles();
|
||||||
|
|
||||||
|
if (errors > 0) {
|
||||||
|
window.ui.showNotification('Batch move', `${success} moved, ${errors} failed`);
|
||||||
|
} else {
|
||||||
|
window.ui.showNotification('Items moved',
|
||||||
|
`${success} item${success !== 1 ? 's' : ''} moved successfully`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (window.app.moveDialogMode === 'file' && window.app.contextMenuTargetFile) {
|
if (window.app.moveDialogMode === 'file' && window.app.contextMenuTargetFile) {
|
||||||
const success = await window.fileOps.moveFile(
|
const success = await window.fileOps.moveFile(
|
||||||
window.app.contextMenuTargetFile.id,
|
window.app.contextMenuTargetFile.id,
|
||||||
|
|||||||
@@ -0,0 +1,456 @@
|
|||||||
|
/**
|
||||||
|
* OxiCloud - Multi-Select & Batch Actions Module
|
||||||
|
* Adds checkboxes to both grid and list views, a batch action bar,
|
||||||
|
* and batch delete / move / download operations.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const multiSelect = {
|
||||||
|
/** Currently selected items: { id, name, type: 'file'|'folder', parentId } */
|
||||||
|
_selected: new Map(),
|
||||||
|
|
||||||
|
/** Last clicked index for Shift-range selection */
|
||||||
|
_lastClickedIndex: -1,
|
||||||
|
|
||||||
|
/** Whether the batch bar is currently visible */
|
||||||
|
_barVisible: false,
|
||||||
|
|
||||||
|
// ── Public API ──────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Number of selected items */
|
||||||
|
get count() { return this._selected.size; },
|
||||||
|
|
||||||
|
/** All selected items as an array */
|
||||||
|
get items() { return Array.from(this._selected.values()); },
|
||||||
|
|
||||||
|
/** True when at least one item is selected */
|
||||||
|
get hasSelection() { return this._selected.size > 0; },
|
||||||
|
|
||||||
|
/** Get selected files only */
|
||||||
|
get files() { return this.items.filter(i => i.type === 'file'); },
|
||||||
|
|
||||||
|
/** Get selected folders only */
|
||||||
|
get folders() { return this.items.filter(i => i.type === 'folder'); },
|
||||||
|
|
||||||
|
// ── Selection state management ──────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle an item in the selection.
|
||||||
|
* @param {string} id
|
||||||
|
* @param {string} name
|
||||||
|
* @param {'file'|'folder'} type
|
||||||
|
* @param {string} parentId parent / folder id
|
||||||
|
* @returns {boolean} new selected state
|
||||||
|
*/
|
||||||
|
toggle(id, name, type, parentId) {
|
||||||
|
if (this._selected.has(id)) {
|
||||||
|
this._selected.delete(id);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this._selected.set(id, { id, name, type, parentId });
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Select a single item (add if not present) */
|
||||||
|
select(id, name, type, parentId) {
|
||||||
|
this._selected.set(id, { id, name, type, parentId });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Deselect a single item */
|
||||||
|
deselect(id) {
|
||||||
|
this._selected.delete(id);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Clear the whole selection */
|
||||||
|
clear() {
|
||||||
|
this._selected.clear();
|
||||||
|
this._lastClickedIndex = -1;
|
||||||
|
// Remove visual state from DOM
|
||||||
|
document.querySelectorAll('.file-card.selected, .file-item.selected').forEach(el => {
|
||||||
|
el.classList.remove('selected');
|
||||||
|
});
|
||||||
|
// Uncheck all item checkboxes
|
||||||
|
document.querySelectorAll('.item-checkbox').forEach(cb => cb.checked = false);
|
||||||
|
this._syncUI();
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Select all visible items */
|
||||||
|
selectAll() {
|
||||||
|
this._selectAllInContainer('files-grid', '.file-card');
|
||||||
|
this._selectAllInContainer('files-list-view', '.file-item');
|
||||||
|
this._syncUI();
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Deselect/select all toggle */
|
||||||
|
toggleAll() {
|
||||||
|
const allItems = this._getAllVisibleItems();
|
||||||
|
if (this._selected.size === allItems.length && allItems.length > 0) {
|
||||||
|
this.clear();
|
||||||
|
} else {
|
||||||
|
this.selectAll();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── DOM helpers ─────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Gather info from a DOM element and add to selection */
|
||||||
|
_selectElement(el) {
|
||||||
|
const info = this._extractInfo(el);
|
||||||
|
if (info) {
|
||||||
|
this.select(info.id, info.name, info.type, info.parentId);
|
||||||
|
el.classList.add('selected');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_selectAllInContainer(containerId, selector) {
|
||||||
|
const container = document.getElementById(containerId);
|
||||||
|
if (!container) return;
|
||||||
|
container.querySelectorAll(selector).forEach(el => this._selectElement(el));
|
||||||
|
},
|
||||||
|
|
||||||
|
_getAllVisibleItems() {
|
||||||
|
const gridItems = [...document.querySelectorAll('#files-grid .file-card')];
|
||||||
|
const listItems = [...document.querySelectorAll('#files-list-view .file-item')];
|
||||||
|
// Only return items from the currently visible view
|
||||||
|
const grid = document.getElementById('files-grid');
|
||||||
|
if (grid && grid.style.display !== 'none') return gridItems;
|
||||||
|
return listItems;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Extract item info from a DOM element */
|
||||||
|
_extractInfo(el) {
|
||||||
|
if (el.dataset.folderId && el.dataset.folderName !== undefined) {
|
||||||
|
return {
|
||||||
|
id: el.dataset.folderId,
|
||||||
|
name: el.dataset.folderName,
|
||||||
|
type: 'folder',
|
||||||
|
parentId: el.dataset.parentId || ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (el.dataset.fileId) {
|
||||||
|
return {
|
||||||
|
id: el.dataset.fileId,
|
||||||
|
name: el.dataset.fileName,
|
||||||
|
type: 'file',
|
||||||
|
parentId: el.dataset.folderId || ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Click handler (shared by grid + list) ───────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle a checkbox/selection click on an item element.
|
||||||
|
* Supports Shift-click for range selection.
|
||||||
|
*/
|
||||||
|
handleItemClick(el, event) {
|
||||||
|
const items = this._getAllVisibleItems();
|
||||||
|
const index = items.indexOf(el);
|
||||||
|
|
||||||
|
// Also find the matching element in the other view
|
||||||
|
const info = this._extractInfo(el);
|
||||||
|
if (!info) return;
|
||||||
|
|
||||||
|
const selectorOther = info.type === 'folder'
|
||||||
|
? `[data-folder-id="${info.id}"]`
|
||||||
|
: `[data-file-id="${info.id}"]`;
|
||||||
|
const otherEl = [...document.querySelectorAll(selectorOther)]
|
||||||
|
.find(e => e !== el);
|
||||||
|
|
||||||
|
if (event && event.shiftKey && this._lastClickedIndex >= 0 && index >= 0) {
|
||||||
|
// Range selection
|
||||||
|
const start = Math.min(this._lastClickedIndex, index);
|
||||||
|
const end = Math.max(this._lastClickedIndex, index);
|
||||||
|
for (let i = start; i <= end; i++) {
|
||||||
|
this._selectElement(items[i]);
|
||||||
|
// Mirror to other view
|
||||||
|
const iInfo = this._extractInfo(items[i]);
|
||||||
|
if (iInfo) {
|
||||||
|
const sel = iInfo.type === 'folder'
|
||||||
|
? `[data-folder-id="${iInfo.id}"]`
|
||||||
|
: `[data-file-id="${iInfo.id}"]`;
|
||||||
|
document.querySelectorAll(sel).forEach(e => e.classList.add('selected'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Normal toggle
|
||||||
|
const nowSelected = this.toggle(info.id, info.name, info.type, info.parentId);
|
||||||
|
el.classList.toggle('selected', nowSelected);
|
||||||
|
if (otherEl) otherEl.classList.toggle('selected', nowSelected);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._lastClickedIndex = index;
|
||||||
|
this._syncUI();
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Batch action bar ────────────────────────────────────
|
||||||
|
|
||||||
|
/** Create the batch action bar if it doesn't exist */
|
||||||
|
_ensureBar() {
|
||||||
|
if (document.getElementById('batch-action-bar')) return;
|
||||||
|
|
||||||
|
const bar = document.createElement('div');
|
||||||
|
bar.id = 'batch-action-bar';
|
||||||
|
bar.className = 'batch-action-bar';
|
||||||
|
bar.innerHTML = `
|
||||||
|
<div class="batch-bar-left">
|
||||||
|
<button class="batch-bar-close" id="batch-bar-close" title="Cancel selection">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
<span class="batch-bar-count" id="batch-bar-count">0 selected</span>
|
||||||
|
</div>
|
||||||
|
<div class="batch-bar-actions">
|
||||||
|
<button class="batch-btn" id="batch-download" title="Download">
|
||||||
|
<i class="fas fa-download"></i>
|
||||||
|
<span data-i18n="actions.download">Download</span>
|
||||||
|
</button>
|
||||||
|
<button class="batch-btn" id="batch-move" title="Move">
|
||||||
|
<i class="fas fa-arrows-alt"></i>
|
||||||
|
<span data-i18n="actions.move">Move</span>
|
||||||
|
</button>
|
||||||
|
<button class="batch-btn batch-btn-danger" id="batch-delete" title="Delete">
|
||||||
|
<i class="fas fa-trash-alt"></i>
|
||||||
|
<span data-i18n="actions.delete">Delete</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Insert before the files-container (inside main-content)
|
||||||
|
const filesContainer = document.querySelector('.files-container');
|
||||||
|
if (filesContainer && filesContainer.parentNode) {
|
||||||
|
filesContainer.parentNode.insertBefore(bar, filesContainer);
|
||||||
|
} else {
|
||||||
|
document.body.appendChild(bar);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire up events
|
||||||
|
document.getElementById('batch-bar-close').addEventListener('click', () => this.clear());
|
||||||
|
document.getElementById('batch-delete').addEventListener('click', () => this.batchDelete());
|
||||||
|
document.getElementById('batch-move').addEventListener('click', () => this.batchMove());
|
||||||
|
document.getElementById('batch-download').addEventListener('click', () => this.batchDownload());
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Show/hide the bar and update the count */
|
||||||
|
_syncUI() {
|
||||||
|
this._ensureBar();
|
||||||
|
const bar = document.getElementById('batch-action-bar');
|
||||||
|
const count = document.getElementById('batch-bar-count');
|
||||||
|
|
||||||
|
if (this._selected.size > 0) {
|
||||||
|
bar.classList.add('visible');
|
||||||
|
this._barVisible = true;
|
||||||
|
const n = this._selected.size;
|
||||||
|
const itemsText = n === 1
|
||||||
|
? (window.i18n ? window.i18n.t('batch.one_selected') : '1 item selected')
|
||||||
|
: (window.i18n ? window.i18n.t('batch.n_selected', { count: n }) : `${n} items selected`);
|
||||||
|
count.textContent = itemsText;
|
||||||
|
} else {
|
||||||
|
bar.classList.remove('visible');
|
||||||
|
this._barVisible = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update select-all checkbox state
|
||||||
|
this._syncSelectAllCheckbox();
|
||||||
|
|
||||||
|
// Sync individual list-view checkboxes
|
||||||
|
this._syncItemCheckboxes();
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Sync individual item checkboxes with selection state */
|
||||||
|
_syncItemCheckboxes() {
|
||||||
|
document.querySelectorAll('.file-item').forEach(el => {
|
||||||
|
const cb = el.querySelector('.item-checkbox');
|
||||||
|
if (cb) {
|
||||||
|
cb.checked = el.classList.contains('selected');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
_syncSelectAllCheckbox() {
|
||||||
|
const cb = document.getElementById('select-all-checkbox');
|
||||||
|
if (!cb) return;
|
||||||
|
const all = this._getAllVisibleItems();
|
||||||
|
if (all.length === 0) {
|
||||||
|
cb.checked = false;
|
||||||
|
cb.indeterminate = false;
|
||||||
|
} else if (this._selected.size === all.length) {
|
||||||
|
cb.checked = true;
|
||||||
|
cb.indeterminate = false;
|
||||||
|
} else if (this._selected.size > 0) {
|
||||||
|
cb.checked = false;
|
||||||
|
cb.indeterminate = true;
|
||||||
|
} else {
|
||||||
|
cb.checked = false;
|
||||||
|
cb.indeterminate = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Batch operations ────────────────────────────────────
|
||||||
|
|
||||||
|
/** Batch delete (move to trash) */
|
||||||
|
async batchDelete() {
|
||||||
|
const items = this.items;
|
||||||
|
if (items.length === 0) return;
|
||||||
|
|
||||||
|
const n = items.length;
|
||||||
|
const msg = n === 1
|
||||||
|
? (window.i18n
|
||||||
|
? window.i18n.t('dialogs.confirm_delete_file', { name: items[0].name })
|
||||||
|
: `Are you sure you want to move "${items[0].name}" to trash?`)
|
||||||
|
: (window.i18n
|
||||||
|
? window.i18n.t('batch.confirm_delete', { count: n })
|
||||||
|
: `Are you sure you want to move ${n} items to trash?`);
|
||||||
|
|
||||||
|
const confirmed = await showConfirmDialog({
|
||||||
|
title: window.i18n ? window.i18n.t('dialogs.confirm_delete') : 'Move to trash',
|
||||||
|
message: msg,
|
||||||
|
confirmText: window.i18n ? window.i18n.t('actions.delete') : 'Delete',
|
||||||
|
});
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
let success = 0;
|
||||||
|
let errors = 0;
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
try {
|
||||||
|
const endpoint = item.type === 'folder'
|
||||||
|
? `/api/trash/folders/${item.id}`
|
||||||
|
: `/api/trash/files/${item.id}`;
|
||||||
|
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
success++;
|
||||||
|
} else {
|
||||||
|
// Fallback to direct delete
|
||||||
|
const fallback = item.type === 'folder'
|
||||||
|
? `/api/folders/${item.id}`
|
||||||
|
: `/api/files/${item.id}`;
|
||||||
|
const r2 = await fetch(fallback, { method: 'DELETE', headers: getAuthHeaders() });
|
||||||
|
if (r2.ok) success++;
|
||||||
|
else errors++;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error deleting item:', item, e);
|
||||||
|
errors++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.clear();
|
||||||
|
window.loadFiles();
|
||||||
|
|
||||||
|
if (errors > 0) {
|
||||||
|
window.ui.showNotification('Batch delete',
|
||||||
|
`${success} moved to trash, ${errors} failed`);
|
||||||
|
} else {
|
||||||
|
window.ui.showNotification('Moved to trash',
|
||||||
|
`${success} item${success !== 1 ? 's' : ''} moved to trash`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Batch move — reuse existing move dialog */
|
||||||
|
async batchMove() {
|
||||||
|
const items = this.items;
|
||||||
|
if (items.length === 0) return;
|
||||||
|
|
||||||
|
// Set a special batch mode flag
|
||||||
|
window.app.moveDialogMode = 'batch';
|
||||||
|
window.app.batchMoveItems = items;
|
||||||
|
|
||||||
|
// Reset selection
|
||||||
|
window.app.selectedTargetFolderId = "";
|
||||||
|
|
||||||
|
// Update dialog title
|
||||||
|
const dialog = document.getElementById('move-file-dialog');
|
||||||
|
const dialogHeader = dialog.querySelector('.rename-dialog-header');
|
||||||
|
const n = items.length;
|
||||||
|
const titleText = window.i18n
|
||||||
|
? window.i18n.t('batch.move_title', { count: n })
|
||||||
|
: `Move ${n} item${n !== 1 ? 's' : ''}`;
|
||||||
|
dialogHeader.innerHTML = `<i class="fas fa-arrows-alt" style="color:#ff5e3a"></i> <span>${titleText}</span>`;
|
||||||
|
|
||||||
|
// Load folders, excluding selected folder IDs
|
||||||
|
const excludeIds = items.filter(i => i.type === 'folder').map(i => i.id);
|
||||||
|
await contextMenus.loadAllFolders(excludeIds[0] || null, 'batch');
|
||||||
|
|
||||||
|
dialog.style.display = 'flex';
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Batch download — downloads each item individually */
|
||||||
|
async batchDownload() {
|
||||||
|
const items = this.items;
|
||||||
|
if (items.length === 0) return;
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.type === 'folder') {
|
||||||
|
await window.fileOps.downloadFolder(item.id, item.name);
|
||||||
|
} else {
|
||||||
|
await window.fileOps.downloadFile(item.id, item.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Initialization ──────────────────────────────────────
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// Inject the select-all checkbox into the list header
|
||||||
|
this._injectListHeaderCheckbox();
|
||||||
|
|
||||||
|
// Override the deselect-on-empty-area handler to also clear our state
|
||||||
|
this._hookGlobalDeselect();
|
||||||
|
|
||||||
|
// Hook into the move dialog confirm to handle batch mode
|
||||||
|
// (handled in contextMenus.js — moveDialogMode === 'batch')
|
||||||
|
|
||||||
|
// Keyboard shortcut: Ctrl+A to select all, Escape to clear
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
// Don't trigger when inside an input/textarea/modal
|
||||||
|
if (e.target.closest('input, textarea, [contenteditable], .rename-dialog, .share-dialog, .confirm-dialog')) return;
|
||||||
|
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
|
||||||
|
// Only when in file view (not favorites, trash etc.)
|
||||||
|
const grid = document.getElementById('files-grid');
|
||||||
|
if (grid && grid.closest('.files-container')) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.selectAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Escape' && this.hasSelection) {
|
||||||
|
this.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Delete' && this.hasSelection) {
|
||||||
|
this.batchDelete();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Inject a checkbox into the list-header (or wire existing one) */
|
||||||
|
_injectListHeaderCheckbox() {
|
||||||
|
const cb = document.getElementById('select-all-checkbox');
|
||||||
|
if (!cb) return;
|
||||||
|
|
||||||
|
cb.addEventListener('change', () => {
|
||||||
|
this.toggleAll();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Override global click deselect to also clear our internal state */
|
||||||
|
_hookGlobalDeselect() {
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
// Don't deselect if clicking on batch bar, context menu, modal, or any file item
|
||||||
|
if (e.target.closest('.file-card, .file-item, .context-menu, .batch-action-bar, .about-modal, .rename-dialog, .share-dialog, .confirm-dialog, .modal-overlay, input, button')) return;
|
||||||
|
|
||||||
|
if (this.hasSelection) {
|
||||||
|
this.clear();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Expose globally
|
||||||
|
window.multiSelect = multiSelect;
|
||||||
+24
-4
@@ -813,6 +813,7 @@ const ui = {
|
|||||||
|
|
||||||
// Improved: Structure and classes for list view
|
// Improved: Structure and classes for list view
|
||||||
folderListElement.innerHTML = `
|
folderListElement.innerHTML = `
|
||||||
|
<div class="list-item-checkbox"><input type="checkbox" class="item-checkbox"></div>
|
||||||
<div class="name-cell">
|
<div class="name-cell">
|
||||||
<div class="file-icon folder-icon">
|
<div class="file-icon folder-icon">
|
||||||
<i class="fas fa-folder"></i>
|
<i class="fas fa-folder"></i>
|
||||||
@@ -825,8 +826,15 @@ const ui = {
|
|||||||
<div class="date-cell">${formattedDate}</div>
|
<div class="date-cell">${formattedDate}</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// Checkbox click in list view
|
||||||
|
folderListElement.querySelector('.item-checkbox').addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleCardSelection(folderListElement, e);
|
||||||
|
});
|
||||||
|
|
||||||
// Click to navigate
|
// Click to navigate
|
||||||
folderListElement.addEventListener('click', () => {
|
folderListElement.addEventListener('click', (e) => {
|
||||||
|
if (e.target.closest('.list-item-checkbox')) return;
|
||||||
window.app.currentPath = folder.id;
|
window.app.currentPath = folder.id;
|
||||||
this.updateBreadcrumb(folder.name);
|
this.updateBreadcrumb(folder.name);
|
||||||
window.loadFiles();
|
window.loadFiles();
|
||||||
@@ -1043,6 +1051,7 @@ const ui = {
|
|||||||
fileListElement.dataset.folderId = file.folder_id || "";
|
fileListElement.dataset.folderId = file.folder_id || "";
|
||||||
|
|
||||||
fileListElement.innerHTML = `
|
fileListElement.innerHTML = `
|
||||||
|
<div class="list-item-checkbox"><input type="checkbox" class="item-checkbox"></div>
|
||||||
<div class="name-cell">
|
<div class="name-cell">
|
||||||
<div class="file-icon ${iconSpecialClass}">
|
<div class="file-icon ${iconSpecialClass}">
|
||||||
<i class="${iconClass}"></i>
|
<i class="${iconClass}"></i>
|
||||||
@@ -1055,6 +1064,12 @@ const ui = {
|
|||||||
<div class="date-cell">${formattedDate}</div>
|
<div class="date-cell">${formattedDate}</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// Checkbox click in list view
|
||||||
|
fileListElement.querySelector('.item-checkbox').addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleCardSelection(fileListElement, e);
|
||||||
|
});
|
||||||
|
|
||||||
// Make draggable (list view)
|
// Make draggable (list view)
|
||||||
fileListElement.setAttribute('draggable', 'true');
|
fileListElement.setAttribute('draggable', 'true');
|
||||||
|
|
||||||
@@ -1071,7 +1086,8 @@ const ui = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// View or download on click
|
// View or download on click
|
||||||
fileListElement.addEventListener('click', () => {
|
fileListElement.addEventListener('click', (e) => {
|
||||||
|
if (e.target.closest('.list-item-checkbox')) return;
|
||||||
// Track this file access for recent files
|
// Track this file access for recent files
|
||||||
if (window.recent) {
|
if (window.recent) {
|
||||||
document.dispatchEvent(new CustomEvent('file-accessed', {
|
document.dispatchEvent(new CustomEvent('file-accessed', {
|
||||||
@@ -1121,10 +1137,14 @@ const ui = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggle selection state of a file/folder card.
|
* Toggle selection state of a file/folder card.
|
||||||
* Each click toggles that card independently (multi-select by default).
|
* Routes through the multiSelect module so batch actions know about selected items.
|
||||||
*/
|
*/
|
||||||
function toggleCardSelection(card, event) {
|
function toggleCardSelection(card, event) {
|
||||||
card.classList.toggle('selected');
|
if (window.multiSelect) {
|
||||||
|
window.multiSelect.handleItemClick(card, event);
|
||||||
|
} else {
|
||||||
|
card.classList.toggle('selected');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
// OxiCloud Service Worker
|
// OxiCloud Service Worker
|
||||||
const CACHE_NAME = 'oxicloud-cache-v5';
|
const CACHE_NAME = 'oxicloud-cache-v6';
|
||||||
const ASSETS_TO_CACHE = [
|
const ASSETS_TO_CACHE = [
|
||||||
'/',
|
'/',
|
||||||
'/index.html',
|
'/index.html',
|
||||||
|
|||||||
Reference in New Issue
Block a user