This commit is contained in:
pika 2025-03-22 12:30:45 +01:00
commit acb3c7642a
23 changed files with 3940 additions and 0 deletions

1455
app/static/css/custom.css Normal file

File diff suppressed because it is too large Load diff

302
app/static/css/upload.css Normal file
View file

@ -0,0 +1,302 @@
/* Upload specific styles */
.upload-container {
max-width: 800px;
margin: 0 auto;
background: var(--card-bg);
border-radius: var(--border-radius-md);
box-shadow: var(--shadow-md);
padding: 1.5rem;
}
.upload-tabs {
display: flex;
border-bottom: 1px solid var(--border-color);
margin-bottom: 1.5rem;
}
.tab-btn {
background: none;
border: none;
padding: 0.75rem 1.5rem;
font-size: 1rem;
font-weight: var(--font-weight-medium);
color: var(--body-color);
cursor: pointer;
border-bottom: 2px solid transparent;
transition: var(--transition-base);
}
.tab-btn.active {
color: var(--link-color);
border-bottom-color: var(--link-color);
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
.upload-location {
padding: 0.75rem;
background-color: var(--body-bg);
border-radius: var(--border-radius);
margin-bottom: 1.5rem;
}
.upload-dropzone {
border: 2px dashed var(--border-color);
border-radius: var(--border-radius);
padding: 2rem;
text-align: center;
margin-bottom: 1.5rem;
transition: all 0.2s;
cursor: pointer;
}
.upload-dropzone.highlight {
border-color: var(--link-color);
background-color: rgba(59, 130, 246, 0.05);
}
.upload-icon {
font-size: 3rem;
color: var(--secondary);
margin-bottom: 1rem;
}
.upload-progress-container {
background-color: var(--body-bg);
border-radius: var(--border-radius);
padding: 1rem;
margin-bottom: 1.5rem;
}
.upload-progress-container h4 {
margin: 0 0 1rem 0;
font-size: 1rem;
color: var(--body-color);
}
.progress-overall {
margin-bottom: 1rem;
}
.progress-label {
display: flex;
justify-content: space-between;
margin-bottom: 0.5rem;
font-size: 0.875rem;
color: var(--body-color);
}
.progress-bar-container {
height: 8px;
background-color: var(--border-color);
border-radius: var(--border-radius-full);
overflow: hidden;
}
.progress-bar {
height: 100%;
background-color: var(--primary-color);
width: 0%;
transition: width 0.3s;
}
.upload-stats {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 1rem;
margin-top: 0.75rem;
font-size: 0.75rem;
}
.stat {
display: flex;
flex-direction: column;
min-width: 80px;
}
.stat-label {
color: var(--secondary);
margin-bottom: 0.25rem;
}
.stat-value {
font-weight: var(--font-weight-medium);
}
.selected-files {
background-color: var(--body-bg);
border-radius: var(--border-radius);
padding: 1rem;
margin-bottom: 1.5rem;
}
.selected-files h4 {
margin: 0 0 1rem 0;
font-size: 1rem;
color: var(--body-color);
}
.file-list {
max-height: 300px;
overflow-y: auto;
}
.file-item {
display: flex;
align-items: center;
padding: 0.75rem;
margin-bottom: 0.5rem;
background-color: var(--card-bg);
border-radius: var(--border-radius);
box-shadow: var(--shadow-sm);
transition: all 0.2s;
}
.file-item.success {
background-color: rgba(16, 185, 129, 0.1);
}
.file-item.error {
background-color: rgba(239, 68, 68, 0.1);
}
.file-item-icon {
margin-right: 1rem;
font-size: 1.25rem;
color: var(--secondary);
}
.file-item.success .file-item-icon {
color: var(--success);
}
.file-item.error .file-item-icon {
color: var(--danger);
}
.file-item-details {
flex: 1;
}
.file-item-name {
font-size: 0.9rem;
font-weight: var(--font-weight-medium);
color: var(--body-color);
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-item-size {
font-size: 0.75rem;
color: var(--secondary);
margin: 0;
}
.file-item-status {
margin-left: 1rem;
}
.status-indicator {
width: 12px;
height: 12px;
border-radius: 50%;
}
.status-indicator.waiting {
background-color: var(--secondary);
animation: pulse 1.5s infinite;
}
.status-indicator.uploading {
background-color: var(--info);
animation: pulse 1.5s infinite;
}
.status-indicator.success {
background-color: var(--success);
}
.status-indicator.error {
background-color: var(--danger);
}
@keyframes pulse {
0% {
opacity: 0.3;
}
50% {
opacity: 1;
}
100% {
opacity: 0.3;
}
}
.empty-message {
color: var(--secondary);
text-align: center;
padding: 1rem;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 1rem;
}
.alerts {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 1000;
max-width: 400px;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.alert {
padding: 1rem;
border-radius: var(--border-radius);
background-color: var(--card-bg);
box-shadow: var(--shadow-md);
display: flex;
justify-content: space-between;
align-items: flex-start;
animation: alertIn 0.5s forwards;
}
@keyframes alertIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes alertOut {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(100%);
opacity: 0;
}
}

470
app/static/js/upload.js Normal file
View file

@ -0,0 +1,470 @@
document.addEventListener('DOMContentLoaded', function () {
// File Upload JavaScript
const fileForm = document.getElementById('file-upload-form');
const folderForm = document.getElementById('folder-upload-form');
const fileInput = document.getElementById('file-input');
const folderInput = document.getElementById('folder-input');
const fileDropzone = document.getElementById('file-dropzone');
const folderDropzone = document.getElementById('folder-dropzone');
const fileList = document.getElementById('file-list');
const folderList = document.getElementById('folder-file-list');
// Progress elements
const progressBar = document.getElementById('progress-bar');
const progressPercentage = document.getElementById('progress-percentage');
const folderProgressBar = document.getElementById('folder-progress-bar');
const folderProgressPercentage = document.getElementById('folder-progress-percentage');
const uploadSpeed = document.getElementById('upload-speed');
const folderUploadSpeed = document.getElementById('folder-upload-speed');
const uploadedSize = document.getElementById('uploaded-size');
const folderUploadedSize = document.getElementById('folder-uploaded-size');
const timeRemaining = document.getElementById('time-remaining');
const folderTimeRemaining = document.getElementById('folder-time-remaining');
// Variables for tracking upload progress
let uploadStartTime = 0;
let lastUploadedBytes = 0;
let totalBytes = 0;
let uploadedBytes = 0;
let uploadIntervalId = null;
// Initialize upload forms
if (fileInput) {
fileInput.addEventListener('change', function () {
if (this.files.length > 0) {
prepareAndUploadFiles(this.files, false);
}
});
}
if (folderInput) {
folderInput.addEventListener('change', function () {
if (this.files.length > 0) {
prepareAndUploadFiles(this.files, true);
}
});
}
// Drag and drop setup
if (fileDropzone) {
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(event => {
fileDropzone.addEventListener(event, preventDefaults, false);
});
['dragenter', 'dragover'].forEach(event => {
fileDropzone.addEventListener(event, function () {
this.classList.add('highlight');
}, false);
});
['dragleave', 'drop'].forEach(event => {
fileDropzone.addEventListener(event, function () {
this.classList.remove('highlight');
}, false);
});
fileDropzone.addEventListener('drop', function (e) {
if (e.dataTransfer.files.length > 0) {
prepareAndUploadFiles(e.dataTransfer.files, false);
}
}, false);
}
if (folderDropzone) {
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(event => {
folderDropzone.addEventListener(event, preventDefaults, false);
});
['dragenter', 'dragover'].forEach(event => {
folderDropzone.addEventListener(event, function () {
this.classList.add('highlight');
}, false);
});
['dragleave', 'drop'].forEach(event => {
folderDropzone.addEventListener(event, function () {
this.classList.remove('highlight');
}, false);
});
folderDropzone.addEventListener('drop', function (e) {
// Check if items contains directories
let hasFolder = false;
if (e.dataTransfer.items) {
for (let i = 0; i < e.dataTransfer.items.length; i++) {
const item = e.dataTransfer.items[i].webkitGetAsEntry &&
e.dataTransfer.items[i].webkitGetAsEntry();
if (item && item.isDirectory) {
hasFolder = true;
break;
}
}
}
if (hasFolder) {
showMessage('Folder detected, but browser API limitations prevent direct processing. Please use the Select Folder button.', 'info');
} else if (e.dataTransfer.files.length > 0) {
showMessage('These appear to be files, not a folder. Using the Files tab instead.', 'info');
// Switch to files tab and upload there
document.querySelector('[data-tab="file-tab"]').click();
setTimeout(() => {
prepareAndUploadFiles(e.dataTransfer.files, false);
}, 300);
}
}, false);
}
// Tab switching
const tabBtns = document.querySelectorAll('.tab-btn');
const tabContents = document.querySelectorAll('.tab-content');
tabBtns.forEach(btn => {
btn.addEventListener('click', function () {
const tabId = this.dataset.tab;
// Remove active class from all tabs and contents
tabBtns.forEach(b => b.classList.remove('active'));
tabContents.forEach(c => c.classList.remove('active'));
// Add active class to current tab and content
this.classList.add('active');
document.getElementById(tabId).classList.add('active');
});
});
// Helper functions
function preventDefaults(e) {
e.preventDefault();
e.stopPropagation();
}
function prepareAndUploadFiles(files, isFolder) {
// Reset upload tracking
uploadStartTime = Date.now();
lastUploadedBytes = 0;
totalBytes = 0;
uploadedBytes = 0;
// Calculate total size
for (let i = 0; i < files.length; i++) {
totalBytes += files[i].size;
}
// Display files
const targetList = isFolder ? folderList : fileList;
displayFiles(files, targetList);
// Start upload
uploadFiles(files, isFolder);
// Start progress tracking
if (uploadIntervalId) {
clearInterval(uploadIntervalId);
}
uploadIntervalId = setInterval(updateProgress, 1000);
}
function displayFiles(files, targetList) {
targetList.innerHTML = '';
if (files.length === 0) {
targetList.innerHTML = '<p class="empty-message">No files selected</p>';
return;
}
for (let i = 0; i < files.length; i++) {
const file = files[i];
const item = document.createElement('div');
item.className = 'file-item';
item.id = `file-item-${i}`;
// Get relative path for folder uploads
let displayName = file.name;
if (file.webkitRelativePath && file.webkitRelativePath !== '') {
displayName = file.webkitRelativePath;
}
item.innerHTML = `
<div class="file-item-icon">
<i class="fas ${getFileIcon(file.name)}"></i>
</div>
<div class="file-item-details">
<p class="file-item-name" title="${displayName}">${displayName}</p>
<p class="file-item-size">${formatSize(file.size)}</p>
</div>
<div class="file-item-status">
<div class="status-indicator waiting" id="status-${i}"></div>
</div>
`;
targetList.appendChild(item);
}
}
function uploadFiles(files, isFolder) {
const formData = new FormData();
const folderId = isFolder ?
document.querySelector('#folder-upload-form input[name="folder_id"]').value :
document.querySelector('#file-upload-form input[name="folder_id"]').value;
formData.append('folder_id', folderId);
formData.append('is_folder', isFolder ? '1' : '0');
// Add files to form data
for (let i = 0; i < files.length; i++) {
formData.append('files[]', files[i]);
// If it's a folder upload, also include the path
if (isFolder && files[i].webkitRelativePath) {
formData.append('paths[]', files[i].webkitRelativePath);
} else {
formData.append('paths[]', '');
}
// Update file status to uploading
const statusIndicator = document.getElementById(`status-${i}`);
if (statusIndicator) {
statusIndicator.className = 'status-indicator uploading';
}
}
// Create and configure XHR request
const xhr = new XMLHttpRequest();
xhr.open('POST', '/files/upload_xhr', true);
// Set up progress event
xhr.upload.onprogress = function (e) {
if (e.lengthComputable) {
uploadedBytes = e.loaded;
const percent = Math.round((e.loaded / e.total) * 100);
if (isFolder) {
folderProgressBar.style.width = `${percent}%`;
folderProgressPercentage.textContent = `${percent}%`;
} else {
progressBar.style.width = `${percent}%`;
progressPercentage.textContent = `${percent}%`;
}
}
};
// Set up completion and error handlers
xhr.onload = function () {
if (xhr.status === 200) {
try {
const response = JSON.parse(xhr.responseText);
if (response.success) {
showMessage(`Successfully uploaded ${response.successful} files.`, 'success');
// Update all file statuses to success
for (let i = 0; i < files.length; i++) {
const statusIndicator = document.getElementById(`status-${i}`);
if (statusIndicator) {
statusIndicator.className = 'status-indicator success';
}
}
// Mark specific failures if any
if (response.errors && response.errors.length > 0) {
for (let i = 0; i < response.errors.length; i++) {
// Try to find the file by name
const errorFileName = response.errors[i].split(':')[0];
for (let j = 0; j < files.length; j++) {
if (files[j].name === errorFileName) {
const statusIndicator = document.getElementById(`status-${j}`);
if (statusIndicator) {
statusIndicator.className = 'status-indicator error';
}
break;
}
}
}
// Show error messages
showMessage(`Failed to upload some files. See errors for details.`, 'warning');
response.errors.forEach(err => showMessage(err, 'error'));
}
} else {
showMessage(response.error || 'Upload failed', 'error');
// Update all file statuses to error
for (let i = 0; i < files.length; i++) {
const statusIndicator = document.getElementById(`status-${i}`);
if (statusIndicator) {
statusIndicator.className = 'status-indicator error';
}
}
}
} catch (e) {
showMessage('Error parsing server response', 'error');
}
} else {
showMessage(`Upload failed with status ${xhr.status}`, 'error');
// Update all file statuses to error
for (let i = 0; i < files.length; i++) {
const statusIndicator = document.getElementById(`status-${i}`);
if (statusIndicator) {
statusIndicator.className = 'status-indicator error';
}
}
}
// Stop progress updates
if (uploadIntervalId) {
clearInterval(uploadIntervalId);
uploadIntervalId = null;
}
};
xhr.onerror = function () {
showMessage('Network error during upload', 'error');
// Update all file statuses to error
for (let i = 0; i < files.length; i++) {
const statusIndicator = document.getElementById(`status-${i}`);
if (statusIndicator) {
statusIndicator.className = 'status-indicator error';
}
}
// Stop progress updates
if (uploadIntervalId) {
clearInterval(uploadIntervalId);
uploadIntervalId = null;
}
};
// Send the request
xhr.send(formData);
}
function updateProgress() {
const currentTime = Date.now();
const elapsedSeconds = (currentTime - uploadStartTime) / 1000;
// Calculate upload speed (bytes per second)
const bytesPerSecond = elapsedSeconds > 0 ? uploadedBytes / elapsedSeconds : 0;
// Calculate remaining time
const remainingBytes = totalBytes - uploadedBytes;
let remainingTime = 'calculating...';
if (bytesPerSecond > 0 && remainingBytes > 0) {
const remainingSeconds = remainingBytes / bytesPerSecond;
if (remainingSeconds < 60) {
remainingTime = `${Math.round(remainingSeconds)} seconds`;
} else if (remainingSeconds < 3600) {
remainingTime = `${Math.round(remainingSeconds / 60)} minutes`;
} else {
remainingTime = `${Math.round(remainingSeconds / 3600)} hours`;
}
}
// Update DOM elements
const speed = formatSize(bytesPerSecond) + '/s';
const progress = `${formatSize(uploadedBytes)} / ${formatSize(totalBytes)}`;
// Update regular upload view
if (uploadSpeed) uploadSpeed.textContent = speed;
if (uploadedSize) uploadedSize.textContent = progress;
if (timeRemaining) timeRemaining.textContent = remainingTime;
// Update folder upload view
if (folderUploadSpeed) folderUploadSpeed.textContent = speed;
if (folderUploadedSize) folderUploadedSize.textContent = progress;
if (folderTimeRemaining) folderTimeRemaining.textContent = remainingTime;
}
// Helper Functions
function formatSize(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];
}
function getFileIcon(fileName) {
if (!fileName) return 'fa-file';
const extension = fileName.split('.').pop().toLowerCase();
if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'svg', 'webp'].includes(extension)) {
return 'fa-file-image';
} else if (['mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv'].includes(extension)) {
return 'fa-file-video';
} else if (['mp3', 'wav', 'ogg', 'flac', 'm4a'].includes(extension)) {
return 'fa-file-audio';
} else if (['doc', 'docx'].includes(extension)) {
return 'fa-file-word';
} else if (['xls', 'xlsx'].includes(extension)) {
return 'fa-file-excel';
} else if (['ppt', 'pptx'].includes(extension)) {
return 'fa-file-powerpoint';
} else if (['pdf'].includes(extension)) {
return 'fa-file-pdf';
} else if (['zip', 'rar', '7z', 'tar', 'gz'].includes(extension)) {
return 'fa-file-archive';
} else if (['txt', 'rtf', 'md'].includes(extension)) {
return 'fa-file-alt';
} else if (['html', 'css', 'js', 'php', 'py', 'java', 'c', 'cpp', 'h', 'json', 'xml'].includes(extension)) {
return 'fa-file-code';
}
return 'fa-file';
}
function showMessage(message, type) {
// Create alerts container if it doesn't exist
let alertsContainer = document.querySelector('.alerts');
if (!alertsContainer) {
alertsContainer = document.createElement('div');
alertsContainer.className = 'alerts';
document.body.appendChild(alertsContainer);
}
// Create alert
const alert = document.createElement('div');
alert.className = `alert ${type}`;
alert.innerHTML = `${message} <button class="close">&times;</button>`;
// Add to container
alertsContainer.appendChild(alert);
// Set up close button
const closeBtn = alert.querySelector('.close');
closeBtn.addEventListener('click', function () {
alert.style.animation = 'alertOut 0.5s forwards';
setTimeout(() => alert.remove(), 500);
});
// Auto close after 5 seconds
setTimeout(function () {
if (alert.parentNode) {
alert.style.animation = 'alertOut 0.5s forwards';
setTimeout(() => alert.remove(), 500);
}
}, 5000);
}
function createAlertSection() {
const alertSection = document.createElement('div');
alertSection.className = 'alerts';
document.body.appendChild(alertSection);
return alertSection;
}
function setupAlertDismiss(alert) {
const closeBtn = alert.querySelector('.close');
if (closeBtn) {
closeBtn.addEventListener('click', function () {
alert.style.animation = 'alertOut 0.5s forwards';
setTimeout(() => alert.remove(), 500);
});
}
}
});