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:
Dionisio
2026-02-14 00:12:18 +01:00
parent 68d169266c
commit 82dd7a5c56
7 changed files with 676 additions and 8 deletions
+138 -2
View File
@@ -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;
}
} }
+2
View File
@@ -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
View File
@@ -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>
+35
View File
@@ -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,
+456
View File
@@ -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
View File
@@ -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
View File
@@ -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',