diff --git a/app/storage.py b/app/storage.py
index 29e90d5..ccea22b 100644
--- a/app/storage.py
+++ b/app/storage.py
@@ -289,6 +289,27 @@ class ObjectStorage:
safe_key = self._sanitize_object_key(object_key)
return self._read_metadata(bucket_path.name, safe_key) or {}
+ def _cleanup_empty_parents(self, path: Path, stop_at: Path) -> None:
+ """Remove empty parent directories up to (but not including) stop_at.
+
+ On Windows/OneDrive, directories may be locked briefly after file deletion.
+ This method retries with a small delay to handle that case.
+ """
+ for parent in path.parents:
+ if parent == stop_at:
+ break
+ # Retry a few times with small delays for Windows/OneDrive
+ for attempt in range(3):
+ try:
+ if parent.exists() and not any(parent.iterdir()):
+ parent.rmdir()
+ break # Success, move to next parent
+ except OSError:
+ if attempt < 2:
+ time.sleep(0.1) # Brief delay before retry
+ # Final attempt failed - continue to next parent
+ break
+
def delete_object(self, bucket_name: str, object_key: str) -> None:
bucket_path = self._bucket_path(bucket_name)
path = self._object_path(bucket_name, object_key)
@@ -303,12 +324,7 @@ class ObjectStorage:
self._delete_metadata(bucket_id, rel)
self._invalidate_bucket_stats_cache(bucket_id)
-
- for parent in path.parents:
- if parent == bucket_path:
- break
- if parent.exists() and not any(parent.iterdir()):
- parent.rmdir()
+ self._cleanup_empty_parents(path, bucket_path)
def purge_object(self, bucket_name: str, object_key: str) -> None:
bucket_path = self._bucket_path(bucket_name)
@@ -330,12 +346,7 @@ class ObjectStorage:
# Invalidate bucket stats cache
self._invalidate_bucket_stats_cache(bucket_id)
-
- for parent in target.parents:
- if parent == bucket_path:
- break
- if parent.exists() and not any(parent.iterdir()):
- parent.rmdir()
+ self._cleanup_empty_parents(target, bucket_path)
def is_versioning_enabled(self, bucket_name: str) -> bool:
bucket_path = self._bucket_path(bucket_name)
diff --git a/app/ui.py b/app/ui.py
index 654d2c7..3fe71c0 100644
--- a/app/ui.py
+++ b/app/ui.py
@@ -712,12 +712,15 @@ def object_presign(bucket_name: str, object_key: str):
except IamError as exc:
return jsonify({"error": str(exc)}), 403
- connection_url = "http://127.0.0.1:5000"
- url = f"{connection_url}/presign/{bucket_name}/{object_key}"
+ api_base = current_app.config.get("API_BASE_URL") or "http://127.0.0.1:5000"
+ api_base = api_base.rstrip("/")
+ url = f"{api_base}/presign/{bucket_name}/{object_key}"
+ # Use API base URL for forwarded headers so presigned URLs point to API, not UI
+ parsed_api = urlparse(api_base)
headers = _api_headers()
- headers["X-Forwarded-Host"] = request.host
- headers["X-Forwarded-Proto"] = request.scheme
+ headers["X-Forwarded-Host"] = parsed_api.netloc or "127.0.0.1:5000"
+ headers["X-Forwarded-Proto"] = parsed_api.scheme or "http"
headers["X-Forwarded-For"] = request.remote_addr or "127.0.0.1"
try:
diff --git a/app/version.py b/app/version.py
index 950456f..4a04f44 100644
--- a/app/version.py
+++ b/app/version.py
@@ -1,7 +1,7 @@
"""Central location for the application version string."""
from __future__ import annotations
-APP_VERSION = "0.1.2"
+APP_VERSION = "0.1.3"
def get_version() -> str:
diff --git a/static/css/main.css b/static/css/main.css
index e603255..a75838a 100644
--- a/static/css/main.css
+++ b/static/css/main.css
@@ -396,12 +396,25 @@ code {
.preview-card { top: 1rem; }
.preview-stage {
- min-height: 260px;
background-color: var(--myfsio-preview-bg);
overflow: hidden;
border-color: var(--myfsio-card-border) !important;
}
+.preview-stage:has(#preview-placeholder:not(.d-none)) {
+ min-height: 0;
+}
+
+.preview-stage:has(#preview-image:not(.d-none)),
+.preview-stage:has(#preview-video:not(.d-none)),
+.preview-stage:has(#preview-iframe:not(.d-none)) {
+ min-height: 200px;
+}
+
+#preview-placeholder {
+ padding: 2rem 1rem;
+}
+
.upload-progress-stack {
display: flex;
flex-direction: column;
@@ -928,6 +941,19 @@ pre code {
background-color: var(--myfsio-hover-bg) !important;
}
+.folder-row {
+ background-color: var(--myfsio-section-bg);
+ transition: background-color 0.15s ease;
+}
+
+.folder-row:hover {
+ background-color: var(--myfsio-hover-bg) !important;
+}
+
+.folder-row td:first-child {
+ padding-left: 0.5rem;
+}
+
.btn-group-sm .btn {
padding: 0.25rem 0.6rem;
font-size: 0.875rem;
diff --git a/templates/bucket_detail.html b/templates/bucket_detail.html
index 47f95f4..4e2571e 100644
--- a/templates/bucket_detail.html
+++ b/templates/bucket_detail.html
@@ -84,7 +84,7 @@
Upload
-
+
+
+
-
-
@@ -1022,18 +1035,18 @@
>
-
Upload files to {{ bucket_name }}. Leave object key blank to use the filename.
+
Upload files to {{ bucket_name }}. You can select multiple files at once.
-
-
-
Select a file from your device. Files ≥ 8 MB automatically switch to multipart uploads.
+
+
+
Select one or more files from your device. Files ≥ 8 MB automatically switch to multipart uploads.
Drag & drop files here
-
or click to browse
-
No file selected
+
or click to browse (multiple files supported)
+
No files selected
@@ -1051,12 +1064,17 @@
-
-
-
Leave blank to reuse the original filename.
+
+
+
+
Leave blank to reuse the original filename. (Only applies when uploading a single file)
+
+
+
+
Add a prefix to all uploaded files (e.g., folder/subfolder/).
-
Store custom key/value pairs alongside the object.
+
Store custom key/value pairs alongside each object.
@@ -1064,16 +1082,44 @@
+
+
+
+ Uploading files...
+ 0/0
+
+
+
+
+
+
+
+
+
0 file(s) uploaded successfully
+
+
+
+
0 file(s) failed to upload
+
+
+
@@ -1443,6 +1489,277 @@
if (generatePresignButton) generatePresignButton.disabled = true;
if (downloadButton) downloadButton.classList.add('disabled');
+ // ========== Folder Navigation ==========
+ const folderBreadcrumb = document.getElementById('folder-breadcrumb');
+ const objectsTableBody = document.querySelector('#objects-table tbody');
+ let currentPrefix = '';
+ let allObjects = []; // Store all object data for folder navigation
+
+ // Collect all object data from the table rows
+ rows.forEach(row => {
+ allObjects.push({
+ key: row.dataset.key,
+ size: row.dataset.size,
+ lastModified: row.dataset.lastModified,
+ etag: row.dataset.etag,
+ previewUrl: row.dataset.previewUrl,
+ downloadUrl: row.dataset.downloadUrl,
+ presignEndpoint: row.dataset.presignEndpoint,
+ deleteEndpoint: row.dataset.deleteEndpoint,
+ metadata: row.dataset.metadata,
+ versionsEndpoint: row.dataset.versionsEndpoint,
+ restoreTemplate: row.dataset.restoreTemplate,
+ element: row
+ });
+ });
+
+ // Check if we have any prefixed objects (folders)
+ const hasFolders = allObjects.some(obj => obj.key.includes('/'));
+
+ // Get unique folder prefixes at a given level
+ const getFoldersAtPrefix = (prefix) => {
+ const folders = new Set();
+ const files = [];
+
+ allObjects.forEach(obj => {
+ const key = obj.key;
+ if (!key.startsWith(prefix)) return;
+
+ const remainder = key.slice(prefix.length);
+ const slashIndex = remainder.indexOf('/');
+
+ if (slashIndex === -1) {
+ // This is a file at this level
+ files.push(obj);
+ } else {
+ // This is a folder
+ const folderName = remainder.slice(0, slashIndex + 1);
+ folders.add(prefix + folderName);
+ }
+ });
+
+ return { folders: Array.from(folders).sort(), files };
+ };
+
+ // Count objects in a folder
+ const countObjectsInFolder = (folderPrefix) => {
+ return allObjects.filter(obj => obj.key.startsWith(folderPrefix)).length;
+ };
+
+ // Render breadcrumb
+ const renderBreadcrumb = (prefix) => {
+ if (!folderBreadcrumb) return;
+
+ if (!prefix && !hasFolders) {
+ folderBreadcrumb.classList.add('d-none');
+ return;
+ }
+
+ folderBreadcrumb.classList.remove('d-none');
+ const ol = folderBreadcrumb.querySelector('ol');
+ ol.innerHTML = '';
+
+ // Root item
+ const rootLi = document.createElement('li');
+ rootLi.className = 'breadcrumb-item';
+ if (!prefix) {
+ rootLi.classList.add('active');
+ rootLi.setAttribute('aria-current', 'page');
+ rootLi.innerHTML = `
+
+ Root
+ `;
+ } else {
+ rootLi.innerHTML = `
+
+
+ Root
+
+ `;
+ }
+ ol.appendChild(rootLi);
+
+ // Build path segments
+ if (prefix) {
+ const parts = prefix.split('/').filter(Boolean);
+ let accumulated = '';
+ parts.forEach((part, index) => {
+ accumulated += part + '/';
+ const li = document.createElement('li');
+ li.className = 'breadcrumb-item';
+
+ if (index === parts.length - 1) {
+ li.classList.add('active');
+ li.setAttribute('aria-current', 'page');
+ li.textContent = part;
+ } else {
+ const a = document.createElement('a');
+ a.href = '#';
+ a.className = 'text-decoration-none';
+ a.dataset.folderNav = accumulated;
+ a.textContent = part;
+ li.appendChild(a);
+ }
+ ol.appendChild(li);
+ });
+ }
+
+ // Add click handlers
+ ol.querySelectorAll('[data-folder-nav]').forEach(link => {
+ link.addEventListener('click', (e) => {
+ e.preventDefault();
+ navigateToFolder(link.dataset.folderNav);
+ });
+ });
+ };
+
+ // Get all objects inside a folder (for bulk selection)
+ const getObjectsInFolder = (folderPrefix) => {
+ return allObjects.filter(obj => obj.key.startsWith(folderPrefix));
+ };
+
+ // Create folder row element
+ const createFolderRow = (folderPath) => {
+ const folderName = folderPath.slice(currentPrefix.length).replace(/\/$/, '');
+ const objectCount = countObjectsInFolder(folderPath);
+
+ const tr = document.createElement('tr');
+ tr.className = 'folder-row';
+ tr.dataset.folderPath = folderPath;
+ tr.style.cursor = 'pointer';
+
+ tr.innerHTML = `
+
+
+ |
+
+
+
+ ${escapeHtml(folderName)}/
+
+ ${objectCount} object${objectCount !== 1 ? 's' : ''}
+ |
+
+ —
+ |
+
+
+ |
+ `;
+
+ // Handle folder checkbox
+ const checkbox = tr.querySelector('[data-folder-select]');
+ checkbox?.addEventListener('change', (e) => {
+ e.stopPropagation();
+ const folderObjects = getObjectsInFolder(folderPath);
+ folderObjects.forEach(obj => {
+ const objCheckbox = obj.element.querySelector('[data-object-select]');
+ if (objCheckbox) {
+ objCheckbox.checked = checkbox.checked;
+ }
+ toggleRowSelection(obj.element, checkbox.checked);
+ });
+ });
+
+ tr.addEventListener('click', (e) => {
+ if (e.target.closest('[data-folder-select]') || e.target.closest('button')) return;
+ navigateToFolder(folderPath);
+ });
+
+ return tr;
+ };
+
+ // Navigate to a folder
+ const navigateToFolder = (prefix) => {
+ currentPrefix = prefix;
+ renderBreadcrumb(prefix);
+ renderObjectsView();
+ // Clear selection when navigating
+ selectedRows.clear();
+ // Defer updateBulkDeleteState call to ensure it's defined
+ if (typeof updateBulkDeleteState === 'function') {
+ updateBulkDeleteState();
+ }
+ // Clear preview
+ if (previewPanel) previewPanel.classList.add('d-none');
+ if (previewEmpty) previewEmpty.classList.remove('d-none');
+ activeRow = null;
+ };
+
+ // Render objects view based on current prefix
+ const renderObjectsView = () => {
+ if (!objectsTableBody) return;
+
+ const { folders, files } = getFoldersAtPrefix(currentPrefix);
+
+ // Clear table
+ objectsTableBody.innerHTML = '';
+
+ // Add folder rows first
+ folders.forEach(folderPath => {
+ objectsTableBody.appendChild(createFolderRow(folderPath));
+ });
+
+ // Add file rows
+ files.forEach(obj => {
+ objectsTableBody.appendChild(obj.element);
+ obj.element.style.display = '';
+ // Update displayed key to show just filename when inside a folder
+ const keyCell = obj.element.querySelector('.object-key .fw-medium');
+ if (keyCell && currentPrefix) {
+ const displayName = obj.key.slice(currentPrefix.length);
+ keyCell.textContent = displayName;
+ keyCell.closest('.object-key').title = obj.key; // Full path in tooltip
+ } else if (keyCell) {
+ keyCell.textContent = obj.key; // Reset to full key at root
+ }
+ });
+
+ // Hide files not in current view
+ allObjects.forEach(obj => {
+ if (!files.includes(obj)) {
+ obj.element.style.display = 'none';
+ }
+ });
+
+ // Show empty state if no content
+ if (folders.length === 0 && files.length === 0) {
+ const emptyRow = document.createElement('tr');
+ emptyRow.innerHTML = `
+
+
+
+ Empty folder
+ This folder contains no objects.
+
+ |
+ `;
+ objectsTableBody.appendChild(emptyRow);
+ }
+
+ // Update select all checkbox state - deferred to ensure function is defined
+ if (typeof updateBulkDeleteState === 'function') {
+ updateBulkDeleteState();
+ }
+ };
+
+ // Folder view initialization moved after updateBulkDeleteState is defined
+ // ========== End Folder Navigation ==========
+
const showMessage = ({ title = 'Notice', body = '', bodyHtml = null, variant = 'info', actionText = null, onAction = null }) => {
if (!messageModal) {
window.alert(body || title);
@@ -1589,10 +1906,15 @@
bulkDeleteConfirm.disabled = selectedCount === 0 || bulkDeleting;
}
if (selectAllCheckbox) {
- const total = rows.length;
+ // Count only visible rows in current folder view
+ const visibleRows = hasFolders
+ ? allObjects.filter(obj => obj.key.startsWith(currentPrefix) && !obj.key.slice(currentPrefix.length).includes('/')).map(obj => obj.element)
+ : Array.from(rows);
+ const total = visibleRows.filter(r => r.style.display !== 'none').length;
+ const visibleSelectedCount = visibleRows.filter(r => r.style.display !== 'none' && selectedRows.has(r.dataset.key)).length;
selectAllCheckbox.disabled = total === 0;
- selectAllCheckbox.checked = selectedCount > 0 && selectedCount === total && total > 0;
- selectAllCheckbox.indeterminate = selectedCount > 0 && selectedCount < total;
+ selectAllCheckbox.checked = visibleSelectedCount > 0 && visibleSelectedCount === total && total > 0;
+ selectAllCheckbox.indeterminate = visibleSelectedCount > 0 && visibleSelectedCount < total;
}
};
@@ -2206,15 +2528,49 @@
updateBulkDeleteState();
+ // Initialize folder view if there are folders (must be after updateBulkDeleteState is defined)
+ if (hasFolders) {
+ renderBreadcrumb('');
+ renderObjectsView();
+ }
+
bulkDeleteButton?.addEventListener('click', () => openBulkDeleteModal());
bulkDeleteConfirm?.addEventListener('click', () => performBulkDelete());
document.getElementById('object-search')?.addEventListener('input', (event) => {
const term = event.target.value.toLowerCase();
- rows.forEach((row) => {
- const key = row.dataset.key.toLowerCase();
- row.style.display = key.includes(term) ? '' : 'none';
- });
+
+ if (hasFolders) {
+ // With folder navigation: re-render view with search filter
+ const { folders, files } = getFoldersAtPrefix(currentPrefix);
+ const tbody = objectsTableBody;
+
+ // Clear and re-add matching content
+ tbody.innerHTML = '';
+
+ // Filter and add matching folders
+ folders.forEach(folderPath => {
+ const folderName = folderPath.slice(currentPrefix.length).replace(/\/$/, '').toLowerCase();
+ if (folderName.includes(term)) {
+ tbody.appendChild(createFolderRow(folderPath));
+ }
+ });
+
+ // Filter and add matching files
+ files.forEach(obj => {
+ const keyName = obj.key.slice(currentPrefix.length).toLowerCase();
+ if (keyName.includes(term)) {
+ tbody.appendChild(obj.element);
+ obj.element.style.display = '';
+ }
+ });
+ } else {
+ // Original behavior without folders
+ rows.forEach((row) => {
+ const key = row.dataset.key.toLowerCase();
+ row.style.display = key.includes(term) ? '' : 'none';
+ });
+ }
});
refreshVersionsButton?.addEventListener('click', () => {
@@ -2263,20 +2619,235 @@
});
if (uploadForm && uploadFileInput) {
+ const uploadSubmitBtn = document.getElementById('uploadSubmitBtn');
+ const uploadCancelBtn = document.getElementById('uploadCancelBtn');
+ const uploadBtnText = document.getElementById('uploadBtnText');
+ const bulkUploadProgress = document.getElementById('bulkUploadProgress');
+ const bulkUploadStatus = document.getElementById('bulkUploadStatus');
+ const bulkUploadCounter = document.getElementById('bulkUploadCounter');
+ const bulkUploadProgressBar = document.getElementById('bulkUploadProgressBar');
+ const bulkUploadCurrentFile = document.getElementById('bulkUploadCurrentFile');
+ const bulkUploadResults = document.getElementById('bulkUploadResults');
+ const bulkUploadSuccessAlert = document.getElementById('bulkUploadSuccessAlert');
+ const bulkUploadErrorAlert = document.getElementById('bulkUploadErrorAlert');
+ const bulkUploadSuccessCount = document.getElementById('bulkUploadSuccessCount');
+ const bulkUploadErrorCount = document.getElementById('bulkUploadErrorCount');
+ const bulkUploadErrorList = document.getElementById('bulkUploadErrorList');
+ const uploadKeyPrefix = document.getElementById('uploadKeyPrefix');
+ const singleFileOptions = document.getElementById('singleFileOptions');
+ let isUploading = false;
+
const refreshUploadDropLabel = () => {
if (!uploadDropZoneLabel) return;
const files = uploadFileInput.files;
if (!files || files.length === 0) {
uploadDropZoneLabel.textContent = 'No file selected';
+ if (singleFileOptions) singleFileOptions.classList.remove('d-none');
return;
}
uploadDropZoneLabel.textContent = files.length === 1 ? files[0].name : `${files.length} files selected`;
+ // Hide single file object key option when multiple files selected
+ if (singleFileOptions) {
+ singleFileOptions.classList.toggle('d-none', files.length > 1);
+ }
+ };
+
+ const updateUploadBtnText = () => {
+ if (!uploadBtnText) return;
+ const files = uploadFileInput.files;
+ if (!files || files.length <= 1) {
+ uploadBtnText.textContent = 'Upload';
+ } else {
+ uploadBtnText.textContent = `Upload ${files.length} files`;
+ }
+ };
+
+ const resetUploadUI = () => {
+ if (bulkUploadProgress) bulkUploadProgress.classList.add('d-none');
+ if (bulkUploadResults) bulkUploadResults.classList.add('d-none');
+ if (bulkUploadSuccessAlert) bulkUploadSuccessAlert.classList.remove('d-none');
+ if (bulkUploadErrorAlert) bulkUploadErrorAlert.classList.add('d-none');
+ if (bulkUploadErrorList) bulkUploadErrorList.innerHTML = '';
+ if (uploadSubmitBtn) uploadSubmitBtn.disabled = false;
+ if (uploadFileInput) uploadFileInput.disabled = false;
+ isUploading = false;
+ };
+
+ const uploadSingleFile = async (file, keyPrefix = '', metadata = null) => {
+ const formData = new FormData();
+ formData.append('object', file);
+ const objectKey = keyPrefix ? `${keyPrefix}${file.name}` : file.name;
+ formData.append('object_key', objectKey);
+ if (metadata) {
+ formData.append('metadata', JSON.stringify(metadata));
+ }
+ // Get CSRF token
+ const csrfToken = document.querySelector('input[name="csrf_token"]')?.value;
+ if (csrfToken) {
+ formData.append('csrf_token', csrfToken);
+ }
+
+ const response = await fetch(uploadForm.action, {
+ method: 'POST',
+ body: formData,
+ headers: {
+ 'X-Requested-With': 'XMLHttpRequest'
+ }
+ });
+
+ const data = await response.json().catch(() => ({}));
+ if (!response.ok || data.status === 'error') {
+ throw new Error(data.message || 'Upload failed');
+ }
+ return data;
+ };
+
+ const performBulkUpload = async (files) => {
+ if (isUploading || !files || files.length === 0) return;
+
+ isUploading = true;
+ const keyPrefix = (uploadKeyPrefix?.value || '').trim();
+ const metadataRaw = uploadForm.querySelector('textarea[name="metadata"]')?.value?.trim();
+ let metadata = null;
+ if (metadataRaw) {
+ try {
+ metadata = JSON.parse(metadataRaw);
+ } catch {
+ showMessage({ title: 'Invalid metadata', body: 'Metadata must be valid JSON.', variant: 'danger' });
+ resetUploadUI();
+ return;
+ }
+ }
+
+ // Show progress UI
+ if (bulkUploadProgress) bulkUploadProgress.classList.remove('d-none');
+ if (bulkUploadResults) bulkUploadResults.classList.add('d-none');
+ if (uploadSubmitBtn) uploadSubmitBtn.disabled = true;
+ if (uploadFileInput) uploadFileInput.disabled = true;
+
+ const successFiles = [];
+ const errorFiles = [];
+ const total = files.length;
+
+ for (let i = 0; i < total; i++) {
+ const file = files[i];
+ const current = i + 1;
+
+ // Update progress
+ 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' });
+ }
+ }
+
+ // Show results
+ if (bulkUploadProgress) bulkUploadProgress.classList.add('d-none');
+ if (bulkUploadResults) bulkUploadResults.classList.remove('d-none');
+
+ if (bulkUploadSuccessCount) bulkUploadSuccessCount.textContent = successFiles.length;
+ if (successFiles.length === 0 && bulkUploadSuccessAlert) {
+ bulkUploadSuccessAlert.classList.add('d-none');
+ }
+
+ if (errorFiles.length > 0) {
+ if (bulkUploadErrorCount) bulkUploadErrorCount.textContent = errorFiles.length;
+ if (bulkUploadErrorAlert) bulkUploadErrorAlert.classList.remove('d-none');
+ if (bulkUploadErrorList) {
+ bulkUploadErrorList.innerHTML = errorFiles
+ .map(f => `${escapeHtml(f.name)}: ${escapeHtml(f.error)}`)
+ .join('');
+ }
+ }
+
+ isUploading = false;
+
+ // Reload page if any files were uploaded successfully
+ if (successFiles.length > 0) {
+ // Keep button disabled and show uploading state until reload
+ if (uploadBtnText) uploadBtnText.textContent = 'Refreshing...';
+ // Short delay to show results, then reload
+ window.setTimeout(() => window.location.reload(), 800);
+ } else {
+ // Only re-enable if no success (all failed)
+ if (uploadSubmitBtn) uploadSubmitBtn.disabled = false;
+ if (uploadFileInput) uploadFileInput.disabled = false;
+ }
};
refreshUploadDropLabel();
- uploadFileInput.addEventListener('change', refreshUploadDropLabel);
+ uploadFileInput.addEventListener('change', () => {
+ refreshUploadDropLabel();
+ updateUploadBtnText();
+ resetUploadUI();
+ });
uploadDropZone?.addEventListener('click', () => uploadFileInput?.click());
+ // Handle form submission for bulk upload
+ uploadForm.addEventListener('submit', async (event) => {
+ const files = uploadFileInput.files;
+ if (!files || files.length === 0) return;
+
+ const keyPrefix = (uploadKeyPrefix?.value || '').trim();
+
+ // For single file with custom object key and NO prefix, use default form submission
+ if (files.length === 1 && !keyPrefix) {
+ const customKey = uploadForm.querySelector('input[name="object_key"]')?.value?.trim();
+ if (customKey) {
+ // Single file with custom key - let form submit normally
+ // Disable button immediately for feedback
+ if (uploadSubmitBtn) {
+ uploadSubmitBtn.disabled = true;
+ if (uploadBtnText) uploadBtnText.textContent = 'Uploading...';
+ }
+ return;
+ }
+ }
+
+ // Bulk upload or prefix specified - handle with JavaScript
+ event.preventDefault();
+
+ // Disable button immediately
+ if (uploadSubmitBtn) {
+ uploadSubmitBtn.disabled = true;
+ if (uploadBtnText) uploadBtnText.textContent = 'Uploading...';
+ }
+
+ await performBulkUpload(Array.from(files));
+ });
+
+ // Pre-fill key prefix with current folder when modal opens
+ uploadModalEl?.addEventListener('show.bs.modal', () => {
+ if (hasFolders && currentPrefix) {
+ uploadKeyPrefix.value = currentPrefix;
+ // Auto-expand advanced options if there's a prefix
+ const advancedToggle = document.querySelector('[data-bs-target="#advancedUploadOptions"]');
+ const advancedCollapse = document.getElementById('advancedUploadOptions');
+ if (advancedToggle && advancedCollapse && !advancedCollapse.classList.contains('show')) {
+ new bootstrap.Collapse(advancedCollapse, { show: true });
+ }
+ } else if (uploadKeyPrefix) {
+ // Clear prefix when at root
+ uploadKeyPrefix.value = '';
+ }
+ });
+
+ // Reset UI when modal is closed
+ uploadModalEl?.addEventListener('hidden.bs.modal', () => {
+ resetUploadUI();
+ uploadFileInput.value = '';
+ refreshUploadDropLabel();
+ updateUploadBtnText();
+ });
+
const preventDefaults = (event) => {
event.preventDefault();
event.stopPropagation();
@@ -2344,14 +2915,34 @@
selectAllCheckbox?.addEventListener('change', (event) => {
const shouldSelect = Boolean(event.target?.checked);
- rows.forEach((row) => {
- const checkbox = row.querySelector('[data-object-select]');
- if (!checkbox || checkbox.disabled) {
- return;
- }
- checkbox.checked = shouldSelect;
- toggleRowSelection(row, shouldSelect);
- });
+
+ if (hasFolders) {
+ // Select all objects that start with current prefix (including inside subfolders)
+ const objectsInCurrentView = allObjects.filter(obj => obj.key.startsWith(currentPrefix));
+ objectsInCurrentView.forEach(obj => {
+ const checkbox = obj.element.querySelector('[data-object-select]');
+ if (checkbox && !checkbox.disabled) {
+ checkbox.checked = shouldSelect;
+ }
+ toggleRowSelection(obj.element, shouldSelect);
+ });
+
+ // Also toggle folder checkboxes
+ document.querySelectorAll('[data-folder-select]').forEach(cb => {
+ cb.checked = shouldSelect;
+ });
+ } else {
+ // Original behavior without folders
+ rows.forEach((row) => {
+ if (row.style.display === 'none') return;
+ const checkbox = row.querySelector('[data-object-select]');
+ if (!checkbox || checkbox.disabled) {
+ return;
+ }
+ checkbox.checked = shouldSelect;
+ toggleRowSelection(row, shouldSelect);
+ });
+ }
setTimeout(updateBulkDownloadState, 0);
});