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

@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import io
import json import json
import uuid import uuid
import psutil import psutil
@@ -28,7 +29,7 @@ from flask_wtf.csrf import generate_csrf
from .acl import AclService, create_canned_acl, CANNED_ACLS from .acl import AclService, create_canned_acl, CANNED_ACLS
from .bucket_policies import BucketPolicyStore from .bucket_policies import BucketPolicyStore
from .connections import ConnectionStore, RemoteConnection from .connections import ConnectionStore, RemoteConnection
from .extensions import limiter from .extensions import limiter, csrf
from .iam import IamError from .iam import IamError
from .kms import KMSManager from .kms import KMSManager
from .replication import ReplicationManager, ReplicationRule from .replication import ReplicationManager, ReplicationRule
@@ -564,6 +565,7 @@ def initiate_multipart_upload(bucket_name: str):
@ui_bp.put("/buckets/<bucket_name>/multipart/<upload_id>/parts") @ui_bp.put("/buckets/<bucket_name>/multipart/<upload_id>/parts")
@limiter.exempt @limiter.exempt
@csrf.exempt
def upload_multipart_part(bucket_name: str, upload_id: str): def upload_multipart_part(bucket_name: str, upload_id: str):
principal = _current_principal() principal = _current_principal()
try: try:
@@ -577,7 +579,11 @@ def upload_multipart_part(bucket_name: str, upload_id: str):
if part_number < 1: if part_number < 1:
return jsonify({"error": "partNumber must be >= 1"}), 400 return jsonify({"error": "partNumber must be >= 1"}), 400
try: try:
etag = _storage().upload_multipart_part(bucket_name, upload_id, part_number, request.stream) data = request.get_data()
if not data:
return jsonify({"error": "Empty request body"}), 400
stream = io.BytesIO(data)
etag = _storage().upload_multipart_part(bucket_name, upload_id, part_number, stream)
except StorageError as exc: except StorageError as exc:
return jsonify({"error": str(exc)}), 400 return jsonify({"error": str(exc)}), 400
return jsonify({"etag": etag, "part_number": part_number}) return jsonify({"etag": etag, "part_number": part_number})

View File

