Files
DumbDrop/public/index.html
Greirson Lee-Thorp c24e866074 feat(storage): Implement S3 and local storage adapters with enhanced configuration (#54)
- Introduced a storage adapter factory to dynamically select between local and S3 storage based on the STORAGE_TYPE environment variable.
- Added S3 adapter for handling file operations on AWS S3, including multipart uploads and presigned URLs.
- Implemented local storage adapter for managing file operations on the local filesystem.
- Enhanced configuration validation to ensure proper setup for both storage types.
- Updated .env.example and README.md to document new storage configuration options and usage.

This commit significantly improves the application's flexibility in handling file uploads by supporting both local and cloud storage options, enhancing user experience and deployment versatility.

Fixes #25
2025-05-05 22:17:45 -07:00

581 lines
35 KiB
HTML

<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{SITE_TITLE}} - Simple File Upload</title>
<link rel="stylesheet" href="{{BASE_URL}}styles.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/toastify-js/src/toastify.min.css">
<script src="https://cdn.jsdelivr.net/npm/toastify-js"></script>
<link rel="manifest" href="{{BASE_URL}}manifest.json">
<link rel="icon" type="image/svg+xml" href="{{BASE_URL}}assets/icon.svg">
<script>window.BASE_URL = '{{BASE_URL}}';</script>
</head>
<body>
<div class="container">
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle dark mode">
<svg xmlns="http://www.w3.org/2000/svg" class="theme-toggle-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<!-- Moon icon (shown in light mode) -->
<path class="moon" d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
<!-- Sun icon (shown in dark mode) -->
<circle class="sun" cx="12" cy="12" r="5" style="display:none"/>
<line class="sun" x1="12" y1="1" x2="12" y2="3" style="display:none"/>
<line class="sun" x1="12" y1="21" x2="12" y2="23" style="display:none"/>
<line class="sun" x1="4.22" y1="4.22" x2="5.64" y2="5.64" style="display:none"/>
<line class="sun" x1="18.36" y1="18.36" x2="19.78" y2="19.78" style="display:none"/>
<line class="sun" x1="1" y1="12" x2="3" y2="12" style="display:none"/>
<line class="sun" x1="21" y1="12" x2="23" y2="12" style="display:none"/>
<line class="sun" x1="4.22" y1="19.78" x2="5.64" y2="18.36" style="display:none"/>
<line class="sun" x1="18.36" y1="5.64" x2="19.78" y2="4.22" style="display:none"/>
</svg>
</button>
<h1>{{SITE_TITLE}}</h1>
<div class="upload-container" id="dropZone">
<div class="upload-content">
<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
<p>Drag and drop files or folders here<br>or</p>
<input type="file" id="fileInput" multiple hidden>
<input type="file" id="folderInput" webkitdirectory directory multiple hidden>
<div class="button-group">
<button onclick="document.getElementById('fileInput').click()">Browse Files</button>
<button onclick="document.getElementById('folderInput').click()">Browse Folders</button>
</div>
</div>
</div>
<div id="uploadProgress"></div> <!-- Original progress bar container -->
<div id="fileList" class="file-list"></div> <!-- Original file list container -->
<button id="uploadButton" class="upload-button" style="display: none;">Upload Files</button>
<footer>
{{FOOTER_CONTENT}}
</footer>
</div>
<script defer>
// *** MODIFIED CHUNK_SIZE for S3 compatibility ***
const CHUNK_SIZE = 1024 * 1024 * 5; // 5MB chunks
const RETRY_DELAY = 1000; // 1 second delay between retries
// Read MAX_RETRIES from the injected server value, with a fallback
const MAX_RETRIES_STR = '{{MAX_RETRIES}}';
let maxRetries = 5; // Default value
if (MAX_RETRIES_STR && MAX_RETRIES_STR !== '{{MAX_RETRIES}}') {
const parsedRetries = parseInt(MAX_RETRIES_STR, 10);
if (!isNaN(parsedRetries) && parsedRetries >= 0) {
maxRetries = parsedRetries;
} else {
console.warn(`Invalid MAX_RETRIES value "${MAX_RETRIES_STR}" received from server, defaulting to ${maxRetries}.`);
}
} else {
console.warn('MAX_RETRIES not injected by server, defaulting to 5.');
}
window.MAX_RETRIES = maxRetries;
console.log(`Max retries for chunk uploads: ${window.MAX_RETRIES}`);
const AUTO_UPLOAD_STR = '{{AUTO_UPLOAD}}';
const AUTO_UPLOAD = ['true', '1', 'yes'].includes(AUTO_UPLOAD_STR.toLowerCase());
// Utility function to generate a unique batch ID
function generateBatchId() {
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
// Utility function to format file sizes
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
class FileUploader {
constructor(file, batchId) {
this.file = file;
this.batchId = batchId;
this.uploadId = null; // Application's upload ID
this.position = 0;
this.progressElement = null; // For the separate progress bar
this.chunkSize = CHUNK_SIZE; // Use constant
this.lastUploadedBytes = 0; // Used for rate calculation in original progress bar
this.lastUploadTime = null; // Used for rate calculation
this.uploadRate = 0; // Used for rate calculation
this.maxRetries = window.MAX_RETRIES;
this.retryDelay = RETRY_DELAY;
// *** ADDED partNumber for S3 ***
this.partNumber = 1;
// *** ADDED completed flag ***
this.completed = false;
}
async start() {
try {
this.createProgressElement(); // Create the progress bar UI element
this.updateProgress(0); // Initial progress update to 0%
await this.initUpload();
// Handle zero-byte files completed during init
if (this.uploadId && this.uploadId.startsWith('zero-byte-')) {
console.log(`Zero-byte file ${this.file.name} handled by server during init.`);
this.updateProgress(100);
this.completed = true;
return true;
}
if (this.uploadId && this.file.size > 0) {
await this.uploadChunks();
} else if (this.file.size === 0 && !this.completed) {
console.warn(`File ${this.file.name} is zero bytes, but init didn't indicate completion.`);
this.updateProgress(100);
this.completed = true;
}
// Return completion status
return this.completed;
} catch (error) {
console.error(`Upload failed for ${this.file.webkitRelativePath || this.file.name}:`, error);
if (this.progressElement) {
this.progressElement.infoSpan.textContent = `Error: ${error.message}`;
this.progressElement.infoSpan.style.color = 'var(--danger-color)';
}
await this.cancelUploadOnServer(); // Attempt cancellation
this.completed = false;
return false;
}
}
async initUpload() {
// (initUpload logic is identical to the previous version - uses fetch to /init)
const uploadPath = this.file.webkitRelativePath || this.file.name;
const consistentPath = uploadPath.replace(/\\/g, '/');
console.log(`[Uploader] Initializing upload for: ${consistentPath} (Size: ${this.file.size}, Batch: ${this.batchId})`);
const headers = { 'Content-Type': 'application/json' };
if (this.batchId) headers['X-Batch-ID'] = this.batchId;
const apiUrlPath = '/api/upload/init';
const fullApiUrl = window.BASE_URL + (apiUrlPath.startsWith('/') ? apiUrlPath.substring(1) : apiUrlPath);
const response = await fetch(fullApiUrl, {
method: 'POST',
headers,
body: JSON.stringify({ filename: consistentPath, fileSize: this.file.size })
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: `Server error ${response.status}` }));
throw new Error(errorData.details || errorData.error || `Init failed: ${response.status}`);
}
const data = await response.json();
if (!data.uploadId) throw new Error('Server did not return uploadId');
this.uploadId = data.uploadId;
console.log(`[Uploader] Init successful. App Upload ID: ${this.uploadId}`);
}
async uploadChunks() {
// Create progress element if not already done (might happen if start didn't create it due to early exit/error)
if (!this.progressElement) this.createProgressElement();
while (this.position < this.file.size && !this.completed) { // Check completed flag
const chunkStartPosition = this.position;
const chunk = await this.readChunk(); // Reads based on this.position, updates this.position
const currentPartNumber = this.partNumber; // *** Get current part number ***
try {
console.debug(`[Uploader] Attempting Part ${currentPartNumber}, Bytes ${chunkStartPosition}-${this.position-1}`);
// *** Pass partNumber to upload function ***
const result = await this.uploadChunkWithRetry(chunk, chunkStartPosition, currentPartNumber);
// *** Increment part number AFTER successful upload ***
this.partNumber++;
// *** Check if server response indicates completion ***
if (result.completed) {
console.log(`[Uploader] Server indicated completion after Part ${currentPartNumber}.`);
this.completed = true;
this.updateProgress(100); // Update original progress bar
break; // Exit loop
}
} catch (error) {
console.error(`[Uploader] UploadChunks failed permanently after retries for Part ${this.partNumber}. File: ${this.file.webkitRelativePath || this.file.name}`);
throw error; // Propagate up
}
}
// Check completion after loop, same as before
if (!this.completed && this.position >= this.file.size) {
console.warn(`[Uploader] Reached end of file but not marked completed by server. Assuming complete.`);
this.completed = true;
this.updateProgress(100);
}
}
async readChunk() {
// (readChunk logic is identical)
const start = this.position;
const end = Math.min(this.position + this.chunkSize, this.file.size);
const blob = this.file.slice(start, end);
this.position = end; // Update position *after* slicing
return await blob.arrayBuffer();
}
// *** MODIFIED: Added partNumber parameter ***
async uploadChunkWithRetry(chunk, chunkStartPosition, partNumber) {
// *** MODIFIED: Append partNumber query parameter to URL ***
const chunkApiUrlPath = `/api/upload/chunk/${this.uploadId}?partNumber=${partNumber}`;
const fullChunkApiUrl = window.BASE_URL + (chunkApiUrlPath.startsWith('/') ? chunkApiUrlPath.substring(1) : chunkApiUrlPath);
let lastError = null;
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
try {
if (attempt > 0) {
console.warn(`[Uploader] Retrying Part ${partNumber} upload for ${this.file.webkitRelativePath || this.file.name} (Attempt ${attempt}/${this.maxRetries})...`);
this.updateProgressElementInfo(`Retrying attempt ${attempt}...`, 'var(--warning-color)');
} else {
// Update status text for the current part (optional, depends if you want this level of detail)
// this.updateProgressElementInfo(`uploading part ${partNumber}...`);
}
const controller = new AbortController();
// Increase timeout slightly for S3 potentially
const timeoutId = setTimeout(() => controller.abort(), 60000);
console.debug(`[Uploader] Sending Part ${partNumber} to ${fullChunkApiUrl}`);
const response = await fetch(fullChunkApiUrl, { // Use modified URL
method: 'POST',
headers: {
'Content-Type': 'application/octet-stream',
'X-Batch-ID': this.batchId
},
body: chunk,
signal: controller.signal
});
clearTimeout(timeoutId);
if (response.ok) {
const data = await response.json(); // Contains { bytesReceived, progress, completed }
if (attempt > 0) console.log(`[Uploader] Part ${partNumber} upload successful on retry attempt ${attempt}.`);
else console.debug(`[Uploader] Part ${partNumber} uploaded successfully.`);
// *** Use server-provided progress for original progress bar ***
this.updateProgress(data.progress);
this.updateProgressElementInfo('uploading...'); // Reset info message
// *** Return the data which includes the 'completed' flag ***
return data;
} else {
// (Error handling logic for non-OK responses remains the same)
let errorText = `Server error ${response.status}`; try { errorText = (await response.json()).error || errorText } catch(e){}
if (response.status === 404 && attempt > 0) {
console.warn(`[Uploader] Received 404 on retry, assuming completed.`);
this.completed = true;
this.updateProgress(100); // Update original progress bar
return { completed: true, progress: 100, bytesReceived: this.file.size }; // Simulate success
}
lastError = new Error(`Failed Part ${partNumber}: ${errorText}`);
console.error(`Attempt ${attempt} failed: ${lastError.message}`);
this.updateProgressElementInfo(`Attempt ${attempt} failed: ${response.statusText}`, 'var(--danger-color)');
}
} catch (error) {
// (Network/Abort error handling remains the same)
lastError = error;
if (error.name === 'AbortError') { console.error(`Attempt ${attempt} timed out.`); this.updateProgressElementInfo(`Attempt ${attempt} timed out`, 'var(--danger-color)');}
else { console.error(`Attempt ${attempt} network error: ${error.message}`); this.updateProgressElementInfo(`Attempt ${attempt} network error`, 'var(--danger-color)'); }
}
// (Retry delay logic remains the same)
if (attempt < this.maxRetries) await new Promise(r => setTimeout(r, Math.min(this.retryDelay * Math.pow(2, attempt), 30000)));
} // End retry loop
console.error(`[Uploader] Part ${partNumber} upload failed permanently after ${this.maxRetries} retries.`);
this.updateProgressElementInfo(`Upload failed after ${this.maxRetries} retries`, 'var(--danger-color)');
throw lastError || new Error(`Part ${partNumber} failed after ${this.maxRetries} retries.`);
}
// --- Original Progress Bar UI Methods ---
// (These methods remain identical to the original file content)
createProgressElement() {
if (this.progressElement) return; // Avoid duplicates if called multiple times
const container = document.createElement('div');
container.className = 'progress-container';
container.setAttribute('data-upload-id', this.uploadId || `pending-${this.file.name}`); // Use unique identifier
const label = document.createElement('div');
label.className = 'progress-label';
label.textContent = this.file.webkitRelativePath || this.file.name;
const progress = document.createElement('div');
progress.className = 'progress';
const bar = document.createElement('div');
bar.className = 'progress-bar';
const status = document.createElement('div');
status.className = 'progress-status';
const info = document.createElement('div');
info.className = 'progress-info';
info.textContent = 'initializing...';
const details = document.createElement('div');
details.className = 'progress-details';
details.textContent = `0 Bytes of ${formatFileSize(this.file.size)} (0.0%)`;
status.appendChild(info);
status.appendChild(details);
progress.appendChild(bar);
container.appendChild(label);
container.appendChild(progress);
container.appendChild(status);
document.getElementById('uploadProgress').appendChild(container);
this.progressElement = { container, bar, infoSpan: info, detailsSpan: details };
this.lastUploadTime = Date.now(); // Initialize for rate calculation
this.lastUploadedBytes = 0;
}
updateProgress(percent) {
// Ensure element exists, create if necessary (though start() usually does)
if (!this.progressElement) this.createProgressElement();
if (!this.progressElement) return; // Still couldn't create it? Bail.
const clampedPercent = Math.max(0, Math.min(100, percent));
this.progressElement.bar.style.width = `${clampedPercent}%`;
// Calculate upload rate using server response bytes (as original)
// Note: For S3, data.bytesReceived might not perfectly reflect total uploaded bytes.
// We'll use this.position primarily for display bytes, but rate calculation follows original logic.
const currentTime = Date.now();
const timeDiff = (currentTime - (this.lastUploadTime || currentTime)) / 1000;
// Using this.position for rate might be visually smoother, but let's stick to original logic for now.
// We need the `bytesReceived` from the server response if we want to use it here...
// Let's fallback to using this.position for rate calculation as well, like the progress display.
const bytesDiff = this.position - this.lastUploadedBytes;
if (timeDiff > 0.1 && bytesDiff > 0) {
this.uploadRate = bytesDiff / timeDiff;
this.lastUploadedBytes = this.position;
this.lastUploadTime = currentTime;
} else if (timeDiff > 5) { // Reset rate if stalled
this.uploadRate = 0;
}
// Format rate (same as original)
let rateText = 'Calculating...';
if (this.uploadRate > 0) { /* ... format rate ... */ const units=['B/s','KB/s','MB/s','GB/s']; let i=0, r=this.uploadRate; while(r>=1024 && i<units.length-1){r/=1024;i++;} rateText=`${r.toFixed(1)} ${units[i]}`; }
else if (this.position > 0 || clampedPercent > 0) { rateText = '0.0 B/s'; }
// Update info/details (same as original)
const statusText = clampedPercent >= 100 ? 'complete' : 'uploading...';
if (!this.progressElement.infoSpan.textContent.startsWith('Retry') &&
!this.progressElement.infoSpan.textContent.startsWith('Attempt') &&
!this.progressElement.infoSpan.textContent.startsWith('Error')) {
this.updateProgressElementInfo(`${rateText} · ${statusText}`);
}
// Display progress using this.position (client's view) and clampedPercent
this.progressElement.detailsSpan.textContent =
`${formatFileSize(this.position)} of ${formatFileSize(this.file.size)} (${clampedPercent.toFixed(1)}%)`;
// Fade out (same as original)
if (clampedPercent === 100) {
this.progressElement.container.style.opacity = '0.5';
setTimeout(() => {
if (this.progressElement && this.progressElement.container) {
this.progressElement.container.remove();
this.progressElement = null;
}
}, 2000);
}
}
updateProgressElementInfo(message, color = '') {
// (Identical to original)
if (this.progressElement && this.progressElement.infoSpan) {
this.progressElement.infoSpan.textContent = message;
this.progressElement.infoSpan.style.color = color;
}
}
// --- Cancellation Logic ---
async cancelUploadOnServer() {
// (Identical to original, just ensure checks use this.completed and this.uploadId)
if (!this.uploadId || this.completed || this.uploadId.startsWith('zero-byte-')) return;
console.log(`[Uploader] Attempting server cancel for ${this.uploadId}`);
try {
const cancelApiUrlPath = `/api/upload/cancel/${this.uploadId}`;
const fullUrl = window.BASE_URL + (cancelApiUrlPath.startsWith('/') ? cancelApiUrlPath.substring(1) : cancelApiUrlPath);
fetch(fullUrl, { method: 'POST' }).catch(err => console.warn(`Cancel request failed:`, err));
} catch (e) { console.warn(`Error initiating cancel:`, e); }
}
} // End FileUploader Class
// --- Original UI Handlers and Logic ---
// (All the following code remains identical to the original file)
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('fileInput');
const folderInput = document.getElementById('folderInput');
const fileList = document.getElementById('fileList'); // Refers to the original div#fileList
const uploadButton = document.getElementById('uploadButton');
let filesToUpload = []; // Renamed variable
// --- Drag and Drop Folder Handling (getAllFileEntries) ---
async function getAllFileEntries(dataTransferItems) {
// (Keep original implementation)
console.debug('Starting getAllFileEntries with items:', Array.from(dataTransferItems).map(item => ({ kind: item.kind, type: item.type })));
let fileEntries = []; let rootFolderName = null;
async function traverseEntry(entry, path = '') {
console.debug('Traversing entry:', { name: entry.name, isFile: entry.isFile, isDirectory: entry.isDirectory, currentPath: path });
if (entry.isFile) {
const file = await new Promise((resolve, reject) => entry.file(f => {
const fileWithPath = new File([f], entry.name, { type: f.type, lastModified: f.lastModified });
const fullPath = path ? `${path}/${entry.name}` : entry.name;
Object.defineProperty(fileWithPath, 'webkitRelativePath', { value: fullPath.replace(/\\/g, '/'), writable: false, configurable: true, enumerable: true });
resolve(fileWithPath);
}, reject));
fileEntries.push(file);
} else if (entry.isDirectory) {
if (!path && !rootFolderName) rootFolderName = entry.name;
const dirReader = entry.createReader(); let entries = [];
let readEntries = await new Promise((resolve, reject) => { const readBatch = () => dirReader.readEntries(batch => batch.length > 0 ? (entries=entries.concat(batch), readBatch()) : resolve(entries), reject); readBatch(); });
const dirPath = path ? `${path}/${entry.name}` : entry.name;
for (const childEntry of entries) await traverseEntry(childEntry, dirPath);
}
}
try {
const entryPromises = Array.from(dataTransferItems).map(item => item.webkitGetAsEntry()).filter(Boolean).map(entry => traverseEntry(entry));
await Promise.all(entryPromises);
fileEntries.sort((a, b) => (a.webkitRelativePath || a.name).localeCompare(b.webkitRelativePath || b.name));
console.debug('getAllFileEntries result:', fileEntries.map(f=>f.webkitRelativePath || f.name));
return fileEntries;
} catch (error) { console.error('Error in getAllFileEntries:', error); throw error; }
}
// --- Event Listeners (Original) ---
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(ev => { dropZone.addEventListener(ev, preventDefaults); document.body.addEventListener(ev, preventDefaults); });
['dragenter', 'dragover'].forEach(ev => dropZone.addEventListener(ev, highlight));
['dragleave', 'drop'].forEach(ev => dropZone.addEventListener(ev, unhighlight));
dropZone.addEventListener('drop', handleDrop);
fileInput.addEventListener('change', handleFilesFromInput); // Use renamed handler
folderInput.addEventListener('change', handleFilesFromInput); // Use renamed handler
uploadButton.addEventListener('click', startUploads);
// --- Event Handler Functions (Original) ---
function preventDefaults(e) { e.preventDefault(); e.stopPropagation(); }
function highlight(e) { dropZone.classList.add('highlight'); }
function unhighlight(e) { dropZone.classList.remove('highlight'); }
async function handleDrop(e) {
// Use original logic, just assign to filesToUpload
const items = e.dataTransfer.items;
if (items && items.length > 0 && items[0].webkitGetAsEntry) {
const loadingItem = document.createElement('div'); loadingItem.className = 'file-item loading'; loadingItem.textContent = 'Processing dropped items...'; fileList.innerHTML = ''; fileList.appendChild(loadingItem); uploadButton.style.display = 'none';
try {
const newFiles = await getAllFileEntries(items); if (newFiles.length === 0) throw new Error('No valid files found.');
filesToUpload = newFiles; updateFileList(); if (AUTO_UPLOAD) startUploads(); else uploadButton.style.display = 'block';
} catch (error) { console.error('Error processing dropped items:', error); loadingItem.textContent = `Error: ${error.message}`; loadingItem.style.color = 'var(--danger-color)'; setTimeout(() => loadingItem.remove(), 3000); filesToUpload = []; updateFileList(); }
finally { if (loadingItem.parentNode === fileList) loadingItem.remove(); }
} else {
filesToUpload = [...e.dataTransfer.files].filter(f => f.size >= 0); updateFileList(); if (AUTO_UPLOAD) startUploads(); else if (filesToUpload.length > 0) uploadButton.style.display = 'block';
}
}
function handleFilesFromInput(e) {
// Use original logic, just assign to filesToUpload
const input = e.target; const selectedFiles = [...input.files];
if (input.id === 'folderInput' && selectedFiles.length > 0 && !('webkitRelativePath' in selectedFiles[0])) { alert('Folder upload not fully supported.'); filesToUpload = []; }
else { filesToUpload = selectedFiles.filter(f => f.size >= 0); if (input.id === 'folderInput') console.log('Folder files:', filesToUpload.map(f => ({ name: f.name, path: f.webkitRelativePath }))); }
updateFileList();
if (AUTO_UPLOAD && filesToUpload.length > 0) startUploads();
else if (filesToUpload.length > 0) uploadButton.style.display = 'block'; else uploadButton.style.display = 'none';
input.value = '';
}
// --- File List UI Update (Original Simple List) ---
function updateFileList() {
// Keep the original simpler list rendering
console.debug('Updating original file list UI for', filesToUpload.length, 'files');
fileList.innerHTML = ''; // Clear current list
if (filesToUpload.length === 0) {
fileList.innerHTML = '<div class="file-item placeholder">No files selected.</div>'; // Show placeholder in original div
uploadButton.style.display = 'none';
return;
}
filesToUpload.forEach(file => {
const fileItem = document.createElement('div');
fileItem.className = 'file-item'; // Use original class
const displayName = file.webkitRelativePath || file.name;
fileItem.innerHTML = `📄 ${displayName} (${formatFileSize(file.size)})`;
fileList.appendChild(fileItem);
});
uploadButton.style.display = (!AUTO_UPLOAD && filesToUpload.length > 0) ? 'block' : 'none';
}
// Add original styles for list items (if they were in the script, otherwise they are in styles.css)
const style = document.createElement('style');
style.textContent = `
.file-item { background: var(--container-bg); padding: 10px 15px; border-radius: 5px; text-align: left; box-shadow: 0 2px 4px rgba(0,0,0,0.1); margin-bottom: 10px; }
.file-item.placeholder { text-align: center; opacity: 0.6; box-shadow: none; background: transparent; }
.file-item.loading { text-align: center; padding: 15px; background: var(--container-bg); border-radius: 5px; margin: 10px 0; animation: pulse 1.5s infinite; }
@keyframes pulse { 0% { opacity: 0.6; } 50% { opacity: 1; } 100% { opacity: 0.6; } }
/* Ensure progress bar styles don't conflict if somehow left over */
.progress-container { transition: opacity 0.5s ease-out; }
`;
document.head.appendChild(style);
// --- Upload Process (Original Structure) ---
async function startUploads() {
if (filesToUpload.length === 0) { Toastify({ text: "No files selected.", duration: 3000 }).showToast(); return; }
uploadButton.disabled = true;
uploadButton.textContent = 'Uploading...';
document.getElementById('uploadProgress').innerHTML = ''; // Clear the separate progress bar container
const batchId = generateBatchId();
let successfulUploads = 0;
let failedUploads = 0;
// Process uploads sequentially (same loop as original)
for (const file of filesToUpload) {
const uploader = new FileUploader(file, batchId); // Create uploader instance
try {
const success = await uploader.start(); // Start the upload
if (success) successfulUploads++; else failedUploads++;
} catch (error) {
console.error(`Unhandled error during upload start for ${file.name}:`, error);
failedUploads++;
// Progress bar might show error via uploader's catch block
}
} // End for...of loop
// --- Show Summary Toast (Original logic) ---
const totalFiles = filesToUpload.length;
let toastMessage = `Uploaded ${successfulUploads} of ${totalFiles} files`;
let toastBackground = successfulUploads === totalFiles ? "#4CAF50" : "#f44336";
if (successfulUploads > 0 && failedUploads > 0) toastBackground = "#ff9800"; // Orange if partial success
Toastify({ text: toastMessage, duration: 3000, gravity: "bottom", position: "right", style: { background: toastBackground } }).showToast();
// --- Reset UI State (Original logic) ---
filesToUpload = []; // Clear the list of files
updateFileList(); // Clear the displayed file list
// Progress bars are removed automatically by the uploader on completion/error
uploadButton.disabled = false;
uploadButton.textContent = 'Upload Files';
uploadButton.style.display = 'none';
fileInput.value = '';
folderInput.value = '';
}
// --- Theme Management (Original) ---
function setTheme(theme) { document.documentElement.setAttribute('data-theme', theme); localStorage.setItem('theme', theme); const m=document.querySelectorAll('.moon'); const s=document.querySelectorAll('.sun'); if(theme==='dark'){m.forEach(p=>p.style.display='none');s.forEach(p=>p.style.display='');}else{m.forEach(p=>p.style.display='');s.forEach(p=>p.style.display='none');} }
function toggleTheme() { const c=document.documentElement.getAttribute('data-theme'); setTheme(c==='dark'?'light':'dark'); }
const savedTheme = localStorage.getItem('theme') || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'); setTheme(savedTheme);
// --- Initial Setup ---
updateFileList(); // Initialize the simple file list display
</script>
</body>
</html>