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

@@ -647,17 +647,18 @@ code {
}
.upload-dropzone.upload-locked {
opacity: 0.5;
cursor: not-allowed;
background-color: var(--myfsio-preview-bg);
background-color: rgba(59, 130, 246, 0.05);
border-color: #3b82f6;
border-style: dashed;
}
.upload-dropzone.upload-locked::after {
content: 'Upload in progress...';
content: 'Drop more files to add to queue';
display: block;
margin-top: 0.5rem;
font-size: 0.8rem;
color: var(--myfsio-muted);
color: #3b82f6;
font-weight: 500;
}
.metadata-stack .metadata-entry + .metadata-entry {
@@ -1878,6 +1879,37 @@ body.theme-transitioning * {
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 {
display: flex;
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
};
})();