diff --git a/app/ui.py b/app/ui.py index 34cbb4b..233ef62 100644 --- a/app/ui.py +++ b/app/ui.py @@ -1,5 +1,6 @@ from __future__ import annotations +import io import json import uuid import psutil @@ -28,7 +29,7 @@ from flask_wtf.csrf import generate_csrf from .acl import AclService, create_canned_acl, CANNED_ACLS from .bucket_policies import BucketPolicyStore from .connections import ConnectionStore, RemoteConnection -from .extensions import limiter +from .extensions import limiter, csrf from .iam import IamError from .kms import KMSManager from .replication import ReplicationManager, ReplicationRule @@ -564,6 +565,7 @@ def initiate_multipart_upload(bucket_name: str): @ui_bp.put("/buckets//multipart//parts") @limiter.exempt +@csrf.exempt def upload_multipart_part(bucket_name: str, upload_id: str): principal = _current_principal() try: @@ -577,7 +579,11 @@ def upload_multipart_part(bucket_name: str, upload_id: str): if part_number < 1: return jsonify({"error": "partNumber must be >= 1"}), 400 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: return jsonify({"error": str(exc)}), 400 return jsonify({"etag": etag, "part_number": part_number}) diff --git a/static/css/main.css b/static/css/main.css index 1d39238..1ca7d53 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -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; diff --git a/static/js/bucket-detail-operations.js b/static/js/bucket-detail-operations.js new file mode 100644 index 0000000..d5791d4 --- /dev/null +++ b/static/js/bucket-detail-operations.js @@ -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 = `${escapeHtml(data.error || 'Failed to load')}`; + return; + } + + const rules = data.rules || []; + if (rules.length === 0) { + body.innerHTML = 'No lifecycle rules configured'; + 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 ` + + ${escapeHtml(rule.id)} + ${escapeHtml(rule.prefix || '(all)')} + ${actions.map(a => `
${escapeHtml(a)}
`).join('')} + + ${escapeHtml(rule.status)} + + + + + + `; + }).join(''); + } catch (err) { + body.innerHTML = `${escapeHtml(err.message)}`; + } + } + + 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 = `${escapeHtml(data.error || 'Failed to load')}`; + return; + } + + const rules = data.rules || []; + if (rules.length === 0) { + body.innerHTML = 'No CORS rules configured'; + return; + } + + body.innerHTML = rules.map((rule, idx) => ` + + ${(rule.allowed_origins || []).map(o => `${escapeHtml(o)}`).join('')} + ${(rule.allowed_methods || []).map(m => `${escapeHtml(m)}`).join('')} + ${(rule.allowed_headers || []).slice(0, 3).join(', ')}${(rule.allowed_headers || []).length > 3 ? '...' : ''} + ${rule.max_age_seconds || 0}s + + + + + `).join(''); + } catch (err) { + body.innerHTML = `${escapeHtml(err.message)}`; + } + } + + 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 = `${escapeHtml(data.error || 'Failed to load')}`; + return; + } + + const grants = data.grants || []; + if (grants.length === 0) { + body.innerHTML = 'No ACL grants configured'; + 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 ` + + ${escapeHtml(grantee)} + ${escapeHtml(grant.permission)} + ${escapeHtml(grant.grantee_type)} + + `; + }).join(''); + } catch (err) { + body.innerHTML = `${escapeHtml(err.message)}`; + } + } + + 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 + }; +})(); diff --git a/static/js/bucket-detail-upload.js b/static/js/bucket-detail-upload.js new file mode 100644 index 0000000..6b6a655 --- /dev/null +++ b/static/js/bucket-detail-upload.js @@ -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 = ` +
+
+
${callbacks.escapeHtml(file.name)}
+
${callbacks.formatBytes(file.size)}
+
+
Preparing...
+
+
+
+
+
+
+ 0 B + 0% +
+
+ `; + 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 = `
${callbacks.escapeHtml(error)}
`; + } + } + } + + 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 => `
  • ${callbacks.escapeHtml(f.name)}: ${callbacks.escapeHtml(f.error)}
  • `) + .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 + }; +})(); diff --git a/static/js/bucket-detail-utils.js b/static/js/bucket-detail-utils.js new file mode 100644 index 0000000..43cc91e --- /dev/null +++ b/static/js/bucket-detail-utils.js @@ -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, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + 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 + }; +})(); diff --git a/templates/bucket_detail.html b/templates/bucket_detail.html index 270e10f..403bf56 100644 --- a/templates/bucket_detail.html +++ b/templates/bucket_detail.html @@ -403,7 +403,7 @@ @@ -628,27 +628,25 @@ {% endif %} {% if can_manage_versioning %} -
    - {% if versioning_enabled %} - - {% else %} + + - {% endif %}
    + {% endif %} {% else %}
    @@ -1656,7 +1654,7 @@
    - +
    @@ -1829,6 +1827,27 @@
    +
    +
    +
    + + + + + Queued files + +
    + 0 + +
    +
    +
      +
      +
      @@ -1874,6 +1893,26 @@
      +
      +
      +
      +
      +
      + Uploading files... +
      + +
      +
      +
      +
      +
      Preparing...
      +
      +
      +