add basic file management to web server
This commit is contained in:
parent
c87a06edb8
commit
ecff988a29
@ -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,7 +350,9 @@ void CrossPointWebServer::handleFileListData() const {
|
||||
bool seenFirst = false;
|
||||
JsonDocument doc;
|
||||
|
||||
scanFiles(currentPath.c_str(), [this, &output, &doc, seenFirst](const FileInfo& info) mutable {
|
||||
scanFiles(
|
||||
currentPath.c_str(),
|
||||
[this, &output, &doc, seenFirst](const FileInfo& info) mutable {
|
||||
doc.clear();
|
||||
doc["name"] = info.name;
|
||||
doc["size"] = info.size;
|
||||
@ -338,7 +362,8 @@ void CrossPointWebServer::handleFileListData() const {
|
||||
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());
|
||||
Serial.printf("[%lu] [WEB] Skipping file entry with oversized JSON for name: %s\n", millis(),
|
||||
info.name.c_str());
|
||||
return;
|
||||
}
|
||||
|
||||
@ -348,7 +373,8 @@ void CrossPointWebServer::handleFileListData() const {
|
||||
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)");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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">×</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()">×</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()">×</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("&", "&")
|
||||
@ -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('"', '"');
|
||||
const escapedPath = path.replaceAll("'", "\\'").replaceAll('"', '"');
|
||||
|
||||
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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user