add basic file management to web server

This commit is contained in:
cottongin 2026-01-24 13:05:01 -05:00
parent c87a06edb8
commit ecff988a29
No known key found for this signature in database
GPG Key ID: 0ECC91FE4655C262
3 changed files with 1133 additions and 52 deletions

View File

@ -115,6 +115,15 @@ void CrossPointWebServer::begin() {
// Download endpoint
server->on("/download", HTTP_GET, [this] { handleDownload(); });
// Rename endpoint
server->on("/rename", HTTP_POST, [this] { handleRename(); });
// Copy endpoint
server->on("/copy", HTTP_POST, [this] { handleCopy(); });
// Move endpoint
server->on("/move", HTTP_POST, [this] { handleMove(); });
server->onNotFound([this] { handleNotFound(); });
Serial.printf("[%lu] [WEB] [MEM] Free heap after route setup: %d bytes\n", millis(), ESP.getFreeHeap());
@ -239,7 +248,8 @@ void CrossPointWebServer::handleStatus() const {
server->send(200, "application/json", json);
}
void CrossPointWebServer::scanFiles(const char* path, const std::function<void(FileInfo)>& callback) const {
void CrossPointWebServer::scanFiles(const char* path, const std::function<void(FileInfo)>& callback,
bool showHidden) const {
FsFile root = SdMan.open(path);
if (!root) {
Serial.printf("[%lu] [WEB] Failed to open directory: %s\n", millis(), path);
@ -252,7 +262,7 @@ void CrossPointWebServer::scanFiles(const char* path, const std::function<void(F
return;
}
Serial.printf("[%lu] [WEB] Scanning files in: %s\n", millis(), path);
Serial.printf("[%lu] [WEB] Scanning files in: %s (showHidden=%d)\n", millis(), path, showHidden);
FsFile file = root.openNextFile();
char name[500];
@ -260,10 +270,16 @@ void CrossPointWebServer::scanFiles(const char* path, const std::function<void(F
file.getName(name, sizeof(name));
auto fileName = String(name);
// Skip hidden items (starting with ".")
bool shouldHide = fileName.startsWith(".");
// Skip hidden items (starting with ".") unless showHidden is true
// Always skip .crosspoint folder (internal cache) even when showing hidden
bool shouldHide = false;
if (fileName.startsWith(".")) {
if (!showHidden || fileName.equals(".crosspoint")) {
shouldHide = true;
}
}
// Check against explicitly hidden items list
// Check against explicitly hidden items list (always hidden)
if (!shouldHide) {
for (size_t i = 0; i < HIDDEN_ITEMS_COUNT; i++) {
if (fileName.equals(HIDDEN_ITEMS[i])) {
@ -320,6 +336,12 @@ void CrossPointWebServer::handleFileListData() const {
}
}
// Check if we should show hidden files
bool showHidden = false;
if (server->hasArg("showHidden")) {
showHidden = server->arg("showHidden") == "true";
}
server->setContentLength(CONTENT_LENGTH_UNKNOWN);
server->send(200, "application/json", "");
server->sendContent("[");
@ -328,27 +350,31 @@ void CrossPointWebServer::handleFileListData() const {
bool seenFirst = false;
JsonDocument doc;
scanFiles(currentPath.c_str(), [this, &output, &doc, seenFirst](const FileInfo& info) mutable {
doc.clear();
doc["name"] = info.name;
doc["size"] = info.size;
doc["isDirectory"] = info.isDirectory;
doc["isEpub"] = info.isEpub;
scanFiles(
currentPath.c_str(),
[this, &output, &doc, seenFirst](const FileInfo& info) mutable {
doc.clear();
doc["name"] = info.name;
doc["size"] = info.size;
doc["isDirectory"] = info.isDirectory;
doc["isEpub"] = info.isEpub;
const size_t written = serializeJson(doc, output, outputSize);
if (written >= outputSize) {
// JSON output truncated; skip this entry to avoid sending malformed JSON
Serial.printf("[%lu] [WEB] Skipping file entry with oversized JSON for name: %s\n", millis(), info.name.c_str());
return;
}
const size_t written = serializeJson(doc, output, outputSize);
if (written >= outputSize) {
// JSON output truncated; skip this entry to avoid sending malformed JSON
Serial.printf("[%lu] [WEB] Skipping file entry with oversized JSON for name: %s\n", millis(),
info.name.c_str());
return;
}
if (seenFirst) {
server->sendContent(",");
} else {
seenFirst = true;
}
server->sendContent(output);
});
if (seenFirst) {
server->sendContent(",");
} else {
seenFirst = true;
}
server->sendContent(output);
},
showHidden);
server->sendContent("]");
// End of streamed response, empty chunk to signal client
server->sendContent("");
@ -1017,3 +1043,413 @@ void CrossPointWebServer::handleDownload() const {
Serial.printf("[%lu] [WEB] Download complete: %s (%d bytes in %lu ms, %.1f KB/s)\n", millis(), filename.c_str(),
totalSent, elapsed, kbps);
}
void CrossPointWebServer::handleRename() const {
// Get path and new name from form data
if (!server->hasArg("path") || !server->hasArg("newName")) {
server->send(400, "text/plain", "Missing path or newName parameter");
return;
}
String itemPath = server->arg("path");
const String newName = server->arg("newName");
// Validate new name
if (newName.isEmpty()) {
server->send(400, "text/plain", "New name cannot be empty");
return;
}
// Reject names containing path separators
if (newName.indexOf('/') >= 0 || newName.indexOf('\\') >= 0) {
server->send(400, "text/plain", "Name cannot contain path separators");
return;
}
// Ensure path starts with /
if (!itemPath.startsWith("/")) {
itemPath = "/" + itemPath;
}
// Validate path
if (itemPath.isEmpty() || itemPath == "/") {
server->send(400, "text/plain", "Cannot rename root directory");
return;
}
// Security check: prevent renaming protected items
const String oldName = itemPath.substring(itemPath.lastIndexOf('/') + 1);
if (oldName.startsWith(".")) {
server->send(403, "text/plain", "Cannot rename system files");
return;
}
for (size_t i = 0; i < HIDDEN_ITEMS_COUNT; i++) {
if (oldName.equals(HIDDEN_ITEMS[i])) {
server->send(403, "text/plain", "Cannot rename protected items");
return;
}
}
// Check if source exists
if (!SdMan.exists(itemPath.c_str())) {
server->send(404, "text/plain", "Item not found");
return;
}
// Build new path (same directory, new name)
const int lastSlash = itemPath.lastIndexOf('/');
String newPath;
if (lastSlash == 0) {
newPath = "/" + newName;
} else {
newPath = itemPath.substring(0, lastSlash + 1) + newName;
}
// Check if destination already exists
if (SdMan.exists(newPath.c_str())) {
server->send(400, "text/plain", "An item with that name already exists");
return;
}
Serial.printf("[%lu] [WEB] Renaming: %s -> %s\n", millis(), itemPath.c_str(), newPath.c_str());
// Perform the rename
esp_task_wdt_reset();
if (SdMan.rename(itemPath.c_str(), newPath.c_str())) {
Serial.printf("[%lu] [WEB] Rename successful\n", millis());
server->send(200, "text/plain", "Renamed successfully");
} else {
Serial.printf("[%lu] [WEB] Rename failed\n", millis());
server->send(500, "text/plain", "Failed to rename item");
}
}
bool CrossPointWebServer::copyFile(const String& srcPath, const String& destPath) const {
FsFile srcFile;
FsFile destFile;
// Open source file
if (!SdMan.openFileForRead("COPY", srcPath, srcFile)) {
Serial.printf("[%lu] [WEB] Copy failed - cannot open source: %s\n", millis(), srcPath.c_str());
return false;
}
// Check if destination exists and remove it
if (SdMan.exists(destPath.c_str())) {
SdMan.remove(destPath.c_str());
}
// Open destination file
if (!SdMan.openFileForWrite("COPY", destPath, destFile)) {
Serial.printf("[%lu] [WEB] Copy failed - cannot create dest: %s\n", millis(), destPath.c_str());
srcFile.close();
return false;
}
// Copy in chunks
constexpr size_t COPY_BUFFER_SIZE = 4096;
uint8_t buffer[COPY_BUFFER_SIZE];
size_t totalCopied = 0;
const size_t fileSize = srcFile.size();
while (srcFile.available()) {
esp_task_wdt_reset();
yield();
const size_t bytesRead = srcFile.read(buffer, COPY_BUFFER_SIZE);
if (bytesRead == 0) break;
const size_t bytesWritten = destFile.write(buffer, bytesRead);
if (bytesWritten != bytesRead) {
Serial.printf("[%lu] [WEB] Copy failed - write error at %d bytes\n", millis(), totalCopied);
srcFile.close();
destFile.close();
SdMan.remove(destPath.c_str());
return false;
}
totalCopied += bytesWritten;
}
srcFile.close();
destFile.close();
Serial.printf("[%lu] [WEB] Copy complete: %s -> %s (%d bytes)\n", millis(), srcPath.c_str(), destPath.c_str(),
totalCopied);
return true;
}
bool CrossPointWebServer::copyFolder(const String& srcPath, const String& destPath) const {
// Create destination directory
if (!SdMan.exists(destPath.c_str())) {
if (!SdMan.mkdir(destPath.c_str())) {
Serial.printf("[%lu] [WEB] Copy folder failed - cannot create dest dir: %s\n", millis(), destPath.c_str());
return false;
}
}
// Open source directory
FsFile srcDir = SdMan.open(srcPath.c_str());
if (!srcDir || !srcDir.isDirectory()) {
Serial.printf("[%lu] [WEB] Copy folder failed - cannot open source dir: %s\n", millis(), srcPath.c_str());
return false;
}
// Iterate through source directory
FsFile entry = srcDir.openNextFile();
char name[256];
bool success = true;
while (entry && success) {
esp_task_wdt_reset();
yield();
entry.getName(name, sizeof(name));
const String entryName = String(name);
// Skip hidden files
if (!entryName.startsWith(".")) {
String srcEntryPath = srcPath;
if (!srcEntryPath.endsWith("/")) srcEntryPath += "/";
srcEntryPath += entryName;
String destEntryPath = destPath;
if (!destEntryPath.endsWith("/")) destEntryPath += "/";
destEntryPath += entryName;
if (entry.isDirectory()) {
success = copyFolder(srcEntryPath, destEntryPath);
} else {
success = copyFile(srcEntryPath, destEntryPath);
}
}
entry.close();
entry = srcDir.openNextFile();
}
srcDir.close();
return success;
}
void CrossPointWebServer::handleCopy() const {
// Get source and destination paths
if (!server->hasArg("srcPath") || !server->hasArg("destPath")) {
server->send(400, "text/plain", "Missing srcPath or destPath parameter");
return;
}
String srcPath = server->arg("srcPath");
String destPath = server->arg("destPath");
// Ensure paths start with /
if (!srcPath.startsWith("/")) srcPath = "/" + srcPath;
if (!destPath.startsWith("/")) destPath = "/" + destPath;
// Validate paths
if (srcPath.isEmpty() || srcPath == "/") {
server->send(400, "text/plain", "Cannot copy root directory");
return;
}
// Security check: prevent copying protected items
const String srcName = srcPath.substring(srcPath.lastIndexOf('/') + 1);
if (srcName.startsWith(".")) {
server->send(403, "text/plain", "Cannot copy system files");
return;
}
for (size_t i = 0; i < HIDDEN_ITEMS_COUNT; i++) {
if (srcName.equals(HIDDEN_ITEMS[i])) {
server->send(403, "text/plain", "Cannot copy protected items");
return;
}
}
// Check if source exists
if (!SdMan.exists(srcPath.c_str())) {
server->send(404, "text/plain", "Source item not found");
return;
}
// Check if destination already exists
if (SdMan.exists(destPath.c_str())) {
server->send(400, "text/plain", "Destination already exists");
return;
}
// Prevent copying a folder into itself
if (destPath.startsWith(srcPath + "/")) {
server->send(400, "text/plain", "Cannot copy a folder into itself");
return;
}
Serial.printf("[%lu] [WEB] Copying: %s -> %s\n", millis(), srcPath.c_str(), destPath.c_str());
// Check if source is a file or directory
FsFile srcFile = SdMan.open(srcPath.c_str());
const bool isDirectory = srcFile.isDirectory();
srcFile.close();
bool success;
if (isDirectory) {
success = copyFolder(srcPath, destPath);
} else {
success = copyFile(srcPath, destPath);
}
if (success) {
Serial.printf("[%lu] [WEB] Copy successful\n", millis());
server->send(200, "text/plain", "Copied successfully");
} else {
Serial.printf("[%lu] [WEB] Copy failed\n", millis());
server->send(500, "text/plain", "Failed to copy item");
}
}
// Helper to recursively delete a folder
static bool deleteFolderRecursive(const String& path) {
FsFile dir = SdMan.open(path.c_str());
if (!dir || !dir.isDirectory()) {
return false;
}
FsFile entry = dir.openNextFile();
char name[256];
bool success = true;
while (entry && success) {
esp_task_wdt_reset();
yield();
entry.getName(name, sizeof(name));
const String entryName = String(name);
String entryPath = path;
if (!entryPath.endsWith("/")) entryPath += "/";
entryPath += entryName;
if (entry.isDirectory()) {
entry.close();
success = deleteFolderRecursive(entryPath);
} else {
entry.close();
success = SdMan.remove(entryPath.c_str());
}
if (success) {
entry = dir.openNextFile();
}
}
dir.close();
// Now remove the empty directory
if (success) {
success = SdMan.rmdir(path.c_str());
}
return success;
}
void CrossPointWebServer::handleMove() const {
// Get source and destination paths
if (!server->hasArg("srcPath") || !server->hasArg("destPath")) {
server->send(400, "text/plain", "Missing srcPath or destPath parameter");
return;
}
String srcPath = server->arg("srcPath");
String destPath = server->arg("destPath");
// Ensure paths start with /
if (!srcPath.startsWith("/")) srcPath = "/" + srcPath;
if (!destPath.startsWith("/")) destPath = "/" + destPath;
// Validate paths
if (srcPath.isEmpty() || srcPath == "/") {
server->send(400, "text/plain", "Cannot move root directory");
return;
}
// Security check: prevent moving protected items
const String srcName = srcPath.substring(srcPath.lastIndexOf('/') + 1);
if (srcName.startsWith(".")) {
server->send(403, "text/plain", "Cannot move system files");
return;
}
for (size_t i = 0; i < HIDDEN_ITEMS_COUNT; i++) {
if (srcName.equals(HIDDEN_ITEMS[i])) {
server->send(403, "text/plain", "Cannot move protected items");
return;
}
}
// Check if source exists
if (!SdMan.exists(srcPath.c_str())) {
server->send(404, "text/plain", "Source item not found");
return;
}
// Check if destination already exists
if (SdMan.exists(destPath.c_str())) {
server->send(400, "text/plain", "Destination already exists");
return;
}
// Prevent moving a folder into itself
if (destPath.startsWith(srcPath + "/")) {
server->send(400, "text/plain", "Cannot move a folder into itself");
return;
}
Serial.printf("[%lu] [WEB] Moving: %s -> %s\n", millis(), srcPath.c_str(), destPath.c_str());
// First, try atomic rename (fast, works on same filesystem)
esp_task_wdt_reset();
if (SdMan.rename(srcPath.c_str(), destPath.c_str())) {
Serial.printf("[%lu] [WEB] Move successful (via rename)\n", millis());
server->send(200, "text/plain", "Moved successfully");
return;
}
// Fallback: copy + delete (for cross-directory moves that rename doesn't support)
Serial.printf("[%lu] [WEB] Rename failed, trying copy+delete fallback\n", millis());
// Check if source is a file or directory
FsFile srcFile = SdMan.open(srcPath.c_str());
const bool isDirectory = srcFile.isDirectory();
srcFile.close();
bool copySuccess;
if (isDirectory) {
copySuccess = copyFolder(srcPath, destPath);
} else {
copySuccess = copyFile(srcPath, destPath);
}
if (!copySuccess) {
Serial.printf("[%lu] [WEB] Move failed - copy step failed\n", millis());
server->send(500, "text/plain", "Failed to move item");
return;
}
// Delete source
bool deleteSuccess;
if (isDirectory) {
deleteSuccess = deleteFolderRecursive(srcPath);
} else {
deleteSuccess = SdMan.remove(srcPath.c_str());
}
if (deleteSuccess) {
Serial.printf("[%lu] [WEB] Move successful (via copy+delete)\n", millis());
server->send(200, "text/plain", "Moved successfully");
} else {
// Copy succeeded but delete failed - warn but don't fail completely
Serial.printf("[%lu] [WEB] Move partial - copied but failed to delete source\n", millis());
server->send(200, "text/plain", "Moved (but source may still exist)");
}
}

View File

@ -46,7 +46,7 @@ class CrossPointWebServer {
static void wsEventCallback(uint8_t num, WStype_t type, uint8_t* payload, size_t length);
// File scanning
void scanFiles(const char* path, const std::function<void(FileInfo)>& callback) const;
void scanFiles(const char* path, const std::function<void(FileInfo)>& callback, bool showHidden = false) const;
String formatFileSize(size_t bytes) const;
bool isEpubFile(const String& filename) const;
@ -64,4 +64,11 @@ class CrossPointWebServer {
void handleUnarchive() const;
void handleArchivedList() const;
void handleDownload() const;
void handleRename() const;
void handleCopy() const;
void handleMove() const;
// Helper for copy operations
bool copyFile(const String& srcPath, const String& destPath) const;
bool copyFolder(const String& srcPath, const String& destPath) const;
};

View File

@ -348,9 +348,196 @@
color: #27ae60;
}
.actions-col {
width: 120px;
width: 60px;
text-align: center;
}
.checkbox-col {
width: 40px;
text-align: center;
}
.checkbox-col input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
}
/* Selection toolbar */
.selection-toolbar {
display: none;
background-color: #3498db;
color: white;
padding: 12px 15px;
border-radius: 4px;
margin-bottom: 15px;
align-items: center;
gap: 15px;
flex-wrap: wrap;
}
.selection-toolbar.show {
display: flex;
}
.selection-toolbar .selection-count {
font-weight: 600;
}
.selection-toolbar .toolbar-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.selection-toolbar button {
background-color: rgba(255,255,255,0.2);
color: white;
border: none;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 0.9em;
display: flex;
align-items: center;
gap: 4px;
}
.selection-toolbar button:hover {
background-color: rgba(255,255,255,0.3);
}
.selection-toolbar .clear-selection {
margin-left: auto;
background-color: transparent;
font-size: 1.2em;
padding: 4px 8px;
}
/* Row selected state */
.row-selected {
background-color: #d6eaf8 !important;
}
.row-selected:hover {
background-color: #aed6f1 !important;
}
/* Action dropdown */
.action-dropdown {
position: relative;
display: inline-block;
}
.action-dropdown-btn {
background: none;
border: none;
cursor: pointer;
font-size: 1.3em;
padding: 4px 10px;
border-radius: 4px;
color: #7f8c8d;
transition: all 0.15s;
}
.action-dropdown-btn:hover {
background-color: #ecf0f1;
color: #2c3e50;
}
.action-dropdown-menu {
display: none;
position: absolute;
right: 0;
top: 100%;
background: white;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
min-width: 150px;
z-index: 100;
overflow: hidden;
}
.action-dropdown-menu.show {
display: block;
}
.action-dropdown-menu button,
.action-dropdown-menu a {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 10px 14px;
border: none;
background: none;
text-align: left;
cursor: pointer;
color: #2c3e50;
text-decoration: none;
font-size: 0.95em;
}
.action-dropdown-menu button:hover,
.action-dropdown-menu a:hover {
background-color: #f8f9fa;
}
.action-dropdown-menu .danger {
color: #e74c3c;
}
.action-dropdown-menu .danger:hover {
background-color: #fee;
}
.action-dropdown-menu .divider {
height: 1px;
background-color: #eee;
margin: 4px 0;
}
/* Hidden toggle button */
.hidden-toggle {
background-color: #95a5a6;
}
.hidden-toggle:hover {
background-color: #7f8c8d;
}
.hidden-toggle.active {
background-color: #9b59b6;
}
.hidden-toggle.active:hover {
background-color: #8e44ad;
}
/* Hidden file styling */
.hidden-file {
opacity: 0.6;
}
/* Copy/Move modal folder browser */
.copy-move-breadcrumb {
color: #7f8c8d;
font-size: 0.95em;
padding: 8px 0;
}
.copy-move-breadcrumb a {
color: #3498db;
text-decoration: none;
cursor: pointer;
}
.copy-move-breadcrumb a:hover {
text-decoration: underline;
}
.copy-move-breadcrumb .sep {
margin: 0 6px;
color: #bdc3c7;
}
.copy-move-breadcrumb .current {
color: #2c3e50;
font-weight: 500;
}
.folder-list-item {
display: flex;
align-items: center;
padding: 10px 12px;
cursor: pointer;
border-bottom: 1px solid #eee;
transition: background-color 0.15s;
}
.folder-list-item:last-child {
border-bottom: none;
}
.folder-list-item:hover {
background-color: #f8f9fa;
}
.folder-list-item .folder-icon {
margin-right: 10px;
font-size: 1.1em;
}
.folder-list-empty {
text-align: center;
color: #95a5a6;
padding: 20px;
font-style: italic;
}
/* Archived files button */
.archived-action-btn {
background-color: #9b59b6;
@ -629,11 +816,31 @@
font-size: 1.1em;
}
.actions-col {
width: 70px;
width: 50px;
}
.delete-btn, .archive-btn, .download-btn {
font-size: 1em;
padding: 2px 4px;
.checkbox-col {
width: 32px;
}
.action-dropdown-btn {
font-size: 1.1em;
padding: 2px 6px;
}
.action-dropdown-menu {
min-width: 130px;
}
.action-dropdown-menu button,
.action-dropdown-menu a {
padding: 8px 12px;
font-size: 0.9em;
}
.selection-toolbar {
padding: 10px 12px;
gap: 10px;
font-size: 0.9em;
}
.selection-toolbar button {
padding: 5px 10px;
font-size: 0.85em;
}
.no-files {
padding: 20px;
@ -658,6 +865,7 @@
<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 archived-action-btn" onclick="openArchivedModal()">📦 Archived <span id="archivedCount"></span></button>
<button class="action-btn hidden-toggle" id="hiddenToggle" onclick="toggleHiddenFiles()">👁 Hidden</button>
</div>
</div>
@ -677,6 +885,17 @@
<span class="summary-inline" id="folder-summary"></span>
</div>
<!-- Selection Toolbar -->
<div class="selection-toolbar" id="selectionToolbar">
<span class="selection-count"><span id="selectedCount">0</span> selected</span>
<div class="toolbar-actions">
<button onclick="bulkCopy()">📋 Copy</button>
<button onclick="bulkMove()">📁 Move</button>
<button onclick="bulkDelete()">🗑️ Delete</button>
</div>
<button class="clear-selection" onclick="clearSelection()" title="Clear selection">&times;</button>
</div>
<div id="file-table">
<div class="loader-container">
<span class="loader"></span>
@ -783,10 +1002,55 @@
</div>
</div>
<!-- Rename Modal -->
<div class="modal-overlay" id="renameModal">
<div class="modal">
<button class="modal-close" onclick="closeRenameModal()">&times;</button>
<h3>✏️ Rename</h3>
<div class="folder-form">
<p class="file-info">Enter a new name:</p>
<input type="text" id="renameNewName" class="folder-input" placeholder="New name...">
<input type="hidden" id="renameItemPath">
<input type="hidden" id="renameItemIsFolder">
<button class="folder-btn" onclick="confirmRename()">Rename</button>
<button class="delete-btn-cancel" onclick="closeRenameModal()">Cancel</button>
</div>
</div>
</div>
<!-- Copy/Move Destination Modal -->
<div class="modal-overlay" id="copyMoveModal">
<div class="modal" style="max-width: 500px;">
<button class="modal-close" onclick="closeCopyMoveModal()">&times;</button>
<h3 id="copyMoveTitle">📋 Copy to...</h3>
<div class="folder-form">
<p class="file-info">Select destination folder:</p>
<div class="copy-move-breadcrumb" id="copyMoveBreadcrumb"></div>
<div id="copyMoveFolderList" style="max-height: 250px; overflow-y: auto; border: 1px solid #ddd; border-radius: 4px; margin: 10px 0;">
<div class="loader-container"><span class="loader"></span></div>
</div>
<input type="hidden" id="copyMoveOperation">
<input type="hidden" id="copyMoveSourcePaths">
<input type="hidden" id="copyMoveDestPath" value="/">
<button class="archive-btn-confirm" onclick="confirmCopyMove()">
<span id="copyMoveConfirmText">Copy Here</span>
</button>
<button class="delete-btn-cancel" onclick="closeCopyMoveModal()">Cancel</button>
</div>
</div>
</div>
<script>
// get current path from query parameter
const currentPath = decodeURIComponent(new URLSearchParams(window.location.search).get('path') || '/');
// Selection state
let selectedItems = [];
let allFiles = [];
// Hidden files toggle state (persisted in localStorage)
let showHidden = localStorage.getItem('showHiddenFiles') === 'true';
function escapeHtml(unsafe) {
return unsafe
.replaceAll("&", "&amp;")
@ -814,6 +1078,21 @@
});
});
// Close dropdowns when clicking outside
document.addEventListener('click', function(e) {
if (!e.target.closest('.action-dropdown')) {
document.querySelectorAll('.action-dropdown-menu.show').forEach(m => m.classList.remove('show'));
}
});
// Update hidden toggle button state
const hiddenToggle = document.getElementById('hiddenToggle');
if (showHidden) {
hiddenToggle.classList.add('active');
} else {
hiddenToggle.classList.remove('active');
}
const breadcrumbs = document.getElementById('directory-breadcrumbs');
const fileTable = document.getElementById('file-table');
@ -833,7 +1112,8 @@
let files = [];
try {
const response = await fetch('/api/files?path=' + encodeURIComponent(currentPath));
const url = '/api/files?path=' + encodeURIComponent(currentPath) + (showHidden ? '&showHidden=true' : '');
const response = await fetch(url);
if (!response.ok) {
throw new Error('Failed to load files: ' + response.status + ' ' + response.statusText);
}
@ -844,6 +1124,11 @@
return;
}
// Store files for selection management
allFiles = files;
selectedItems = [];
updateSelectionUI();
let folderCount = 0;
let totalSize = 0;
files.forEach(file => {
@ -857,7 +1142,7 @@
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>';
fileTableContent += '<tr><th class="checkbox-col"><input type="checkbox" id="selectAll" 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
@ -874,36 +1159,31 @@
return ext.endsWith('.epub') || ext.endsWith('.txt') || ext.endsWith('.xtc') || ext.endsWith('.xtch');
}
sortedFiles.forEach(file => {
if (file.isDirectory) {
let folderPath = currentPath;
if (!folderPath.endsWith("/")) folderPath += "/";
folderPath += file.name;
sortedFiles.forEach((file, index) => {
let itemPath = currentPath;
if (!itemPath.endsWith("/")) itemPath += "/";
itemPath += file.name;
fileTableContent += '<tr class="folder-row">';
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>`;
const isHiddenItem = file.name.startsWith('.');
const hiddenClass = isHiddenItem ? ' hidden-file' : '';
if (file.isDirectory) {
fileTableContent += `<tr class="folder-row${hiddenClass}" data-path="${escapeHtml(itemPath)}" data-name="${escapeHtml(file.name)}" data-is-folder="true">`;
fileTableContent += `<td class="checkbox-col"><input type="checkbox" class="row-checkbox" data-index="${index}" onchange="toggleRowSelection(${index})"></td>`;
fileTableContent += `<td><span class="file-icon">📁</span><a href="/files?path=${encodeURIComponent(itemPath)}" class="folder-link">${escapeHtml(file.name)}</a><span class="folder-badge">FOLDER</span></td>`;
fileTableContent += '<td>Folder</td>';
fileTableContent += '<td>-</td>';
fileTableContent += `<td class="actions-col"><button class="delete-btn" onclick="openDeleteModal('${file.name.replaceAll("'", "\\'")}', '${folderPath.replaceAll("'", "\\'")}', true)" title="Delete folder">🗑️</button></td>`;
fileTableContent += `<td class="actions-col">${buildActionDropdown(file.name, itemPath, true, false)}</td>`;
fileTableContent += '</tr>';
} else {
let filePath = currentPath;
if (!filePath.endsWith("/")) filePath += "/";
filePath += file.name;
fileTableContent += `<tr class="${file.isEpub ? 'epub-file' : ''}">`;
fileTableContent += `<tr class="${file.isEpub ? 'epub-file' : ''}${hiddenClass}" data-path="${escapeHtml(itemPath)}" data-name="${escapeHtml(file.name)}" data-is-folder="false">`;
fileTableContent += `<td class="checkbox-col"><input type="checkbox" class="row-checkbox" data-index="${index}" onchange="toggleRowSelection(${index})"></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>';
fileTableContent += `<td>${file.name.split('.').pop().toUpperCase()}</td>`;
fileTableContent += `<td>${formatFileSize(file.size)}</td>`;
fileTableContent += '<td class="actions-col">';
fileTableContent += `<a href="/download?path=${encodeURIComponent(filePath)}" class="download-btn" title="Download file">⬇️</a>`;
if (isBookFormat(file.name)) {
fileTableContent += `<button class="archive-btn" onclick="openArchiveModal('${file.name.replaceAll("'", "\\'")}', '${filePath.replaceAll("'", "\\'")}')" title="Archive book">📦</button>`;
}
fileTableContent += `<button class="delete-btn" onclick="openDeleteModal('${file.name.replaceAll("'", "\\'")}', '${filePath.replaceAll("'", "\\'")}', false)" title="Delete file">🗑️</button>`;
fileTableContent += '</td>';
fileTableContent += `<td class="actions-col">${buildActionDropdown(file.name, itemPath, false, isBookFormat(file.name))}</td>`;
fileTableContent += '</tr>';
}
});
@ -913,6 +1193,364 @@
}
}
function buildActionDropdown(name, path, isFolder, isBook) {
const escapedName = name.replaceAll("'", "\\'").replaceAll('"', '&quot;');
const escapedPath = path.replaceAll("'", "\\'").replaceAll('"', '&quot;');
let html = '<div class="action-dropdown">';
html += '<button class="action-dropdown-btn" onclick="toggleDropdown(event, this)" title="Actions"></button>';
html += '<div class="action-dropdown-menu">';
if (!isFolder) {
html += `<a href="/download?path=${encodeURIComponent(path)}">⬇️ Download</a>`;
}
html += `<button onclick="openRenameModal('${escapedName}', '${escapedPath}', ${isFolder})">✏️ Rename</button>`;
html += `<button onclick="openCopyModal('${escapedPath}')">📋 Copy to...</button>`;
html += `<button onclick="openMoveModal('${escapedPath}')">📁 Move to...</button>`;
if (isBook) {
html += '<div class="divider"></div>';
html += `<button onclick="openArchiveModal('${escapedName}', '${escapedPath}')">📦 Archive</button>`;
}
html += '<div class="divider"></div>';
html += `<button class="danger" onclick="openDeleteModal('${escapedName}', '${escapedPath}', ${isFolder})">🗑️ Delete</button>`;
html += '</div></div>';
return html;
}
function toggleDropdown(event, btn) {
event.stopPropagation();
const menu = btn.nextElementSibling;
const wasOpen = menu.classList.contains('show');
// Close all other dropdowns
document.querySelectorAll('.action-dropdown-menu.show').forEach(m => m.classList.remove('show'));
if (!wasOpen) {
menu.classList.add('show');
}
}
// Selection management
function toggleSelectAll(checkbox) {
const rowCheckboxes = document.querySelectorAll('.row-checkbox');
if (checkbox.checked) {
selectedItems = allFiles.map((f, i) => i);
rowCheckboxes.forEach(cb => {
cb.checked = true;
cb.closest('tr').classList.add('row-selected');
});
} else {
selectedItems = [];
rowCheckboxes.forEach(cb => {
cb.checked = false;
cb.closest('tr').classList.remove('row-selected');
});
}
updateSelectionUI();
}
function toggleRowSelection(index) {
const checkbox = document.querySelector(`.row-checkbox[data-index="${index}"]`);
const row = checkbox.closest('tr');
if (checkbox.checked) {
if (!selectedItems.includes(index)) {
selectedItems.push(index);
}
row.classList.add('row-selected');
} else {
selectedItems = selectedItems.filter(i => i !== index);
row.classList.remove('row-selected');
}
// Update select all checkbox
const selectAll = document.getElementById('selectAll');
if (selectAll) {
selectAll.checked = selectedItems.length === allFiles.length;
}
updateSelectionUI();
}
function clearSelection() {
selectedItems = [];
document.querySelectorAll('.row-checkbox').forEach(cb => {
cb.checked = false;
cb.closest('tr').classList.remove('row-selected');
});
const selectAll = document.getElementById('selectAll');
if (selectAll) selectAll.checked = false;
updateSelectionUI();
}
function updateSelectionUI() {
const toolbar = document.getElementById('selectionToolbar');
const countSpan = document.getElementById('selectedCount');
if (selectedItems.length > 0) {
toolbar.classList.add('show');
countSpan.textContent = selectedItems.length;
} else {
toolbar.classList.remove('show');
}
}
function getSelectedPaths() {
return selectedItems.map(index => {
const file = allFiles[index];
let path = currentPath;
if (!path.endsWith('/')) path += '/';
return path + file.name;
});
}
// Hidden files toggle
function toggleHiddenFiles() {
showHidden = !showHidden;
localStorage.setItem('showHiddenFiles', showHidden);
hydrate();
}
// Rename modal functions
function openRenameModal(name, path, isFolder) {
document.getElementById('renameNewName').value = name;
document.getElementById('renameItemPath').value = path;
document.getElementById('renameItemIsFolder').value = isFolder ? 'true' : 'false';
document.getElementById('renameModal').classList.add('open');
document.getElementById('renameNewName').focus();
document.getElementById('renameNewName').select();
}
function closeRenameModal() {
document.getElementById('renameModal').classList.remove('open');
}
function confirmRename() {
const path = document.getElementById('renameItemPath').value;
const newName = document.getElementById('renameNewName').value.trim();
if (!newName) {
alert('Please enter a name');
return;
}
if (newName.includes('/') || newName.includes('\\')) {
alert('Name cannot contain path separators');
return;
}
const formData = new FormData();
formData.append('path', path);
formData.append('newName', newName);
const xhr = new XMLHttpRequest();
xhr.open('POST', '/rename', true);
xhr.onload = function() {
if (xhr.status === 200) {
closeRenameModal();
hydrate();
} else {
alert('Failed to rename: ' + xhr.responseText);
}
};
xhr.onerror = function() {
alert('Failed to rename - network error');
};
xhr.send(formData);
}
// Copy/Move modal functions
let copyMoveCurrentPath = '/';
function openCopyModal(sourcePath) {
openCopyMoveModal('copy', [sourcePath]);
}
function openMoveModal(sourcePath) {
openCopyMoveModal('move', [sourcePath]);
}
function openCopyMoveModal(operation, sourcePaths) {
document.getElementById('copyMoveOperation').value = operation;
document.getElementById('copyMoveSourcePaths').value = JSON.stringify(sourcePaths);
document.getElementById('copyMoveTitle').textContent = operation === 'copy' ? '📋 Copy to...' : '📁 Move to...';
document.getElementById('copyMoveConfirmText').textContent = operation === 'copy' ? 'Copy Here' : 'Move Here';
copyMoveCurrentPath = '/';
document.getElementById('copyMoveDestPath').value = '/';
document.getElementById('copyMoveModal').classList.add('open');
loadCopyMoveFolders();
}
function closeCopyMoveModal() {
document.getElementById('copyMoveModal').classList.remove('open');
}
async function loadCopyMoveFolders() {
const folderList = document.getElementById('copyMoveFolderList');
const breadcrumb = document.getElementById('copyMoveBreadcrumb');
folderList.innerHTML = '<div class="loader-container"><span class="loader"></span></div>';
// Build breadcrumb
let breadcrumbHtml = '';
if (copyMoveCurrentPath === '/') {
breadcrumbHtml = '<span class="current">🏠 Root</span>';
} else {
breadcrumbHtml = '<a onclick="navigateCopyMove(\'/\')">🏠 Root</a>';
const parts = copyMoveCurrentPath.split('/').filter(p => p);
parts.forEach((part, index) => {
const partPath = '/' + parts.slice(0, index + 1).join('/');
if (index === parts.length - 1) {
breadcrumbHtml += '<span class="sep">/</span><span class="current">' + escapeHtml(part) + '</span>';
} else {
breadcrumbHtml += '<span class="sep">/</span><a onclick="navigateCopyMove(\'' + partPath.replaceAll("'", "\\'") + '\')">' + escapeHtml(part) + '</a>';
}
});
}
breadcrumb.innerHTML = breadcrumbHtml;
// Fetch folders only (include hidden folders if showHidden is enabled)
try {
const url = '/api/files?path=' + encodeURIComponent(copyMoveCurrentPath) + (showHidden ? '&showHidden=true' : '');
const response = await fetch(url);
if (!response.ok) throw new Error('Failed to load folders');
const files = await response.json();
const folders = files.filter(f => f.isDirectory).sort((a, b) => a.name.localeCompare(b.name));
if (folders.length === 0) {
folderList.innerHTML = '<div class="folder-list-empty">No subfolders</div>';
} else {
let html = '';
folders.forEach(folder => {
let folderPath = copyMoveCurrentPath;
if (!folderPath.endsWith('/')) folderPath += '/';
folderPath += folder.name;
const isHidden = folder.name.startsWith('.');
html += `<div class="folder-list-item${isHidden ? ' hidden-file' : ''}" onclick="navigateCopyMove('${folderPath.replaceAll("'", "\\'")}')">`;
html += '<span class="folder-icon">📁</span>';
html += escapeHtml(folder.name);
html += '</div>';
});
folderList.innerHTML = html;
}
} catch (e) {
console.error(e);
folderList.innerHTML = '<div class="folder-list-empty">Error loading folders</div>';
}
}
function navigateCopyMove(path) {
copyMoveCurrentPath = path;
document.getElementById('copyMoveDestPath').value = path;
loadCopyMoveFolders();
}
async function confirmCopyMove() {
const operation = document.getElementById('copyMoveOperation').value;
const sourcePaths = JSON.parse(document.getElementById('copyMoveSourcePaths').value);
const destFolder = document.getElementById('copyMoveDestPath').value;
const endpoint = operation === 'copy' ? '/copy' : '/move';
let successCount = 0;
let failCount = 0;
for (const srcPath of sourcePaths) {
const srcName = srcPath.substring(srcPath.lastIndexOf('/') + 1);
let destPath = destFolder;
if (!destPath.endsWith('/')) destPath += '/';
destPath += srcName;
const formData = new FormData();
formData.append('srcPath', srcPath);
formData.append('destPath', destPath);
try {
const response = await fetch(endpoint, { method: 'POST', body: formData });
if (response.ok) {
successCount++;
} else {
failCount++;
console.error(`${operation} failed for ${srcPath}: ${await response.text()}`);
}
} catch (e) {
failCount++;
console.error(`${operation} failed for ${srcPath}:`, e);
}
}
closeCopyMoveModal();
clearSelection();
if (failCount > 0) {
alert(`${operation === 'copy' ? 'Copied' : 'Moved'} ${successCount} items, ${failCount} failed`);
}
hydrate();
}
// Bulk operations
function bulkCopy() {
const paths = getSelectedPaths();
if (paths.length === 0) return;
openCopyMoveModal('copy', paths);
}
function bulkMove() {
const paths = getSelectedPaths();
if (paths.length === 0) return;
openCopyMoveModal('move', paths);
}
function bulkDelete() {
const paths = getSelectedPaths();
if (paths.length === 0) return;
if (!confirm(`Delete ${paths.length} item(s)? This cannot be undone.`)) {
return;
}
let successCount = 0;
let failCount = 0;
Promise.all(paths.map(async (path) => {
const isFolder = allFiles.find(f => {
let itemPath = currentPath;
if (!itemPath.endsWith('/')) itemPath += '/';
return itemPath + f.name === path;
})?.isDirectory || false;
const formData = new FormData();
formData.append('path', path);
formData.append('type', isFolder ? 'folder' : 'file');
try {
const response = await fetch('/delete', { method: 'POST', body: formData });
if (response.ok) {
successCount++;
} else {
failCount++;
}
} catch (e) {
failCount++;
}
})).then(() => {
clearSelection();
if (failCount > 0) {
alert(`Deleted ${successCount} items, ${failCount} failed`);
}
hydrate();
});
}
// Modal functions
function openUploadModal() {
document.getElementById('uploadPathDisplay').textContent = currentPath === '/' ? '/ 🏠' : currentPath;