mirror of
https://github.com/daveallie/crosspoint-reader.git
synced 2026-02-04 06:37:38 +03:00
Add support for uploading multiple epubs (#202)
Some checks are pending
CI / build (push) Waiting to run
Some checks are pending
CI / build (push) Waiting to run
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:
parent
5e9626eb2a
commit
062d69dc2a
@ -341,6 +341,90 @@
|
|||||||
width: 60px;
|
width: 60px;
|
||||||
text-align: center;
|
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 modal */
|
||||||
.delete-warning {
|
.delete-warning {
|
||||||
color: #e74c3c;
|
color: #e74c3c;
|
||||||
@ -505,6 +589,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</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="card">
|
||||||
<div class="contents-header">
|
<div class="contents-header">
|
||||||
<h2 class="contents-title">Contents</h2>
|
<h2 class="contents-title">Contents</h2>
|
||||||
@ -531,7 +625,7 @@
|
|||||||
<h3>📤 Upload file</h3>
|
<h3>📤 Upload file</h3>
|
||||||
<div class="upload-form">
|
<div class="upload-form">
|
||||||
<p class="file-info">Select a file to upload to <strong id="uploadPathDisplay"></strong></p>
|
<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>
|
<button id="uploadBtn" class="upload-btn" onclick="uploadFile()" disabled>Upload</button>
|
||||||
<div id="progress-container">
|
<div id="progress-container">
|
||||||
<div id="progress-bar"><div id="progress-fill"></div></div>
|
<div id="progress-bar"><div id="progress-fill"></div></div>
|
||||||
@ -717,22 +811,21 @@
|
|||||||
function validateFile() {
|
function validateFile() {
|
||||||
const fileInput = document.getElementById('fileInput');
|
const fileInput = document.getElementById('fileInput');
|
||||||
const uploadBtn = document.getElementById('uploadBtn');
|
const uploadBtn = document.getElementById('uploadBtn');
|
||||||
const file = fileInput.files[0];
|
const files = fileInput.files;
|
||||||
uploadBtn.disabled = !file;
|
uploadBtn.disabled = !(files.length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function uploadFile() {
|
let failedUploadsGlobal = [];
|
||||||
const fileInput = document.getElementById('fileInput');
|
|
||||||
const file = fileInput.files[0];
|
|
||||||
|
|
||||||
if (!file) {
|
function uploadFile() {
|
||||||
alert('Please select a file first!');
|
const fileInput = document.getElementById('fileInput');
|
||||||
|
const files = Array.from(fileInput.files);
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
alert('Please select at least one file!');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', file);
|
|
||||||
|
|
||||||
const progressContainer = document.getElementById('progress-container');
|
const progressContainer = document.getElementById('progress-container');
|
||||||
const progressFill = document.getElementById('progress-fill');
|
const progressFill = document.getElementById('progress-fill');
|
||||||
const progressText = document.getElementById('progress-text');
|
const progressText = document.getElementById('progress-text');
|
||||||
@ -741,41 +834,160 @@
|
|||||||
progressContainer.style.display = 'block';
|
progressContainer.style.display = 'block';
|
||||||
uploadBtn.disabled = true;
|
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 xhr = new XMLHttpRequest();
|
const xhr = new XMLHttpRequest();
|
||||||
// Include path as query parameter since multipart form data doesn't make
|
// Include path as query parameter since multipart form data doesn't make
|
||||||
// form fields available until after file upload completes
|
// form fields available until after file upload completes
|
||||||
xhr.open('POST', '/upload?path=' + encodeURIComponent(currentPath), true);
|
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) {
|
if (e.lengthComputable) {
|
||||||
const percent = Math.round((e.loaded / e.total) * 100);
|
const percent = Math.round((e.loaded / e.total) * 100);
|
||||||
progressFill.style.width = percent + '%';
|
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) {
|
if (xhr.status === 200) {
|
||||||
progressText.textContent = 'Upload complete!';
|
currentIndex++;
|
||||||
setTimeout(function() {
|
uploadNextFile(); // upload next file
|
||||||
window.location.reload();
|
|
||||||
}, 1000);
|
|
||||||
} else {
|
} else {
|
||||||
progressText.textContent = 'Upload failed: ' + xhr.responseText;
|
// Track failure and continue with next file
|
||||||
progressFill.style.backgroundColor = '#e74c3c';
|
failedFiles.push({ name: file.name, error: xhr.responseText, file: file });
|
||||||
uploadBtn.disabled = false;
|
currentIndex++;
|
||||||
|
uploadNextFile();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
xhr.onerror = function() {
|
xhr.onerror = function () {
|
||||||
progressText.textContent = 'Upload failed - network error';
|
// Track network error and continue with next file
|
||||||
progressFill.style.backgroundColor = '#e74c3c';
|
failedFiles.push({ name: file.name, error: 'network error', file: file });
|
||||||
uploadBtn.disabled = false;
|
currentIndex++;
|
||||||
|
uploadNextFile();
|
||||||
};
|
};
|
||||||
|
|
||||||
xhr.send(formData);
|
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() {
|
function createFolder() {
|
||||||
const folderName = document.getElementById('folderName').value.trim();
|
const folderName = document.getElementById('folderName').value.trim();
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user