Fix multipart upload failure; Improve upload UX;

This commit is contained in:
2026-01-03 17:27:46 +08:00
parent c78f7fa6b0
commit 2d60e36fbf
6 changed files with 1313 additions and 178 deletions

View File

@@ -403,7 +403,7 @@
</button>
<button type="button" class="btn btn-outline-secondary preset-btn {% if preset_choice == 'public' %}active{% endif %}" data-preset="public">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
<path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm7.5-6.923c-.67.204-1.335.82-1.887 1.855A7.97 7.97 0 0 0 5.145 4H7.5V1.077zM4.09 4a9.267 9.267 0 0 1 .64-1.539 6.7 6.7 0 0 1 .597-.933A7.025 7.025 0 0 0 2.255 4H4.09zm-.582 3.5c.03-.877.138-1.718.312-2.5H1.674a6.958 6.958 0 0 0-.656 2.5h2.49zM4.847 5a12.5 12.5 0 0 0-.338 2.5H7.5V5H4.847zM8.5 5v2.5h2.99a12.495 12.495 0 0 0-.337-2.5H8.5zM4.51 8.5a12.5 12.5 0 0 0 .337 2.5H7.5V8.5H4.51zm3.99 0V11h2.653c.187-.765.306-1.608.338-2.5H8.5z"/>
<path d="M15 14s1 0 1-1-1-4-5-4-5 3-5 4 1 1 1 1h8Zm-7.978-1A.261.261 0 0 1 7 12.996c.001-.264.167-1.03.76-1.72C8.312 10.629 9.282 10 11 10c1.717 0 2.687.63 3.24 1.276.593.69.758 1.457.76 1.72l-.008.002a.274.274 0 0 1-.014.002H7.022ZM11 7a2 2 0 1 0 0-4 2 2 0 0 0 0 4Zm3-2a3 3 0 1 1-6 0 3 3 0 0 1 6 0ZM6.936 9.28a5.88 5.88 0 0 0-1.23-.247A7.35 7.35 0 0 0 5 9c-4 0-5 3-5 4 0 .667.333 1 1 1h4.216A2.238 2.238 0 0 1 5 13c0-1.01.377-2.042 1.09-2.904.243-.294.526-.569.846-.816ZM4.92 10A5.493 5.493 0 0 0 4 13H1c0-.26.164-1.03.76-1.724.545-.636 1.492-1.256 3.16-1.275ZM1.5 5.5a3 3 0 1 1 6 0 3 3 0 0 1-6 0Zm3-2a2 2 0 1 0 0 4 2 2 0 0 0 0-4Z"/>
</svg>
Public Read
</button>
@@ -628,27 +628,25 @@
{% endif %}
{% if can_manage_versioning %}
<form method="post" action="{{ url_for('ui.update_bucket_versioning', bucket_name=bucket_name) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
{% if versioning_enabled %}
<input type="hidden" name="state" value="suspend" />
<button class="btn btn-outline-danger" type="submit" data-confirm-suspend>
<button class="btn btn-outline-danger" type="button" data-bs-toggle="modal" data-bs-target="#suspendVersioningModal">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
<path d="M5.5 3.5A1.5 1.5 0 0 1 7 5v6a1.5 1.5 0 0 1-3 0V5a1.5 1.5 0 0 1 1.5-1.5zm5 0A1.5 1.5 0 0 1 12 5v6a1.5 1.5 0 0 1-3 0V5a1.5 1.5 0 0 1 1.5-1.5z"/>
</svg>
Suspend Versioning
</button>
{% else %}
<form method="post" action="{{ url_for('ui.update_bucket_versioning', bucket_name=bucket_name) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<input type="hidden" name="state" value="enable" />
<button class="btn btn-success" type="submit">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M10.854 8.146a.5.5 0 0 1 0 .708l-3 3a.5.5 0 0 1-.708 0l-1.5-1.5a.5.5 0 0 1 .708-.708L7.5 10.793l2.646-2.647a.5.5 0 0 1 .708 0z"/>
<path d="M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1zM0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8z"/>
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zm-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z"/>
</svg>
Enable Versioning
</button>
{% endif %}
</form>
{% endif %}
{% else %}
<div class="text-center py-3">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" class="text-muted mb-2" viewBox="0 0 16 16">
@@ -1656,7 +1654,7 @@
<div class="d-flex align-items-start mb-3">
<div class="bg-primary bg-opacity-10 rounded p-2 me-3">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="text-primary" viewBox="0 0 16 16">
<path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm7.5-6.923c-.67.204-1.335.82-1.887 1.855A7.97 7.97 0 0 0 5.145 4H7.5V1.077z"/>
<path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm7.5-6.923c-.67.204-1.335.82-1.887 1.855A7.97 7.97 0 0 0 5.145 4H7.5V1.077zM4.09 4a9.267 9.267 0 0 1 .64-1.539 6.7 6.7 0 0 1 .597-.933A7.025 7.025 0 0 0 2.255 4H4.09zm-.582 3.5c.03-.877.138-1.718.312-2.5H1.674a6.958 6.958 0 0 0-.656 2.5h2.49zM4.847 5a12.5 12.5 0 0 0-.338 2.5H7.5V5H4.847zM8.5 5v2.5h2.99a12.495 12.495 0 0 0-.337-2.5H8.5zM4.51 8.5a12.5 12.5 0 0 0 .337 2.5H7.5V8.5H4.51zm3.99 0V11h2.653c.187-.765.306-1.608.338-2.5H8.5zM5.145 12c.138.386.295.744.468 1.068.552 1.035 1.218 1.65 1.887 1.855V12H5.145zm.182 2.472a6.696 6.696 0 0 1-.597-.933A9.268 9.268 0 0 1 4.09 12H2.255a7.024 7.024 0 0 0 3.072 2.472zM3.82 11a13.652 13.652 0 0 1-.312-2.5h-2.49c.062.89.291 1.733.656 2.5H3.82zm6.853 3.472A7.024 7.024 0 0 0 13.745 12H11.91a9.27 9.27 0 0 1-.64 1.539 6.688 6.688 0 0 1-.597.933zM8.5 12v2.923c.67-.204 1.335-.82 1.887-1.855.173-.324.33-.682.468-1.068H8.5zm3.68-1h2.146c.365-.767.594-1.61.656-2.5h-2.49a13.65 13.65 0 0 1-.312 2.5zm2.802-3.5a6.959 6.959 0 0 0-.656-2.5H12.18c.174.782.282 1.623.312 2.5h2.49zM11.27 2.461c.247.464.462.98.64 1.539h1.835a7.024 7.024 0 0 0-3.072-2.472c.218.284.418.598.597.933zM10.855 4a7.966 7.966 0 0 0-.468-1.068C9.835 1.897 9.17 1.282 8.5 1.077V4h2.355z"/>
</svg>
</div>
<div>
@@ -1829,6 +1827,27 @@
<div class="col-12">
<div class="upload-progress-stack" data-upload-progress></div>
</div>
<div class="col-12 d-none" id="uploadQueueContainer">
<div class="border rounded p-2" style="max-height: 150px; overflow-y: auto;">
<div class="d-flex align-items-center justify-content-between mb-2">
<span class="small fw-medium text-muted">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
<path d="M2 2a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v13.5a.5.5 0 0 1-.777.416L8 13.101l-5.223 2.815A.5.5 0 0 1 2 15.5V2zm2-1a1 1 0 0 0-1 1v12.566l4.723-2.482a.5.5 0 0 1 .554 0L13 14.566V2a1 1 0 0 0-1-1H4z"/>
</svg>
Queued files
</span>
<div class="d-flex align-items-center gap-2">
<span class="badge bg-secondary-subtle text-secondary" id="uploadQueueCount">0</span>
<button type="button" class="btn btn-outline-danger btn-sm py-0 px-1" id="clearUploadQueueBtn" title="Clear queue">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" viewBox="0 0 16 16">
<path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z"/>
</svg>
</button>
</div>
</div>
<ul class="list-unstyled mb-0 small" id="uploadQueueList"></ul>
</div>
</div>
<div class="col-12 d-none" id="bulkUploadProgress">
<div class="alert alert-info small mb-0">
<div class="d-flex justify-content-between align-items-center mb-2">
@@ -1874,6 +1893,26 @@
</div>
</div>
<div id="floatingUploadProgress" class="floating-upload-progress d-none">
<div class="floating-upload-content">
<div class="d-flex align-items-center justify-content-between mb-2">
<div class="d-flex align-items-center">
<div class="spinner-border spinner-border-sm text-primary me-2" role="status"></div>
<span class="fw-semibold" id="floatingUploadTitle">Uploading files...</span>
</div>
<button type="button" class="btn btn-sm btn-outline-secondary" id="floatingUploadExpand" title="Show upload modal">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M5.828 10.172a.5.5 0 0 0-.707 0l-4.096 4.096V11.5a.5.5 0 0 0-1 0v3.975a.5.5 0 0 0 .5.5H4.5a.5.5 0 0 0 0-1H1.732l4.096-4.096a.5.5 0 0 0 0-.707zm4.344 0a.5.5 0 0 1 .707 0l4.096 4.096V11.5a.5.5 0 1 1 1 0v3.975a.5.5 0 0 1-.5.5H11.5a.5.5 0 0 1 0-1h2.768l-4.096-4.096a.5.5 0 0 1 0-.707zm0-4.344a.5.5 0 0 0 .707 0l4.096-4.096V4.5a.5.5 0 1 0 1 0V.525a.5.5 0 0 0-.5-.5H11.5a.5.5 0 0 0 0 1h2.768l-4.096 4.096a.5.5 0 0 0 0 .707zm-4.344 0a.5.5 0 0 1-.707 0L1.025 1.732V4.5a.5.5 0 0 1-1 0V.525a.5.5 0 0 1 .5-.5H4.5a.5.5 0 0 1 0 1H1.732l4.096 4.096a.5.5 0 0 1 0 .707z"/>
</svg>
</button>
</div>
<div class="progress mb-1" style="height: 6px;">
<div class="progress-bar bg-primary" id="floatingUploadProgressBar" role="progressbar" style="width: 0%;"></div>
</div>
<div class="text-muted small" id="floatingUploadStatus">Preparing...</div>
</div>
</div>
<div class="modal fade" id="deleteBucketModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
@@ -2092,6 +2131,46 @@
</div>
</div>
<div class="modal fade" id="suspendVersioningModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header border-0 pb-0">
<h5 class="modal-title">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-warning" viewBox="0 0 16 16">
<path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z"/>
</svg>
Suspend Versioning
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="alert alert-warning d-flex align-items-start mb-3" role="alert">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" class="flex-shrink-0 me-2 mt-1" viewBox="0 0 16 16">
<path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z"/>
</svg>
<div>
<strong>Important:</strong> Suspending versioning will stop creating new versions for uploaded objects, but <strong>existing versioned objects will remain stored</strong> and continue to consume storage space.
</div>
</div>
<p class="text-muted small mb-0">To permanently remove old versions, you must re-enable versioning and manually delete them, or configure a lifecycle rule to expire noncurrent versions.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<form method="POST" action="{{ url_for('ui.update_bucket_versioning', bucket_name=bucket_name) }}" class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<input type="hidden" name="state" value="suspend" />
<button type="submit" class="btn btn-warning">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
<path d="M5.5 3.5A1.5 1.5 0 0 1 7 5v6a1.5 1.5 0 0 1-3 0V5a1.5 1.5 0 0 1 1.5-1.5zm5 0A1.5 1.5 0 0 1 12 5v6a1.5 1.5 0 0 1-3 0V5a1.5 1.5 0 0 1 1.5-1.5z"/>
</svg>
Suspend Versioning
</button>
</form>
</div>
</div>
</div>
</div>
<div class="modal fade" id="disableReplicationModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
@@ -2318,100 +2397,37 @@
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('static', filename='js/bucket-detail-utils.js') }}"></script>
<script src="{{ url_for('static', filename='js/bucket-detail-upload.js') }}"></script>
<script src="{{ url_for('static', filename='js/bucket-detail-operations.js') }}"></script>
<script>
function setupJsonAutoIndent(textarea) {
if (!textarea) return;
textarea.addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
const start = this.selectionStart;
const end = this.selectionEnd;
const value = this.value;
const lineStart = value.lastIndexOf('\n', start - 1) + 1;
const currentLine = value.substring(lineStart, start);
const indentMatch = currentLine.match(/^(\s*)/);
let indent = indentMatch ? indentMatch[1] : '';
const trimmedLine = currentLine.trim();
const lastChar = trimmedLine.slice(-1);
let newIndent = indent;
let insertAfter = '';
if (lastChar === '{' || lastChar === '[') {
newIndent = indent + ' ';
const charAfterCursor = value.substring(start, start + 1).trim();
if ((lastChar === '{' && charAfterCursor === '}') ||
(lastChar === '[' && charAfterCursor === ']')) {
insertAfter = '\n' + indent;
}
} else if (lastChar === ',' || lastChar === ':') {
newIndent = indent;
}
const insertion = '\n' + newIndent + insertAfter;
const newValue = value.substring(0, start) + insertion + value.substring(end);
this.value = newValue;
const newCursorPos = start + 1 + newIndent.length;
this.selectionStart = this.selectionEnd = newCursorPos;
this.dispatchEvent(new Event('input', { bubbles: true }));
const { formatBytes, escapeHtml, fallbackCopy, setupJsonAutoIndent } = window.BucketDetailUtils || {
formatBytes: (bytes) => {
if (!Number.isFinite(bytes)) return `${bytes} bytes`;
const units = ['bytes', 'KB', 'MB', 'GB', 'TB'];
let i = 0;
let size = bytes;
while (size >= 1024 && i < units.length - 1) {
size /= 1024;
i++;
}
if (e.key === 'Tab') {
e.preventDefault();
const start = this.selectionStart;
const end = this.selectionEnd;
if (e.shiftKey) {
const lineStart = this.value.lastIndexOf('\n', start - 1) + 1;
const lineContent = this.value.substring(lineStart, start);
if (lineContent.startsWith(' ')) {
this.value = this.value.substring(0, lineStart) +
this.value.substring(lineStart + 2);
this.selectionStart = this.selectionEnd = Math.max(lineStart, start - 2);
}
} else {
this.value = this.value.substring(0, start) + ' ' + this.value.substring(end);
this.selectionStart = this.selectionEnd = start + 2;
}
this.dispatchEvent(new Event('input', { bubbles: true }));
}
});
}
return `${size.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
},
escapeHtml: (value) => {
if (value === null || value === undefined) return '';
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
},
fallbackCopy: () => false,
setupJsonAutoIndent: () => {}
};
setupJsonAutoIndent(document.getElementById('policyDocument'));
const formatBytes = (bytes) => {
if (!Number.isFinite(bytes)) return `${bytes} bytes`;
const units = ['bytes', 'KB', 'MB', 'GB', 'TB'];
let i = 0;
let size = bytes;
while (size >= 1024 && i < units.length - 1) {
size /= 1024;
i++;
}
return `${size.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
};
const escapeHtml = (value) => {
if (value === null || value === undefined) return '';
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
};
const selectAllCheckbox = document.querySelector('[data-select-all]');
const bulkDeleteButton = document.querySelector('[data-bulk-delete-trigger]');
const bulkDeleteLabel = bulkDeleteButton?.querySelector('[data-bulk-delete-label]');
@@ -4137,10 +4153,89 @@
const bulkUploadErrorList = document.getElementById('bulkUploadErrorList');
const uploadKeyPrefix = document.getElementById('uploadKeyPrefix');
const singleFileOptions = document.getElementById('singleFileOptions');
const floatingProgress = document.getElementById('floatingUploadProgress');
const floatingProgressBar = document.getElementById('floatingUploadProgressBar');
const floatingProgressStatus = document.getElementById('floatingUploadStatus');
const floatingProgressTitle = document.getElementById('floatingUploadTitle');
const floatingProgressExpand = document.getElementById('floatingUploadExpand');
const uploadQueueContainer = document.getElementById('uploadQueueContainer');
const uploadQueueList = document.getElementById('uploadQueueList');
const uploadQueueCount = document.getElementById('uploadQueueCount');
const clearUploadQueueBtn = document.getElementById('clearUploadQueueBtn');
let isUploading = false;
let uploadQueue = [];
let uploadStats = {
totalFiles: 0,
completedFiles: 0,
totalBytes: 0,
uploadedBytes: 0,
currentFileBytes: 0,
currentFileLoaded: 0,
currentFileName: ''
};
window.addEventListener('beforeunload', (e) => {
if (isUploading) {
e.preventDefault();
e.returnValue = 'Upload in progress. Are you sure you want to leave?';
return e.returnValue;
}
});
const showFloatingProgress = () => {
if (floatingProgress) {
floatingProgress.classList.remove('d-none');
}
};
const hideFloatingProgress = () => {
if (floatingProgress) {
floatingProgress.classList.add('d-none');
}
};
const updateFloatingProgress = () => {
const { totalFiles, completedFiles, totalBytes, uploadedBytes, currentFileLoaded, currentFileName } = uploadStats;
const effectiveUploaded = uploadedBytes + currentFileLoaded;
if (floatingProgressBar && totalBytes > 0) {
const percent = Math.round((effectiveUploaded / totalBytes) * 100);
floatingProgressBar.style.width = `${percent}%`;
}
if (floatingProgressStatus) {
const bytesText = `${formatBytes(effectiveUploaded)} / ${formatBytes(totalBytes)}`;
const queuedCount = uploadQueue.length;
let statusText = `${completedFiles}/${totalFiles} files`;
if (queuedCount > 0) {
statusText += ` (+${queuedCount} queued)`;
}
statusText += `${bytesText}`;
floatingProgressStatus.textContent = statusText;
}
if (floatingProgressTitle) {
const remaining = totalFiles - completedFiles;
const queuedCount = uploadQueue.length;
let title = `Uploading ${remaining} file${remaining !== 1 ? 's' : ''}`;
if (queuedCount > 0) {
title += ` (+${queuedCount} queued)`;
}
floatingProgressTitle.textContent = title + '...';
}
};
floatingProgressExpand?.addEventListener('click', () => {
if (uploadModal) {
uploadModal.show();
}
});
const refreshUploadDropLabel = () => {
if (!uploadDropZoneLabel) return;
if (isUploading) {
uploadDropZoneLabel.textContent = 'Drop files here to add to queue';
if (singleFileOptions) singleFileOptions.classList.add('d-none');
return;
}
const files = uploadFileInput.files;
if (!files || files.length === 0) {
uploadDropZoneLabel.textContent = 'No file selected';
@@ -4156,6 +4251,16 @@
const updateUploadBtnText = () => {
if (!uploadBtnText) return;
if (isUploading) {
const files = uploadFileInput.files;
if (files && files.length > 0) {
uploadBtnText.textContent = `Add ${files.length} to queue`;
if (uploadSubmitBtn) uploadSubmitBtn.disabled = false;
} else {
uploadBtnText.textContent = 'Uploading...';
}
return;
}
const files = uploadFileInput.files;
if (!files || files.length <= 1) {
uploadBtnText.textContent = 'Upload';
@@ -4179,6 +4284,7 @@
uploadDropZone.style.pointerEvents = '';
}
isUploading = false;
hideFloatingProgress();
};
const MULTIPART_THRESHOLD = 8 * 1024 * 1024;
@@ -4275,10 +4381,15 @@
loaded: uploadedBytes,
total: file.size
});
uploadStats.currentFileLoaded = uploadedBytes;
updateFloatingProgress();
const partResp = await fetch(`${partUrl}?partNumber=${partNumber}`, {
method: 'PUT',
headers: { 'X-CSRFToken': csrfToken || '' },
headers: {
'X-CSRFToken': csrfToken || '',
'Content-Type': 'application/octet-stream'
},
body: chunk
});
@@ -4295,6 +4406,8 @@
loaded: uploadedBytes,
total: file.size
});
uploadStats.currentFileLoaded = uploadedBytes;
updateFloatingProgress();
}
updateProgressItem(progressItem, { status: 'Completing...', loaded: file.size, total: file.size });
@@ -4338,6 +4451,8 @@
loaded: e.loaded,
total: e.total
});
uploadStats.currentFileLoaded = e.loaded;
updateFloatingProgress();
}
});
@@ -4399,18 +4514,148 @@
const setUploadLockState = (locked) => {
if (uploadDropZone) {
uploadDropZone.classList.toggle('upload-locked', locked);
uploadDropZone.style.pointerEvents = locked ? 'none' : '';
}
if (uploadFileInput) {
uploadFileInput.disabled = locked;
};
let uploadSuccessFiles = [];
let uploadErrorFiles = [];
let isProcessingQueue = false;
const updateQueueListDisplay = () => {
if (!uploadQueueList || !uploadQueueContainer || !uploadQueueCount) return;
if (uploadQueue.length === 0) {
uploadQueueContainer.classList.add('d-none');
return;
}
uploadQueueContainer.classList.remove('d-none');
uploadQueueCount.textContent = uploadQueue.length;
uploadQueueList.innerHTML = uploadQueue.map((item, idx) => `
<li class="d-flex align-items-center justify-content-between py-1 ${idx > 0 ? 'border-top' : ''}">
<span class="text-truncate me-2" style="max-width: 300px;" title="${escapeHtml(item.file.name)}">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" class="text-muted me-1" viewBox="0 0 16 16">
<path d="M4 0h5.293A1 1 0 0 1 10 .293L13.707 4a1 1 0 0 1 .293.707V14a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2zm5.5 1.5v2a1 1 0 0 0 1 1h2l-3-3z"/>
</svg>
${escapeHtml(item.file.name)}
</span>
<span class="text-muted">${formatBytes(item.file.size)}</span>
</li>
`).join('');
};
const addFilesToQueue = (files, keyPrefix, metadata) => {
for (const file of files) {
uploadQueue.push({ file, keyPrefix, metadata });
uploadStats.totalFiles++;
uploadStats.totalBytes += file.size;
}
updateFloatingProgress();
updateQueueListDisplay();
};
const clearUploadQueue = () => {
const clearedCount = uploadQueue.length;
if (clearedCount === 0) return;
for (const item of uploadQueue) {
uploadStats.totalFiles--;
uploadStats.totalBytes -= item.file.size;
}
uploadQueue.length = 0;
updateFloatingProgress();
updateQueueListDisplay();
};
if (clearUploadQueueBtn) {
clearUploadQueueBtn.addEventListener('click', clearUploadQueue);
}
const processUploadQueue = async () => {
if (isProcessingQueue) return;
isProcessingQueue = true;
while (uploadQueue.length > 0) {
const item = uploadQueue.shift();
const { file, keyPrefix, metadata } = item;
updateQueueListDisplay();
uploadStats.currentFileName = file.name;
uploadStats.currentFileBytes = file.size;
uploadStats.currentFileLoaded = 0;
if (bulkUploadCounter) {
const queuedCount = uploadQueue.length;
let counterText = `${uploadStats.completedFiles + 1}/${uploadStats.totalFiles}`;
if (queuedCount > 0) {
counterText += ` (+${queuedCount} queued)`;
}
bulkUploadCounter.textContent = counterText;
}
if (bulkUploadCurrentFile) {
bulkUploadCurrentFile.textContent = `Uploading: ${file.name}`;
}
if (bulkUploadProgressBar) {
const percent = Math.round(((uploadStats.completedFiles + 1) / uploadStats.totalFiles) * 100);
bulkUploadProgressBar.style.width = `${percent}%`;
}
updateFloatingProgress();
try {
await uploadSingleFile(file, keyPrefix, metadata);
uploadSuccessFiles.push(file.name);
} catch (error) {
uploadErrorFiles.push({ name: file.name, error: error.message || 'Unknown error' });
}
uploadStats.uploadedBytes += file.size;
uploadStats.completedFiles++;
uploadStats.currentFileLoaded = 0;
updateFloatingProgress();
}
isProcessingQueue = false;
if (uploadQueue.length === 0) {
finishUploadSession();
}
};
const finishUploadSession = () => {
if (bulkUploadProgress) bulkUploadProgress.classList.add('d-none');
if (bulkUploadResults) bulkUploadResults.classList.remove('d-none');
if (bulkUploadSuccessCount) bulkUploadSuccessCount.textContent = uploadSuccessFiles.length;
if (uploadSuccessFiles.length === 0 && bulkUploadSuccessAlert) {
bulkUploadSuccessAlert.classList.add('d-none');
}
if (uploadErrorFiles.length > 0) {
if (bulkUploadErrorCount) bulkUploadErrorCount.textContent = uploadErrorFiles.length;
if (bulkUploadErrorAlert) bulkUploadErrorAlert.classList.remove('d-none');
if (bulkUploadErrorList) {
bulkUploadErrorList.innerHTML = uploadErrorFiles
.map(f => `<li><strong>${escapeHtml(f.name)}</strong>: ${escapeHtml(f.error)}</li>`)
.join('');
}
}
isUploading = false;
setUploadLockState(false);
refreshUploadDropLabel();
updateUploadBtnText();
updateQueueListDisplay();
if (uploadSuccessFiles.length > 0) {
if (uploadBtnText) uploadBtnText.textContent = 'Refreshing...';
const objectsTabUrl = window.location.pathname + '?tab=objects';
window.setTimeout(() => window.location.href = objectsTabUrl, 800);
} else {
if (uploadSubmitBtn) uploadSubmitBtn.disabled = false;
if (uploadFileInput) uploadFileInput.disabled = false;
}
};
const performBulkUpload = async (files) => {
if (isUploading || !files || files.length === 0) return;
if (!files || files.length === 0) return;
isUploading = true;
setUploadLockState(true);
const keyPrefix = (uploadKeyPrefix?.value || '').trim();
const metadataRaw = uploadForm.querySelector('textarea[name="metadata"]')?.value?.trim();
let metadata = null;
@@ -4419,78 +4664,52 @@
metadata = JSON.parse(metadataRaw);
} catch {
showMessage({ title: 'Invalid metadata', body: 'Metadata must be valid JSON.', variant: 'danger' });
resetUploadUI();
return;
}
}
if (bulkUploadProgress) bulkUploadProgress.classList.remove('d-none');
if (bulkUploadResults) bulkUploadResults.classList.add('d-none');
if (uploadSubmitBtn) uploadSubmitBtn.disabled = true;
if (uploadFileInput) uploadFileInput.disabled = true;
if (!isUploading) {
isUploading = true;
uploadSuccessFiles = [];
uploadErrorFiles = [];
uploadStats = {
totalFiles: 0,
completedFiles: 0,
totalBytes: 0,
uploadedBytes: 0,
currentFileBytes: 0,
currentFileLoaded: 0,
currentFileName: ''
};
const successFiles = [];
const errorFiles = [];
const total = files.length;
for (let i = 0; i < total; i++) {
const file = files[i];
const current = i + 1;
if (bulkUploadCounter) bulkUploadCounter.textContent = `${current}/${total}`;
if (bulkUploadCurrentFile) bulkUploadCurrentFile.textContent = `Uploading: ${file.name}`;
if (bulkUploadProgressBar) {
const percent = Math.round((current / total) * 100);
bulkUploadProgressBar.style.width = `${percent}%`;
}
try {
await uploadSingleFile(file, keyPrefix, metadata);
successFiles.push(file.name);
} catch (error) {
errorFiles.push({ name: file.name, error: error.message || 'Unknown error' });
}
if (bulkUploadProgress) bulkUploadProgress.classList.remove('d-none');
if (bulkUploadResults) bulkUploadResults.classList.add('d-none');
if (uploadSubmitBtn) uploadSubmitBtn.disabled = true;
refreshUploadDropLabel();
updateUploadBtnText();
}
if (bulkUploadProgress) bulkUploadProgress.classList.add('d-none');
if (bulkUploadResults) bulkUploadResults.classList.remove('d-none');
if (bulkUploadSuccessCount) bulkUploadSuccessCount.textContent = successFiles.length;
if (successFiles.length === 0 && bulkUploadSuccessAlert) {
bulkUploadSuccessAlert.classList.add('d-none');
}
if (errorFiles.length > 0) {
if (bulkUploadErrorCount) bulkUploadErrorCount.textContent = errorFiles.length;
if (bulkUploadErrorAlert) bulkUploadErrorAlert.classList.remove('d-none');
if (bulkUploadErrorList) {
bulkUploadErrorList.innerHTML = errorFiles
.map(f => `<li><strong>${escapeHtml(f.name)}</strong>: ${escapeHtml(f.error)}</li>`)
.join('');
}
}
const fileCount = files.length;
addFilesToQueue(Array.from(files), keyPrefix, metadata);
isUploading = false;
setUploadLockState(false);
if (successFiles.length > 0) {
if (uploadBtnText) uploadBtnText.textContent = 'Refreshing...';
window.setTimeout(() => window.location.reload(), 800);
} else {
if (uploadSubmitBtn) uploadSubmitBtn.disabled = false;
if (uploadFileInput) uploadFileInput.disabled = false;
if (uploadFileInput) {
uploadFileInput.value = '';
}
refreshUploadDropLabel();
updateUploadBtnText();
processUploadQueue();
};
refreshUploadDropLabel();
uploadFileInput.addEventListener('change', () => {
if (isUploading) return;
refreshUploadDropLabel();
updateUploadBtnText();
resetUploadUI();
if (!isUploading) {
resetUploadUI();
}
});
uploadDropZone?.addEventListener('click', () => {
if (isUploading) return;
uploadFileInput?.click();
});
@@ -4537,11 +4756,25 @@
}
});
uploadModalEl?.addEventListener('hide.bs.modal', (event) => {
if (isUploading) {
showFloatingProgress();
}
});
uploadModalEl?.addEventListener('hidden.bs.modal', () => {
resetUploadUI();
uploadFileInput.value = '';
refreshUploadDropLabel();
updateUploadBtnText();
if (!isUploading) {
resetUploadUI();
uploadFileInput.value = '';
refreshUploadDropLabel();
updateUploadBtnText();
}
});
uploadModalEl?.addEventListener('show.bs.modal', () => {
if (isUploading) {
hideFloatingProgress();
}
});
const preventDefaults = (event) => {
@@ -4554,7 +4787,6 @@
['dragenter', 'dragover'].forEach((eventName) => {
target.addEventListener(eventName, (event) => {
preventDefaults(event);
if (isUploading) return;
if (highlightClass) {
target.classList.add(highlightClass);
}
@@ -4569,14 +4801,19 @@
});
});
target.addEventListener('drop', (event) => {
if (isUploading) return;
if (!event.dataTransfer?.files?.length || !uploadFileInput) {
if (!event.dataTransfer?.files?.length) {
return;
}
uploadFileInput.files = event.dataTransfer.files;
uploadFileInput.dispatchEvent(new Event('change', { bubbles: true }));
if (autoOpenModal && uploadModal) {
uploadModal.show();
if (isUploading) {
performBulkUpload(event.dataTransfer.files);
} else {
if (uploadFileInput) {
uploadFileInput.files = event.dataTransfer.files;
uploadFileInput.dispatchEvent(new Event('change', { bubbles: true }));
}
if (autoOpenModal && uploadModal) {
uploadModal.show();
}
}
});
};