mirror of
https://github.com/daveallie/crosspoint-reader.git
synced 2026-02-04 22:57:50 +03:00
## Summary
* **What is the goal of this PR?** Fix WiFi file transfer stability
issues (especially crashes during uploads) and improve upload speed via
WebSocket binary protocol. File transfers now don't really crash as
much, if they do it recovers and speed has gone form 50KB/s to 300+KB/s.
* **What changes are included?**
- **WebSocket upload support** - Adds WebSocket binary protocol for file
uploads, achieving faster speeds 335 KB/s vs HTTP multipart)
- **Watchdog stability fixes** - Adds `esp_task_wdt_reset()` calls
throughout upload path to prevent watchdog timeouts during:
- File creation (FAT allocation can be slow)
- SD card write operations
- HTTP header parsing
- WebSocket chunk processing
- **4KB write buffering** - Batches SD card writes to reduce I/O
overhead
- **WiFi health monitoring** - Detects WiFi disconnection in STA mode
and exits gracefully
- **Improved handleClient loop** - 500 iterations with periodic watchdog
resets and button checks for responsiveness
- **Progress bar improvements** - Fixed jumping/inaccurate progress by
capping local progress at 95% until server confirms completion
- **Exit button responsiveness** - Button now checked inside the
handleClient loop every 64 iterations
- **Reduced exit delays** - Decreased shutdown delays from ~850ms to
~140ms
**Files changed:**
- `platformio.ini` - Added WebSockets library dependency
- `CrossPointWebServer.cpp/h` - WebSocket server, upload buffering,
watchdog resets
- `CrossPointWebServerActivity.cpp` - WiFi monitoring, improved loop,
button handling
- `FilesPage.html` - WebSocket upload JavaScript with HTTP fallback
## Additional Context
- WebSocket uses 4KB chunks with backpressure management to prevent
ESP32 buffer overflow
- Falls back to HTTP automatically if WebSocket connection fails
- The main bottleneck now is SD card write speed (~44% of transfer
time), not WiFi
- STA mode was more prone to crashes than AP mode due to external
network factors; WiFi health monitoring helps detect and handle
disconnections gracefully
---
### AI Usage
Did you use AI tools to help write this code? _**YES**_ Claude did it
ALL, I have no idea what I am doing, but my books transfer fast now.
---------
Co-authored-by: Claude <noreply@anthropic.com>
1222 lines
33 KiB
HTML
1222 lines
33 KiB
HTML
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<title>CrossPoint Reader - Files</title>
|
|
<style>
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
|
Oxygen, Ubuntu, sans-serif;
|
|
max-width: 800px;
|
|
margin: 0 auto;
|
|
padding: 20px;
|
|
background-color: #f5f5f5;
|
|
color: #333;
|
|
}
|
|
h1 {
|
|
color: #2c3e50;
|
|
margin-bottom: 5px;
|
|
}
|
|
h2 {
|
|
color: #34495e;
|
|
margin-top: 0;
|
|
}
|
|
.card {
|
|
background: white;
|
|
border-radius: 8px;
|
|
padding: 20px;
|
|
margin: 15px 0;
|
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
}
|
|
.page-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
gap: 15px;
|
|
margin-bottom: 20px;
|
|
padding-bottom: 15px;
|
|
border-bottom: 2px solid #3498db;
|
|
}
|
|
.page-header-left {
|
|
display: flex;
|
|
align-items: baseline;
|
|
gap: 12px;
|
|
flex-wrap: wrap;
|
|
}
|
|
.breadcrumb-inline {
|
|
color: #7f8c8d;
|
|
font-size: 1.1em;
|
|
}
|
|
.breadcrumb-inline a {
|
|
color: #3498db;
|
|
text-decoration: none;
|
|
}
|
|
.breadcrumb-inline a:hover {
|
|
text-decoration: underline;
|
|
}
|
|
.breadcrumb-inline .sep {
|
|
margin: 0 6px;
|
|
color: #bdc3c7;
|
|
}
|
|
.breadcrumb-inline .current {
|
|
color: #2c3e50;
|
|
font-weight: 500;
|
|
}
|
|
.nav-links {
|
|
margin: 20px 0;
|
|
}
|
|
.nav-links a {
|
|
display: inline-block;
|
|
padding: 10px 20px;
|
|
background-color: #3498db;
|
|
color: white;
|
|
text-decoration: none;
|
|
border-radius: 4px;
|
|
margin-right: 10px;
|
|
}
|
|
.nav-links a:hover {
|
|
background-color: #2980b9;
|
|
}
|
|
/* Action buttons */
|
|
.action-buttons {
|
|
display: flex;
|
|
gap: 10px;
|
|
}
|
|
.action-btn {
|
|
color: white;
|
|
padding: 10px 16px;
|
|
border: none;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font-size: 0.95em;
|
|
font-weight: 600;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
}
|
|
.upload-action-btn {
|
|
background-color: #27ae60;
|
|
}
|
|
.upload-action-btn:hover {
|
|
background-color: #219a52;
|
|
}
|
|
.folder-action-btn {
|
|
background-color: #f39c12;
|
|
}
|
|
.folder-action-btn:hover {
|
|
background-color: #d68910;
|
|
}
|
|
/* Upload modal */
|
|
.modal-overlay {
|
|
display: none;
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background: rgba(0, 0, 0, 0.5);
|
|
z-index: 200;
|
|
justify-content: center;
|
|
align-items: center;
|
|
}
|
|
.modal-overlay.open {
|
|
display: flex;
|
|
}
|
|
.modal {
|
|
background: white;
|
|
border-radius: 8px;
|
|
padding: 25px;
|
|
max-width: 450px;
|
|
width: 90%;
|
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
|
|
}
|
|
.modal h3 {
|
|
margin: 0 0 15px 0;
|
|
color: #2c3e50;
|
|
}
|
|
.modal-close {
|
|
float: right;
|
|
background: none;
|
|
border: none;
|
|
font-size: 1.5em;
|
|
cursor: pointer;
|
|
color: #7f8c8d;
|
|
line-height: 1;
|
|
}
|
|
.modal-close:hover {
|
|
color: #2c3e50;
|
|
}
|
|
.file-table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
}
|
|
.file-table th,
|
|
.file-table td {
|
|
padding: 12px;
|
|
text-align: left;
|
|
border-bottom: 1px solid #eee;
|
|
}
|
|
.file-table th {
|
|
background-color: #f8f9fa;
|
|
font-weight: 600;
|
|
color: #7f8c8d;
|
|
}
|
|
.file-table tr:hover {
|
|
background-color: #f8f9fa;
|
|
}
|
|
.epub-file {
|
|
background-color: #e8f6e9 !important;
|
|
}
|
|
.epub-file:hover {
|
|
background-color: #d4edda !important;
|
|
}
|
|
.folder-row {
|
|
background-color: #fff9e6 !important;
|
|
}
|
|
.folder-row:hover {
|
|
background-color: #fff3cd !important;
|
|
}
|
|
.epub-badge {
|
|
display: inline-block;
|
|
padding: 2px 8px;
|
|
background-color: #27ae60;
|
|
color: white;
|
|
border-radius: 10px;
|
|
font-size: 0.75em;
|
|
margin-left: 8px;
|
|
}
|
|
.folder-badge {
|
|
display: inline-block;
|
|
padding: 2px 8px;
|
|
background-color: #f39c12;
|
|
color: white;
|
|
border-radius: 10px;
|
|
font-size: 0.75em;
|
|
margin-left: 8px;
|
|
}
|
|
.file-icon {
|
|
margin-right: 8px;
|
|
}
|
|
.folder-link {
|
|
color: #2c3e50;
|
|
text-decoration: none;
|
|
cursor: pointer;
|
|
}
|
|
.folder-link:hover {
|
|
color: #3498db;
|
|
text-decoration: underline;
|
|
}
|
|
.upload-form {
|
|
margin-top: 10px;
|
|
}
|
|
.upload-form input[type="file"] {
|
|
margin: 10px 0;
|
|
width: 100%;
|
|
}
|
|
.upload-btn {
|
|
background-color: #27ae60;
|
|
color: white;
|
|
padding: 10px 20px;
|
|
border: none;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font-size: 1em;
|
|
width: 100%;
|
|
}
|
|
.upload-btn:hover {
|
|
background-color: #219a52;
|
|
}
|
|
.upload-btn:disabled {
|
|
background-color: #95a5a6;
|
|
cursor: not-allowed;
|
|
}
|
|
.file-info {
|
|
color: #7f8c8d;
|
|
font-size: 0.85em;
|
|
margin: 8px 0;
|
|
}
|
|
.no-files {
|
|
text-align: center;
|
|
color: #95a5a6;
|
|
padding: 40px;
|
|
font-style: italic;
|
|
}
|
|
.message {
|
|
padding: 15px;
|
|
border-radius: 4px;
|
|
margin: 15px 0;
|
|
}
|
|
.message.success {
|
|
background-color: #d4edda;
|
|
color: #155724;
|
|
border: 1px solid #c3e6cb;
|
|
}
|
|
.message.error {
|
|
background-color: #f8d7da;
|
|
color: #721c24;
|
|
border: 1px solid #f5c6cb;
|
|
}
|
|
.contents-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 12px;
|
|
}
|
|
.contents-title {
|
|
font-size: 1.1em;
|
|
font-weight: 600;
|
|
color: #34495e;
|
|
margin: 0;
|
|
}
|
|
.summary-inline {
|
|
color: #7f8c8d;
|
|
font-size: 0.9em;
|
|
}
|
|
#progress-container {
|
|
display: none;
|
|
margin-top: 10px;
|
|
}
|
|
#progress-bar {
|
|
width: 100%;
|
|
height: 20px;
|
|
background-color: #e0e0e0;
|
|
border-radius: 10px;
|
|
overflow: hidden;
|
|
}
|
|
#progress-fill {
|
|
height: 100%;
|
|
background-color: #27ae60;
|
|
width: 0%;
|
|
transition: width 0.3s;
|
|
}
|
|
#progress-text {
|
|
text-align: center;
|
|
margin-top: 5px;
|
|
font-size: 0.9em;
|
|
color: #7f8c8d;
|
|
}
|
|
.folder-form {
|
|
margin-top: 10px;
|
|
}
|
|
.folder-input {
|
|
width: 100%;
|
|
padding: 10px;
|
|
border: 1px solid #ddd;
|
|
border-radius: 4px;
|
|
font-size: 1em;
|
|
margin-bottom: 10px;
|
|
box-sizing: border-box;
|
|
}
|
|
.folder-btn {
|
|
background-color: #f39c12;
|
|
color: white;
|
|
padding: 10px 20px;
|
|
border: none;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font-size: 1em;
|
|
width: 100%;
|
|
}
|
|
.folder-btn:hover {
|
|
background-color: #d68910;
|
|
}
|
|
/* Delete button styles */
|
|
.delete-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:hover {
|
|
background-color: #fee;
|
|
color: #e74c3c;
|
|
}
|
|
.actions-col {
|
|
width: 60px;
|
|
text-align: center;
|
|
}
|
|
/* Failed uploads banner */
|
|
.failed-uploads-banner {
|
|
background-color: #fff3cd;
|
|
border: 1px solid #ffc107;
|
|
border-radius: 4px;
|
|
padding: 15px;
|
|
margin-bottom: 15px;
|
|
display: none;
|
|
}
|
|
.failed-uploads-banner.show {
|
|
display: block;
|
|
}
|
|
.failed-uploads-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 10px;
|
|
}
|
|
.failed-uploads-title {
|
|
font-weight: 600;
|
|
color: #856404;
|
|
margin: 0;
|
|
}
|
|
.dismiss-btn {
|
|
background: none;
|
|
border: none;
|
|
font-size: 1.2em;
|
|
cursor: pointer;
|
|
color: #856404;
|
|
padding: 0;
|
|
line-height: 1;
|
|
}
|
|
.dismiss-btn:hover {
|
|
color: #533f03;
|
|
}
|
|
.failed-file-item {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 8px 0;
|
|
border-bottom: 1px solid #ffe69c;
|
|
}
|
|
.failed-file-item:last-child {
|
|
border-bottom: none;
|
|
}
|
|
.failed-file-info {
|
|
flex: 1;
|
|
}
|
|
.failed-file-name {
|
|
font-weight: 500;
|
|
color: #856404;
|
|
}
|
|
.failed-file-error {
|
|
font-size: 0.85em;
|
|
color: #856404;
|
|
opacity: 0.8;
|
|
}
|
|
.retry-btn {
|
|
background-color: #ffc107;
|
|
color: #533f03;
|
|
border: none;
|
|
padding: 6px 12px;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font-size: 0.9em;
|
|
font-weight: 600;
|
|
}
|
|
.retry-btn:hover {
|
|
background-color: #e0a800;
|
|
}
|
|
.retry-all-btn {
|
|
background-color: #ffc107;
|
|
color: #533f03;
|
|
border: none;
|
|
padding: 8px 16px;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font-size: 0.95em;
|
|
font-weight: 600;
|
|
margin-top: 10px;
|
|
}
|
|
.retry-all-btn:hover {
|
|
background-color: #e0a800;
|
|
}
|
|
/* Delete modal */
|
|
.delete-warning {
|
|
color: #e74c3c;
|
|
font-weight: 600;
|
|
margin: 10px 0;
|
|
}
|
|
.delete-item-name {
|
|
font-weight: 600;
|
|
color: #2c3e50;
|
|
word-break: break-all;
|
|
}
|
|
.delete-btn-confirm {
|
|
background-color: #e74c3c;
|
|
color: white;
|
|
padding: 10px 20px;
|
|
border: none;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font-size: 1em;
|
|
width: 100%;
|
|
}
|
|
.delete-btn-confirm:hover {
|
|
background-color: #c0392b;
|
|
}
|
|
.delete-btn-cancel {
|
|
background-color: #95a5a6;
|
|
color: white;
|
|
padding: 10px 20px;
|
|
border: none;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font-size: 1em;
|
|
width: 100%;
|
|
margin-top: 10px;
|
|
}
|
|
.delete-btn-cancel:hover {
|
|
background-color: #7f8c8d;
|
|
}
|
|
.loader-container {
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
margin: 20px 0;
|
|
}
|
|
.loader {
|
|
width: 48px;
|
|
height: 48px;
|
|
border: 5px solid #AAA;
|
|
border-bottom-color: transparent;
|
|
border-radius: 50%;
|
|
display: inline-block;
|
|
box-sizing: border-box;
|
|
animation: rotation 1s linear infinite;
|
|
}
|
|
@keyframes rotation {
|
|
from {
|
|
transform: rotate(0deg);
|
|
}
|
|
to {
|
|
transform: rotate(360deg);
|
|
}
|
|
}
|
|
/* Mobile responsive styles */
|
|
@media (max-width: 600px) {
|
|
body {
|
|
padding: 10px;
|
|
font-size: 14px;
|
|
}
|
|
.card {
|
|
padding: 12px;
|
|
margin: 10px 0;
|
|
}
|
|
.page-header {
|
|
gap: 10px;
|
|
margin-bottom: 12px;
|
|
padding-bottom: 10px;
|
|
}
|
|
.page-header-left {
|
|
gap: 8px;
|
|
}
|
|
h1 {
|
|
font-size: 1.3em;
|
|
}
|
|
.breadcrumb-inline {
|
|
font-size: 0.95em;
|
|
}
|
|
.nav-links a {
|
|
padding: 8px 12px;
|
|
margin-right: 6px;
|
|
font-size: 0.9em;
|
|
}
|
|
.action-buttons {
|
|
gap: 6px;
|
|
}
|
|
.action-btn {
|
|
padding: 8px 10px;
|
|
font-size: 0.85em;
|
|
}
|
|
.file-table th,
|
|
.file-table td {
|
|
padding: 8px 6px;
|
|
font-size: 0.9em;
|
|
}
|
|
.file-table th {
|
|
font-size: 0.85em;
|
|
}
|
|
.file-icon {
|
|
margin-right: 4px;
|
|
}
|
|
.epub-badge,
|
|
.folder-badge {
|
|
padding: 2px 5px;
|
|
font-size: 0.65em;
|
|
margin-left: 4px;
|
|
}
|
|
.contents-header {
|
|
margin-bottom: 8px;
|
|
flex-wrap: wrap;
|
|
gap: 4px;
|
|
}
|
|
.contents-title {
|
|
font-size: 1em;
|
|
}
|
|
.summary-inline {
|
|
font-size: 0.8em;
|
|
}
|
|
.modal {
|
|
padding: 15px;
|
|
}
|
|
.modal h3 {
|
|
font-size: 1.1em;
|
|
}
|
|
.actions-col {
|
|
width: 40px;
|
|
}
|
|
.delete-btn {
|
|
font-size: 1em;
|
|
padding: 2px 4px;
|
|
}
|
|
.no-files {
|
|
padding: 20px;
|
|
font-size: 0.9em;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="nav-links">
|
|
<a href="/">Home</a>
|
|
<a href="/files">File Manager</a>
|
|
</div>
|
|
|
|
<div class="page-header">
|
|
<div class="page-header-left">
|
|
<h1>📁 File Manager</h1>
|
|
<div class="breadcrumb-inline" id="directory-breadcrumbs"></div>
|
|
</div>
|
|
|
|
<div class="action-buttons">
|
|
<button class="action-btn upload-action-btn" onclick="openUploadModal()">📤 Upload</button>
|
|
<button class="action-btn folder-action-btn" onclick="openFolderModal()">📁 New Folder</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Failed Uploads Banner -->
|
|
<div class="failed-uploads-banner" id="failedUploadsBanner">
|
|
<div class="failed-uploads-header">
|
|
<h3 class="failed-uploads-title">⚠️ Some files failed to upload</h3>
|
|
<button class="dismiss-btn" onclick="dismissFailedUploads()" title="Dismiss">×</button>
|
|
</div>
|
|
<div id="failedFilesList"></div>
|
|
<button class="retry-all-btn" onclick="retryAllFailedUploads()">Retry All Failed Uploads</button>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div class="contents-header">
|
|
<h2 class="contents-title">Contents</h2>
|
|
<span class="summary-inline" id="folder-summary"></span>
|
|
</div>
|
|
|
|
<div id="file-table">
|
|
<div class="loader-container">
|
|
<span class="loader"></span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<p style="text-align: center; color: #95a5a6; margin: 0;">
|
|
CrossPoint E-Reader • Open Source
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Upload Modal -->
|
|
<div class="modal-overlay" id="uploadModal">
|
|
<div class="modal">
|
|
<button class="modal-close" onclick="closeUploadModal()">×</button>
|
|
<h3>📤 Upload file</h3>
|
|
<div class="upload-form">
|
|
<p class="file-info">Select a file to upload to <strong id="uploadPathDisplay"></strong></p>
|
|
<input type="file" id="fileInput" onchange="validateFile()" multiple>
|
|
<button id="uploadBtn" class="upload-btn" onclick="uploadFile()" disabled>Upload</button>
|
|
<div id="progress-container">
|
|
<div id="progress-bar"><div id="progress-fill"></div></div>
|
|
<div id="progress-text"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- New Folder Modal -->
|
|
<div class="modal-overlay" id="folderModal">
|
|
<div class="modal">
|
|
<button class="modal-close" onclick="closeFolderModal()">×</button>
|
|
<h3>📁 New Folder</h3>
|
|
<div class="folder-form">
|
|
<p class="file-info">Create a new folder in <strong id="folderPathDisplay"></strong></p>
|
|
<input type="text" id="folderName" class="folder-input" placeholder="Folder name...">
|
|
<button class="folder-btn" onclick="createFolder()">Create Folder</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Delete Confirmation Modal -->
|
|
<div class="modal-overlay" id="deleteModal">
|
|
<div class="modal">
|
|
<button class="modal-close" onclick="closeDeleteModal()">×</button>
|
|
<h3>🗑️ Delete Item</h3>
|
|
<div class="folder-form">
|
|
<p class="delete-warning">⚠️ This action cannot be undone!</p>
|
|
<p class="file-info">Are you sure you want to delete:</p>
|
|
<p class="delete-item-name" id="deleteItemName"></p>
|
|
<input type="hidden" id="deleteItemPath">
|
|
<input type="hidden" id="deleteItemType">
|
|
<button class="delete-btn-confirm" onclick="confirmDelete()">Delete</button>
|
|
<button class="delete-btn-cancel" onclick="closeDeleteModal()">Cancel</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
// get current path from query parameter
|
|
const currentPath = decodeURIComponent(new URLSearchParams(window.location.search).get('path') || '/');
|
|
|
|
function escapeHtml(unsafe) {
|
|
return unsafe
|
|
.replaceAll("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """)
|
|
.replaceAll("'", "'");
|
|
}
|
|
|
|
function formatFileSize(bytes) {
|
|
if (bytes === 0) return '0 B';
|
|
const k = 1024;
|
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)).toLocaleString() + ' ' + sizes[i];
|
|
}
|
|
|
|
async function hydrate() {
|
|
// Close modals when clicking overlay
|
|
document.querySelectorAll('.modal-overlay').forEach(function(overlay) {
|
|
overlay.addEventListener('click', function(e) {
|
|
if (e.target === overlay) {
|
|
overlay.classList.remove('open');
|
|
}
|
|
});
|
|
});
|
|
|
|
const breadcrumbs = document.getElementById('directory-breadcrumbs');
|
|
const fileTable = document.getElementById('file-table');
|
|
|
|
let breadcrumbContent = '<span class="sep">/</span>';
|
|
if (currentPath === '/') {
|
|
breadcrumbContent += '<span class="current">🏠</span>';
|
|
} else {
|
|
breadcrumbContent += '<a href="/files">🏠</a>';
|
|
const pathSegments = currentPath.split('/');
|
|
pathSegments.slice(1, pathSegments.length - 1).forEach(function(segment, index) {
|
|
breadcrumbContent += '<span class="sep">/</span><a href="/files?path=' + encodeURIComponent(pathSegments.slice(0, index + 2).join('/')) + '">' + escapeHtml(segment) + '</a>';
|
|
});
|
|
breadcrumbContent += '<span class="sep">/</span>';
|
|
breadcrumbContent += '<span class="current">' + escapeHtml(pathSegments[pathSegments.length - 1]) + '</span>';
|
|
}
|
|
breadcrumbs.innerHTML = breadcrumbContent;
|
|
|
|
let files = [];
|
|
try {
|
|
const response = await fetch('/api/files?path=' + encodeURIComponent(currentPath));
|
|
if (!response.ok) {
|
|
throw new Error('Failed to load files: ' + response.status + ' ' + response.statusText);
|
|
}
|
|
files = await response.json();
|
|
} catch (e) {
|
|
console.error(e);
|
|
fileTable.innerHTML = '<div class="no-files">An error occurred while loading the files</div>';
|
|
return;
|
|
}
|
|
|
|
let folderCount = 0;
|
|
let totalSize = 0;
|
|
files.forEach(file => {
|
|
if (file.isDirectory) folderCount++;
|
|
totalSize += file.size;
|
|
});
|
|
|
|
document.getElementById('folder-summary').innerHTML = `${folderCount} folders, ${files.length - folderCount} files, ${formatFileSize(totalSize)}`;
|
|
|
|
if (files.length === 0) {
|
|
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>';
|
|
|
|
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;
|
|
|
|
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>`;
|
|
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 += '</tr>';
|
|
} else {
|
|
let filePath = currentPath;
|
|
if (!filePath.endsWith("/")) filePath += "/";
|
|
filePath += file.name;
|
|
|
|
fileTableContent += `<tr class="${file.isEpub ? 'epub-file' : ''}">`;
|
|
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"><button class="delete-btn" onclick="openDeleteModal('${file.name.replaceAll("'", "\\'")}', '${filePath.replaceAll("'", "\\'")}', false)" title="Delete file">🗑️</button></td>`;
|
|
fileTableContent += '</tr>';
|
|
}
|
|
});
|
|
|
|
fileTableContent += '</table>';
|
|
fileTable.innerHTML = fileTableContent;
|
|
}
|
|
}
|
|
|
|
// Modal functions
|
|
function openUploadModal() {
|
|
document.getElementById('uploadPathDisplay').textContent = currentPath === '/' ? '/ 🏠' : currentPath;
|
|
document.getElementById('uploadModal').classList.add('open');
|
|
}
|
|
|
|
function closeUploadModal() {
|
|
document.getElementById('uploadModal').classList.remove('open');
|
|
document.getElementById('fileInput').value = '';
|
|
document.getElementById('uploadBtn').disabled = true;
|
|
document.getElementById('progress-container').style.display = 'none';
|
|
document.getElementById('progress-fill').style.width = '0%';
|
|
document.getElementById('progress-fill').style.backgroundColor = '#27ae60';
|
|
}
|
|
|
|
function openFolderModal() {
|
|
document.getElementById('folderPathDisplay').textContent = currentPath === '/' ? '/ 🏠' : currentPath;
|
|
document.getElementById('folderModal').classList.add('open');
|
|
document.getElementById('folderName').value = '';
|
|
}
|
|
|
|
function closeFolderModal() {
|
|
document.getElementById('folderModal').classList.remove('open');
|
|
}
|
|
|
|
function validateFile() {
|
|
const fileInput = document.getElementById('fileInput');
|
|
const uploadBtn = document.getElementById('uploadBtn');
|
|
const files = fileInput.files;
|
|
uploadBtn.disabled = !(files.length > 0);
|
|
}
|
|
|
|
let failedUploadsGlobal = [];
|
|
let wsConnection = null;
|
|
const WS_PORT = 81;
|
|
const WS_CHUNK_SIZE = 4096; // 4KB chunks - smaller for ESP32 stability
|
|
|
|
// Get WebSocket URL based on current page location
|
|
function getWsUrl() {
|
|
const host = window.location.hostname;
|
|
return `ws://${host}:${WS_PORT}/`;
|
|
}
|
|
|
|
// Upload file via WebSocket (faster, binary protocol)
|
|
function uploadFileWebSocket(file, onProgress, onComplete, onError) {
|
|
return new Promise((resolve, reject) => {
|
|
const ws = new WebSocket(getWsUrl());
|
|
let uploadStarted = false;
|
|
let sendingChunks = false;
|
|
|
|
ws.binaryType = 'arraybuffer';
|
|
|
|
ws.onopen = function() {
|
|
console.log('[WS] Connected, starting upload:', file.name);
|
|
// Send start message: START:<filename>:<size>:<path>
|
|
ws.send(`START:${file.name}:${file.size}:${currentPath}`);
|
|
};
|
|
|
|
ws.onmessage = async function(event) {
|
|
const msg = event.data;
|
|
console.log('[WS] Message:', msg);
|
|
|
|
if (msg === 'READY') {
|
|
uploadStarted = true;
|
|
sendingChunks = true;
|
|
|
|
// Small delay to let connection stabilize
|
|
await new Promise(r => setTimeout(r, 50));
|
|
|
|
try {
|
|
// Send file in chunks
|
|
const totalSize = file.size;
|
|
let offset = 0;
|
|
|
|
while (offset < totalSize && ws.readyState === WebSocket.OPEN) {
|
|
const chunkSize = Math.min(WS_CHUNK_SIZE, totalSize - offset);
|
|
const chunk = file.slice(offset, offset + chunkSize);
|
|
const buffer = await chunk.arrayBuffer();
|
|
|
|
// Wait for buffer to clear - more aggressive backpressure
|
|
while (ws.bufferedAmount > WS_CHUNK_SIZE * 2 && ws.readyState === WebSocket.OPEN) {
|
|
await new Promise(r => setTimeout(r, 5));
|
|
}
|
|
|
|
if (ws.readyState !== WebSocket.OPEN) {
|
|
throw new Error('WebSocket closed during upload');
|
|
}
|
|
|
|
ws.send(buffer);
|
|
offset += chunkSize;
|
|
|
|
// Update local progress - cap at 95% since server still needs to write
|
|
// Final 100% shown when server confirms DONE
|
|
if (onProgress) {
|
|
const cappedOffset = Math.min(offset, Math.floor(totalSize * 0.95));
|
|
onProgress(cappedOffset, totalSize);
|
|
}
|
|
}
|
|
|
|
sendingChunks = false;
|
|
console.log('[WS] All chunks sent, waiting for DONE');
|
|
} catch (err) {
|
|
console.error('[WS] Error sending chunks:', err);
|
|
sendingChunks = false;
|
|
ws.close();
|
|
reject(err);
|
|
}
|
|
} else if (msg.startsWith('PROGRESS:')) {
|
|
// Server confirmed progress - log for debugging but don't update UI
|
|
// (local progress is smoother, server progress causes jumping)
|
|
console.log('[WS] Server progress:', msg);
|
|
} else if (msg === 'DONE') {
|
|
// Show 100% when server confirms completion
|
|
if (onProgress) onProgress(file.size, file.size);
|
|
ws.close();
|
|
if (onComplete) onComplete();
|
|
resolve();
|
|
} else if (msg.startsWith('ERROR:')) {
|
|
const error = msg.substring(6);
|
|
ws.close();
|
|
if (onError) onError(error);
|
|
reject(new Error(error));
|
|
}
|
|
};
|
|
|
|
ws.onerror = function(event) {
|
|
console.error('[WS] Error:', event);
|
|
if (!uploadStarted) {
|
|
reject(new Error('WebSocket connection failed'));
|
|
} else if (!sendingChunks) {
|
|
reject(new Error('WebSocket error during upload'));
|
|
}
|
|
};
|
|
|
|
ws.onclose = function(event) {
|
|
console.log('[WS] Connection closed, code:', event.code, 'reason:', event.reason);
|
|
if (sendingChunks) {
|
|
reject(new Error('WebSocket closed unexpectedly'));
|
|
}
|
|
};
|
|
});
|
|
}
|
|
|
|
// Upload file via HTTP (fallback method)
|
|
function uploadFileHTTP(file, onProgress, onComplete, onError) {
|
|
return new Promise((resolve, reject) => {
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
|
|
const xhr = new XMLHttpRequest();
|
|
xhr.open('POST', '/upload?path=' + encodeURIComponent(currentPath), true);
|
|
|
|
xhr.upload.onprogress = function(e) {
|
|
if (e.lengthComputable && onProgress) {
|
|
onProgress(e.loaded, e.total);
|
|
}
|
|
};
|
|
|
|
xhr.onload = function() {
|
|
if (xhr.status === 200) {
|
|
if (onComplete) onComplete();
|
|
resolve();
|
|
} else {
|
|
const error = xhr.responseText || 'Upload failed';
|
|
if (onError) onError(error);
|
|
reject(new Error(error));
|
|
}
|
|
};
|
|
|
|
xhr.onerror = function() {
|
|
const error = 'Network error';
|
|
if (onError) onError(error);
|
|
reject(new Error(error));
|
|
};
|
|
|
|
xhr.send(formData);
|
|
});
|
|
}
|
|
|
|
function uploadFile() {
|
|
const fileInput = document.getElementById('fileInput');
|
|
const files = Array.from(fileInput.files);
|
|
|
|
if (files.length === 0) {
|
|
alert('Please select at least one file!');
|
|
return;
|
|
}
|
|
|
|
const progressContainer = document.getElementById('progress-container');
|
|
const progressFill = document.getElementById('progress-fill');
|
|
const progressText = document.getElementById('progress-text');
|
|
const uploadBtn = document.getElementById('uploadBtn');
|
|
|
|
progressContainer.style.display = 'block';
|
|
uploadBtn.disabled = true;
|
|
|
|
let currentIndex = 0;
|
|
const failedFiles = [];
|
|
let useWebSocket = true; // Try WebSocket first
|
|
|
|
async function uploadNextFile() {
|
|
if (currentIndex >= files.length) {
|
|
// All files processed - show summary
|
|
if (failedFiles.length === 0) {
|
|
progressFill.style.backgroundColor = '#4caf50';
|
|
progressText.textContent = 'All uploads complete!';
|
|
setTimeout(() => {
|
|
closeUploadModal();
|
|
hydrate();
|
|
}, 1000);
|
|
} else {
|
|
progressFill.style.backgroundColor = '#e74c3c';
|
|
const failedList = failedFiles.map(f => f.name).join(', ');
|
|
progressText.textContent = `${files.length - failedFiles.length}/${files.length} uploaded. Failed: ${failedList}`;
|
|
failedUploadsGlobal = failedFiles;
|
|
setTimeout(() => {
|
|
closeUploadModal();
|
|
showFailedUploadsBanner();
|
|
hydrate();
|
|
}, 2000);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const file = files[currentIndex];
|
|
progressFill.style.width = '0%';
|
|
progressFill.style.backgroundColor = '#27ae60';
|
|
const methodText = useWebSocket ? ' [WS]' : ' [HTTP]';
|
|
progressText.textContent = `Uploading ${file.name} (${currentIndex + 1}/${files.length})${methodText}`;
|
|
|
|
const onProgress = (loaded, total) => {
|
|
const percent = Math.round((loaded / total) * 100);
|
|
progressFill.style.width = percent + '%';
|
|
const speed = ''; // Could calculate speed here
|
|
progressText.textContent = `Uploading ${file.name} (${currentIndex + 1}/${files.length})${methodText} — ${percent}%`;
|
|
};
|
|
|
|
const onComplete = () => {
|
|
currentIndex++;
|
|
uploadNextFile();
|
|
};
|
|
|
|
const onError = (error) => {
|
|
failedFiles.push({ name: file.name, error: error, file: file });
|
|
currentIndex++;
|
|
uploadNextFile();
|
|
};
|
|
|
|
try {
|
|
if (useWebSocket) {
|
|
await uploadFileWebSocket(file, onProgress, null, null);
|
|
onComplete();
|
|
} else {
|
|
await uploadFileHTTP(file, onProgress, null, null);
|
|
onComplete();
|
|
}
|
|
} catch (error) {
|
|
console.error('Upload error:', error);
|
|
if (useWebSocket && error.message === 'WebSocket connection failed') {
|
|
// Fall back to HTTP for all subsequent uploads
|
|
console.log('WebSocket failed, falling back to HTTP');
|
|
useWebSocket = false;
|
|
// Retry this file with HTTP
|
|
try {
|
|
await uploadFileHTTP(file, onProgress, null, null);
|
|
onComplete();
|
|
} catch (httpError) {
|
|
onError(httpError.message);
|
|
}
|
|
} else {
|
|
onError(error.message);
|
|
}
|
|
}
|
|
}
|
|
|
|
uploadNextFile();
|
|
}
|
|
|
|
function showFailedUploadsBanner() {
|
|
const banner = document.getElementById('failedUploadsBanner');
|
|
const filesList = document.getElementById('failedFilesList');
|
|
|
|
filesList.innerHTML = '';
|
|
|
|
failedUploadsGlobal.forEach((failedFile, index) => {
|
|
const item = document.createElement('div');
|
|
item.className = 'failed-file-item';
|
|
item.innerHTML = `
|
|
<div class="failed-file-info">
|
|
<div class="failed-file-name">📄 ${escapeHtml(failedFile.name)}</div>
|
|
<div class="failed-file-error">Error: ${escapeHtml(failedFile.error)}</div>
|
|
</div>
|
|
<button class="retry-btn" onclick="retrySingleUpload(${index})">Retry</button>
|
|
`;
|
|
filesList.appendChild(item);
|
|
});
|
|
|
|
// Ensure retry all button is visible
|
|
const retryAllBtn = banner.querySelector('.retry-all-btn');
|
|
if (retryAllBtn) retryAllBtn.style.display = '';
|
|
|
|
banner.classList.add('show');
|
|
}
|
|
|
|
function dismissFailedUploads() {
|
|
const banner = document.getElementById('failedUploadsBanner');
|
|
banner.classList.remove('show');
|
|
failedUploadsGlobal = [];
|
|
}
|
|
|
|
function retrySingleUpload(index) {
|
|
const failedFile = failedUploadsGlobal[index];
|
|
if (!failedFile) return;
|
|
|
|
// Create a DataTransfer to set the file input
|
|
const dt = new DataTransfer();
|
|
dt.items.add(failedFile.file);
|
|
|
|
const fileInput = document.getElementById('fileInput');
|
|
fileInput.files = dt.files;
|
|
|
|
// Remove this file from failed list
|
|
failedUploadsGlobal.splice(index, 1);
|
|
|
|
// If no more failed files, hide banner
|
|
if (failedUploadsGlobal.length === 0) {
|
|
dismissFailedUploads();
|
|
}
|
|
|
|
// Open modal and trigger upload
|
|
openUploadModal();
|
|
validateFile();
|
|
}
|
|
|
|
function retryAllFailedUploads() {
|
|
if (failedUploadsGlobal.length === 0) return;
|
|
|
|
// Create a DataTransfer with all failed files
|
|
const dt = new DataTransfer();
|
|
failedUploadsGlobal.forEach(failedFile => {
|
|
dt.items.add(failedFile.file);
|
|
});
|
|
|
|
const fileInput = document.getElementById('fileInput');
|
|
fileInput.files = dt.files;
|
|
|
|
// Clear failed files list
|
|
failedUploadsGlobal = [];
|
|
dismissFailedUploads();
|
|
|
|
// Open modal and trigger upload
|
|
openUploadModal();
|
|
validateFile();
|
|
}
|
|
|
|
function createFolder() {
|
|
const folderName = document.getElementById('folderName').value.trim();
|
|
|
|
if (!folderName) {
|
|
alert('Please enter a folder name!');
|
|
return;
|
|
}
|
|
|
|
// Validate folder name (no special characters except underscore and hyphen)
|
|
const validName = /^[a-zA-Z0-9_\-]+$/.test(folderName);
|
|
if (!validName) {
|
|
alert('Folder name can only contain letters, numbers, underscores, and hyphens.');
|
|
return;
|
|
}
|
|
|
|
const formData = new FormData();
|
|
formData.append('name', folderName);
|
|
formData.append('path', currentPath);
|
|
|
|
const xhr = new XMLHttpRequest();
|
|
xhr.open('POST', '/mkdir', true);
|
|
|
|
xhr.onload = function() {
|
|
if (xhr.status === 200) {
|
|
window.location.reload();
|
|
} else {
|
|
alert('Failed to create folder: ' + xhr.responseText);
|
|
}
|
|
};
|
|
|
|
xhr.onerror = function() {
|
|
alert('Failed to create folder - network error');
|
|
};
|
|
|
|
xhr.send(formData);
|
|
}
|
|
|
|
// Delete functions
|
|
function openDeleteModal(name, path, isFolder) {
|
|
document.getElementById('deleteItemName').textContent = (isFolder ? '📁 ' : '📄 ') + name;
|
|
document.getElementById('deleteItemPath').value = path;
|
|
document.getElementById('deleteItemType').value = isFolder ? 'folder' : 'file';
|
|
document.getElementById('deleteModal').classList.add('open');
|
|
}
|
|
|
|
function closeDeleteModal() {
|
|
document.getElementById('deleteModal').classList.remove('open');
|
|
}
|
|
|
|
function confirmDelete() {
|
|
const path = document.getElementById('deleteItemPath').value;
|
|
const itemType = document.getElementById('deleteItemType').value;
|
|
|
|
const formData = new FormData();
|
|
formData.append('path', path);
|
|
formData.append('type', itemType);
|
|
|
|
const xhr = new XMLHttpRequest();
|
|
xhr.open('POST', '/delete', true);
|
|
|
|
xhr.onload = function() {
|
|
if (xhr.status === 200) {
|
|
window.location.reload();
|
|
} else {
|
|
alert('Failed to delete: ' + xhr.responseText);
|
|
closeDeleteModal();
|
|
}
|
|
};
|
|
|
|
xhr.onerror = function() {
|
|
alert('Failed to delete - network error');
|
|
closeDeleteModal();
|
|
};
|
|
|
|
xhr.send(formData);
|
|
}
|
|
|
|
hydrate();
|
|
</script>
|
|
</body>
|
|
</html>
|