Merge pull request #129 from christianlouis/copilot/enable-drag-and-drop-upload

Add drag-and-drop file upload to Files view with shared upload module
This commit is contained in:
Christian Krakau-Louis
2026-02-08 07:16:49 +01:00
committed by GitHub
10 changed files with 648 additions and 195 deletions
+108
View File
@@ -0,0 +1,108 @@
# Changelog
All notable changes to DocuElevate will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [0.3.3] - 2026-02-08
### Added
- **Drag-and-drop file upload on Files page**: You can now drag and drop files anywhere on the Files view (`/files`) to upload them, making it more convenient to add documents without navigating to the dedicated Upload page
- Visual drop overlay that appears when dragging files over the Files page
- Upload progress modal in bottom-right corner showing real-time upload status
- Reusable `upload.js` module extracted from upload page for better code maintainability
- Tests for drag-and-drop functionality presence in Files view
- Enhanced visual feedback with animations and improved styling
### Changed
- Refactored upload functionality into a shared JavaScript module (`/static/js/upload.js`)
- Updated Upload page to use the new shared upload module
- Improved drop zone visual styling with better colors and animations
### Fixed
- N/A
### Security
- Continued security improvements from v0.3.2 (authlib, starlette updates)
## [0.3.2] - 2026-02-06
### Added
- Comprehensive test infrastructure with pytest
- Security scanning with CodeQL and Bandit
- SECURITY_AUDIT.md documentation
- API integration tests
- Configuration validation tests
- Enhanced CI/CD workflows
- Pre-commit hooks configuration
### Changed
- Updated README with improved documentation structure
- Enhanced .gitignore for better security
### Fixed
- Critical security vulnerabilities in authlib (upgraded to 1.6.5+)
- Critical DoS vulnerability in starlette (upgraded to 0.49.1+)
### Security
- Improved SESSION_SECRET validation and handling
- Enhanced security practices documentation
## [0.3.1] - 2026-01-15
### Added
- Files view with sorting and filtering capabilities
- Bulk operations (delete, reprocess) for multiple files
- File detail view with processing history
- Processing flow visualization
### Changed
- Improved UI responsiveness
- Enhanced error handling and user feedback
### Fixed
- Various bug fixes in file processing pipeline
## [0.3.0] - 2025-12-20
### Added
- OAuth2 authentication support with Authentik
- Multi-provider storage support (Dropbox, Google Drive, OneDrive, S3, Nextcloud)
- Azure Document Intelligence integration for OCR
- OpenAI metadata extraction
- Gotenberg PDF conversion service integration
- IMAP integration for email attachment processing
- REST API with FastAPI
- Web UI for document management
- Celery task queue for asynchronous processing
### Changed
- Major architectural improvements
- Database schema optimizations
## [0.2.0] - 2025-11-01
### Added
- Basic document upload functionality
- Simple storage integration
- Basic metadata extraction
## [0.1.0] - 2025-10-01
### Added
- Initial release
- Core document processing framework
- Basic file handling
---
[Unreleased]: https://github.com/christianlouis/DocuElevate/compare/v0.3.3...HEAD
[0.3.3]: https://github.com/christianlouis/DocuElevate/compare/v0.3.2...v0.3.3
[0.3.2]: https://github.com/christianlouis/DocuElevate/compare/v0.3.1...v0.3.2
[0.3.1]: https://github.com/christianlouis/DocuElevate/compare/v0.3.0...v0.3.1
[0.3.0]: https://github.com/christianlouis/DocuElevate/compare/v0.2.0...v0.3.0
[0.2.0]: https://github.com/christianlouis/DocuElevate/compare/v0.1.0...v0.2.0
[0.1.0]: https://github.com/christianlouis/DocuElevate/releases/tag/v0.1.0
+4 -1
View File
@@ -34,13 +34,14 @@ DocuElevate follows [Semantic Versioning 2.0.0](https://semver.org/):
### v0.3.3 - Security & Testing Hardening (February 2026)
**Target Date:** February 15, 2026
**Status:** 🚧 In Progress
**Theme:** Security, Quality, Testing
**Theme:** Security, Quality, Testing, UX Improvements
#### Goals
- [x] Fix critical security vulnerabilities (authlib, starlette)
- [x] Implement comprehensive test suite
- [x] Add security scanning (CodeQL, Bandit)
- [x] Improve CI/CD pipeline
- [x] Add drag-and-drop file upload to Files view
- [ ] Achieve 60% test coverage
- [ ] Add pre-commit hooks
- [ ] Update all dependencies to latest secure versions
@@ -50,6 +51,8 @@ DocuElevate follows [Semantic Versioning 2.0.0](https://semver.org/):
- [x] pytest configuration and fixtures
- [x] API integration tests
- [x] Configuration validation tests
- [x] Drag-and-drop upload functionality in Files view
- [x] Reusable upload.js module for code DRYness
- [ ] Task processing tests
- [ ] Storage provider integration tests
- [x] Updated CI/CD workflows
+11 -7
View File
@@ -79,20 +79,24 @@ Users can choose to send documents to any combination of these destinations thro
## Features
- **Intuitive File Upload**:
- Drag-and-drop file upload on both Upload and Files pages—upload anywhere on the Files page
- Real-time upload progress with validation
- Support for PDF, Office documents, images, and more (up to 500MB per file)
- **Document Upload & Storage**:
- Manual uploads (via API or UI) to Dropbox, Nextcloud, Google Drive, or Paperless.
- Manual uploads (via API or UI) to Dropbox, Nextcloud, Google Drive, or Paperless
- **OCR Processing (Azure)**:
- Extract text from scanned PDFs using Azure Document Intelligence.
- Extract text from scanned PDFs using Azure Document Intelligence
- **Metadata Extraction (OpenAI)**:
- Use GPT to classify, label, or otherwise enrich the text with structured metadata.
- Use GPT to classify, label, or otherwise enrich the text with structured metadata
- **PDF Conversion (Gotenberg)**:
- Convert non-PDF attachments (e.g., Word docs, images) into PDFs.
- Convert non-PDF attachments (e.g., Word docs, images) into PDFs
- **Document Management (Paperless NGX)**:
- Store processed documents and metadata in a Paperless NGX instance.
- Store processed documents and metadata in a Paperless NGX instance
- **IMAP Integration**:
- Fetch documents from multiple mailboxes (including Gmail) and automatically enqueue them for processing.
- Fetch documents from multiple mailboxes (including Gmail) and automatically enqueue them for processing
- **Authentication**:
- Secure access to the system using **Authentik** for OAuth2-based login.
- Secure access to the system using **Authentik** for OAuth2-based login
## Frameworks Used
+6 -1
View File
@@ -111,7 +111,7 @@ This document tracks actionable tasks for the current development cycle. For lon
- [ ] Add dark mode
- [ ] Add loading spinners for async operations
- [ ] Improve error messages for users
- [ ] Add drag-and-drop file upload
- [x] Add drag-and-drop file upload (completed 2026-02-08)
- [ ] Add file type icons
- [ ] Implement toast notifications
- [ ] Add keyboard shortcuts
@@ -211,6 +211,11 @@ This document tracks actionable tasks for the current development cycle. For lon
## ✅ Completed (Recent)
### 2026-02-08
- [x] Added drag-and-drop file upload to Files view
- [x] Extracted reusable upload.js module for code reuse
- [x] Enhanced UX with visual drop overlay and upload progress modal
### 2026-02-06
- [x] Created comprehensive test infrastructure
- [x] Fixed critical security vulnerabilities
+1 -1
View File
@@ -1 +1 @@
0.1.0-test
0.3.3
+27 -5
View File
@@ -43,13 +43,35 @@ DocuElevate features a simple navigation system with the following main sections
## Uploading Documents
### Web Upload
DocuElevate provides multiple convenient ways to upload documents to the system.
### Web Upload (Upload Page)
1. Navigate to the **Upload** page
2. Drag and drop files onto the upload area, or click to browse your files
3. Select files to upload (supported formats include PDF, Word documents, images, etc.)
4. Click "Upload" to begin the process
5. Your documents will be processed automatically according to the system configuration
2. **Drag and drop** files onto the upload area, or **click** the upload area to browse your files
3. Select files to upload (supported formats include PDF, Word documents, Excel spreadsheets, PowerPoint presentations, images, and more)
4. The system will automatically validate and upload your files
5. You'll see real-time progress for each file being uploaded
6. Your documents will be processed automatically according to the system configuration
#### Supported File Types
- **Documents**: PDF, Word (.doc, .docx), Excel (.xls, .xlsx), PowerPoint (.ppt, .pptx)
- **Images**: JPEG, PNG, GIF, BMP, TIFF, WebP, SVG
- **Text**: Plain text (.txt), CSV, RTF, HTML, XML, Markdown
- **Maximum file size**: 500MB per file
### Drag-and-Drop on Files Page
For even more convenience, you can upload files directly from the **Files** page:
1. Navigate to the **Files** page where you view your processed documents
2. **Drag files from your computer** and drop them **anywhere** on the page
3. A visual overlay will appear to confirm you're in drag mode
4. Release the files to begin uploading
5. An upload progress modal will appear in the bottom-right corner
6. The page will automatically refresh to show your newly uploaded files once complete
This feature allows you to quickly add new files without navigating away from your document management view.
### Email Attachments
+264
View File
@@ -0,0 +1,264 @@
// frontend/static/js/upload.js
// Reusable drag-and-drop upload functionality for DocuElevate
// Configuration
const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500MB
// Allowed file types
const ACCEPTED_TYPES = {
// PDF files
'application/pdf': true,
// Image formats
'image/jpeg': true, 'image/jpg': true, 'image/png': true,
'image/gif': true, 'image/bmp': true, 'image/tiff': true,
'image/webp': true, 'image/svg+xml': true,
// Office document formats - Word
'application/msword': true,
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': true,
'application/vnd.openxmlformats-officedocument.wordprocessingml.template': true,
'application/vnd.ms-word.document.macroEnabled.12': true,
// Excel
'application/vnd.ms-excel': true,
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': true,
'application/vnd.openxmlformats-officedocument.spreadsheetml.template': true,
'application/vnd.ms-excel.sheet.macroEnabled.12': true,
// PowerPoint
'application/vnd.ms-powerpoint': true,
'application/vnd.openxmlformats-officedocument.presentationml.presentation': true,
'application/vnd.openxmlformats-officedocument.presentationml.template': true,
'application/vnd.openxmlformats-officedocument.presentationml.slideshow': true,
// Other common formats
'text/plain': true,
'text/csv': true,
'application/rtf': true,
'text/rtf': true,
'text/html': true,
'application/xml': true,
'text/xml': true
};
// File extensions that are always allowed (even if mime type is not recognized)
const ACCEPTED_EXTENSIONS = [
'.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
'.odt', '.ods', '.odp', '.rtf', '.txt', '.csv',
'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.webp', '.svg', '.md'
];
/**
* Process a list of files for upload
* @param {FileList} files - Files to process
* @param {HTMLElement} progressContainer - Container element for progress display
* @param {HTMLElement} statusMessage - Element for status message display
*/
function processFiles(files, progressContainer, statusMessage) {
if (files.length === 0) return;
if (statusMessage) {
statusMessage.textContent = `Processing ${files.length} file(s)...`;
}
// Clear previous upload progress
if (progressContainer) {
progressContainer.innerHTML = "";
}
// Process each file
for (let i = 0; i < files.length; i++) {
const file = files[i];
validateAndUpload(file, progressContainer, statusMessage);
}
}
/**
* Validate and upload a single file
* @param {File} file - File to validate and upload
* @param {HTMLElement} progressContainer - Container element for progress display
* @param {HTMLElement} statusMessage - Element for status message display
*/
function validateAndUpload(file, progressContainer, statusMessage) {
// Create progress element for this file
const fileProgress = document.createElement("div");
fileProgress.className = "flex flex-col mb-2";
fileProgress.innerHTML = `
<div class="flex justify-between">
<span class="text-sm truncate" title="${file.name}">${file.name}</span>
<span class="text-xs text-gray-500">${formatFileSize(file.size)}</span>
</div>
<div class="w-full bg-gray-200 h-2 rounded-full mt-1">
<div class="file-progress-bar bg-blue-500 h-2 rounded-full" style="width: 0%"></div>
</div>
<div class="file-status text-xs text-gray-600 mt-1">Validating...</div>
`;
if (progressContainer) {
progressContainer.appendChild(fileProgress);
}
const progressBar = fileProgress.querySelector(".file-progress-bar");
const statusEl = fileProgress.querySelector(".file-status");
// Validate file type by checking both MIME type and extension
const isValidMimeType = ACCEPTED_TYPES[file.type] || false;
const fileExtension = '.' + file.name.split('.').pop().toLowerCase();
const isValidExtension = ACCEPTED_EXTENSIONS.includes(fileExtension);
if (!isValidMimeType && !isValidExtension) {
statusEl.textContent = `Error: ${file.name} - Unsupported file type`;
statusEl.className = "text-xs text-red-500 mt-1";
return;
}
// Validate file size
if (file.size > MAX_FILE_SIZE) {
statusEl.textContent = `Error: ${file.name} - File size exceeds 500MB limit`;
statusEl.className = "text-xs text-red-500 mt-1";
return;
}
// Upload the file
uploadFile(file, progressBar, statusEl, statusMessage);
}
/**
* Upload a file to the server
* @param {File} file - File to upload
* @param {HTMLElement} progressBar - Progress bar element
* @param {HTMLElement} statusEl - Status element
* @param {HTMLElement} statusMessage - Overall status message element
*/
async function uploadFile(file, progressBar, statusEl, statusMessage) {
statusEl.textContent = `Uploading...`;
try {
let formData = new FormData();
formData.append("file", file);
const xhr = new XMLHttpRequest();
xhr.open("POST", "/api/ui-upload", true);
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
const percentComplete = (e.loaded / e.total) * 100;
progressBar.style.width = percentComplete + "%";
statusEl.textContent = `Uploading: ${Math.round(percentComplete)}%`;
}
};
xhr.onload = function() {
if (xhr.status === 200) {
const result = JSON.parse(xhr.responseText);
progressBar.style.width = "100%";
progressBar.className = "file-progress-bar bg-green-500 h-2 rounded-full";
statusEl.textContent = `Success: Task ID: ${result.task_id}`;
statusEl.className = "text-xs text-green-600 mt-1";
updateOverallStatus(statusMessage);
} else {
throw new Error(`Upload failed with status ${xhr.status}`);
}
};
xhr.onerror = function() {
throw new Error("Network error occurred");
};
xhr.send(formData);
} catch (err) {
statusEl.textContent = `Error: ${err.message}`;
statusEl.className = "text-xs text-red-500 mt-1";
progressBar.className = "file-progress-bar bg-red-500 h-2 rounded-full";
updateOverallStatus(statusMessage);
}
}
/**
* Update the overall status message based on file statuses
* @param {HTMLElement} statusMessage - Status message element
*/
function updateOverallStatus(statusMessage) {
if (!statusMessage) return;
// Count success/failure
const fileStatuses = document.querySelectorAll('.file-status');
let completed = 0;
let total = fileStatuses.length;
fileStatuses.forEach(status => {
if (status.textContent.includes('Success') || status.textContent.includes('Error')) {
completed++;
}
});
if (completed === total) {
statusMessage.textContent = `All uploads completed (${completed}/${total})`;
} else {
statusMessage.textContent = `Uploading files (${completed}/${total})`;
}
}
/**
* Format file size for display
* @param {number} bytes - File size in bytes
* @returns {string} Formatted file size
*/
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];
}
/**
* Initialize drag-and-drop on an element
* @param {HTMLElement} element - Element to enable drag-and-drop on
* @param {HTMLElement} progressContainer - Container for progress display
* @param {HTMLElement} statusMessage - Element for status messages
* @param {Object} options - Additional options
*/
function initDragAndDrop(element, progressContainer, statusMessage, options = {}) {
if (!element) {
console.error("Element not found for drag-and-drop initialization");
return;
}
// Add event listeners for drag-and-drop
element.addEventListener("dragover", (e) => {
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = "copy";
// Add visual feedback
if (options.dragOverClass) {
element.classList.add(options.dragOverClass);
}
});
element.addEventListener("dragleave", (e) => {
e.preventDefault();
e.stopPropagation();
// Remove visual feedback
if (options.dragOverClass) {
element.classList.remove(options.dragOverClass);
}
});
element.addEventListener("drop", (e) => {
e.preventDefault();
e.stopPropagation();
// Remove visual feedback
if (options.dragOverClass) {
element.classList.remove(options.dragOverClass);
}
if (e.dataTransfer.files.length) {
processFiles(e.dataTransfer.files, progressContainer, statusMessage);
}
});
}
+186
View File
@@ -3,7 +3,100 @@
{% block head_extra %}
<script src="/static/js/common.js"></script>
<script src="/static/js/upload.js"></script>
<style>
/* Drop overlay styles - covers entire page for drag-and-drop anywhere */
.drop-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: rgba(59, 130, 246, 0.15);
border: 6px dashed #3b82f6;
z-index: 999;
pointer-events: none;
justify-content: center;
align-items: center;
}
.drop-overlay.active {
display: flex;
animation: fadeIn 0.2s ease-in;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.drop-message {
background-color: white;
padding: 2rem 3rem;
border-radius: 0.5rem;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
text-align: center;
pointer-events: none;
}
.drop-message i {
font-size: 4rem;
color: #3b82f6;
margin-bottom: 1rem;
display: block;
}
.drop-message p {
font-size: 1.5rem;
font-weight: 600;
color: #1f2937;
margin-bottom: 0.5rem;
}
.drop-message .drop-hint {
font-size: 1rem;
color: #6b7280;
font-weight: 400;
margin-top: 0.5rem;
}
/* Upload progress modal */
.upload-modal {
display: none;
position: fixed;
bottom: 1rem;
right: 1rem;
width: 400px;
max-width: 90vw;
background-color: white;
border-radius: 0.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
z-index: 1000;
overflow: hidden;
}
.upload-modal.active {
display: block;
}
.upload-modal-header {
background-color: #3b82f6;
color: white;
padding: 0.75rem 1rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.upload-modal-body {
padding: 1rem;
max-height: 400px;
overflow-y: auto;
}
.close-modal-btn {
background: none;
border: none;
color: white;
cursor: pointer;
font-size: 1.25rem;
}
.close-modal-btn:hover {
opacity: 0.8;
}
<style>
.file-table {
width: 100%;
border-collapse: collapse;
@@ -241,6 +334,29 @@
{% endblock %}
{% block content %}
<!-- Drop overlay -->
<div id="dropOverlay" class="drop-overlay">
<div class="drop-message">
<i class="fas fa-cloud-upload-alt"></i>
<p>Drop files anywhere to upload</p>
<div class="drop-hint">Supports PDF, Office docs, images, and more</div>
</div>
</div>
<!-- Upload progress modal -->
<div id="uploadModal" class="upload-modal">
<div class="upload-modal-header">
<span><i class="fas fa-upload"></i> Uploading Files</span>
<button class="close-modal-btn" onclick="closeUploadModal()">
<i class="fas fa-times"></i>
</button>
</div>
<div class="upload-modal-body">
<div id="uploadStatusMessage" class="text-sm text-gray-700 mb-2"></div>
<div id="uploadProgressContainer" class="space-y-2"></div>
</div>
</div>
<div class="container mx-auto px-4 py-8">
<h2 class="text-3xl font-bold mb-6">File Records</h2>
@@ -673,6 +789,76 @@
alert(`Error reprocessing files: ${error.message}`);
});
}
// ===== Drag-and-Drop Upload Functionality =====
const dropOverlay = document.getElementById('dropOverlay');
const uploadModal = document.getElementById('uploadModal');
const uploadStatusMessage = document.getElementById('uploadStatusMessage');
const uploadProgressContainer = document.getElementById('uploadProgressContainer');
let dragCounter = 0; // Track nested drag events
// Show overlay when dragging files over the window
window.addEventListener('dragenter', (e) => {
e.preventDefault();
dragCounter++;
// Only show overlay if dragging files
if (e.dataTransfer.types.includes('Files')) {
dropOverlay.classList.add('active');
}
});
window.addEventListener('dragleave', (e) => {
e.preventDefault();
dragCounter--;
if (dragCounter === 0) {
dropOverlay.classList.remove('active');
}
});
window.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
});
window.addEventListener('drop', (e) => {
e.preventDefault();
dragCounter = 0;
dropOverlay.classList.remove('active');
if (e.dataTransfer.files.length > 0) {
// Show upload modal
uploadModal.classList.add('active');
uploadProgressContainer.innerHTML = '';
// Process the dropped files
processFiles(e.dataTransfer.files, uploadProgressContainer, uploadStatusMessage);
// Optionally reload page after uploads complete (with a delay)
setTimeout(() => {
const fileStatuses = document.querySelectorAll('.file-status');
let allCompleted = true;
fileStatuses.forEach(status => {
if (!status.textContent.includes('Success') && !status.textContent.includes('Error')) {
allCompleted = false;
}
});
if (allCompleted && fileStatuses.length > 0) {
// Refresh the page after a short delay to show the new files
setTimeout(() => {
window.location.reload();
}, 2000);
}
}, 1000);
}
});
function closeUploadModal() {
uploadModal.classList.remove('active');
}
</script>
</div>
{% endblock %}
+19 -180
View File
@@ -37,55 +37,8 @@
{% endblock %}
{% block scripts %}
<script src="/static/js/upload.js"></script>
<script>
// Configuration
const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500MB
// Allowed file types
const ACCEPTED_TYPES = {
// PDF files
'application/pdf': true,
// Image formats
'image/jpeg': true, 'image/jpg': true, 'image/png': true,
'image/gif': true, 'image/bmp': true, 'image/tiff': true,
'image/webp': true, 'image/svg+xml': true,
// Office document formats - Word
'application/msword': true,
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': true,
'application/vnd.openxmlformats-officedocument.wordprocessingml.template': true,
'application/vnd.ms-word.document.macroEnabled.12': true,
// Excel
'application/vnd.ms-excel': true,
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': true,
'application/vnd.openxmlformats-officedocument.spreadsheetml.template': true,
'application/vnd.ms-excel.sheet.macroEnabled.12': true,
// PowerPoint
'application/vnd.ms-powerpoint': true,
'application/vnd.openxmlformats-officedocument.presentationml.presentation': true,
'application/vnd.openxmlformats-officedocument.presentationml.template': true,
'application/vnd.openxmlformats-officedocument.presentationml.slideshow': true,
// Other common formats
'text/plain': true,
'text/csv': true,
'application/rtf': true,
'text/rtf': true,
'text/html': true,
'application/xml': true,
'text/xml': true
};
// File extensions that are always allowed (even if mime type is not recognized)
const ACCEPTED_EXTENSIONS = [
'.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
'.odt', '.ods', '.odp', '.rtf', '.txt', '.csv',
'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.webp', '.svg', '.md'
];
// Drag-and-drop / file input logic
const dropZone = document.getElementById("dropZone");
const fileInput = document.getElementById("fileInput");
@@ -109,146 +62,32 @@
e.preventDefault();
dropZone.classList.remove("bg-gray-100");
if (e.dataTransfer.files.length) {
processFiles(e.dataTransfer.files);
// Clear previous upload progress
uploadProgress.innerHTML = "";
// Create progress container
const progressContainer = document.createElement("div");
progressContainer.className = "space-y-2";
uploadProgress.appendChild(progressContainer);
// Use the shared processFiles function
processFiles(e.dataTransfer.files, progressContainer, statusMessage);
}
}
function handleFileSelect(e) {
if (e.target.files.length) {
processFiles(e.target.files);
}
}
function processFiles(files) {
if (files.length === 0) return;
statusMessage.textContent = `Processing ${files.length} file(s)...`;
// Clear previous upload progress
uploadProgress.innerHTML = "";
// Create progress container
const progressContainer = document.createElement("div");
progressContainer.className = "space-y-2";
uploadProgress.appendChild(progressContainer);
// Process each file
for (let i = 0; i < files.length; i++) {
const file = files[i];
validateAndUpload(file, progressContainer);
}
}
function validateAndUpload(file, progressContainer) {
// Create progress element for this file
const fileProgress = document.createElement("div");
fileProgress.className = "flex flex-col mb-2";
fileProgress.innerHTML = `
<div class="flex justify-between">
<span class="text-sm truncate" title="${file.name}">${file.name}</span>
<span class="text-xs text-gray-500">${formatFileSize(file.size)}</span>
</div>
<div class="w-full bg-gray-200 h-2 rounded-full mt-1">
<div class="file-progress-bar bg-blue-500 h-2 rounded-full" style="width: 0%"></div>
</div>
<div class="file-status text-xs text-gray-600 mt-1">Validating...</div>
`;
progressContainer.appendChild(fileProgress);
const progressBar = fileProgress.querySelector(".file-progress-bar");
const statusEl = fileProgress.querySelector(".file-status");
// Validate file type by checking both MIME type and extension
const isValidMimeType = ACCEPTED_TYPES[file.type] || false;
const fileExtension = '.' + file.name.split('.').pop().toLowerCase();
const isValidExtension = ACCEPTED_EXTENSIONS.includes(fileExtension);
if (!isValidMimeType && !isValidExtension) {
statusEl.textContent = `Error: ${file.name} - Unsupported file type`;
statusEl.className = "text-xs text-red-500 mt-1";
return;
}
// Validate file size
if (file.size > MAX_FILE_SIZE) {
statusEl.textContent = `Error: ${file.name} - File size exceeds 500MB limit`;
statusEl.className = "text-xs text-red-500 mt-1";
return;
}
// Upload the file
uploadFile(file, progressBar, statusEl);
}
async function uploadFile(file, progressBar, statusEl) {
statusEl.textContent = `Uploading...`;
try {
let formData = new FormData();
formData.append("file", file);
// Clear previous upload progress
uploadProgress.innerHTML = "";
const xhr = new XMLHttpRequest();
xhr.open("POST", "/api/ui-upload", true);
// Create progress container
const progressContainer = document.createElement("div");
progressContainer.className = "space-y-2";
uploadProgress.appendChild(progressContainer);
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
const percentComplete = (e.loaded / e.total) * 100;
progressBar.style.width = percentComplete + "%";
statusEl.textContent = `Uploading: ${Math.round(percentComplete)}%`;
}
};
xhr.onload = function() {
if (xhr.status === 200) {
const result = JSON.parse(xhr.responseText);
progressBar.style.width = "100%";
progressBar.className = "file-progress-bar bg-green-500 h-2 rounded-full";
statusEl.textContent = `Success: Task ID: ${result.task_id}`;
statusEl.className = "text-xs text-green-600 mt-1";
updateOverallStatus();
} else {
throw new Error(`Upload failed with status ${xhr.status}`);
}
};
xhr.onerror = function() {
throw new Error("Network error occurred");
};
xhr.send(formData);
} catch (err) {
statusEl.textContent = `Error: ${err.message}`;
statusEl.className = "text-xs text-red-500 mt-1";
progressBar.className = "file-progress-bar bg-red-500 h-2 rounded-full";
updateOverallStatus();
// Use the shared processFiles function
processFiles(e.target.files, progressContainer, statusMessage);
}
}
function updateOverallStatus() {
// Count success/failure
const fileStatuses = document.querySelectorAll('.file-status');
let completed = 0;
let total = fileStatuses.length;
fileStatuses.forEach(status => {
if (status.textContent.includes('Success') || status.textContent.includes('Error')) {
completed++;
}
});
if (completed === total) {
statusMessage.textContent = `All uploads completed (${completed}/${total})`;
} else {
statusMessage.textContent = `Uploading files (${completed}/${total})`;
}
}
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];
}
</script>
{% endblock %}
+22
View File
@@ -68,3 +68,25 @@ class TestFilesView:
# The key test: ensure the min/max functions work in the template
# (they're used for pagination on lines 397 and 406 of files.html)
_assert_no_template_errors(content)
def test_files_view_includes_drag_drop_elements(self, client: TestClient, db_session):
"""Test that the /files view includes drag-and-drop upload elements."""
# Access the /files view
response = client.get("/files")
assert response.status_code == 200
content = response.text
# Check that drag-and-drop elements are present
assert "dropOverlay" in content, "Drop overlay element should be present"
assert "uploadModal" in content, "Upload modal element should be present"
assert "drop-overlay" in content, "Drop overlay CSS class should be present"
assert "upload-modal" in content, "Upload modal CSS class should be present"
# Check that the upload.js script is included
assert "/static/js/upload.js" in content, "upload.js script should be included"
# Check for drag-and-drop event handlers
assert "dragenter" in content or "drag" in content, "Drag event handlers should be present"
assert "Drop files anywhere to upload" in content, "Drop message should be present"