Add support for uploading multiple epubs (#202)
Upload multiple files at once in sequence. Add retry button for files that fail ## Summary * **What is the goal of this PR?** Add support for selecting multiple epub files in one go, before uploading them all to the device * **What changes are included?** Allow multiple selections to be submitted to the input field. Sends each file to the device one by one in a loop Adds retry logic and UI for easy re-trying of failed uploads Addresses #201 button now says "Choose Files", and shows the number of files you selected <img width="506" height="199" alt="image" src="https://github.com/user-attachments/assets/64b0b921-1e67-438e-9cd7-57d5466f2456" /> Shows which file is uploading: <img width="521" height="283" alt="image" src="https://github.com/user-attachments/assets/17b4d349-0698-4712-984c-b72fcdcb0918" /> Failed upload dialog: <img width="851" height="441" alt="image" src="https://github.com/user-attachments/assets/e8bf4aa6-d3d2-4c0b-9c7a-420e8c413033" /> <img width="834" height="641" alt="image" src="https://github.com/user-attachments/assets/656a9732-3963-4844-94e3-4d8736f6d9d5" />
This commit is contained in:
@@ -341,6 +341,90 @@
|
||||
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;
|
||||
@@ -505,6 +589,16 @@
|
||||
</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>
|
||||
@@ -531,7 +625,7 @@
|
||||
<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()">
|
||||
<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>
|
||||
@@ -717,65 +811,183 @@
|
||||
function validateFile() {
|
||||
const fileInput = document.getElementById('fileInput');
|
||||
const uploadBtn = document.getElementById('uploadBtn');
|
||||
const file = fileInput.files[0];
|
||||
uploadBtn.disabled = !file;
|
||||
const files = fileInput.files;
|
||||
uploadBtn.disabled = !(files.length > 0);
|
||||
}
|
||||
|
||||
function uploadFile() {
|
||||
const fileInput = document.getElementById('fileInput');
|
||||
const file = fileInput.files[0];
|
||||
let failedUploadsGlobal = [];
|
||||
|
||||
if (!file) {
|
||||
alert('Please select a file first!');
|
||||
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 = [];
|
||||
|
||||
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(); // Refresh file list instead of reloading
|
||||
}, 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}`;
|
||||
|
||||
// Store failed files globally and show banner
|
||||
failedUploadsGlobal = failedFiles;
|
||||
|
||||
setTimeout(() => {
|
||||
closeUploadModal();
|
||||
showFailedUploadsBanner();
|
||||
hydrate(); // Refresh file list to show successfully uploaded files
|
||||
}, 2000);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const file = files[currentIndex];
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
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;
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
// Include path as query parameter since multipart form data doesn't make
|
||||
// form fields available until after file upload completes
|
||||
xhr.open('POST', '/upload?path=' + encodeURIComponent(currentPath), true);
|
||||
|
||||
xhr.upload.onprogress = function(e) {
|
||||
progressFill.style.width = '0%';
|
||||
progressFill.style.backgroundColor = '#4caf50';
|
||||
progressText.textContent = `Uploading ${file.name} (${currentIndex + 1}/${files.length})`;
|
||||
|
||||
xhr.upload.onprogress = function (e) {
|
||||
if (e.lengthComputable) {
|
||||
const percent = Math.round((e.loaded / e.total) * 100);
|
||||
progressFill.style.width = percent + '%';
|
||||
progressText.textContent = 'Uploading: ' + percent + '%';
|
||||
progressText.textContent =
|
||||
`Uploading ${file.name} (${currentIndex + 1}/${files.length}) — ${percent}%`;
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onload = function() {
|
||||
xhr.onload = function () {
|
||||
if (xhr.status === 200) {
|
||||
progressText.textContent = 'Upload complete!';
|
||||
setTimeout(function() {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
currentIndex++;
|
||||
uploadNextFile(); // upload next file
|
||||
} else {
|
||||
progressText.textContent = 'Upload failed: ' + xhr.responseText;
|
||||
progressFill.style.backgroundColor = '#e74c3c';
|
||||
uploadBtn.disabled = false;
|
||||
// Track failure and continue with next file
|
||||
failedFiles.push({ name: file.name, error: xhr.responseText, file: file });
|
||||
currentIndex++;
|
||||
uploadNextFile();
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = function() {
|
||||
progressText.textContent = 'Upload failed - network error';
|
||||
progressFill.style.backgroundColor = '#e74c3c';
|
||||
uploadBtn.disabled = false;
|
||||
xhr.onerror = function () {
|
||||
// Track network error and continue with next file
|
||||
failedFiles.push({ name: file.name, error: 'network error', file: file });
|
||||
currentIndex++;
|
||||
uploadNextFile();
|
||||
};
|
||||
|
||||
xhr.send(formData);
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user