feat: enhance file deletion functionality with multi-select (#682)
## Summary * **What is the goal of this PR?** Enhances the file manager with multi-select deletion functionality and improved UI formatting. * **What changes are included?** * Added multi-select capability for file deletion in the web interface * Fixed formatting issues in file table for folder rows * Updated [.gitignore] to exclude additional build artifacts and cache files * Refactored CrossPointWebServer.cpp to support batch file deletion * Enhanced FilesPage.html with improved UI for file selection and deletion ## Additional Context * The file deletion endpoint now handles multiple files in a single request, improving efficiency when removing multiple files * Changes are focused on the web file manager component only --- ### AI Usage While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? _**PARTIALLY**_ --------- Co-authored-by: Jessica Harrison <jessica.harrison@entelect.co.za> Co-authored-by: Dave Allie <dave@daveallie.com>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -10,4 +10,5 @@ build
|
||||
**/__pycache__/
|
||||
/compile_commands.json
|
||||
/.cache
|
||||
.history/
|
||||
/.venv
|
||||
|
||||
@@ -914,19 +914,39 @@ void CrossPointWebServer::handleMove() const {
|
||||
}
|
||||
|
||||
void CrossPointWebServer::handleDelete() const {
|
||||
// Get path from form data
|
||||
if (!server->hasArg("path")) {
|
||||
server->send(400, "text/plain", "Missing path");
|
||||
// Check if 'paths' argument is provided
|
||||
if (!server->hasArg("paths")) {
|
||||
server->send(400, "text/plain", "Missing paths");
|
||||
return;
|
||||
}
|
||||
|
||||
String itemPath = server->arg("path");
|
||||
const String itemType = server->hasArg("type") ? server->arg("type") : "file";
|
||||
// Parse paths
|
||||
String pathsArg = server->arg("paths");
|
||||
JsonDocument doc;
|
||||
DeserializationError error = deserializeJson(doc, pathsArg);
|
||||
if (error) {
|
||||
server->send(400, "text/plain", "Invalid paths format");
|
||||
return;
|
||||
}
|
||||
|
||||
auto paths = doc.as<JsonArray>();
|
||||
if (paths.isNull() || paths.size() == 0) {
|
||||
server->send(400, "text/plain", "No paths provided");
|
||||
return;
|
||||
}
|
||||
|
||||
// Iterate over paths and delete each item
|
||||
bool allSuccess = true;
|
||||
String failedItems;
|
||||
|
||||
for (const auto& p : paths) {
|
||||
auto itemPath = p.as<String>();
|
||||
|
||||
// Validate path
|
||||
if (itemPath.isEmpty() || itemPath == "/") {
|
||||
server->send(400, "text/plain", "Cannot delete root directory");
|
||||
return;
|
||||
failedItems += itemPath + " (cannot delete root); ";
|
||||
allSuccess = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ensure path starts with /
|
||||
@@ -937,61 +957,66 @@ void CrossPointWebServer::handleDelete() const {
|
||||
// Security check: prevent deletion of protected items
|
||||
const String itemName = itemPath.substring(itemPath.lastIndexOf('/') + 1);
|
||||
|
||||
// Check if item starts with a dot (hidden/system file)
|
||||
// Hidden/system files are protected
|
||||
if (itemName.startsWith(".")) {
|
||||
LOG_DBG("WEB", "Delete rejected - hidden/system item: %s", itemPath.c_str());
|
||||
server->send(403, "text/plain", "Cannot delete system files");
|
||||
return;
|
||||
failedItems += itemPath + " (hidden/system file); ";
|
||||
allSuccess = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check against explicitly protected items
|
||||
bool isProtected = false;
|
||||
for (size_t i = 0; i < HIDDEN_ITEMS_COUNT; i++) {
|
||||
if (itemName.equals(HIDDEN_ITEMS[i])) {
|
||||
LOG_DBG("WEB", "Delete rejected - protected item: %s", itemPath.c_str());
|
||||
server->send(403, "text/plain", "Cannot delete protected items");
|
||||
return;
|
||||
isProtected = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (isProtected) {
|
||||
failedItems += itemPath + " (protected file); ";
|
||||
allSuccess = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if item exists
|
||||
if (!Storage.exists(itemPath.c_str())) {
|
||||
LOG_DBG("WEB", "Delete failed - item not found: %s", itemPath.c_str());
|
||||
server->send(404, "text/plain", "Item not found");
|
||||
return;
|
||||
failedItems += itemPath + " (not found); ";
|
||||
allSuccess = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
LOG_DBG("WEB", "Attempting to delete %s: %s", itemType.c_str(), itemPath.c_str());
|
||||
|
||||
// Decide whether it's a directory or file by opening it
|
||||
bool success = false;
|
||||
|
||||
if (itemType == "folder") {
|
||||
// For folders, try to remove (will fail if not empty)
|
||||
FsFile dir = Storage.open(itemPath.c_str());
|
||||
if (dir && dir.isDirectory()) {
|
||||
// Check if folder is empty
|
||||
FsFile entry = dir.openNextFile();
|
||||
FsFile f = Storage.open(itemPath.c_str());
|
||||
if (f && f.isDirectory()) {
|
||||
// For folders, ensure empty before removing
|
||||
FsFile entry = f.openNextFile();
|
||||
if (entry) {
|
||||
// Folder is not empty
|
||||
entry.close();
|
||||
dir.close();
|
||||
LOG_DBG("WEB", "Delete failed - folder not empty: %s", itemPath.c_str());
|
||||
server->send(400, "text/plain", "Folder is not empty. Delete contents first.");
|
||||
return;
|
||||
}
|
||||
dir.close();
|
||||
f.close();
|
||||
failedItems += itemPath + " (folder not empty); ";
|
||||
allSuccess = false;
|
||||
continue;
|
||||
}
|
||||
f.close();
|
||||
success = Storage.rmdir(itemPath.c_str());
|
||||
} else {
|
||||
// For files, use remove
|
||||
// It's a file (or couldn't open as dir) — remove file
|
||||
if (f) f.close();
|
||||
success = Storage.remove(itemPath.c_str());
|
||||
clearEpubCacheIfNeeded(itemPath);
|
||||
}
|
||||
|
||||
if (success) {
|
||||
LOG_DBG("WEB", "Successfully deleted: %s", itemPath.c_str());
|
||||
server->send(200, "text/plain", "Deleted successfully");
|
||||
if (!success) {
|
||||
failedItems += itemPath + " (deletion failed); ";
|
||||
allSuccess = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (allSuccess) {
|
||||
server->send(200, "text/plain", "All items deleted successfully");
|
||||
} else {
|
||||
LOG_ERR("WEB", "Failed to delete: %s", itemPath.c_str());
|
||||
server->send(500, "text/plain", "Failed to delete item");
|
||||
server->send(500, "text/plain", "Failed to delete some items: " + failedItems);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -653,6 +653,7 @@
|
||||
<div class="action-buttons">
|
||||
<button class="action-btn upload-action-btn" onclick="openUploadModal()">📤 Upload</button>
|
||||
<button class="action-btn folder-action-btn" onclick="openFolderModal()">📁 New Folder</button>
|
||||
<button class="action-btn" style="background-color:#e74c3c" onclick="openDeleteSelectedModal()">🗑️ Delete Selected</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -719,13 +720,11 @@
|
||||
<div class="modal-overlay" id="deleteModal">
|
||||
<div class="modal">
|
||||
<button class="modal-close" onclick="closeDeleteModal()">×</button>
|
||||
<h3>🗑️ Delete Item</h3>
|
||||
<h3>🗑️ Delete Item(s)</h3>
|
||||
<div class="folder-form">
|
||||
<p class="delete-warning">⚠️ This action cannot be undone!</p>
|
||||
<p class="file-info">Are you sure you want to delete:</p>
|
||||
<p class="delete-item-name" id="deleteItemName"></p>
|
||||
<input type="hidden" id="deleteItemPath">
|
||||
<input type="hidden" id="deleteItemType">
|
||||
<p class="file-info">Are you sure you want to delete the following item(s)?</p>
|
||||
<div id="deleteItemList" style="max-height:240px; overflow:auto; margin-bottom:10px;"></div>
|
||||
<button class="delete-btn-confirm" onclick="confirmDelete()">Delete</button>
|
||||
<button class="delete-btn-cancel" onclick="closeDeleteModal()">Cancel</button>
|
||||
</div>
|
||||
@@ -837,7 +836,10 @@
|
||||
fileTable.innerHTML = '<div class="no-files">This folder is empty</div>';
|
||||
} else {
|
||||
let fileTableContent = '<table class="file-table">';
|
||||
fileTableContent += '<tr><th>Name</th><th>Type</th><th>Size</th><th class="actions-col">Actions</th></tr>';
|
||||
|
||||
// Add select-all checkbox column
|
||||
fileTableContent += '<tr><th style="width:40px"><input type="checkbox" id="selectAllCheckbox" onchange="toggleSelectAll(this)"></th><th>Name</th><th>Type</th><th>Size</th><th class="actions-col">Actions</th></tr>';
|
||||
|
||||
|
||||
const sortedFiles = files.sort((a, b) => {
|
||||
// Directories first, then epub files, then other files, alphabetically within each group
|
||||
@@ -854,7 +856,9 @@
|
||||
if (!folderPath.endsWith("/")) folderPath += "/";
|
||||
folderPath += file.name;
|
||||
|
||||
fileTableContent += '<tr class="folder-row">';
|
||||
// Checkbox cell + folder row
|
||||
fileTableContent += `<tr class="folder-row">`;
|
||||
fileTableContent += `<td><input type="checkbox" class="select-item" data-path="${encodeURIComponent(folderPath)}" data-name="${escapeHtml(file.name)}" data-type="folder"></td>`;
|
||||
fileTableContent += `<td><span class="file-icon">📁</span><a href="/files?path=${encodeURIComponent(folderPath)}" class="folder-link">${escapeHtml(file.name)}</a><span class="folder-badge">FOLDER</span></td>`;
|
||||
fileTableContent += '<td>Folder</td>';
|
||||
fileTableContent += '<td>-</td>';
|
||||
@@ -865,7 +869,9 @@
|
||||
if (!filePath.endsWith("/")) filePath += "/";
|
||||
filePath += file.name;
|
||||
|
||||
// Checkbox cell + file row
|
||||
fileTableContent += `<tr class="${file.isEpub ? 'epub-file' : ''}">`;
|
||||
fileTableContent += `<td><input type="checkbox" class="select-item" data-path="${encodeURIComponent(filePath)}" data-name="${escapeHtml(file.name)}" data-type="file"></td>`;
|
||||
fileTableContent += `<td><span class="file-icon">${file.isEpub ? '📗' : '📄'}</span>${escapeHtml(file.name)}`;
|
||||
if (file.isEpub) fileTableContent += '<span class="epub-badge">EPUB</span>';
|
||||
fileTableContent += '</td>';
|
||||
@@ -910,6 +916,92 @@
|
||||
document.getElementById('folderModal').classList.remove('open');
|
||||
}
|
||||
|
||||
// Toggle select-all checkbox
|
||||
function toggleSelectAll(master) {
|
||||
const checked = master.checked;
|
||||
document.querySelectorAll('.select-item').forEach(cb => {
|
||||
cb.checked = checked;
|
||||
});
|
||||
}
|
||||
|
||||
function getSelectedItems() {
|
||||
const items = [];
|
||||
document.querySelectorAll('.select-item:checked').forEach(cb => {
|
||||
items.push({
|
||||
name: cb.dataset.name || decodeURIComponent(cb.dataset.path).split('/').pop(),
|
||||
path: decodeURIComponent(cb.dataset.path),
|
||||
isFolder: cb.dataset.type === 'folder'
|
||||
});
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
// Open delete modal for currently selected checkboxes
|
||||
function openDeleteSelectedModal() {
|
||||
const items = getSelectedItems();
|
||||
if (items.length === 0) {
|
||||
alert('Please select at least one item to delete.');
|
||||
return;
|
||||
}
|
||||
openDeleteModalForItems(items);
|
||||
}
|
||||
|
||||
// Open delete modal for a single item (keeps backwards compatibility with per-row delete button)
|
||||
function openDeleteModal(name, path, isFolder) {
|
||||
openDeleteModalForItems([{ name: name, path: path, isFolder: !!isFolder }]);
|
||||
}
|
||||
|
||||
let deleteItemsGlobal = [];
|
||||
|
||||
function openDeleteModalForItems(items) {
|
||||
deleteItemsGlobal = items;
|
||||
const listEl = document.getElementById('deleteItemList');
|
||||
listEl.innerHTML = '';
|
||||
items.forEach(it => {
|
||||
const div = document.createElement('div');
|
||||
div.style.marginBottom = '6px';
|
||||
div.textContent = (it.isFolder ? '📁 ' : '📄 ') + it.path;
|
||||
listEl.appendChild(div);
|
||||
});
|
||||
document.getElementById('deleteModal').classList.add('open');
|
||||
}
|
||||
|
||||
function closeDeleteModal() {
|
||||
document.getElementById('deleteModal').classList.remove('open');
|
||||
}
|
||||
|
||||
function confirmDelete() {
|
||||
if (!deleteItemsGlobal || deleteItemsGlobal.length === 0) {
|
||||
closeDeleteModal();
|
||||
return;
|
||||
}
|
||||
|
||||
const paths = deleteItemsGlobal.map(it => {
|
||||
// Ensure path starts with /
|
||||
let p = it.path;
|
||||
if (!p.startsWith('/')) p = '/' + p;
|
||||
return p;
|
||||
});
|
||||
|
||||
const body = 'paths=' + encodeURIComponent(JSON.stringify(paths));
|
||||
fetch('/delete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body
|
||||
}).then(async res => {
|
||||
if (res.ok) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
const text = await res.text();
|
||||
alert('Failed to delete: ' + text);
|
||||
closeDeleteModal();
|
||||
}
|
||||
}).catch(() => {
|
||||
alert('Failed to delete - network error');
|
||||
closeDeleteModal();
|
||||
});
|
||||
}
|
||||
|
||||
function validateFile() {
|
||||
const fileInput = document.getElementById('fileInput');
|
||||
const uploadBtn = document.getElementById('uploadBtn');
|
||||
@@ -1440,47 +1532,6 @@ function retryAllFailedUploads() {
|
||||
|
||||
xhr.send(formData);
|
||||
}
|
||||
|
||||
// Delete functions
|
||||
function openDeleteModal(name, path, isFolder) {
|
||||
document.getElementById('deleteItemName').textContent = (isFolder ? '📁 ' : '📄 ') + name;
|
||||
document.getElementById('deleteItemPath').value = path;
|
||||
document.getElementById('deleteItemType').value = isFolder ? 'folder' : 'file';
|
||||
document.getElementById('deleteModal').classList.add('open');
|
||||
}
|
||||
|
||||
function closeDeleteModal() {
|
||||
document.getElementById('deleteModal').classList.remove('open');
|
||||
}
|
||||
|
||||
function confirmDelete() {
|
||||
const path = document.getElementById('deleteItemPath').value;
|
||||
const itemType = document.getElementById('deleteItemType').value;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('path', path);
|
||||
formData.append('type', itemType);
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '/delete', true);
|
||||
|
||||
xhr.onload = function() {
|
||||
if (xhr.status === 200) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert('Failed to delete: ' + xhr.responseText);
|
||||
closeDeleteModal();
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = function() {
|
||||
alert('Failed to delete - network error');
|
||||
closeDeleteModal();
|
||||
};
|
||||
|
||||
xhr.send(formData);
|
||||
}
|
||||
|
||||
hydrate();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user