@@ -647,17 +647,18 @@ code {
} }
.upload-dropzone.upload-locked { .upload-dropzone.upload-locked {
opacity: 0.5; background-color: rgba(59, 130, 246, 0.05);
cursor: not-allowed; border-color: #3b82f6;
background-color: var(--myfsio-preview-bg); border-style: dashed;
} }
.upload-dropzone.upload-locked::after { .upload-dropzone.upload-locked::after {
content: 'Upload in progress...'; content: 'Drop more files to add to queue';
display: block; display: block;
margin-top: 0.5rem; margin-top: 0.5rem;
font-size: 0.8rem; font-size: 0.8rem;
color: var(--myfsio-muted); color: #3b82f6;
font-weight: 500;
} }
.metadata-stack .metadata-entry + .metadata-entry { .metadata-stack .metadata-entry + .metadata-entry {
@@ -1878,6 +1879,37 @@ body.theme-transitioning * {
z-index: 1050; z-index: 1050;
} }
.btn-icon.dropdown-toggle::after {
display: none;
}
.floating-upload-progress {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
z-index: 1055;
min-width: 320px;
max-width: 400px;
animation: slideInUp 0.3s ease-out;
}
@keyframes slideInUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.floating-upload-content {
background: var(--myfsio-card-bg);
border: 1px solid var(--myfsio-card-border);
border-radius: 0.75rem;
padding: 1rem;
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.15), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
}
[data-theme='dark'] .floating-upload-content {
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.4), 0 8px 10px -6px rgba(0, 0, 0, 0.3);
}
.objects-header-responsive { .objects-header-responsive {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;

View File

@@ -0,0 +1,192 @@
window.BucketDetailOperations = (function() {
'use strict';
let showMessage = function() {};
let escapeHtml = function(s) { return s; };
function init(config) {
showMessage = config.showMessage || showMessage;
escapeHtml = config.escapeHtml || escapeHtml;
}
async function loadLifecycleRules(card, endpoint) {
if (!card || !endpoint) return;
const body = card.querySelector('[data-lifecycle-body]');
if (!body) return;
try {
const response = await fetch(endpoint);
const data = await response.json();
if (!response.ok) {
body.innerHTML = `<tr><td colspan="5" class="text-center text-danger py-3">${escapeHtml(data.error || 'Failed to load')}</td></tr>`;
return;
}
const rules = data.rules || [];
if (rules.length === 0) {
body.innerHTML = '<tr><td colspan="5" class="text-center text-muted py-3">No lifecycle rules configured</td></tr>';
return;
}
body.innerHTML = rules.map(rule => {
const actions = [];
if (rule.expiration_days) actions.push(`Delete after ${rule.expiration_days} days`);
if (rule.noncurrent_days) actions.push(`Delete old versions after ${rule.noncurrent_days} days`);
if (rule.abort_mpu_days) actions.push(`Abort incomplete MPU after ${rule.abort_mpu_days} days`);
return `
<tr>
<td class="fw-medium">${escapeHtml(rule.id)}</td>
<td><code>${escapeHtml(rule.prefix || '(all)')}</code></td>
<td>${actions.map(a => `<div class="small">${escapeHtml(a)}</div>`).join('')}</td>
<td>
<span class="badge ${rule.status === 'Enabled' ? 'text-bg-success' : 'text-bg-secondary'}">${escapeHtml(rule.status)}</span>
</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-danger" onclick="BucketDetailOperations.deleteLifecycleRule('${escapeHtml(rule.id)}')">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" viewBox="0 0 16 16">
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
</svg>
</button>
</td>
</tr>
`;
}).join('');
} catch (err) {
body.innerHTML = `<tr><td colspan="5" class="text-center text-danger py-3">${escapeHtml(err.message)}</td></tr>`;
}
}
async function loadCorsRules(card, endpoint) {
if (!card || !endpoint) return;
const body = document.getElementById('cors-rules-body');
if (!body) return;
try {
const response = await fetch(endpoint);
const data = await response.json();
if (!response.ok) {
body.innerHTML = `<tr><td colspan="5" class="text-center text-danger py-3">${escapeHtml(data.error || 'Failed to load')}</td></tr>`;
return;
}
const rules = data.rules || [];
if (rules.length === 0) {
body.innerHTML = '<tr><td colspan="5" class="text-center text-muted py-3">No CORS rules configured</td></tr>';
return;
}
body.innerHTML = rules.map((rule, idx) => `
<tr>
<td>${(rule.allowed_origins || []).map(o => `<code class="d-block">${escapeHtml(o)}</code>`).join('')}</td>
<td>${(rule.allowed_methods || []).map(m => `<span class="badge text-bg-secondary me-1">${escapeHtml(m)}</span>`).join('')}</td>
<td class="small text-muted">${(rule.allowed_headers || []).slice(0, 3).join(', ')}${(rule.allowed_headers || []).length > 3 ? '...' : ''}</td>
<td class="text-muted">${rule.max_age_seconds || 0}s</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-danger" onclick="BucketDetailOperations.deleteCorsRule(${idx})">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" viewBox="0 0 16 16">
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
</svg>
</button>
</td>
</tr>
`).join('');
} catch (err) {
body.innerHTML = `<tr><td colspan="5" class="text-center text-danger py-3">${escapeHtml(err.message)}</td></tr>`;
}
}
async function loadAcl(card, endpoint) {
if (!card || !endpoint) return;
const body = card.querySelector('[data-acl-body]');
if (!body) return;
try {
const response = await fetch(endpoint);
const data = await response.json();
if (!response.ok) {
body.innerHTML = `<tr><td colspan="3" class="text-center text-danger py-3">${escapeHtml(data.error || 'Failed to load')}</td></tr>`;
return;
}
const grants = data.grants || [];
if (grants.length === 0) {
body.innerHTML = '<tr><td colspan="3" class="text-center text-muted py-3">No ACL grants configured</td></tr>';
return;
}
body.innerHTML = grants.map(grant => {
const grantee = grant.grantee_type === 'CanonicalUser'
? grant.display_name || grant.grantee_id
: grant.grantee_uri || grant.grantee_type;
return `
<tr>
<td class="fw-medium">${escapeHtml(grantee)}</td>
<td><span class="badge text-bg-info">${escapeHtml(grant.permission)}</span></td>
<td class="text-muted small">${escapeHtml(grant.grantee_type)}</td>
</tr>
`;
}).join('');
} catch (err) {
body.innerHTML = `<tr><td colspan="3" class="text-center text-danger py-3">${escapeHtml(err.message)}</td></tr>`;
}
}
async function deleteLifecycleRule(ruleId) {
if (!confirm(`Delete lifecycle rule "${ruleId}"?`)) return;
const card = document.getElementById('lifecycle-rules-card');
if (!card) return;
const endpoint = card.dataset.lifecycleUrl;
const csrfToken = window.getCsrfToken ? window.getCsrfToken() : '';
try {
const resp = await fetch(endpoint, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken },
body: JSON.stringify({ rule_id: ruleId })
});
const data = await resp.json();
if (!resp.ok) throw new Error(data.error || 'Failed to delete');
showMessage({ title: 'Rule deleted', body: `Lifecycle rule "${ruleId}" has been deleted.`, variant: 'success' });
loadLifecycleRules(card, endpoint);
} catch (err) {
showMessage({ title: 'Delete failed', body: err.message, variant: 'danger' });
}
}
async function deleteCorsRule(index) {
if (!confirm('Delete this CORS rule?')) return;
const card = document.getElementById('cors-rules-card');
if (!card) return;
const endpoint = card.dataset.corsUrl;
const csrfToken = window.getCsrfToken ? window.getCsrfToken() : '';
try {
const resp = await fetch(endpoint, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken },
body: JSON.stringify({ rule_index: index })
});
const data = await resp.json();
if (!resp.ok) throw new Error(data.error || 'Failed to delete');
showMessage({ title: 'Rule deleted', body: 'CORS rule has been deleted.', variant: 'success' });
loadCorsRules(card, endpoint);
} catch (err) {
showMessage({ title: 'Delete failed', body: err.message, variant: 'danger' });
}
}
return {
init: init,
loadLifecycleRules: loadLifecycleRules,
loadCorsRules: loadCorsRules,
loadAcl: loadAcl,
deleteLifecycleRule: deleteLifecycleRule,
deleteCorsRule: deleteCorsRule
};
})();

View File

