Merge pull request #108 from jpirnay/feature-webui-update
feat: webui update (integrate some ideas from upstream #1715 by pablohc)
This commit is contained in:
+22
-1
@@ -2,11 +2,13 @@
|
|||||||
#include "HalStorage.h"
|
#include "HalStorage.h"
|
||||||
|
|
||||||
#include <FS.h> // need to be included before SdFat.h for compatibility with FS.h's File class
|
#include <FS.h> // need to be included before SdFat.h for compatibility with FS.h's File class
|
||||||
|
#include <HalClock.h>
|
||||||
#include <Logging.h>
|
#include <Logging.h>
|
||||||
#include <SDCardManager.h>
|
#include <SDCardManager.h>
|
||||||
#include <SdFat.h>
|
#include <SdFat.h>
|
||||||
|
|
||||||
#include <cassert>
|
#include <cassert>
|
||||||
|
#include <ctime>
|
||||||
#include <new>
|
#include <new>
|
||||||
|
|
||||||
#define SDCard SDCardManager::getInstance()
|
#define SDCard SDCardManager::getInstance()
|
||||||
@@ -20,7 +22,26 @@ HalStorage::HalStorage() {
|
|||||||
|
|
||||||
// begin() and ready() are only called from setup, no need to acquire mutex for them
|
// begin() and ready() are only called from setup, no need to acquire mutex for them
|
||||||
|
|
||||||
bool HalStorage::begin() { return SDCard.begin(); }
|
bool HalStorage::begin() {
|
||||||
|
if (!SDCard.begin()) return false;
|
||||||
|
FsDateTime::setCallback([](uint16_t* date, uint16_t* time) {
|
||||||
|
if (!HalClock::isSynced()) {
|
||||||
|
*date = FS_DATE(1980, 1, 1);
|
||||||
|
*time = FS_TIME(0, 0, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const time_t t = HalClock::now();
|
||||||
|
const struct tm* tm = localtime(&t);
|
||||||
|
if (!tm) {
|
||||||
|
*date = FS_DATE(1980, 1, 1);
|
||||||
|
*time = FS_TIME(0, 0, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
*date = FS_DATE(tm->tm_year + 1900, tm->tm_mon + 1, tm->tm_mday);
|
||||||
|
*time = FS_TIME(tm->tm_hour, tm->tm_min, tm->tm_sec);
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
bool HalStorage::ready() const { return SDCard.ready(); }
|
bool HalStorage::ready() const { return SDCard.ready(); }
|
||||||
|
|
||||||
|
|||||||
@@ -998,13 +998,6 @@ void CrossPointWebServer::handleMove() const {
|
|||||||
server->send(403, "text/plain", "Cannot move protected item");
|
server->send(403, "text/plain", "Cannot move protected item");
|
||||||
return;
|
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 (!Storage.exists(itemPath.c_str())) {
|
if (!Storage.exists(itemPath.c_str())) {
|
||||||
server->send(404, "text/plain", "Item not found");
|
server->send(404, "text/plain", "Item not found");
|
||||||
@@ -1210,9 +1203,9 @@ void CrossPointWebServer::handleSettingsPage() const {
|
|||||||
void CrossPointWebServer::handleGetSettings() const {
|
void CrossPointWebServer::handleGetSettings() const {
|
||||||
const auto& settings = getSettingsList();
|
const auto& settings = getSettingsList();
|
||||||
|
|
||||||
server->setContentLength(CONTENT_LENGTH_UNKNOWN);
|
String result;
|
||||||
server->send(200, "application/json", "");
|
result.reserve(4096);
|
||||||
server->sendContent("[");
|
result += "[";
|
||||||
|
|
||||||
char output[512];
|
char output[512];
|
||||||
constexpr size_t outputSize = sizeof(output);
|
constexpr size_t outputSize = sizeof(output);
|
||||||
@@ -1226,6 +1219,8 @@ void CrossPointWebServer::handleGetSettings() const {
|
|||||||
doc["key"] = s.key;
|
doc["key"] = s.key;
|
||||||
doc["name"] = I18N.get(s.nameId);
|
doc["name"] = I18N.get(s.nameId);
|
||||||
doc["category"] = I18N.get(s.category);
|
doc["category"] = I18N.get(s.category);
|
||||||
|
doc["subcategory"] = s.subcategory != StrId::STR_NONE_OPT ? I18N.get(s.subcategory) : "";
|
||||||
|
doc["submenu"] = s.submenu != StrId::STR_NONE_OPT ? I18N.get(s.submenu) : "";
|
||||||
|
|
||||||
switch (s.type) {
|
switch (s.type) {
|
||||||
case SettingType::TOGGLE: {
|
case SettingType::TOGGLE: {
|
||||||
@@ -1272,21 +1267,23 @@ void CrossPointWebServer::handleGetSettings() const {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const size_t written = serializeJson(doc, output, outputSize);
|
const size_t written = serializeJson(doc, output, outputSize);
|
||||||
|
const char* jsonEntry = output;
|
||||||
|
String dynBuffer;
|
||||||
if (written >= outputSize) {
|
if (written >= outputSize) {
|
||||||
LOG_DBG("WEB", "Skipping oversized setting JSON for: %s", s.key);
|
serializeJson(doc, dynBuffer);
|
||||||
continue;
|
jsonEntry = dynBuffer.c_str();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (seenFirst) {
|
if (seenFirst) {
|
||||||
server->sendContent(",");
|
result += ",";
|
||||||
} else {
|
} else {
|
||||||
seenFirst = true;
|
seenFirst = true;
|
||||||
}
|
}
|
||||||
server->sendContent(output);
|
result += jsonEntry;
|
||||||
}
|
}
|
||||||
|
|
||||||
server->sendContent("]");
|
result += "]";
|
||||||
server->sendContent("");
|
server->send(200, "application/json", result);
|
||||||
LOG_DBG("WEB", "Served settings API");
|
LOG_DBG("WEB", "Served settings API");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+278
-97
@@ -7,6 +7,9 @@
|
|||||||
<title>CrossPoint Reader - Files</title>
|
<title>CrossPoint Reader - Files</title>
|
||||||
<script src="/js/jszip.min.js"></script>
|
<script src="/js/jszip.min.js"></script>
|
||||||
<style>
|
<style>
|
||||||
|
html {
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
}
|
||||||
:root {
|
:root {
|
||||||
--font-color: #333;
|
--font-color: #333;
|
||||||
--bg: #f5f5f5;
|
--bg: #f5f5f5;
|
||||||
@@ -159,6 +162,14 @@
|
|||||||
background-color: #d68910;
|
background-color: #d68910;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.move-action-btn {
|
||||||
|
background-color: #2980b9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.move-action-btn:hover {
|
||||||
|
background-color: #2471a3;
|
||||||
|
}
|
||||||
|
|
||||||
.delete-action-btn {
|
.delete-action-btn {
|
||||||
background-color: #e74c3c;
|
background-color: #e74c3c;
|
||||||
}
|
}
|
||||||
@@ -167,6 +178,11 @@
|
|||||||
background-color: #c0392b;
|
background-color: #c0392b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.action-btn:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
/* Drag & drop overlay */
|
/* Drag & drop overlay */
|
||||||
.drop-overlay {
|
.drop-overlay {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -313,6 +329,35 @@
|
|||||||
.file-table th {
|
.file-table th {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--label-color);
|
color: var(--label-color);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: var(--card-bg);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-table th.sortable {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-table th.sortable:hover {
|
||||||
|
color: var(--font-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-indicator {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 4px;
|
||||||
|
font-size: 0.75em;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-table th.sort-active .sort-indicator {
|
||||||
|
opacity: 1;
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-table tbody tr:nth-child(even) {
|
||||||
|
background-color: rgba(0, 0, 0, 0.03);
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-table tr:hover {
|
.file-table tr:hover {
|
||||||
@@ -1786,7 +1831,8 @@
|
|||||||
<div class="action-buttons">
|
<div class="action-buttons">
|
||||||
<button class="action-btn upload-action-btn" onclick="openUploadModal()">📤 Upload</button>
|
<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 folder-action-btn" onclick="openFolderModal()">📁 New Folder</button>
|
||||||
<button class="action-btn delete-action-btn" onclick="openDeleteSelectedModal()">🗑️ Delete Selected</button>
|
<button id="moveActionBtn" class="action-btn move-action-btn" onclick="openMoveSelectedModal()" disabled>➡️ Move</button>
|
||||||
|
<button id="deleteActionBtn" class="action-btn delete-action-btn" onclick="openDeleteSelectedModal()" disabled>🗑️ Delete</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -2042,12 +2088,10 @@
|
|||||||
<div class="modal-overlay" id="moveModal">
|
<div class="modal-overlay" id="moveModal">
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
<button class="modal-close" onclick="closeMoveModal()">×</button>
|
<button class="modal-close" onclick="closeMoveModal()">×</button>
|
||||||
<h3>📂 Move File</h3>
|
<h3>➡️ Move File</h3>
|
||||||
<div class="folder-form">
|
<div class="folder-form">
|
||||||
<p class="file-info">Moving <strong id="moveItemName"></strong></p>
|
<p class="file-info">Moving <strong id="moveItemName"></strong></p>
|
||||||
<input type="text" id="moveDestPath" class="folder-input" list="moveFolderOptions"
|
<select id="moveDestPath" class="folder-input"></select>
|
||||||
placeholder="/Destination/Folder">
|
|
||||||
<datalist id="moveFolderOptions"></datalist>
|
|
||||||
<input type="hidden" id="moveItemPath">
|
<input type="hidden" id="moveItemPath">
|
||||||
<button class="move-btn-confirm" onclick="confirmMove()">Move</button>
|
<button class="move-btn-confirm" onclick="confirmMove()">Move</button>
|
||||||
<button class="delete-btn-cancel" onclick="closeMoveModal()">Cancel</button>
|
<button class="delete-btn-cancel" onclick="closeMoveModal()">Cancel</button>
|
||||||
@@ -2093,6 +2137,8 @@
|
|||||||
if (!notification) {
|
if (!notification) {
|
||||||
notification = document.createElement('div');
|
notification = document.createElement('div');
|
||||||
notification.id = 'notification';
|
notification.id = 'notification';
|
||||||
|
notification.setAttribute('role', 'status');
|
||||||
|
notification.setAttribute('aria-live', 'polite');
|
||||||
notification.style.cssText = `
|
notification.style.cssText = `
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 20px;
|
top: 20px;
|
||||||
@@ -2109,6 +2155,10 @@
|
|||||||
document.body.appendChild(notification);
|
document.body.appendChild(notification);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cancel any in-flight hide/removal timers so a new message is not prematurely dismissed
|
||||||
|
clearTimeout(notification._hideTimeout);
|
||||||
|
clearTimeout(notification._removeTimeout);
|
||||||
|
|
||||||
// Set styles based on type
|
// Set styles based on type
|
||||||
const styles = {
|
const styles = {
|
||||||
'success': 'background-color: #27ae60;',
|
'success': 'background-color: #27ae60;',
|
||||||
@@ -2124,10 +2174,10 @@
|
|||||||
notification.style.transform = 'translateX(0)';
|
notification.style.transform = 'translateX(0)';
|
||||||
|
|
||||||
// Auto-hide after 5 seconds
|
// Auto-hide after 5 seconds
|
||||||
setTimeout(() => {
|
notification._hideTimeout = setTimeout(() => {
|
||||||
notification.style.opacity = '0';
|
notification.style.opacity = '0';
|
||||||
notification.style.transform = 'translateX(100%)';
|
notification.style.transform = 'translateX(100%)';
|
||||||
setTimeout(() => {
|
notification._removeTimeout = setTimeout(() => {
|
||||||
if (notification.parentNode) {
|
if (notification.parentNode) {
|
||||||
notification.parentNode.removeChild(notification);
|
notification.parentNode.removeChild(notification);
|
||||||
}
|
}
|
||||||
@@ -2205,67 +2255,132 @@
|
|||||||
if (files.length === 0) {
|
if (files.length === 0) {
|
||||||
fileTable.innerHTML = '<div class="no-files">This folder is empty</div>';
|
fileTable.innerHTML = '<div class="no-files">This folder is empty</div>';
|
||||||
} else {
|
} else {
|
||||||
let fileTableContent = '<table class="file-table">';
|
currentFiles = files;
|
||||||
|
renderFileTable();
|
||||||
// Add select-all checkbox column
|
|
||||||
fileTableContent += '<tr><th style="width:40px"><input type="checkbox" id="selectAllCheckbox" onchange="toggleSelectAll(this)"></th><th>Name</th><th>Type</th><th>Size</th><th class="actions-col">Actions</th></tr>';
|
|
||||||
|
|
||||||
|
|
||||||
const sortedFiles = files.sort((a, b) => {
|
|
||||||
// Directories first, then epub files, then other files, alphabetically within each group
|
|
||||||
if (a.isDirectory && !b.isDirectory) return -1;
|
|
||||||
if (!a.isDirectory && b.isDirectory) return 1;
|
|
||||||
if (a.isEpub && !b.isEpub) return -1;
|
|
||||||
if (!a.isEpub && b.isEpub) return 1;
|
|
||||||
return a.name.localeCompare(b.name);
|
|
||||||
});
|
|
||||||
|
|
||||||
sortedFiles.forEach(file => {
|
|
||||||
if (file.isDirectory) {
|
|
||||||
let folderPath = currentPath;
|
|
||||||
if (!folderPath.endsWith("/")) folderPath += "/";
|
|
||||||
folderPath += file.name;
|
|
||||||
|
|
||||||
// Checkbox cell + folder row
|
|
||||||
fileTableContent += `<tr class="folder-row">`;
|
|
||||||
fileTableContent += `<td><input type="checkbox" class="select-item" data-path="${encodeURIComponent(folderPath)}" data-name="${escapeHtml(file.name)}" data-type="folder"></td>`;
|
|
||||||
fileTableContent += `<td><span class="file-icon">📁</span><a href="/files?path=${encodeURIComponent(folderPath)}" class="folder-link">${escapeHtml(file.name)}</a><span class="folder-badge">FOLDER</span></td>`;
|
|
||||||
fileTableContent += '<td>Folder</td>';
|
|
||||||
fileTableContent += '<td>-</td>';
|
|
||||||
fileTableContent += `<td class="actions-col"><div class="action-icon-group">`;
|
|
||||||
fileTableContent += `<button class="move-btn" onclick="openMoveModal('${file.name.replaceAll("'", "\\'")}', '${folderPath.replaceAll("'", "\\'")}', true)" title="Move folder">📂</button>`;
|
|
||||||
fileTableContent += `<button class="rename-btn" onclick="openRenameModal('${file.name.replaceAll("'", "\\'")}', '${folderPath.replaceAll("'", "\\'")}', true)" title="Rename folder">✏️</button>`;
|
|
||||||
fileTableContent += `<button class="delete-btn" onclick="openDeleteModal('${file.name.replaceAll("'", "\\'")}', '${folderPath.replaceAll("'", "\\'")}', true)" title="Delete folder">🗑️</button>`;
|
|
||||||
fileTableContent += `</div></td>`;
|
|
||||||
fileTableContent += '</tr>';
|
|
||||||
} else {
|
|
||||||
let filePath = currentPath;
|
|
||||||
if (!filePath.endsWith("/")) filePath += "/";
|
|
||||||
filePath += file.name;
|
|
||||||
|
|
||||||
// Checkbox cell + file row
|
|
||||||
fileTableContent += `<tr class="${file.isEpub ? 'epub-file' : ''}">`;
|
|
||||||
fileTableContent += `<td><input type="checkbox" class="select-item" data-path="${encodeURIComponent(filePath)}" data-name="${escapeHtml(file.name)}" data-type="file"></td>`;
|
|
||||||
fileTableContent += `<td><span class="file-icon">${file.isEpub ? '📗' : '📄'}</span>`;
|
|
||||||
fileTableContent += `<a rel="noopener noreferrer" target="_blank" href="/download?path=${encodeURIComponent(filePath)}" class="file-link">${escapeHtml(displayFileName(file.name))}</a>`;
|
|
||||||
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"><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>';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
fileTableContent += '</table>';
|
|
||||||
fileTable.innerHTML = fileTableContent;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sort state: column is 'name'|'type'|'size', dir is 'asc'|'desc'
|
||||||
|
let sortState = { column: 'name', dir: 'asc' };
|
||||||
|
let currentFiles = [];
|
||||||
|
|
||||||
|
function getSortKey(file) {
|
||||||
|
if (file.isDirectory) return 0;
|
||||||
|
const ext = file.name.includes('.') ? file.name.split('.').pop().toLowerCase() : '';
|
||||||
|
if (ext === 'epub') return 1;
|
||||||
|
if (ext === 'xtc') return 2;
|
||||||
|
if (ext === 'txt') return 3;
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortFiles(files) {
|
||||||
|
return [...files].sort((a, b) => {
|
||||||
|
// Directories always first regardless of sort column
|
||||||
|
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
|
||||||
|
|
||||||
|
let cmp = 0;
|
||||||
|
if (sortState.column === 'name') {
|
||||||
|
cmp = a.name.localeCompare(b.name);
|
||||||
|
} else if (sortState.column === 'type') {
|
||||||
|
cmp = getSortKey(a) - getSortKey(b) || a.name.localeCompare(b.name);
|
||||||
|
} else if (sortState.column === 'size') {
|
||||||
|
cmp = a.size - b.size || a.name.localeCompare(b.name);
|
||||||
|
}
|
||||||
|
return sortState.dir === 'asc' ? cmp : -cmp;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSort(column) {
|
||||||
|
if (sortState.column === column) {
|
||||||
|
sortState.dir = sortState.dir === 'asc' ? 'desc' : 'asc';
|
||||||
|
} else {
|
||||||
|
sortState.column = column;
|
||||||
|
sortState.dir = 'asc';
|
||||||
|
}
|
||||||
|
renderFileTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFileTable() {
|
||||||
|
const fileTable = document.getElementById('file-table');
|
||||||
|
const sortedFiles = sortFiles(currentFiles);
|
||||||
|
|
||||||
|
const ind = (col) => {
|
||||||
|
const active = sortState.column === col;
|
||||||
|
const arrow = active ? (sortState.dir === 'asc' ? '▲' : '▼') : '▲';
|
||||||
|
return `<span class="sort-indicator">${arrow}</span>`;
|
||||||
|
};
|
||||||
|
const thClass = (col) => `class="sortable${sortState.column === col ? ' sort-active' : ''}"`;
|
||||||
|
|
||||||
|
let fileTableContent = '<table class="file-table"><thead>';
|
||||||
|
fileTableContent += `<tr>`;
|
||||||
|
fileTableContent += `<th style="width:40px;text-align:center"><input type="checkbox" id="selectAllCheckbox" onchange="toggleSelectAll(this)"></th>`;
|
||||||
|
fileTableContent += `<th ${thClass('name')} onclick="setSort('name')">Name${ind('name')}</th>`;
|
||||||
|
fileTableContent += `<th ${thClass('type')} onclick="setSort('type')">Type${ind('type')}</th>`;
|
||||||
|
fileTableContent += `<th ${thClass('size')} onclick="setSort('size')">Size${ind('size')}</th>`;
|
||||||
|
fileTableContent += `<th class="actions-col">Actions</th>`;
|
||||||
|
fileTableContent += `</tr></thead><tbody>`;
|
||||||
|
|
||||||
|
sortedFiles.forEach(file => {
|
||||||
|
if (file.isDirectory) {
|
||||||
|
let folderPath = currentPath;
|
||||||
|
if (!folderPath.endsWith("/")) folderPath += "/";
|
||||||
|
folderPath += file.name;
|
||||||
|
|
||||||
|
const isProtected = file.name.startsWith('.');
|
||||||
|
fileTableContent += `<tr class="folder-row">`;
|
||||||
|
fileTableContent += isProtected
|
||||||
|
? '<td style="text-align:center"></td>'
|
||||||
|
: `<td style="text-align:center"><input type="checkbox" class="select-item" data-path="${encodeURIComponent(folderPath)}" data-name="${escapeHtml(file.name)}" data-type="folder"></td>`;
|
||||||
|
fileTableContent += `<td><span class="file-icon">📁</span><a href="/files?path=${encodeURIComponent(folderPath)}" class="folder-link">${escapeHtml(file.name)}</a><span class="folder-badge">FOLDER</span></td>`;
|
||||||
|
fileTableContent += '<td>Folder</td>';
|
||||||
|
fileTableContent += '<td>-</td>';
|
||||||
|
fileTableContent += `<td class="actions-col"><div class="action-icon-group">`;
|
||||||
|
fileTableContent += `<button class="move-btn" data-action="move" data-name="${escapeHtml(file.name)}" data-path="${escapeHtml(folderPath)}" data-is-folder="true" title="Move folder">📂</button>`;
|
||||||
|
fileTableContent += `<button class="rename-btn" data-action="rename" data-name="${escapeHtml(file.name)}" data-path="${escapeHtml(folderPath)}" data-is-folder="true" title="Rename folder">✏️</button>`;
|
||||||
|
fileTableContent += `<button class="delete-btn" data-action="delete" data-name="${escapeHtml(file.name)}" data-path="${escapeHtml(folderPath)}" data-is-folder="true" title="Delete folder">🗑️</button>`;
|
||||||
|
fileTableContent += `</div></td>`;
|
||||||
|
fileTableContent += '</tr>';
|
||||||
|
} else {
|
||||||
|
let filePath = currentPath;
|
||||||
|
if (!filePath.endsWith("/")) filePath += "/";
|
||||||
|
filePath += file.name;
|
||||||
|
|
||||||
|
fileTableContent += `<tr class="${file.isEpub ? 'epub-file' : ''}">`;
|
||||||
|
fileTableContent += `<td style="text-align:center"><input type="checkbox" class="select-item" data-path="${encodeURIComponent(filePath)}" data-name="${escapeHtml(file.name)}" data-type="file"></td>`;
|
||||||
|
fileTableContent += `<td><span class="file-icon">${file.isEpub ? '📗' : '📄'}</span>`;
|
||||||
|
fileTableContent += `<a rel="noopener noreferrer" target="_blank" href="/download?path=${encodeURIComponent(filePath)}" class="file-link">${escapeHtml(displayFileName(file.name))}</a>`;
|
||||||
|
if (file.isEpub) fileTableContent += '<span class="epub-badge">EPUB</span>';
|
||||||
|
fileTableContent += '</td>';
|
||||||
|
fileTableContent += `<td>${file.name.includes('.') ? file.name.split('.').pop().toUpperCase() : '-'}</td>`;
|
||||||
|
fileTableContent += `<td>${formatFileSize(file.size)}</td>`;
|
||||||
|
fileTableContent += `<td class="actions-col"><div class="action-icon-group">`;
|
||||||
|
fileTableContent += `<button class="move-btn" data-action="move" data-name="${escapeHtml(file.name)}" data-path="${escapeHtml(filePath)}" data-is-folder="false" title="Move file">📂</button>`;
|
||||||
|
fileTableContent += `<button class="rename-btn" data-action="rename" data-name="${escapeHtml(file.name)}" data-path="${escapeHtml(filePath)}" data-is-folder="false" title="Rename file">✏️</button>`;
|
||||||
|
fileTableContent += `<button class="delete-btn" data-action="delete" data-name="${escapeHtml(file.name)}" data-path="${escapeHtml(filePath)}" data-is-folder="false" title="Delete file">🗑️</button>`;
|
||||||
|
fileTableContent += `</div></td>`;
|
||||||
|
fileTableContent += '</tr>';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
fileTableContent += '</tbody></table>';
|
||||||
|
fileTable.innerHTML = fileTableContent;
|
||||||
|
document.getElementById('file-table').addEventListener('change', function(e) {
|
||||||
|
if (e.target.classList.contains('select-item')) updateToolbarState();
|
||||||
|
});
|
||||||
|
document.getElementById('file-table').addEventListener('click', function(e) {
|
||||||
|
const btn = e.target.closest('button[data-action]');
|
||||||
|
if (!btn) return;
|
||||||
|
const name = btn.dataset.name;
|
||||||
|
const path = btn.dataset.path;
|
||||||
|
const isFolder = btn.dataset.isFolder === 'true';
|
||||||
|
const action = btn.dataset.action;
|
||||||
|
if (action === 'move') openMoveModalForItem(name, path, isFolder);
|
||||||
|
else if (action === 'rename') openRenameModal(name, path, isFolder);
|
||||||
|
else if (action === 'delete') openDeleteModal(name, path, isFolder);
|
||||||
|
});
|
||||||
|
updateToolbarState();
|
||||||
|
}
|
||||||
|
|
||||||
// Modal functions
|
// Modal functions
|
||||||
function openUploadModal() {
|
function openUploadModal() {
|
||||||
// Reset converter variables to defaults
|
// Reset converter variables to defaults
|
||||||
@@ -3195,6 +3310,7 @@
|
|||||||
document.querySelectorAll('.select-item').forEach(cb => {
|
document.querySelectorAll('.select-item').forEach(cb => {
|
||||||
cb.checked = checked;
|
cb.checked = checked;
|
||||||
});
|
});
|
||||||
|
updateToolbarState();
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSelectedItems() {
|
function getSelectedItems() {
|
||||||
@@ -3209,6 +3325,14 @@
|
|||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateToolbarState() {
|
||||||
|
const items = getSelectedItems();
|
||||||
|
const fileCount = items.filter(it => !it.isFolder).length;
|
||||||
|
const folderCount = items.filter(it => it.isFolder).length;
|
||||||
|
document.getElementById('deleteActionBtn').disabled = items.length === 0;
|
||||||
|
document.getElementById('moveActionBtn').disabled = fileCount === 0 || folderCount > 0;
|
||||||
|
}
|
||||||
|
|
||||||
// Open delete modal for currently selected checkboxes
|
// Open delete modal for currently selected checkboxes
|
||||||
function openDeleteSelectedModal() {
|
function openDeleteSelectedModal() {
|
||||||
const items = getSelectedItems();
|
const items = getSelectedItems();
|
||||||
@@ -5545,6 +5669,8 @@
|
|||||||
options.add('/');
|
options.add('/');
|
||||||
const parent = getParentPath(currentPath);
|
const parent = getParentPath(currentPath);
|
||||||
if (parent) options.add(parent);
|
if (parent) options.add(parent);
|
||||||
|
options.add('/.sleep');
|
||||||
|
options.add('/.crosspoint');
|
||||||
|
|
||||||
async function fetchFolders(path) {
|
async function fetchFolders(path) {
|
||||||
try {
|
try {
|
||||||
@@ -5575,25 +5701,69 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const dataList = document.getElementById('moveFolderOptions');
|
const select = document.getElementById('moveDestPath');
|
||||||
dataList.innerHTML = '';
|
const prevValue = select.value;
|
||||||
Array.from(options).sort().forEach(path => {
|
select.innerHTML = '';
|
||||||
|
|
||||||
|
const currentNormalized = currentPath === '/' ? '/' : currentPath;
|
||||||
|
const normalFolders = Array.from(options).filter(p => !p.startsWith('/.'));
|
||||||
|
const currentFolder = normalFolders.find(p => p === currentNormalized);
|
||||||
|
const otherFolders = normalFolders.filter(p => p !== currentNormalized);
|
||||||
|
const protectedFolders = Array.from(options).filter(p => p === '/.crosspoint' || p === '/.sleep');
|
||||||
|
|
||||||
|
if (currentFolder !== undefined) {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = currentFolder;
|
||||||
|
option.textContent = currentFolder + ' (current)';
|
||||||
|
option.disabled = true;
|
||||||
|
option.selected = true;
|
||||||
|
select.appendChild(option);
|
||||||
|
}
|
||||||
|
otherFolders.sort().forEach(path => {
|
||||||
const option = document.createElement('option');
|
const option = document.createElement('option');
|
||||||
option.value = path;
|
option.value = path;
|
||||||
dataList.appendChild(option);
|
option.textContent = path;
|
||||||
|
select.appendChild(option);
|
||||||
});
|
});
|
||||||
|
if (protectedFolders.length > 0) {
|
||||||
|
const sep = document.createElement('option');
|
||||||
|
sep.disabled = true;
|
||||||
|
sep.textContent = '── System ──';
|
||||||
|
select.appendChild(sep);
|
||||||
|
protectedFolders.sort().forEach(path => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = path;
|
||||||
|
option.textContent = path;
|
||||||
|
select.appendChild(option);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
select.value = prevValue;
|
||||||
|
if (!select.value || select.options[select.selectedIndex] && select.options[select.selectedIndex].disabled) {
|
||||||
|
for (let i = 0; i < select.options.length; i++) {
|
||||||
|
if (!select.options[i].disabled) { select.value = select.options[i].value; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openMoveModal(name, path, isFolder = false) {
|
function openMoveModal(items) {
|
||||||
const icon = isFolder ? '📁' : '📄';
|
const label = items.length === 1
|
||||||
document.getElementById('moveItemName').textContent = icon + ' ' + name;
|
? (items[0].isFolder ? '📁' : '📄') + ' ' + items[0].name
|
||||||
document.getElementById('moveItemPath').value = path;
|
: `📄 ${items.length} files`;
|
||||||
document.getElementById('moveDestPath').value = currentPath === '/' ? '/' : currentPath;
|
document.getElementById('moveItemName').textContent = label;
|
||||||
|
document.getElementById('moveItemPath').value = JSON.stringify(items.map(it => it.path));
|
||||||
document.getElementById('moveModal').classList.add('open');
|
document.getElementById('moveModal').classList.add('open');
|
||||||
loadMoveFolderOptions();
|
loadMoveFolderOptions();
|
||||||
setTimeout(() => {
|
}
|
||||||
document.getElementById('moveDestPath').focus();
|
|
||||||
}, 50);
|
function openMoveSelectedModal() {
|
||||||
|
const items = getSelectedItems().filter(it => !it.isFolder);
|
||||||
|
if (items.length === 0) return;
|
||||||
|
openMoveModal(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMoveModalForItem(name, path, isFolder) {
|
||||||
|
openMoveModal([{ name: name, path: path, isFolder: !!isFolder }]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeMoveModal() {
|
function closeMoveModal() {
|
||||||
@@ -5601,36 +5771,47 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function confirmMove() {
|
function confirmMove() {
|
||||||
const path = document.getElementById('moveItemPath').value;
|
const paths = JSON.parse(document.getElementById('moveItemPath').value);
|
||||||
const destPath = normalizePath(document.getElementById('moveDestPath').value);
|
const destPath = normalizePath(document.getElementById('moveDestPath').value);
|
||||||
|
|
||||||
if (!destPath) {
|
if (!destPath) {
|
||||||
alert('Please enter a destination folder.');
|
showNotification('Please select a destination folder.', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const formData = new FormData();
|
closeMoveModal();
|
||||||
formData.append('path', path);
|
let hasErrors = false;
|
||||||
formData.append('dest', destPath);
|
let successfulMoves = 0;
|
||||||
|
|
||||||
const xhr = new XMLHttpRequest();
|
function moveNext(index) {
|
||||||
xhr.open('POST', '/move', true);
|
if (index >= paths.length) {
|
||||||
|
if (successfulMoves > 0) window.location.reload();
|
||||||
xhr.onload = function () {
|
return;
|
||||||
if (xhr.status === 200) {
|
|
||||||
window.location.reload();
|
|
||||||
} else {
|
|
||||||
alert('Failed to move: ' + xhr.responseText);
|
|
||||||
}
|
}
|
||||||
closeMoveModal();
|
const formData = new FormData();
|
||||||
};
|
formData.append('path', paths[index]);
|
||||||
|
formData.append('dest', destPath);
|
||||||
|
|
||||||
xhr.onerror = function () {
|
const xhr = new XMLHttpRequest();
|
||||||
alert('Failed to move - network error');
|
xhr.open('POST', '/move', true);
|
||||||
closeMoveModal();
|
xhr.onload = function() {
|
||||||
};
|
if (xhr.status === 200) {
|
||||||
|
successfulMoves++;
|
||||||
|
} else {
|
||||||
|
hasErrors = true;
|
||||||
|
showNotification('Failed to move ' + paths[index] + ': ' + xhr.responseText, 'error');
|
||||||
|
}
|
||||||
|
moveNext(index + 1);
|
||||||
|
};
|
||||||
|
xhr.onerror = function() {
|
||||||
|
hasErrors = true;
|
||||||
|
showNotification('Failed to move ' + paths[index] + ' - network error', 'error');
|
||||||
|
moveNext(index + 1);
|
||||||
|
};
|
||||||
|
xhr.send(formData);
|
||||||
|
}
|
||||||
|
|
||||||
xhr.send(formData);
|
moveNext(0);
|
||||||
}
|
}
|
||||||
hydrate();
|
hydrate();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -6,6 +6,9 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>CrossPoint Reader</title>
|
<title>CrossPoint Reader</title>
|
||||||
<style>
|
<style>
|
||||||
|
html {
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
}
|
||||||
:root {
|
:root {
|
||||||
--font-color: #333;
|
--font-color: #333;
|
||||||
--bg: #f5f5f5;
|
--bg: #f5f5f5;
|
||||||
|
|||||||
@@ -5,6 +5,9 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>CrossPoint Reader - Settings</title>
|
<title>CrossPoint Reader - Settings</title>
|
||||||
<style>
|
<style>
|
||||||
|
html {
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
}
|
||||||
:root {
|
:root {
|
||||||
--font-color: #333;
|
--font-color: #333;
|
||||||
--bg: #f5f5f5;
|
--bg: #f5f5f5;
|
||||||
@@ -72,6 +75,19 @@
|
|||||||
background-color: var(--accent-hover-color);
|
background-color: var(--accent-hover-color);
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
.section-header {
|
||||||
|
font-size: 0.75em;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--accent-color);
|
||||||
|
padding: 14px 0 4px;
|
||||||
|
border-bottom: 1px solid var(--accent-color);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.section .section-header:first-of-type {
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
.setting-row {
|
.setting-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -369,7 +385,15 @@
|
|||||||
|
|
||||||
for (const category in groups) {
|
for (const category in groups) {
|
||||||
html += '<div class="card"><h2>' + escapeHtml(category) + '</h2>';
|
html += '<div class="card"><h2>' + escapeHtml(category) + '</h2>';
|
||||||
|
let lastSection = null;
|
||||||
groups[category].forEach(function(s) {
|
groups[category].forEach(function(s) {
|
||||||
|
// Each item carries either a subcategory or a submenu label as its section heading.
|
||||||
|
// Both are treated identically: a new label triggers a section header row.
|
||||||
|
const section = s.subcategory || s.submenu || null;
|
||||||
|
if (section && section !== lastSection) {
|
||||||
|
html += '<div class="section-header">' + escapeHtml(section) + '</div>';
|
||||||
|
lastSection = section;
|
||||||
|
}
|
||||||
html += '<div class="setting-row">' +
|
html += '<div class="setting-row">' +
|
||||||
'<span class="setting-name">' + escapeHtml(s.name) + '</span>' +
|
'<span class="setting-name">' + escapeHtml(s.name) + '</span>' +
|
||||||
'<span class="setting-control">' + renderControl(s) + '</span>' +
|
'<span class="setting-control">' + renderControl(s) + '</span>' +
|
||||||
|
|||||||
Reference in New Issue
Block a user