mirror of
https://github.com/daveallie/crosspoint-reader.git
synced 2026-02-04 14:47:37 +03:00
Merge d968f5fe2f into da4d3b5ea5
This commit is contained in:
commit
68627e890c
@ -44,6 +44,36 @@ void clearEpubCacheIfNeeded(const String& filePath) {
|
||||
Serial.printf("[%lu] [WEB] Cleared epub cache for: %s\n", millis(), filePath.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
String normalizeWebPath(const String& inputPath) {
|
||||
if (inputPath.isEmpty() || inputPath == "/") {
|
||||
return "/";
|
||||
}
|
||||
std::string normalized = FsHelpers::normalisePath(inputPath.c_str());
|
||||
String result = normalized.c_str();
|
||||
if (result.isEmpty()) {
|
||||
return "/";
|
||||
}
|
||||
if (!result.startsWith("/")) {
|
||||
result = "/" + result;
|
||||
}
|
||||
if (result.length() > 1 && result.endsWith("/")) {
|
||||
result = result.substring(0, result.length() - 1);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
bool isProtectedItemName(const String& name) {
|
||||
if (name.startsWith(".")) {
|
||||
return true;
|
||||
}
|
||||
for (size_t i = 0; i < HIDDEN_ITEMS_COUNT; i++) {
|
||||
if (name.equals(HIDDEN_ITEMS[i])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
// File listing page template - now using generated headers:
|
||||
@ -109,6 +139,12 @@ void CrossPointWebServer::begin() {
|
||||
// Create folder endpoint
|
||||
server->on("/mkdir", HTTP_POST, [this] { handleCreateFolder(); });
|
||||
|
||||
// Rename file endpoint
|
||||
server->on("/rename", HTTP_POST, [this] { handleRename(); });
|
||||
|
||||
// Move file endpoint
|
||||
server->on("/move", HTTP_POST, [this] { handleMove(); });
|
||||
|
||||
// Delete file/folder endpoint
|
||||
server->on("/delete", HTTP_POST, [this] { handleDelete(); });
|
||||
|
||||
@ -705,6 +741,181 @@ void CrossPointWebServer::handleCreateFolder() const {
|
||||
}
|
||||
}
|
||||
|
||||
void CrossPointWebServer::handleRename() const {
|
||||
if (!server->hasArg("path") || !server->hasArg("name")) {
|
||||
server->send(400, "text/plain", "Missing path or new name");
|
||||
return;
|
||||
}
|
||||
|
||||
String itemPath = normalizeWebPath(server->arg("path"));
|
||||
String newName = server->arg("name");
|
||||
newName.trim();
|
||||
|
||||
if (itemPath.isEmpty() || itemPath == "/") {
|
||||
server->send(400, "text/plain", "Invalid path");
|
||||
return;
|
||||
}
|
||||
if (newName.isEmpty()) {
|
||||
server->send(400, "text/plain", "New name cannot be empty");
|
||||
return;
|
||||
}
|
||||
if (newName.indexOf('/') >= 0 || newName.indexOf('\\') >= 0) {
|
||||
server->send(400, "text/plain", "Invalid file name");
|
||||
return;
|
||||
}
|
||||
if (isProtectedItemName(newName)) {
|
||||
server->send(403, "text/plain", "Cannot rename to protected name");
|
||||
return;
|
||||
}
|
||||
|
||||
const String itemName = itemPath.substring(itemPath.lastIndexOf('/') + 1);
|
||||
if (isProtectedItemName(itemName)) {
|
||||
server->send(403, "text/plain", "Cannot rename protected item");
|
||||
return;
|
||||
}
|
||||
if (newName == itemName) {
|
||||
server->send(200, "text/plain", "Name unchanged");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!SdMan.exists(itemPath.c_str())) {
|
||||
server->send(404, "text/plain", "Item not found");
|
||||
return;
|
||||
}
|
||||
|
||||
FsFile file = SdMan.open(itemPath.c_str());
|
||||
if (!file) {
|
||||
server->send(500, "text/plain", "Failed to open file");
|
||||
return;
|
||||
}
|
||||
if (file.isDirectory()) {
|
||||
file.close();
|
||||
server->send(400, "text/plain", "Only files can be renamed");
|
||||
return;
|
||||
}
|
||||
|
||||
String parentPath = itemPath.substring(0, itemPath.lastIndexOf('/'));
|
||||
if (parentPath.isEmpty()) {
|
||||
parentPath = "/";
|
||||
}
|
||||
String newPath = parentPath;
|
||||
if (!newPath.endsWith("/")) {
|
||||
newPath += "/";
|
||||
}
|
||||
newPath += newName;
|
||||
|
||||
if (SdMan.exists(newPath.c_str())) {
|
||||
file.close();
|
||||
server->send(409, "text/plain", "Target already exists");
|
||||
return;
|
||||
}
|
||||
|
||||
clearEpubCacheIfNeeded(itemPath);
|
||||
const bool success = file.rename(newPath.c_str());
|
||||
file.close();
|
||||
|
||||
if (success) {
|
||||
Serial.printf("[%lu] [WEB] Renamed file: %s -> %s\n", millis(), itemPath.c_str(), newPath.c_str());
|
||||
server->send(200, "text/plain", "Renamed successfully");
|
||||
} else {
|
||||
Serial.printf("[%lu] [WEB] Failed to rename file: %s -> %s\n", millis(), itemPath.c_str(), newPath.c_str());
|
||||
server->send(500, "text/plain", "Failed to rename file");
|
||||
}
|
||||
}
|
||||
|
||||
void CrossPointWebServer::handleMove() const {
|
||||
if (!server->hasArg("path") || !server->hasArg("dest")) {
|
||||
server->send(400, "text/plain", "Missing path or destination");
|
||||
return;
|
||||
}
|
||||
|
||||
String itemPath = normalizeWebPath(server->arg("path"));
|
||||
String destPath = normalizeWebPath(server->arg("dest"));
|
||||
|
||||
if (itemPath.isEmpty() || itemPath == "/") {
|
||||
server->send(400, "text/plain", "Invalid path");
|
||||
return;
|
||||
}
|
||||
if (destPath.isEmpty()) {
|
||||
server->send(400, "text/plain", "Invalid destination");
|
||||
return;
|
||||
}
|
||||
|
||||
const String itemName = itemPath.substring(itemPath.lastIndexOf('/') + 1);
|
||||
if (isProtectedItemName(itemName)) {
|
||||
server->send(403, "text/plain", "Cannot move protected item");
|
||||
return;
|
||||
}
|
||||
if (destPath != "/") {
|
||||
const String destName = destPath.substring(destPath.lastIndexOf('/') + 1);
|
||||
if (isProtectedItemName(destName)) {
|
||||
server->send(403, "text/plain", "Cannot move into protected folder");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!SdMan.exists(itemPath.c_str())) {
|
||||
server->send(404, "text/plain", "Item not found");
|
||||
return;
|
||||
}
|
||||
|
||||
FsFile file = SdMan.open(itemPath.c_str());
|
||||
if (!file) {
|
||||
server->send(500, "text/plain", "Failed to open file");
|
||||
return;
|
||||
}
|
||||
if (file.isDirectory()) {
|
||||
file.close();
|
||||
server->send(400, "text/plain", "Only files can be moved");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!SdMan.exists(destPath.c_str())) {
|
||||
file.close();
|
||||
server->send(404, "text/plain", "Destination not found");
|
||||
return;
|
||||
}
|
||||
FsFile destDir = SdMan.open(destPath.c_str());
|
||||
if (!destDir || !destDir.isDirectory()) {
|
||||
if (destDir) {
|
||||
destDir.close();
|
||||
}
|
||||
file.close();
|
||||
server->send(400, "text/plain", "Destination is not a folder");
|
||||
return;
|
||||
}
|
||||
destDir.close();
|
||||
|
||||
String newPath = destPath;
|
||||
if (!newPath.endsWith("/")) {
|
||||
newPath += "/";
|
||||
}
|
||||
newPath += itemName;
|
||||
|
||||
if (newPath == itemPath) {
|
||||
file.close();
|
||||
server->send(200, "text/plain", "Already in destination");
|
||||
return;
|
||||
}
|
||||
if (SdMan.exists(newPath.c_str())) {
|
||||
file.close();
|
||||
server->send(409, "text/plain", "Target already exists");
|
||||
return;
|
||||
}
|
||||
|
||||
clearEpubCacheIfNeeded(itemPath);
|
||||
const bool success = file.rename(newPath.c_str());
|
||||
file.close();
|
||||
|
||||
if (success) {
|
||||
Serial.printf("[%lu] [WEB] Moved file: %s -> %s\n", millis(), itemPath.c_str(), newPath.c_str());
|
||||
server->send(200, "text/plain", "Moved successfully");
|
||||
} else {
|
||||
Serial.printf("[%lu] [WEB] Failed to move file: %s -> %s\n", millis(), itemPath.c_str(), newPath.c_str());
|
||||
server->send(500, "text/plain", "Failed to move file");
|
||||
}
|
||||
}
|
||||
|
||||
void CrossPointWebServer::handleDelete() const {
|
||||
// Get path from form data
|
||||
if (!server->hasArg("path")) {
|
||||
|
||||
@ -77,5 +77,7 @@ class CrossPointWebServer {
|
||||
void handleUpload() const;
|
||||
void handleUploadPost() const;
|
||||
void handleCreateFolder() const;
|
||||
void handleRename() const;
|
||||
void handleMove() const;
|
||||
void handleDelete() const;
|
||||
};
|
||||
|
||||
@ -322,25 +322,47 @@
|
||||
.folder-btn:hover {
|
||||
background-color: #d68910;
|
||||
}
|
||||
/* Delete button styles */
|
||||
.delete-btn {
|
||||
/* Action button styles */
|
||||
.delete-btn,
|
||||
.rename-btn,
|
||||
.move-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1.1em;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
color: #95a5a6;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.delete-btn {
|
||||
color: #95a5a6;
|
||||
}
|
||||
.delete-btn:hover {
|
||||
background-color: #fee;
|
||||
color: #e74c3c;
|
||||
}
|
||||
.rename-btn {
|
||||
color: #2980b9;
|
||||
}
|
||||
.rename-btn:hover {
|
||||
background-color: #e8f4fd;
|
||||
}
|
||||
.move-btn {
|
||||
color: #16a085;
|
||||
}
|
||||
.move-btn:hover {
|
||||
background-color: #e6f7f4;
|
||||
}
|
||||
.actions-col {
|
||||
width: 60px;
|
||||
width: 140px;
|
||||
text-align: center;
|
||||
}
|
||||
.action-icon-group {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
/* Failed uploads banner */
|
||||
.failed-uploads-banner {
|
||||
background-color: #fff3cd;
|
||||
@ -463,6 +485,32 @@
|
||||
.delete-btn-cancel:hover {
|
||||
background-color: #7f8c8d;
|
||||
}
|
||||
.rename-btn-confirm {
|
||||
background-color: #3498db;
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 1em;
|
||||
width: 100%;
|
||||
}
|
||||
.rename-btn-confirm:hover {
|
||||
background-color: #2e86c1;
|
||||
}
|
||||
.move-btn-confirm {
|
||||
background-color: #16a085;
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 1em;
|
||||
width: 100%;
|
||||
}
|
||||
.move-btn-confirm:hover {
|
||||
background-color: #138d75;
|
||||
}
|
||||
.loader-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@ -558,12 +606,17 @@
|
||||
font-size: 1.1em;
|
||||
}
|
||||
.actions-col {
|
||||
width: 40px;
|
||||
width: 120px;
|
||||
}
|
||||
.delete-btn {
|
||||
.delete-btn,
|
||||
.rename-btn,
|
||||
.move-btn {
|
||||
font-size: 1em;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
.action-icon-group {
|
||||
gap: 4px;
|
||||
}
|
||||
.no-files {
|
||||
padding: 20px;
|
||||
font-size: 0.9em;
|
||||
@ -665,6 +718,37 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rename Modal -->
|
||||
<div class="modal-overlay" id="renameModal">
|
||||
<div class="modal">
|
||||
<button class="modal-close" onclick="closeRenameModal()">×</button>
|
||||
<h3>✏️ Rename File</h3>
|
||||
<div class="folder-form">
|
||||
<p class="file-info">Renaming <strong id="renameItemName"></strong></p>
|
||||
<input type="text" id="renameNewName" class="folder-input" placeholder="New file name...">
|
||||
<input type="hidden" id="renameItemPath">
|
||||
<button class="rename-btn-confirm" onclick="confirmRename()">Rename</button>
|
||||
<button class="delete-btn-cancel" onclick="closeRenameModal()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Move Modal -->
|
||||
<div class="modal-overlay" id="moveModal">
|
||||
<div class="modal">
|
||||
<button class="modal-close" onclick="closeMoveModal()">×</button>
|
||||
<h3>📂 Move File</h3>
|
||||
<div class="folder-form">
|
||||
<p class="file-info">Moving <strong id="moveItemName"></strong></p>
|
||||
<input type="text" id="moveDestPath" class="folder-input" list="moveFolderOptions" placeholder="/Destination/Folder">
|
||||
<datalist id="moveFolderOptions"></datalist>
|
||||
<input type="hidden" id="moveItemPath">
|
||||
<button class="move-btn-confirm" onclick="confirmMove()">Move</button>
|
||||
<button class="delete-btn-cancel" onclick="closeMoveModal()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// get current path from query parameter
|
||||
const currentPath = decodeURIComponent(new URLSearchParams(window.location.search).get('path') || '/');
|
||||
@ -760,7 +844,7 @@
|
||||
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>';
|
||||
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"><div class="action-icon-group"><button class="delete-btn" onclick="openDeleteModal('${file.name.replaceAll("'", "\\'")}', '${folderPath.replaceAll("'", "\\'")}', true)" title="Delete folder">🗑️</button></div></td>`;
|
||||
fileTableContent += '</tr>';
|
||||
} else {
|
||||
let filePath = currentPath;
|
||||
@ -773,7 +857,11 @@
|
||||
fileTableContent += '</td>';
|
||||
fileTableContent += `<td>${file.name.split('.').pop().toUpperCase()}</td>`;
|
||||
fileTableContent += `<td>${formatFileSize(file.size)}</td>`;
|
||||
fileTableContent += `<td class="actions-col"><button class="delete-btn" onclick="openDeleteModal('${file.name.replaceAll("'", "\\'")}', '${filePath.replaceAll("'", "\\'")}', false)" title="Delete file">🗑️</button></td>`;
|
||||
fileTableContent += `<td class="actions-col"><div class="action-icon-group">`;
|
||||
fileTableContent += `<button class="move-btn" onclick="openMoveModal('${file.name.replaceAll("'", "\\'")}', '${filePath.replaceAll("'", "\\'")}' )" title="Move file">📂</button>`;
|
||||
fileTableContent += `<button class="rename-btn" onclick="openRenameModal('${file.name.replaceAll("'", "\\'")}', '${filePath.replaceAll("'", "\\'")}' )" title="Rename file">✏️</button>`;
|
||||
fileTableContent += `<button class="delete-btn" onclick="openDeleteModal('${file.name.replaceAll("'", "\\'")}', '${filePath.replaceAll("'", "\\'")}', false)" title="Delete file">🗑️</button>`;
|
||||
fileTableContent += `</div></td>`;
|
||||
fileTableContent += '</tr>';
|
||||
}
|
||||
});
|
||||
@ -1175,6 +1263,170 @@ function retryAllFailedUploads() {
|
||||
xhr.send(formData);
|
||||
}
|
||||
|
||||
// Rename functions
|
||||
function openRenameModal(name, path) {
|
||||
document.getElementById('renameItemName').textContent = '📄 ' + name;
|
||||
document.getElementById('renameItemPath').value = path;
|
||||
document.getElementById('renameNewName').value = name;
|
||||
document.getElementById('renameModal').classList.add('open');
|
||||
setTimeout(() => {
|
||||
const input = document.getElementById('renameNewName');
|
||||
input.focus();
|
||||
input.select();
|
||||
}, 50);
|
||||
}
|
||||
|
||||
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 new name.');
|
||||
return;
|
||||
}
|
||||
if (newName.includes('/') || newName.includes('\\')) {
|
||||
alert('File name cannot include slashes.');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('path', path);
|
||||
formData.append('name', newName);
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '/rename', true);
|
||||
|
||||
xhr.onload = function() {
|
||||
if (xhr.status === 200) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert('Failed to rename: ' + xhr.responseText);
|
||||
}
|
||||
closeRenameModal();
|
||||
};
|
||||
|
||||
xhr.onerror = function() {
|
||||
alert('Failed to rename - network error');
|
||||
closeRenameModal();
|
||||
};
|
||||
|
||||
xhr.send(formData);
|
||||
}
|
||||
|
||||
// Move functions
|
||||
function normalizePath(path) {
|
||||
if (!path) return '/';
|
||||
let normalized = path.trim();
|
||||
if (!normalized.startsWith('/')) normalized = '/' + normalized;
|
||||
if (normalized.length > 1 && normalized.endsWith('/')) {
|
||||
normalized = normalized.slice(0, -1);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function getParentPath(path) {
|
||||
const normalized = normalizePath(path);
|
||||
if (normalized === '/') return '/';
|
||||
const idx = normalized.lastIndexOf('/');
|
||||
return idx <= 0 ? '/' : normalized.slice(0, idx);
|
||||
}
|
||||
|
||||
async function loadMoveFolderOptions() {
|
||||
const options = new Set();
|
||||
options.add('/');
|
||||
const parent = getParentPath(currentPath);
|
||||
if (parent) options.add(parent);
|
||||
|
||||
async function fetchFolders(path) {
|
||||
try {
|
||||
const response = await fetch('/api/files?path=' + encodeURIComponent(path));
|
||||
if (!response.ok) return [];
|
||||
return await response.json();
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const rootFiles = await fetchFolders('/');
|
||||
rootFiles.forEach(file => {
|
||||
if (file.isDirectory) {
|
||||
options.add('/' + file.name);
|
||||
}
|
||||
});
|
||||
|
||||
if (currentPath !== '/') {
|
||||
const currentFiles = await fetchFolders(currentPath);
|
||||
currentFiles.forEach(file => {
|
||||
if (file.isDirectory) {
|
||||
let folderPath = currentPath;
|
||||
if (!folderPath.endsWith('/')) folderPath += '/';
|
||||
folderPath += file.name;
|
||||
options.add(folderPath);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const dataList = document.getElementById('moveFolderOptions');
|
||||
dataList.innerHTML = '';
|
||||
Array.from(options).sort().forEach(path => {
|
||||
const option = document.createElement('option');
|
||||
option.value = path;
|
||||
dataList.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
function openMoveModal(name, path) {
|
||||
document.getElementById('moveItemName').textContent = '📄 ' + name;
|
||||
document.getElementById('moveItemPath').value = path;
|
||||
document.getElementById('moveDestPath').value = currentPath === '/' ? '/' : currentPath;
|
||||
document.getElementById('moveModal').classList.add('open');
|
||||
loadMoveFolderOptions();
|
||||
setTimeout(() => {
|
||||
document.getElementById('moveDestPath').focus();
|
||||
}, 50);
|
||||
}
|
||||
|
||||
function closeMoveModal() {
|
||||
document.getElementById('moveModal').classList.remove('open');
|
||||
}
|
||||
|
||||
function confirmMove() {
|
||||
const path = document.getElementById('moveItemPath').value;
|
||||
const destPath = normalizePath(document.getElementById('moveDestPath').value);
|
||||
|
||||
if (!destPath) {
|
||||
alert('Please enter a destination folder.');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('path', path);
|
||||
formData.append('dest', destPath);
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '/move', true);
|
||||
|
||||
xhr.onload = function() {
|
||||
if (xhr.status === 200) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert('Failed to move: ' + xhr.responseText);
|
||||
}
|
||||
closeMoveModal();
|
||||
};
|
||||
|
||||
xhr.onerror = function() {
|
||||
alert('Failed to move - network error');
|
||||
closeMoveModal();
|
||||
};
|
||||
|
||||
xhr.send(formData);
|
||||
}
|
||||
|
||||
// Delete functions
|
||||
function openDeleteModal(name, path, isFolder) {
|
||||
document.getElementById('deleteItemName').textContent = (isFolder ? '📁 ' : '📄 ') + name;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user