@@ -0,0 +1,548 @@
window.BucketDetailUpload = (function() {
'use strict';
const MULTIPART_THRESHOLD = 8 * 1024 * 1024;
const CHUNK_SIZE = 8 * 1024 * 1024;
let state = {
isUploading: false,
uploadProgress: { current: 0, total: 0, currentFile: '' }
};
let elements = {};
let callbacks = {};
function init(config) {
elements = {
uploadForm: config.uploadForm,
uploadFileInput: config.uploadFileInput,
uploadModal: config.uploadModal,
uploadModalEl: config.uploadModalEl,
uploadSubmitBtn: config.uploadSubmitBtn,
uploadCancelBtn: config.uploadCancelBtn,
uploadBtnText: config.uploadBtnText,
uploadDropZone: config.uploadDropZone,
uploadDropZoneLabel: config.uploadDropZoneLabel,
uploadProgressStack: config.uploadProgressStack,
uploadKeyPrefix: config.uploadKeyPrefix,
singleFileOptions: config.singleFileOptions,
bulkUploadProgress: config.bulkUploadProgress,
bulkUploadStatus: config.bulkUploadStatus,
bulkUploadCounter: config.bulkUploadCounter,
bulkUploadProgressBar: config.bulkUploadProgressBar,
bulkUploadCurrentFile: config.bulkUploadCurrentFile,
bulkUploadResults: config.bulkUploadResults,
bulkUploadSuccessAlert: config.bulkUploadSuccessAlert,
bulkUploadErrorAlert: config.bulkUploadErrorAlert,
bulkUploadSuccessCount: config.bulkUploadSuccessCount,
bulkUploadErrorCount: config.bulkUploadErrorCount,
bulkUploadErrorList: config.bulkUploadErrorList,
floatingProgress: config.floatingProgress,
floatingProgressBar: config.floatingProgressBar,
floatingProgressStatus: config.floatingProgressStatus,
floatingProgressTitle: config.floatingProgressTitle,
floatingProgressExpand: config.floatingProgressExpand
};
callbacks = {
showMessage: config.showMessage || function() {},
formatBytes: config.formatBytes || function(b) { return b + ' bytes'; },
escapeHtml: config.escapeHtml || function(s) { return s; },
onUploadComplete: config.onUploadComplete || function() {},
hasFolders: config.hasFolders || function() { return false; },
getCurrentPrefix: config.getCurrentPrefix || function() { return ''; }
};
setupEventListeners();
setupBeforeUnload();
}
function isUploading() {
return state.isUploading;
}
function setupBeforeUnload() {
window.addEventListener('beforeunload', (e) => {
if (state.isUploading) {
e.preventDefault();
e.returnValue = 'Upload in progress. Are you sure you want to leave?';
return e.returnValue;
}
});
}
function showFloatingProgress() {
if (elements.floatingProgress) {
elements.floatingProgress.classList.remove('d-none');
}
}
function hideFloatingProgress() {
if (elements.floatingProgress) {
elements.floatingProgress.classList.add('d-none');
}
}
function updateFloatingProgress(current, total, currentFile) {
state.uploadProgress = { current, total, currentFile: currentFile || '' };
if (elements.floatingProgressBar && total > 0) {
const percent = Math.round((current / total) * 100);
elements.floatingProgressBar.style.width = `${percent}%`;
}
if (elements.floatingProgressStatus) {
if (currentFile) {
elements.floatingProgressStatus.textContent = `${current}/${total} files - ${currentFile}`;
} else {
elements.floatingProgressStatus.textContent = `${current}/${total} files completed`;
}
}
if (elements.floatingProgressTitle) {
elements.floatingProgressTitle.textContent = `Uploading ${total} file${total !== 1 ? 's' : ''}...`;
}
}
function refreshUploadDropLabel() {
if (!elements.uploadDropZoneLabel || !elements.uploadFileInput) return;
const files = elements.uploadFileInput.files;
if (!files || files.length === 0) {
elements.uploadDropZoneLabel.textContent = 'No file selected';
if (elements.singleFileOptions) elements.singleFileOptions.classList.remove('d-none');
return;
}
elements.uploadDropZoneLabel.textContent = files.length === 1 ? files[0].name : `${files.length} files selected`;
if (elements.singleFileOptions) {
elements.singleFileOptions.classList.toggle('d-none', files.length > 1);
}
}
function updateUploadBtnText() {
if (!elements.uploadBtnText || !elements.uploadFileInput) return;
const files = elements.uploadFileInput.files;
if (!files || files.length <= 1) {
elements.uploadBtnText.textContent = 'Upload';
} else {
elements.uploadBtnText.textContent = `Upload ${files.length} files`;
}
}
function resetUploadUI() {
if (elements.bulkUploadProgress) elements.bulkUploadProgress.classList.add('d-none');
if (elements.bulkUploadResults) elements.bulkUploadResults.classList.add('d-none');
if (elements.bulkUploadSuccessAlert) elements.bulkUploadSuccessAlert.classList.remove('d-none');
if (elements.bulkUploadErrorAlert) elements.bulkUploadErrorAlert.classList.add('d-none');
if (elements.bulkUploadErrorList) elements.bulkUploadErrorList.innerHTML = '';
if (elements.uploadSubmitBtn) elements.uploadSubmitBtn.disabled = false;
if (elements.uploadFileInput) elements.uploadFileInput.disabled = false;
if (elements.uploadProgressStack) elements.uploadProgressStack.innerHTML = '';
if (elements.uploadDropZone) {
elements.uploadDropZone.classList.remove('upload-locked');
elements.uploadDropZone.style.pointerEvents = '';
}
state.isUploading = false;
hideFloatingProgress();
}
function setUploadLockState(locked) {
if (elements.uploadDropZone) {
elements.uploadDropZone.classList.toggle('upload-locked', locked);
elements.uploadDropZone.style.pointerEvents = locked ? 'none' : '';
}
if (elements.uploadFileInput) {
elements.uploadFileInput.disabled = locked;
}
}
function createProgressItem(file) {
const item = document.createElement('div');
item.className = 'upload-progress-item';
item.dataset.state = 'uploading';
item.innerHTML = `
<div class="d-flex justify-content-between align-items-start">
<div class="min-width-0 flex-grow-1">
<div class="file-name">${callbacks.escapeHtml(file.name)}</div>
<div class="file-size">${callbacks.formatBytes(file.size)}</div>
</div>
<div class="upload-status text-end ms-2">Preparing...</div>
</div>
<div class="progress-container">
<div class="progress">
<div class="progress-bar bg-primary" role="progressbar" style="width: 0%"></div>
</div>
<div class="progress-text">
<span class="progress-loaded">0 B</span>
<span class="progress-percent">0%</span>
</div>
</div>
`;
return item;
}
function updateProgressItem(item, { loaded, total, status, progressState, error }) {
if (progressState) item.dataset.state = progressState;
const statusEl = item.querySelector('.upload-status');
const progressBar = item.querySelector('.progress-bar');
const progressLoaded = item.querySelector('.progress-loaded');
const progressPercent = item.querySelector('.progress-percent');
if (status) {
statusEl.textContent = status;
statusEl.className = 'upload-status text-end ms-2';
if (progressState === 'success') statusEl.classList.add('success');
if (progressState === 'error') statusEl.classList.add('error');
}
if (typeof loaded === 'number' && typeof total === 'number' && total > 0) {
const percent = Math.round((loaded / total) * 100);
progressBar.style.width = `${percent}%`;
progressLoaded.textContent = `${callbacks.formatBytes(loaded)} / ${callbacks.formatBytes(total)}`;
progressPercent.textContent = `${percent}%`;
}
if (error) {
const progressContainer = item.querySelector('.progress-container');
if (progressContainer) {
progressContainer.innerHTML = `<div class="text-danger small mt-1">${callbacks.escapeHtml(error)}</div>`;
}
}
}
async function uploadMultipart(file, objectKey, metadata, progressItem, urls) {
const csrfToken = document.querySelector('input[name="csrf_token"]')?.value;
updateProgressItem(progressItem, { status: 'Initiating...', loaded: 0, total: file.size });
const initResp = await fetch(urls.initUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken || '' },
body: JSON.stringify({ object_key: objectKey, metadata })
});
if (!initResp.ok) {
const err = await initResp.json().catch(() => ({}));
throw new Error(err.error || 'Failed to initiate upload');
}
const { upload_id } = await initResp.json();
const partUrl = urls.partTemplate.replace('UPLOAD_ID_PLACEHOLDER', upload_id);
const completeUrl = urls.completeTemplate.replace('UPLOAD_ID_PLACEHOLDER', upload_id);
const abortUrl = urls.abortTemplate.replace('UPLOAD_ID_PLACEHOLDER', upload_id);
const parts = [];
const totalParts = Math.ceil(file.size / CHUNK_SIZE);
let uploadedBytes = 0;
try {
for (let partNumber = 1; partNumber <= totalParts; partNumber++) {
const start = (partNumber - 1) * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size);
const chunk = file.slice(start, end);
updateProgressItem(progressItem, {
status: `Part ${partNumber}/${totalParts}`,
loaded: uploadedBytes,
total: file.size
});
const partResp = await fetch(`${partUrl}?partNumber=${partNumber}`, {
method: 'PUT',
headers: { 'X-CSRFToken': csrfToken || '' },
body: chunk
});
if (!partResp.ok) {
const err = await partResp.json().catch(() => ({}));
throw new Error(err.error || `Part ${partNumber} failed`);
}
const partData = await partResp.json();
parts.push({ part_number: partNumber, etag: partData.etag });
uploadedBytes += chunk.size;
updateProgressItem(progressItem, {
loaded: uploadedBytes,
total: file.size
});
}
updateProgressItem(progressItem, { status: 'Completing...', loaded: file.size, total: file.size });
const completeResp = await fetch(completeUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken || '' },
body: JSON.stringify({ parts })
});
if (!completeResp.ok) {
const err = await completeResp.json().catch(() => ({}));
throw new Error(err.error || 'Failed to complete upload');
}
return await completeResp.json();
} catch (err) {
try {
await fetch(abortUrl, { method: 'DELETE', headers: { 'X-CSRFToken': csrfToken || '' } });
} catch {}
throw err;
}
}
async function uploadRegular(file, objectKey, metadata, progressItem, formAction) {
return new Promise((resolve, reject) => {
const formData = new FormData();
formData.append('object', file);
formData.append('object_key', objectKey);
if (metadata) formData.append('metadata', JSON.stringify(metadata));
const csrfToken = document.querySelector('input[name="csrf_token"]')?.value;
if (csrfToken) formData.append('csrf_token', csrfToken);
const xhr = new XMLHttpRequest();
xhr.open('POST', formAction, true);
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
updateProgressItem(progressItem, {
status: 'Uploading...',
loaded: e.loaded,
total: e.total
});
}
});
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
const data = JSON.parse(xhr.responseText);
if (data.status === 'error') {
reject(new Error(data.message || 'Upload failed'));
} else {
resolve(data);
}
} catch {
resolve({});
}
} else {
try {
const data = JSON.parse(xhr.responseText);
reject(new Error(data.message || `Upload failed (${xhr.status})`));
} catch {
reject(new Error(`Upload failed (${xhr.status})`));
}
}
});
xhr.addEventListener('error', () => reject(new Error('Network error')));
xhr.addEventListener('abort', () => reject(new Error('Upload aborted')));
xhr.send(formData);
});
}
async function uploadSingleFile(file, keyPrefix, metadata, progressItem, urls) {
const objectKey = keyPrefix ? `${keyPrefix}${file.name}` : file.name;
const shouldUseMultipart = file.size >= MULTIPART_THRESHOLD && urls.initUrl;
if (!progressItem && elements.uploadProgressStack) {
progressItem = createProgressItem(file);
elements.uploadProgressStack.appendChild(progressItem);
}
try {
let result;
if (shouldUseMultipart) {
updateProgressItem(progressItem, { status: 'Multipart upload...', loaded: 0, total: file.size });
result = await uploadMultipart(file, objectKey, metadata, progressItem, urls);
} else {
updateProgressItem(progressItem, { status: 'Uploading...', loaded: 0, total: file.size });
result = await uploadRegular(file, objectKey, metadata, progressItem, urls.formAction);
}
updateProgressItem(progressItem, { progressState: 'success', status: 'Complete', loaded: file.size, total: file.size });
return result;
} catch (err) {
updateProgressItem(progressItem, { progressState: 'error', status: 'Failed', error: err.message });
throw err;
}
}
async function performBulkUpload(files, urls) {
if (state.isUploading || !files || files.length === 0) return;
state.isUploading = true;
setUploadLockState(true);
const keyPrefix = (elements.uploadKeyPrefix?.value || '').trim();
const metadataRaw = elements.uploadForm?.querySelector('textarea[name="metadata"]')?.value?.trim();
let metadata = null;
if (metadataRaw) {
try {
metadata = JSON.parse(metadataRaw);
} catch {
callbacks.showMessage({ title: 'Invalid metadata', body: 'Metadata must be valid JSON.', variant: 'danger' });
resetUploadUI();
return;
}
}
if (elements.bulkUploadProgress) elements.bulkUploadProgress.classList.remove('d-none');
if (elements.bulkUploadResults) elements.bulkUploadResults.classList.add('d-none');
if (elements.uploadSubmitBtn) elements.uploadSubmitBtn.disabled = true;
if (elements.uploadFileInput) elements.uploadFileInput.disabled = true;
const successFiles = [];
const errorFiles = [];
const total = files.length;
updateFloatingProgress(0, total, files[0]?.name || '');
for (let i = 0; i < total; i++) {
const file = files[i];
const current = i + 1;
if (elements.bulkUploadCounter) elements.bulkUploadCounter.textContent = `${current}/${total}`;
if (elements.bulkUploadCurrentFile) elements.bulkUploadCurrentFile.textContent = `Uploading: ${file.name}`;
if (elements.bulkUploadProgressBar) {
const percent = Math.round((current / total) * 100);
elements.bulkUploadProgressBar.style.width = `${percent}%`;
}
updateFloatingProgress(i, total, file.name);
try {
await uploadSingleFile(file, keyPrefix, metadata, null, urls);
successFiles.push(file.name);
} catch (error) {
errorFiles.push({ name: file.name, error: error.message || 'Unknown error' });
}
}
updateFloatingProgress(total, total);
if (elements.bulkUploadProgress) elements.bulkUploadProgress.classList.add('d-none');
if (elements.bulkUploadResults) elements.bulkUploadResults.classList.remove('d-none');
if (elements.bulkUploadSuccessCount) elements.bulkUploadSuccessCount.textContent = successFiles.length;
if (successFiles.length === 0 && elements.bulkUploadSuccessAlert) {
elements.bulkUploadSuccessAlert.classList.add('d-none');
}
if (errorFiles.length > 0) {
if (elements.bulkUploadErrorCount) elements.bulkUploadErrorCount.textContent = errorFiles.length;
if (elements.bulkUploadErrorAlert) elements.bulkUploadErrorAlert.classList.remove('d-none');
if (elements.bulkUploadErrorList) {
elements.bulkUploadErrorList.innerHTML = errorFiles
.map(f => `<li><strong>${callbacks.escapeHtml(f.name)}</strong>: ${callbacks.escapeHtml(f.error)}</li>`)
.join('');
}
}
state.isUploading = false;
setUploadLockState(false);
if (successFiles.length > 0) {
if (elements.uploadBtnText) elements.uploadBtnText.textContent = 'Refreshing...';
callbacks.onUploadComplete(successFiles, errorFiles);
} else {
if (elements.uploadSubmitBtn) elements.uploadSubmitBtn.disabled = false;
if (elements.uploadFileInput) elements.uploadFileInput.disabled = false;
}
}
function setupEventListeners() {
if (elements.uploadFileInput) {
elements.uploadFileInput.addEventListener('change', () => {
if (state.isUploading) return;
refreshUploadDropLabel();
updateUploadBtnText();
resetUploadUI();
});
}
if (elements.uploadDropZone) {
elements.uploadDropZone.addEventListener('click', () => {
if (state.isUploading) return;
elements.uploadFileInput?.click();
});
}
if (elements.floatingProgressExpand) {
elements.floatingProgressExpand.addEventListener('click', () => {
if (elements.uploadModal) {
elements.uploadModal.show();
}
});
}
if (elements.uploadModalEl) {
elements.uploadModalEl.addEventListener('hide.bs.modal', () => {
if (state.isUploading) {
showFloatingProgress();
}
});
elements.uploadModalEl.addEventListener('hidden.bs.modal', () => {
if (!state.isUploading) {
resetUploadUI();
if (elements.uploadFileInput) elements.uploadFileInput.value = '';
refreshUploadDropLabel();
updateUploadBtnText();
}
});
elements.uploadModalEl.addEventListener('show.bs.modal', () => {
if (state.isUploading) {
hideFloatingProgress();
}
if (callbacks.hasFolders() && callbacks.getCurrentPrefix()) {
if (elements.uploadKeyPrefix) {
elements.uploadKeyPrefix.value = callbacks.getCurrentPrefix();
}
} else if (elements.uploadKeyPrefix) {
elements.uploadKeyPrefix.value = '';
}
});
}
}
function wireDropTarget(target, options) {
const { highlightClass = '', autoOpenModal = false } = options || {};
if (!target) return;
const preventDefaults = (event) => {
event.preventDefault();
event.stopPropagation();
};
['dragenter', 'dragover'].forEach((eventName) => {
target.addEventListener(eventName, (event) => {
preventDefaults(event);
if (state.isUploading) return;
if (highlightClass) {
target.classList.add(highlightClass);
}
});
});
['dragleave', 'drop'].forEach((eventName) => {
target.addEventListener(eventName, (event) => {
preventDefaults(event);
if (highlightClass) {
target.classList.remove(highlightClass);
}
});
});
target.addEventListener('drop', (event) => {
if (state.isUploading) return;
if (!event.dataTransfer?.files?.length || !elements.uploadFileInput) {
return;
}
elements.uploadFileInput.files = event.dataTransfer.files;
elements.uploadFileInput.dispatchEvent(new Event('change', { bubbles: true }));
if (autoOpenModal && elements.uploadModal) {
elements.uploadModal.show();
}
});
}
return {
init: init,
isUploading: isUploading,
performBulkUpload: performBulkUpload,
wireDropTarget: wireDropTarget,
resetUploadUI: resetUploadUI,
refreshUploadDropLabel: refreshUploadDropLabel,
updateUploadBtnText: updateUploadBtnText
};
})();

