Xteink-X4-crosspoint-reader/src/network/html/FilesPage.html
swwilshub a946c83a07
Turbocharge WiFi uploads with WebSocket + watchdog stability (#364)
## 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>
2026-01-14 23:11:28 +11:00

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">&times;</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()">&times;</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()">&times;</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()">&times;</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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
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>