Files
DumbDrop/public/index.html
Greirson Lee-Thorp e963f2bcde feat: Improve dev experience, Improve Environmental Variable and Folder Control, resolves BASE_URL junk (#49)
* feat: Add ALLOWED_IFRAME_ORIGINS configuration and update security headers (#47)

- Introduced ALLOWED_IFRAME_ORIGINS environment variable to specify trusted origins for iframe embedding.
- Updated security headers middleware to conditionally allow specified origins in Content Security Policy.
- Enhanced documentation in README.md to explain the new configuration and its security implications.

Fixes #35

* feat: Update .env.example and .gitignore for improved configuration management

- Enhanced .env.example with detailed comments for environment variables, including upload settings, security options, and notification configurations.
- Updated .gitignore to include additional editor and OS-specific files, ensuring a cleaner repository.
- Modified package.json to add a predev script for Node.js version validation and adjusted the dev script for nodemon.
- Improved server.js shutdown handling to prevent multiple shutdowns and ensure graceful exits.
- Refactored config/index.js to log loaded environment variables and ensure the upload directory exists based on environment settings.
- Cleaned up fileUtils.js by removing unused functions and improving logging for directory creation.

This commit enhances clarity and maintainability of configuration settings and improves application shutdown behavior.

* feat: Update Docker configuration and documentation for upload handling

- Explicitly set the upload directory environment variable in docker-compose.yml to ensure clarity in file storage.
- Simplified the Dockerfile by removing the creation of the local_uploads directory, as it is now managed by the host system.
- Enhanced README.md to reflect changes in upload directory management and provide clearer instructions for users.
- Removed outdated development configuration files to streamline the development setup.

This commit improves the clarity and usability of the Docker setup for file uploads.

* feat: Add Local Development Guide and update README for clarity

- Introduced a comprehensive LOCAL_DEVELOPMENT.md file with setup instructions, testing guidelines, and troubleshooting tips for local development.
- Updated README.md to include a link to the new Local Development Guide and revised sections for clarity regarding upload directory management.
- Enhanced the Quick Start section to direct users to the dedicated local development documentation.

This commit improves the onboarding experience for developers and provides clear instructions for local setup.

* feat: Implement BASE_URL configuration for asset management and API requests

- Added BASE_URL configuration to README.md, emphasizing the need for a trailing slash when deploying under a subpath.
- Updated index.html and login.html to utilize BASE_URL for linking stylesheets, icons, and API requests, ensuring correct asset loading.
- Enhanced app.js to replace placeholders with the actual BASE_URL during HTML rendering.
- Implemented a validation check in config/index.js to ensure BASE_URL is a valid URL and ends with a trailing slash.

This commit improves the flexibility of the application for different deployment scenarios and enhances asset management.

Fixes #34, Fixes #39, Fixes #38

* Update app.js, borked some of the css n such

* resolved BASE_URL breaking frontend

* fix: Update BASE_URL handling and security headers

- Ensured BASE_URL has a trailing slash in app.js to prevent asset loading issues.
- Refactored index.html and login.html to remove leading slashes from API paths for correct concatenation with BASE_URL.
- Enhanced security headers middleware to include 'connect-src' directive in Content Security Policy.

This commit addresses issues with asset management and improves security configurations.
2025-05-04 10:29:48 -07:00

847 lines
36 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>
<div id="fileList" class="file-list"></div>
<button id="uploadButton" class="upload-button" style="display: none;">Upload Files</button>
</div>
<script defer>
const CHUNK_SIZE = 1024 * 1024; // 1MB chunks
const MAX_RETRIES = 3;
const RETRY_DELAY = 1000;
const AUTO_UPLOAD = ['true', '1', 'yes'].includes('{{AUTO_UPLOAD}}'.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;
this.position = 0;
this.progressElement = null;
this.chunkSize = 1024 * 1024; // 1MB chunks
this.lastUploadedBytes = 0;
this.lastUploadTime = null;
this.uploadRate = 0;
}
async start() {
try {
await this.initUpload();
await this.uploadChunks();
return true;
} catch (error) {
console.error('Upload failed:', error);
if (this.progressElement) {
this.progressElement.infoSpan.textContent = `Error: ${error.message}`;
this.progressElement.infoSpan.style.color = 'var(--danger-color)';
}
return false;
}
}
async initUpload() {
// Always use webkitRelativePath if available, otherwise fallback to name
const uploadPath = this.file.webkitRelativePath || this.file.name;
console.log('Initializing upload:', {
path: uploadPath,
size: this.file.size,
batchId: this.batchId
});
const headers = {
'Content-Type': 'application/json'
};
if (this.batchId) {
headers['X-Batch-ID'] = this.batchId;
}
// Remove leading slash from API path before concatenating
const apiUrl = '/api/upload/init'.startsWith('/') ? '/api/upload/init'.substring(1) : '/api/upload/init';
const response = await fetch(window.BASE_URL + apiUrl, {
method: 'POST',
headers,
body: JSON.stringify({
filename: uploadPath.replace(/\\/g, '/'), // Ensure forward slashes
fileSize: this.file.size
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.details || error.error || 'Upload initialization failed');
}
const data = await response.json();
this.uploadId = data.uploadId;
}
async uploadChunks() {
this.createProgressElement();
while (this.position < this.file.size) {
const chunk = await this.readChunk();
await this.uploadChunk(chunk);
}
}
async readChunk() {
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;
return await blob.arrayBuffer();
}
async uploadChunk(chunk) {
// Remove leading slash from API path before concatenating
const chunkApiUrlPath = `/api/upload/chunk/${this.uploadId}`;
const chunkApiUrl = chunkApiUrlPath.startsWith('/') ? chunkApiUrlPath.substring(1) : chunkApiUrlPath;
const response = await fetch(window.BASE_URL + chunkApiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/octet-stream',
'X-Batch-ID': this.batchId
},
body: chunk
});
if (!response.ok) {
throw new Error(`Failed to upload chunk: ${response.statusText}`);
}
const data = await response.json();
this.updateProgress(data.progress);
}
createProgressElement() {
const container = document.createElement('div');
container.className = 'progress-container';
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';
const details = document.createElement('div');
details.className = 'progress-details';
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();
}
updateProgress(percent) {
if (this.progressElement) {
this.progressElement.bar.style.width = `${percent}%`;
// Calculate upload rate
const currentTime = Date.now();
const timeDiff = (currentTime - this.lastUploadTime) / 1000; // Convert to seconds
const bytesDiff = this.position - this.lastUploadedBytes;
if (timeDiff > 0) {
this.uploadRate = bytesDiff / timeDiff; // bytes per second
}
// Format upload rate
let rateText = '0.0 B/s';
if (this.uploadRate > 0) {
const units = ['B/s', 'KB/s', 'MB/s', 'GB/s'];
let unitIndex = 0;
let rate = this.uploadRate;
while (rate >= 1024 && unitIndex < units.length - 1) {
rate /= 1024;
unitIndex++;
}
rateText = `${rate.toFixed(1)} ${units[unitIndex]}`;
}
// Update progress info
this.progressElement.infoSpan.textContent = `${rateText} · ${percent < 100 ? 'uploading...' : 'complete'}`;
this.progressElement.detailsSpan.textContent =
`${formatFileSize(this.position)} of ${formatFileSize(this.file.size)} (${percent.toFixed(1)}%)`;
// Update tracking variables
this.lastUploadedBytes = this.position;
this.lastUploadTime = currentTime;
if (percent === 100) {
setTimeout(() => {
this.progressElement.container.remove();
}, 1000);
}
}
}
}
// UI Event Handlers
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('fileInput');
const folderInput = document.getElementById('folderInput');
const fileList = document.getElementById('fileList');
const uploadButton = document.getElementById('uploadButton');
let files = [];
// For drag and drop folders
async function getAllFileEntries(dataTransferItems) {
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(file => {
console.debug('Processing file in traverseEntry:', {
name: file.name,
size: file.size,
type: file.type,
currentPath: path,
rootFolderName: rootFolderName
});
// If this is the first file and we don't have a root folder name yet,
// use its parent folder name
if (!rootFolderName && path) {
rootFolderName = path.split('/')[0];
console.debug('Set root folder name from file path:', rootFolderName);
}
const fullPath = path ? `${path}/${entry.name}` : entry.name;
console.debug('Constructed full path:', {
path: path,
entryName: entry.name,
fullPath: fullPath
});
const fileWithPath = new File([file], entry.name, {
type: file.type,
lastModified: file.lastModified
});
if (rootFolderName) {
const relativePath = fullPath.startsWith(rootFolderName) ?
fullPath :
`${rootFolderName}/${fullPath}`;
// Use Object.defineProperty to ensure webkitRelativePath is properly set
Object.defineProperty(fileWithPath, 'webkitRelativePath', {
value: relativePath,
writable: false,
configurable: true
});
console.debug('Set webkitRelativePath with root folder:', {
fullPath: fullPath,
rootFolderName: rootFolderName,
relativePath: relativePath,
hasWebkitRelativePath: 'webkitRelativePath' in fileWithPath,
webkitRelativePath: fileWithPath.webkitRelativePath
});
} else {
Object.defineProperty(fileWithPath, 'webkitRelativePath', {
value: fullPath,
writable: false,
configurable: true
});
console.debug('Set webkitRelativePath without root folder:', {
fullPath: fullPath,
hasWebkitRelativePath: 'webkitRelativePath' in fileWithPath,
webkitRelativePath: fileWithPath.webkitRelativePath
});
}
resolve(fileWithPath);
}, reject);
});
fileEntries.push(file);
} else if (entry.isDirectory) {
console.debug('Processing directory:', {
name: entry.name,
currentPath: path,
isRootLevel: !path,
currentRootFolderName: rootFolderName
});
if (!path && !rootFolderName) {
rootFolderName = entry.name;
console.debug('Set root folder name from directory:', rootFolderName);
}
const dirReader = entry.createReader();
let entries = [];
let readEntries = await new Promise((resolve, reject) => {
const readNextBatch = () => {
dirReader.readEntries(batch => {
console.debug('Read directory batch:', {
directoryName: entry.name,
batchSize: batch.length,
entries: batch.map(e => ({
name: e.name,
isFile: e.isFile,
isDirectory: e.isDirectory
}))
});
if (batch.length > 0) {
entries = entries.concat(batch);
readNextBatch();
} else {
resolve(entries);
}
}, reject);
};
readNextBatch();
});
const dirPath = path ? `${path}/${entry.name}` : entry.name;
console.debug('Processing directory contents:', {
directoryName: entry.name,
currentPath: path,
newDirPath: dirPath,
totalEntries: entries.length
});
for (const childEntry of entries) {
await traverseEntry(childEntry, dirPath);
}
}
}
try {
for (const item of dataTransferItems) {
const entry = item.webkitGetAsEntry();
if (entry) {
console.debug('Processing root level item:', {
name: entry.name,
isFile: entry.isFile,
isDirectory: entry.isDirectory
});
await traverseEntry(entry);
}
}
console.debug('Final file entries before sorting:', fileEntries.map(f => ({
name: f.name,
path: f.webkitRelativePath,
size: f.size,
hasWebkitRelativePath: 'webkitRelativePath' in f,
webkitRelativePath: f.webkitRelativePath
})));
fileEntries.sort((a, b) => a.webkitRelativePath.localeCompare(b.webkitRelativePath));
console.debug('Final sorted file entries:', fileEntries.map(f => ({
name: f.name,
path: f.webkitRelativePath,
size: f.size,
hasWebkitRelativePath: 'webkitRelativePath' in f,
webkitRelativePath: f.webkitRelativePath
})));
return fileEntries;
} catch (error) {
console.error('Error in getAllFileEntries:', error);
throw error;
}
}
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
dropZone.addEventListener(eventName, preventDefaults, false);
document.body.addEventListener(eventName, preventDefaults, false);
});
['dragenter', 'dragover'].forEach(eventName => {
dropZone.addEventListener(eventName, highlight, false);
});
['dragleave', 'drop'].forEach(eventName => {
dropZone.addEventListener(eventName, unhighlight, false);
});
dropZone.addEventListener('drop', handleDrop, false);
fileInput.addEventListener('change', handleFiles, false);
folderInput.addEventListener('change', handleFolders, false);
uploadButton.addEventListener('click', startUploads);
function preventDefaults(e) {
e.preventDefault();
e.stopPropagation();
}
function highlight(e) {
dropZone.classList.add('highlight');
}
function unhighlight(e) {
dropZone.classList.remove('highlight');
}
function handleDrop(e) {
const items = e.dataTransfer.items;
if (items && items[0].webkitGetAsEntry) {
// Show loading state
const loadingItem = document.createElement('div');
loadingItem.className = 'file-item loading';
loadingItem.textContent = 'Processing dropped items...';
fileList.appendChild(loadingItem);
getAllFileEntries(items).then(newFiles => {
// Remove loading state
loadingItem.remove();
if (newFiles.length === 0) {
console.warn('No valid files found in drop');
return;
}
files = newFiles;
updateFileList();
if (AUTO_UPLOAD) startUploads();
}).catch(error => {
console.error('Error processing dropped items:', error);
loadingItem.textContent = `Error: ${error.message}`;
loadingItem.style.color = 'var(--danger-color)';
setTimeout(() => loadingItem.remove(), 3000);
});
} else {
// Handle regular files (not folders)
files = [...e.dataTransfer.files];
updateFileList();
if (AUTO_UPLOAD) startUploads();
}
}
function handleFiles(e) {
// Reset the input to allow selecting the same file again
const input = e.target;
files = [...input.files];
updateFileList();
if (AUTO_UPLOAD) {
startUploads().finally(() => {
// Reset the input value after upload
input.value = '';
});
}
}
function handleFolders(e) {
// Reset the input to allow selecting the same folder again
const input = e.target;
files = [...input.files];
// Check for webkitRelativePath support
const missingRelPath = files.some(f => !('webkitRelativePath' in f) || !f.webkitRelativePath);
if (missingRelPath) {
alert('Your browser does not support folder uploads with structure. Please use a modern browser like Chrome or Edge.');
files = [];
updateFileList();
input.value = '';
return;
}
console.log('Folder selection files:', files.map(f => ({
name: f.name,
path: f.webkitRelativePath,
size: f.size
})));
updateFileList();
if (AUTO_UPLOAD) {
startUploads().finally(() => {
// Reset the input value after upload
input.value = '';
});
}
}
function updateFileList() {
console.debug('Starting updateFileList with files:', files.map(f => ({
name: f.name,
path: f.webkitRelativePath,
size: f.size,
hasWebkitRelativePath: 'webkitRelativePath' in f,
webkitRelativePath: f.webkitRelativePath
})));
fileList.innerHTML = '';
const folders = new Map();
let rootFiles = [];
// First, determine if we have a root folder structure
const hasRootFolder = files.some(file => {
const hasPath = 'webkitRelativePath' in file && file.webkitRelativePath;
console.debug('Checking file for root folder:', {
name: file.name,
hasWebkitRelativePath: 'webkitRelativePath' in file,
webkitRelativePath: file.webkitRelativePath,
hasPath: hasPath
});
return hasPath && file.webkitRelativePath.includes('/');
});
console.debug('Folder structure detection:', {
hasRootFolder,
totalFiles: files.length
});
files.forEach(file => {
const path = file.webkitRelativePath || file.name;
console.debug('Processing file in updateFileList:', {
name: file.name,
path: path,
size: file.size,
hasWebkitRelativePath: 'webkitRelativePath' in file
});
if (hasRootFolder) {
const parts = path.split('/');
console.debug('Path parts:', {
path: path,
parts: parts,
length: parts.length
});
if (parts.length > 1) {
const folderName = parts[0];
console.debug('Processing folder:', {
folderName: folderName,
pathParts: parts,
fileName: file.name
});
if (!folders.has(folderName)) {
folders.set(folderName, {
name: folderName,
files: [],
size: 0,
subfolders: new Map()
});
console.debug('Created new folder:', folderName);
}
const folder = folders.get(folderName);
folder.files.push(file);
folder.size += file.size;
if (parts.length > 2) {
console.debug('Processing subfolder structure:', {
folderName: folderName,
subfolderPath: parts.slice(1, -1),
fileName: parts[parts.length - 1]
});
let currentFolder = folder;
const subfolderPath = parts.slice(1, -1);
for (const subfolder of subfolderPath) {
if (!currentFolder.subfolders.has(subfolder)) {
currentFolder.subfolders.set(subfolder, {
name: subfolder,
files: [],
size: 0,
subfolders: new Map()
});
console.debug('Created new subfolder:', {
parentFolder: currentFolder.name,
subfolder: subfolder
});
}
currentFolder = currentFolder.subfolders.get(subfolder);
currentFolder.files.push(file);
currentFolder.size += file.size;
}
}
} else {
console.debug('Adding root file:', file.name);
rootFiles.push(file);
}
} else {
console.debug('Adding file as root file:', file.name);
rootFiles.push(file);
}
});
console.debug('Final structure:', {
rootFiles: rootFiles.map(f => f.name),
folders: Array.from(folders.entries()).map(([name, folder]) => ({
name,
fileCount: folder.files.length,
size: folder.size,
subfolders: Array.from(folder.subfolders.keys())
}))
});
// Function to render a folder's contents
function renderFolder(folder, level = 0) {
const folderItem = document.createElement('div');
folderItem.className = 'file-item folder';
// Calculate total files including subfolders
const totalFiles = folder.files.filter(f => {
const relativePath = f.webkitRelativePath.substring(folder.name.length + 1);
return relativePath.split('/').length === 1;
}).length;
folderItem.innerHTML = `📁 ${folder.name}/ (${formatFileSize(folder.size)} - ${totalFiles} files)`;
// Add files in folder
const filesList = document.createElement('div');
filesList.className = 'folder-files';
filesList.style.marginLeft = `${level * 20}px`;
// First add direct files
folder.files
.filter(f => f.webkitRelativePath.substring(folder.name.length + 1).split('/').length === 1)
.sort((a, b) => a.name.localeCompare(b.name))
.forEach(file => {
const fileItem = document.createElement('div');
fileItem.className = 'file-item nested';
const relativePath = file.webkitRelativePath.substring(folder.name.length + 1);
fileItem.innerHTML = `📄 ${relativePath} (${formatFileSize(file.size)})`;
filesList.appendChild(fileItem);
});
// Then add subfolders recursively
if (folder.subfolders.size > 0) {
Array.from(folder.subfolders.values())
.sort((a, b) => a.name.localeCompare(b.name))
.forEach(subfolder => {
filesList.appendChild(renderFolder(subfolder, level + 1));
});
}
folderItem.appendChild(filesList);
return folderItem;
}
// Add root files first
rootFiles.sort((a, b) => a.name.localeCompare(b.name))
.forEach(file => {
const fileItem = document.createElement('div');
fileItem.className = 'file-item';
fileItem.innerHTML = `📄 ${file.name} (${formatFileSize(file.size)})`;
fileList.appendChild(fileItem);
});
// Add folders
Array.from(folders.values())
.sort((a, b) => a.name.localeCompare(b.name))
.forEach(folder => {
fileList.appendChild(renderFolder(folder));
});
uploadButton.style.display = (!AUTO_UPLOAD && files.length > 0) ? 'block' : 'none';
}
// Add this CSS to the existing styles
const style = document.createElement('style');
style.textContent = `
.folder-files {
margin-left: 20px;
border-left: 1px solid var(--border-color);
padding-left: 10px;
margin-top: 5px;
}
.file-item.nested {
font-size: 0.9em;
margin-top: 3px;
}
.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; }
}
`;
document.head.appendChild(style);
async function startUploads() {
try {
uploadButton.disabled = true;
document.getElementById('uploadProgress').innerHTML = '';
const batchId = generateBatchId();
const results = [];
// Process files sequentially within the same batch to prevent overwhelming the server
for (const file of files) {
const uploader = new FileUploader(file, batchId);
const result = await uploader.start();
results.push(result);
}
const successful = results.filter(r => r).length;
const total = results.length;
Toastify({
text: `Uploaded ${successful} of ${total} files`,
duration: 3000,
gravity: "bottom",
position: "right",
style: {
background: successful === total ? "#4CAF50" : "#f44336"
}
}).showToast();
// Reset file inputs
fileInput.value = '';
folderInput.value = '';
files = [];
updateFileList();
} catch (error) {
console.error('Upload failed:', error);
Toastify({
text: `Upload failed: ${error.message}`,
duration: 3000,
gravity: "bottom",
position: "right",
style: {
background: "#f44336"
}
}).showToast();
} finally {
uploadButton.disabled = false;
}
}
// Theme management
function setTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
// Update icon
const moonPaths = document.querySelectorAll('.moon');
const sunPaths = document.querySelectorAll('.sun');
if (theme === 'dark') {
moonPaths.forEach(path => path.style.display = 'none');
sunPaths.forEach(path => path.style.display = '');
} else {
moonPaths.forEach(path => path.style.display = '');
sunPaths.forEach(path => path.style.display = 'none');
}
}
function toggleTheme() {
const current = document.documentElement.getAttribute('data-theme');
const next = current === 'dark' ? 'light' : 'dark';
setTheme(next);
}
// Initialize theme
const savedTheme = localStorage.getItem('theme') ||
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
setTheme(savedTheme);
document.addEventListener('DOMContentLoaded', function() {
// Rewrite asset URLs to use BASE_URL as prefix if not absolute
const baseUrl = window.BASE_URL;
document.querySelectorAll('link[rel="stylesheet"], link[rel="manifest"], link[rel="icon"]').forEach(link => {
const href = link.getAttribute('href');
if (href && !href.startsWith('http') && !href.startsWith('data:') && !href.startsWith(baseUrl)) {
link.setAttribute('href', baseUrl + href.replace(/^\//, ''));
}
});
});
</script>
</body>
</html>