View File

@@ -0,0 +1,120 @@
window.BucketDetailUtils = (function() {
'use strict';
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 }));
}
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 }));
}
});
}
function 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]}`;
}
function 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;');
}
function fallbackCopy(text) {
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-9999px';
textArea.style.top = '-9999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
let success = false;
try {
success = document.execCommand('copy');
} catch {
success = false;
}
document.body.removeChild(textArea);
return success;
}
return {
setupJsonAutoIndent: setupJsonAutoIndent,
formatBytes: formatBytes,
escapeHtml: escapeHtml,
fallbackCopy: fallbackCopy
};
})();

View File

@@ -403,7 +403,7 @@
</button> </button>
<button type="button" class="btn btn-outline-secondary preset-btn {% if preset_choice == 'public' %}active{% endif %}" data-preset="public"> <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"> <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> </svg>
Public Read Public Read
</button> </button>
@@ -628,27 +628,25 @@
{% endif %} {% endif %}
{% if can_manage_versioning %} {% 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 %} {% if versioning_enabled %}
<input type="hidden" name="state" value="suspend" /> <button class="btn btn-outline-danger" type="button" data-bs-toggle="modal" data-bs-target="#suspendVersioningModal">
<button class="btn btn-outline-danger" type="submit" data-confirm-suspend>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16"> <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"/> <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> </svg>
Suspend Versioning Suspend Versioning
</button> </button>
{% else %} {% 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" /> <input type="hidden" name="state" value="enable" />
<button class="btn btn-success" type="submit"> <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"> <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="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"/>
<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"/>
</svg> </svg>
Enable Versioning Enable Versioning
</button> </button>
{% endif %}
</form> </form>
{% endif %}
{% else %} {% else %}
<div class="text-center py-3"> <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"> <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="d-flex align-items-start mb-3">
<div class="bg-primary bg-opacity-10 rounded p-2 me-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"> <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> </svg>
</div> </div>
<div> <div>
@@ -1829,6 +1827,27 @@
<div class="col-12"> <div class="col-12">
<div class="upload-progress-stack" data-upload-progress></div> <div class="upload-progress-stack" data-upload-progress></div>
</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="col-12 d-none" id="bulkUploadProgress">
<div class="alert alert-info small mb-0"> <div class="alert alert-info small mb-0">
<div class="d-flex justify-content-between align-items-center mb-2"> <div class="d-flex justify-content-between align-items-center mb-2">
@@ -1874,6 +1893,26 @@
</div> </div>
</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 fade" id="deleteBucketModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered"> <div class="modal-dialog modal-dialog-centered">
<div class="modal-content"> <div class="modal-content">
@@ -2092,6 +2131,46 @@
</div> </div>
</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 fade" id="disableReplicationModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered"> <div class="modal-dialog modal-dialog-centered">
<div class="modal-content"> <div class="modal-content">
@@ -2318,100 +2397,37 @@
{% endblock %} {% endblock %}
{% block extra_scripts %} {% 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> <script>
function setupJsonAutoIndent(textarea) { const { formatBytes, escapeHtml, fallbackCopy, setupJsonAutoIndent } = window.BucketDetailUtils || {
if (!textarea) return; formatBytes: (bytes) => {
if (!Number.isFinite(bytes)) return `${bytes} bytes`;
textarea.addEventListener('keydown', function(e) { const units = ['bytes', 'KB', 'MB', 'GB', 'TB'];
if (e.key === 'Enter') { let i = 0;
e.preventDefault(); let size = bytes;
while (size >= 1024 && i < units.length - 1) {
const start = this.selectionStart; size /= 1024;
const end = this.selectionEnd; i++;
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 }));
} }
return `${size.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
if (e.key === 'Tab') { },
e.preventDefault(); escapeHtml: (value) => {
const start = this.selectionStart; if (value === null || value === undefined) return '';
const end = this.selectionEnd; return String(value)
.replace(/&/g, '&amp;')
if (e.shiftKey) { .replace(/</g, '&lt;')
const lineStart = this.value.lastIndexOf('\n', start - 1) + 1; .replace(/>/g, '&gt;')
const lineContent = this.value.substring(lineStart, start); .replace(/"/g, '&quot;')
if (lineContent.startsWith(' ')) { .replace(/'/g, '&#039;');
this.value = this.value.substring(0, lineStart) + },
this.value.substring(lineStart + 2); fallbackCopy: () => false,
this.selectionStart = this.selectionEnd = Math.max(lineStart, start - 2); setupJsonAutoIndent: () => {}
} };
} 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 }));
}
});
}
setupJsonAutoIndent(document.getElementById('policyDocument')); 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 selectAllCheckbox = document.querySelector('[data-select-all]');
const bulkDeleteButton = document.querySelector('[data-bulk-delete-trigger]'); const bulkDeleteButton = document.querySelector('[data-bulk-delete-trigger]');
const bulkDeleteLabel = bulkDeleteButton?.querySelector('[data-bulk-delete-label]'); const bulkDeleteLabel = bulkDeleteButton?.querySelector('[data-bulk-delete-label]');
@@ -4137,10 +4153,89 @@
const bulkUploadErrorList = document.getElementById('bulkUploadErrorList'); const bulkUploadErrorList = document.getElementById('bulkUploadErrorList');
const uploadKeyPrefix = document.getElementById('uploadKeyPrefix'); const uploadKeyPrefix = document.getElementById('uploadKeyPrefix');
const singleFileOptions = document.getElementById('singleFileOptions'); 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 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 = () => { const refreshUploadDropLabel = () => {
if (!uploadDropZoneLabel) return; 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; const files = uploadFileInput.files;
if (!files || files.length === 0) { if (!files || files.length === 0) {
uploadDropZoneLabel.textContent = 'No file selected'; uploadDropZoneLabel.textContent = 'No file selected';
@@ -4156,6 +4251,16 @@
const updateUploadBtnText = () => { const updateUploadBtnText = () => {
if (!uploadBtnText) return; 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; const files = uploadFileInput.files;
if (!files || files.length <= 1) { if (!files || files.length <= 1) {
uploadBtnText.textContent = 'Upload'; uploadBtnText.textContent = 'Upload';
@@ -4179,6 +4284,7 @@
uploadDropZone.style.pointerEvents = ''; uploadDropZone.style.pointerEvents = '';
} }
isUploading = false; isUploading = false;
hideFloatingProgress();
}; };
const MULTIPART_THRESHOLD = 8 * 1024 * 1024; const MULTIPART_THRESHOLD = 8 * 1024 * 1024;
@@ -4275,10 +4381,15 @@
loaded: uploadedBytes, loaded: uploadedBytes,
total: file.size total: file.size
}); });
uploadStats.currentFileLoaded = uploadedBytes;
updateFloatingProgress();
const partResp = await fetch(`${partUrl}?partNumber=${partNumber}`, { const partResp = await fetch(`${partUrl}?partNumber=${partNumber}`, {
method: 'PUT', method: 'PUT',
headers: { 'X-CSRFToken': csrfToken || '' }, headers: {
'X-CSRFToken': csrfToken || '',
'Content-Type': 'application/octet-stream'
},
body: chunk body: chunk
}); });
@@ -4295,6 +4406,8 @@
loaded: uploadedBytes, loaded: uploadedBytes,
total: file.size total: file.size
}); });
uploadStats.currentFileLoaded = uploadedBytes;
updateFloatingProgress();
} }
updateProgressItem(progressItem, { status: 'Completing...', loaded: file.size, total: file.size }); updateProgressItem(progressItem, { status: 'Completing...', loaded: file.size, total: file.size });
@@ -4338,6 +4451,8 @@
loaded: e.loaded, loaded: e.loaded,
total: e.total total: e.total
}); });
uploadStats.currentFileLoaded = e.loaded;
updateFloatingProgress();
} }
}); });
@@ -4399,18 +4514,148 @@
const setUploadLockState = (locked) => { const setUploadLockState = (locked) => {
if (uploadDropZone) { if (uploadDropZone) {
uploadDropZone.classList.toggle('upload-locked', locked); 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) => { 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 keyPrefix = (uploadKeyPrefix?.value || '').trim();
const metadataRaw = uploadForm.querySelector('textarea[name="metadata"]')?.value?.trim(); const metadataRaw = uploadForm.querySelector('textarea[name="metadata"]')?.value?.trim();
let metadata = null; let metadata = null;
@@ -4419,78 +4664,52 @@
metadata = JSON.parse(metadataRaw); metadata = JSON.parse(metadataRaw);
} catch { } catch {
showMessage({ title: 'Invalid metadata', body: 'Metadata must be valid JSON.', variant: 'danger' }); showMessage({ title: 'Invalid metadata', body: 'Metadata must be valid JSON.', variant: 'danger' });
resetUploadUI();
return; return;
} }
} }
if (bulkUploadProgress) bulkUploadProgress.classList.remove('d-none'); if (!isUploading) {
if (bulkUploadResults) bulkUploadResults.classList.add('d-none'); isUploading = true;
if (uploadSubmitBtn) uploadSubmitBtn.disabled = true; uploadSuccessFiles = [];
if (uploadFileInput) uploadFileInput.disabled = true; uploadErrorFiles = [];
uploadStats = {
totalFiles: 0,
completedFiles: 0,
totalBytes: 0,
uploadedBytes: 0,
currentFileBytes: 0,
currentFileLoaded: 0,
currentFileName: ''
};
const successFiles = []; if (bulkUploadProgress) bulkUploadProgress.classList.remove('d-none');
const errorFiles = []; if (bulkUploadResults) bulkUploadResults.classList.add('d-none');
const total = files.length; if (uploadSubmitBtn) uploadSubmitBtn.disabled = true;
refreshUploadDropLabel();
for (let i = 0; i < total; i++) { updateUploadBtnText();
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.add('d-none'); const fileCount = files.length;
if (bulkUploadResults) bulkUploadResults.classList.remove('d-none'); addFilesToQueue(Array.from(files), keyPrefix, metadata);
if (bulkUploadSuccessCount) bulkUploadSuccessCount.textContent = successFiles.length; if (uploadFileInput) {
if (successFiles.length === 0 && bulkUploadSuccessAlert) { uploadFileInput.value = '';
bulkUploadSuccessAlert.classList.add('d-none');
} }
refreshUploadDropLabel();
updateUploadBtnText();
if (errorFiles.length > 0) { processUploadQueue();
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('');
}
}
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;
}
}; };
refreshUploadDropLabel(); refreshUploadDropLabel();
uploadFileInput.addEventListener('change', () => { uploadFileInput.addEventListener('change', () => {
if (isUploading) return;
refreshUploadDropLabel(); refreshUploadDropLabel();
updateUploadBtnText(); updateUploadBtnText();
resetUploadUI(); if (!isUploading) {
resetUploadUI();
}
}); });
uploadDropZone?.addEventListener('click', () => { uploadDropZone?.addEventListener('click', () => {
if (isUploading) return;
uploadFileInput?.click(); uploadFileInput?.click();
}); });
@@ -4537,11 +4756,25 @@
} }
}); });
uploadModalEl?.addEventListener('hide.bs.modal', (event) => {
if (isUploading) {
showFloatingProgress();
}
});
uploadModalEl?.addEventListener('hidden.bs.modal', () => { uploadModalEl?.addEventListener('hidden.bs.modal', () => {
resetUploadUI(); if (!isUploading) {
uploadFileInput.value = ''; resetUploadUI();
refreshUploadDropLabel(); uploadFileInput.value = '';
updateUploadBtnText(); refreshUploadDropLabel();
updateUploadBtnText();
}
});
uploadModalEl?.addEventListener('show.bs.modal', () => {
if (isUploading) {
hideFloatingProgress();
}
}); });
const preventDefaults = (event) => { const preventDefaults = (event) => {
@@ -4554,7 +4787,6 @@
['dragenter', 'dragover'].forEach((eventName) => { ['dragenter', 'dragover'].forEach((eventName) => {
target.addEventListener(eventName, (event) => { target.addEventListener(eventName, (event) => {
preventDefaults(event); preventDefaults(event);
if (isUploading) return;
if (highlightClass) { if (highlightClass) {
target.classList.add(highlightClass); target.classList.add(highlightClass);
} }
@@ -4569,14 +4801,19 @@
}); });
}); });
target.addEventListener('drop', (event) => { target.addEventListener('drop', (event) => {
if (isUploading) return; if (!event.dataTransfer?.files?.length) {
if (!event.dataTransfer?.files?.length || !uploadFileInput) {
return; return;
} }
uploadFileInput.files = event.dataTransfer.files; if (isUploading) {
uploadFileInput.dispatchEvent(new Event('change', { bubbles: true })); performBulkUpload(event.dataTransfer.files);
if (autoOpenModal && uploadModal) { } else {
uploadModal.show(); if (uploadFileInput) {
uploadFileInput.files = event.dataTransfer.files;
uploadFileInput.dispatchEvent(new Event('change', { bubbles: true }));
}
if (autoOpenModal && uploadModal) {
uploadModal.show();
}
} }
}); });
}; };