Improve bucket details UI layout

This commit is contained in:
2025-11-30 21:19:35 +08:00
parent 6a31a9082e
commit 53326f4e41
5 changed files with 684 additions and 53 deletions

View File

@@ -289,6 +289,27 @@ class ObjectStorage:
safe_key = self._sanitize_object_key(object_key) safe_key = self._sanitize_object_key(object_key)
return self._read_metadata(bucket_path.name, safe_key) or {} 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: def delete_object(self, bucket_name: str, object_key: str) -> None:
bucket_path = self._bucket_path(bucket_name) bucket_path = self._bucket_path(bucket_name)
path = self._object_path(bucket_name, object_key) path = self._object_path(bucket_name, object_key)
@@ -303,12 +324,7 @@ class ObjectStorage:
self._delete_metadata(bucket_id, rel) self._delete_metadata(bucket_id, rel)
self._invalidate_bucket_stats_cache(bucket_id) self._invalidate_bucket_stats_cache(bucket_id)
self._cleanup_empty_parents(path, bucket_path)
for parent in path.parents:
if parent == bucket_path:
break
if parent.exists() and not any(parent.iterdir()):
parent.rmdir()
def purge_object(self, bucket_name: str, object_key: str) -> None: def purge_object(self, bucket_name: str, object_key: str) -> None:
bucket_path = self._bucket_path(bucket_name) bucket_path = self._bucket_path(bucket_name)
@@ -330,12 +346,7 @@ class ObjectStorage:
# Invalidate bucket stats cache # Invalidate bucket stats cache
self._invalidate_bucket_stats_cache(bucket_id) self._invalidate_bucket_stats_cache(bucket_id)
self._cleanup_empty_parents(target, bucket_path)
for parent in target.parents:
if parent == bucket_path:
break
if parent.exists() and not any(parent.iterdir()):
parent.rmdir()
def is_versioning_enabled(self, bucket_name: str) -> bool: def is_versioning_enabled(self, bucket_name: str) -> bool:
bucket_path = self._bucket_path(bucket_name) bucket_path = self._bucket_path(bucket_name)

View File

@@ -712,12 +712,15 @@ def object_presign(bucket_name: str, object_key: str):
except IamError as exc: except IamError as exc:
return jsonify({"error": str(exc)}), 403 return jsonify({"error": str(exc)}), 403
connection_url = "http://127.0.0.1:5000" api_base = current_app.config.get("API_BASE_URL") or "http://127.0.0.1:5000"
url = f"{connection_url}/presign/{bucket_name}/{object_key}" 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 = _api_headers()
headers["X-Forwarded-Host"] = request.host headers["X-Forwarded-Host"] = parsed_api.netloc or "127.0.0.1:5000"
headers["X-Forwarded-Proto"] = request.scheme headers["X-Forwarded-Proto"] = parsed_api.scheme or "http"
headers["X-Forwarded-For"] = request.remote_addr or "127.0.0.1" headers["X-Forwarded-For"] = request.remote_addr or "127.0.0.1"
try: try:

View File

@@ -1,7 +1,7 @@
"""Central location for the application version string.""" """Central location for the application version string."""
from __future__ import annotations from __future__ import annotations
APP_VERSION = "0.1.2" APP_VERSION = "0.1.3"
def get_version() -> str: def get_version() -> str:

View File

@@ -396,12 +396,25 @@ code {
.preview-card { top: 1rem; } .preview-card { top: 1rem; }
.preview-stage { .preview-stage {
min-height: 260px;
background-color: var(--myfsio-preview-bg); background-color: var(--myfsio-preview-bg);
overflow: hidden; overflow: hidden;
border-color: var(--myfsio-card-border) !important; 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 { .upload-progress-stack {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -928,6 +941,19 @@ pre code {
background-color: var(--myfsio-hover-bg) !important; 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 { .btn-group-sm .btn {
padding: 0.25rem 0.6rem; padding: 0.25rem 0.6rem;
font-size: 0.875rem; font-size: 0.875rem;

View File

@@ -84,7 +84,7 @@
</svg> </svg>
Upload Upload
</button> </button>
<input id="object-search" class="form-control form-control-sm" type="search" placeholder="Filter objects" /> <input id="object-search" class="form-control form-control-sm" type="search" placeholder="Filter objects" style="max-width: 180px;" />
<button class="btn btn-outline-danger btn-sm d-none" type="button" data-bulk-delete-trigger disabled> <button class="btn btn-outline-danger btn-sm d-none" type="button" data-bulk-delete-trigger disabled>
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="currentColor" class="bi bi-check2-square me-1" viewBox="0 0 16 16"> <svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="currentColor" class="bi bi-check2-square me-1" viewBox="0 0 16 16">
<path d="M14 1a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1zM2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2z" /> <path d="M14 1a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1zM2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2z" />
@@ -100,6 +100,19 @@
Download Download
</button> </button>
</div> </div>
<!-- Folder breadcrumb navigation -->
<nav id="folder-breadcrumb" class="mt-2 d-none" aria-label="Folder navigation">
<ol class="breadcrumb mb-0 small">
<li class="breadcrumb-item">
<a href="#" data-folder-nav="" class="text-decoration-none">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
<path d="M.54 3.87.5 3a2 2 0 0 1 2-2h3.672a2 2 0 0 1 1.414.586l.828.828A2 2 0 0 0 9.828 3H14.5A1.5 1.5 0 0 1 16 4.5v1.384l-4.578-.724a3 3 0 0 0-2.856 1.13l-2.847 3.55A3 3 0 0 0 5 11.373V14H2.5A1.5 1.5 0 0 1 1 12.5v-8A1.5 1.5 0 0 1 2.5 3H6c-.314 0-.6-.172-.742-.438l-.328-.658A.5.5 0 0 0 4.47 1.657z"/>
</svg>
Root
</a>
</li>
</ol>
</nav>
</div> </div>
<div <div
class="table-responsive objects-table-container drop-zone" class="table-responsive objects-table-container drop-zone"
@@ -287,16 +300,16 @@
<div id="version-list" class="list-group list-group-flush small rounded overflow-hidden" style="border: 1px solid var(--myfsio-card-border);"></div> <div id="version-list" class="list-group list-group-flush small rounded overflow-hidden" style="border: 1px solid var(--myfsio-card-border);"></div>
</div> </div>
<div class="preview-stage border rounded position-relative overflow-hidden" style="border-radius: 0.75rem !important;"> <div class="preview-stage border rounded position-relative overflow-hidden" style="border-radius: 0.75rem !important;">
<div id="preview-placeholder" class="text-muted text-center py-5"> <div id="preview-placeholder" class="text-muted text-center">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" class="mb-2 opacity-50" viewBox="0 0 16 16"> <svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="currentColor" class="mb-2 opacity-50" viewBox="0 0 16 16">
<path d="M6.002 5.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0z"/> <path d="M6.002 5.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0z"/>
<path d="M2.002 1a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V3a2 2 0 0 0-2-2h-12zm12 1a1 1 0 0 1 1 1v6.5l-3.777-1.947a.5.5 0 0 0-.577.093l-3.71 3.71-2.66-1.772a.5.5 0 0 0-.63.062L1.002 12V3a1 1 0 0 1 1-1h12z"/> <path d="M2.002 1a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V3a2 2 0 0 0-2-2h-12zm12 1a1 1 0 0 1 1 1v6.5l-3.777-1.947a.5.5 0 0 0-.577.093l-3.71 3.71-2.66-1.772a.5.5 0 0 0-.63.062L1.002 12V3a1 1 0 0 1 1-1h12z"/>
</svg> </svg>
<div class="small">No preview available</div> <div class="small">No preview available</div>
</div> </div>
<img id="preview-image" class="img-fluid d-none" alt="Object preview" /> <img id="preview-image" class="img-fluid d-none w-100" alt="Object preview" style="display: block;" />
<video id="preview-video" class="w-100 d-none" controls></video> <video id="preview-video" class="w-100 d-none" controls style="display: block;"></video>
<iframe id="preview-iframe" class="w-100 d-none" loading="lazy"></iframe> <iframe id="preview-iframe" class="w-100 d-none" loading="lazy" style="min-height: 200px;"></iframe>
</div> </div>
</div> </div>
</div> </div>
@@ -1022,18 +1035,18 @@
> >
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" /> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<div class="modal-body"> <div class="modal-body">
<p class="text-muted small mb-3">Upload files to <code>{{ bucket_name }}</code>. Leave object key blank to use the filename.</p> <p class="text-muted small mb-3">Upload files to <code>{{ bucket_name }}</code>. You can select multiple files at once.</p>
<div class="row g-3"> <div class="row g-3">
<div class="col-12"> <div class="col-12">
<label class="form-label fw-medium">Select file</label> <label class="form-label fw-medium">Select files</label>
<input class="form-control" type="file" name="object" id="uploadFileInput" required /> <input class="form-control" type="file" name="object" id="uploadFileInput" multiple required />
<div class="form-text">Select a file from your device. Files ≥ 8&nbsp;MB automatically switch to multipart uploads.</div> <div class="form-text">Select one or more files from your device. Files ≥ 8&nbsp;MB automatically switch to multipart uploads.</div>
</div> </div>
<div class="col-12"> <div class="col-12">
<div class="upload-dropzone text-center" data-dropzone> <div class="upload-dropzone text-center" data-dropzone>
<p class="fw-semibold mb-1">Drag &amp; drop files here</p> <p class="fw-semibold mb-1">Drag &amp; drop files here</p>
<p class="text-muted small mb-2">or click to browse</p> <p class="text-muted small mb-2">or click to browse (multiple files supported)</p>
<div class="text-muted small" data-dropzone-label>No file selected</div> <div class="text-muted small" data-dropzone-label>No files selected</div>
</div> </div>
</div> </div>
<div class="col-12"> <div class="col-12">
@@ -1051,12 +1064,17 @@
</button> </button>
<div class="collapse" id="uploadAdvancedOptions"> <div class="collapse" id="uploadAdvancedOptions">
<div class="p-3 border-top"> <div class="p-3 border-top">
<label class="form-label fw-medium">Object key</label> <div id="singleFileOptions">
<input class="form-control font-monospace" type="text" name="object_key" placeholder="folder/document.pdf" /> <label class="form-label fw-medium">Object key</label>
<div class="form-text mb-3">Leave blank to reuse the original filename.</div> <input class="form-control font-monospace" type="text" name="object_key" placeholder="folder/document.pdf" />
<div class="form-text mb-3">Leave blank to reuse the original filename. (Only applies when uploading a single file)</div>
</div>
<label class="form-label fw-medium">Key prefix <span class="text-muted fw-normal">(optional)</span></label>
<input class="form-control font-monospace" type="text" name="key_prefix" id="uploadKeyPrefix" placeholder="uploads/2024/" />
<div class="form-text mb-3">Add a prefix to all uploaded files (e.g., <code>folder/subfolder/</code>).</div>
<label class="form-label fw-medium">Metadata <span class="text-muted fw-normal">(JSON)</span></label> <label class="form-label fw-medium">Metadata <span class="text-muted fw-normal">(JSON)</span></label>
<textarea class="form-control font-monospace" name="metadata" rows="3" placeholder='{"project":"demo"}'></textarea> <textarea class="form-control font-monospace" name="metadata" rows="3" placeholder='{"project":"demo"}'></textarea>
<div class="form-text">Store custom key/value pairs alongside the object.</div> <div class="form-text">Store custom key/value pairs alongside each object.</div>
</div> </div>
</div> </div>
</div> </div>
@@ -1064,16 +1082,44 @@
<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="bulkUploadProgress">
<div class="alert alert-info small mb-0">
<div class="d-flex justify-content-between align-items-center mb-2">
<span id="bulkUploadStatus">Uploading files...</span>
<span id="bulkUploadCounter">0/0</span>
</div>
<div class="progress" style="height: 8px;">
<div class="progress-bar progress-bar-striped progress-bar-animated" id="bulkUploadProgressBar" role="progressbar" style="width: 0%;"></div>
</div>
<div id="bulkUploadCurrentFile" class="mt-2 text-muted small"></div>
</div>
</div>
<div class="col-12 d-none" id="bulkUploadResults">
<div class="alert alert-success small mb-2" id="bulkUploadSuccessAlert">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="me-1" viewBox="0 0 16 16">
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zm-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z"/>
</svg>
<span id="bulkUploadSuccessCount">0</span> file(s) uploaded successfully
</div>
<div class="alert alert-danger small mb-0 d-none" id="bulkUploadErrorAlert">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="me-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.566z"/>
<path d="M7.002 12a1 1 0 1 1 2 0 1 1 0 0 1-2 0zM7.1 5.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995z"/>
</svg>
<span id="bulkUploadErrorCount">0</span> file(s) failed to upload
<ul class="mb-0 mt-1" id="bulkUploadErrorList"></ul>
</div>
</div>
</div> </div>
</div> </div>
<div class="modal-footer border-0"> <div class="modal-footer border-0">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button> <button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal" id="uploadCancelBtn">Cancel</button>
<button class="btn btn-primary" type="submit"> <button class="btn btn-primary" type="submit" id="uploadSubmitBtn">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="bi bi-upload me-1" viewBox="0 0 16 16"> <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="bi bi-upload me-1" viewBox="0 0 16 16">
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/> <path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/>
<path d="M7.646 1.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1-.708.708L8.5 2.707V11.5a.5.5 0 0 1-1 0V2.707L5.354 4.854a.5.5 0 1 1-.708-.708l3-3z"/> <path d="M7.646 1.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1-.708.708L8.5 2.707V11.5a.5.5 0 0 1-1 0V2.707L5.354 4.854a.5.5 0 1 1-.708-.708l3-3z"/>
</svg> </svg>
Upload <span id="uploadBtnText">Upload</span>
</button> </button>
</div> </div>
</form> </form>
@@ -1443,6 +1489,277 @@
if (generatePresignButton) generatePresignButton.disabled = true; if (generatePresignButton) generatePresignButton.disabled = true;
if (downloadButton) downloadButton.classList.add('disabled'); 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 = `
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
<path d="M8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4.5a.5.5 0 0 0 .5-.5v-4h2v4a.5.5 0 0 0 .5.5H14a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146zM2.5 14V7.707l5.5-5.5 5.5 5.5V14H10v-4a.5.5 0 0 0-.5-.5h-3a.5.5 0 0 0-.5.5v4H2.5z"/>
</svg>
Root
`;
} else {
rootLi.innerHTML = `
<a href="#" data-folder-nav="" class="text-decoration-none">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
<path d="M8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4.5a.5.5 0 0 0 .5-.5v-4h2v4a.5.5 0 0 0 .5.5H14a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146zM2.5 14V7.707l5.5-5.5 5.5 5.5V14H10v-4a.5.5 0 0 0-.5-.5h-3a.5.5 0 0 0-.5.5v4H2.5z"/>
</svg>
Root
</a>
`;
}
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 = `
<td class="text-center align-middle" onclick="event.stopPropagation();">
<input class="form-check-input" type="checkbox" data-folder-select="${escapeHtml(folderPath)}" aria-label="Select folder" />
</td>
<td class="object-key text-break">
<div class="fw-medium d-flex align-items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" class="text-warning flex-shrink-0" viewBox="0 0 16 16">
<path d="M9.828 3h3.982a2 2 0 0 1 1.992 2.181l-.637 7A2 2 0 0 1 13.174 14H2.825a2 2 0 0 1-1.991-1.819l-.637-7a1.99 1.99 0 0 1 .342-1.31L.5 3a2 2 0 0 1 2-2h3.672a2 2 0 0 1 1.414.586l.828.828A2 2 0 0 0 9.828 3zm-8.322.12C1.72 3.042 1.95 3 2.19 3h5.396l-.707-.707A1 1 0 0 0 6.172 2H2.5a1 1 0 0 0-1 .981l.006.139z"/>
</svg>
<span>${escapeHtml(folderName)}/</span>
</div>
<div class="text-muted small ms-4 ps-2">${objectCount} object${objectCount !== 1 ? 's' : ''}</div>
</td>
<td class="text-end text-nowrap">
<span class="text-muted small">—</span>
</td>
<td class="text-end">
<button type="button" class="btn btn-outline-primary btn-sm" title="Open folder">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z"/>
</svg>
</button>
</td>
`;
// 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 = `
<td colspan="4" class="py-5">
<div class="empty-state">
<div class="empty-state-icon mx-auto" style="width: 64px; height: 64px;">
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="currentColor" viewBox="0 0 16 16">
<path d="M9.828 3h3.982a2 2 0 0 1 1.992 2.181l-.637 7A2 2 0 0 1 13.174 14H2.825a2 2 0 0 1-1.991-1.819l-.637-7a1.99 1.99 0 0 1 .342-1.31L.5 3a2 2 0 0 1 2-2h3.672a2 2 0 0 1 1.414.586l.828.828A2 2 0 0 0 9.828 3zm-8.322.12C1.72 3.042 1.95 3 2.19 3h5.396l-.707-.707A1 1 0 0 0 6.172 2H2.5a1 1 0 0 0-1 .981l.006.139z"/>
</svg>
</div>
<h6 class="mb-2">Empty folder</h6>
<p class="text-muted small mb-0">This folder contains no objects.</p>
</div>
</td>
`;
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 }) => { const showMessage = ({ title = 'Notice', body = '', bodyHtml = null, variant = 'info', actionText = null, onAction = null }) => {
if (!messageModal) { if (!messageModal) {
window.alert(body || title); window.alert(body || title);
@@ -1589,10 +1906,15 @@
bulkDeleteConfirm.disabled = selectedCount === 0 || bulkDeleting; bulkDeleteConfirm.disabled = selectedCount === 0 || bulkDeleting;
} }
if (selectAllCheckbox) { 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.disabled = total === 0;
selectAllCheckbox.checked = selectedCount > 0 && selectedCount === total && total > 0; selectAllCheckbox.checked = visibleSelectedCount > 0 && visibleSelectedCount === total && total > 0;
selectAllCheckbox.indeterminate = selectedCount > 0 && selectedCount < total; selectAllCheckbox.indeterminate = visibleSelectedCount > 0 && visibleSelectedCount < total;
} }
}; };
@@ -2206,15 +2528,49 @@
updateBulkDeleteState(); updateBulkDeleteState();
// Initialize folder view if there are folders (must be after updateBulkDeleteState is defined)
if (hasFolders) {
renderBreadcrumb('');
renderObjectsView();
}
bulkDeleteButton?.addEventListener('click', () => openBulkDeleteModal()); bulkDeleteButton?.addEventListener('click', () => openBulkDeleteModal());
bulkDeleteConfirm?.addEventListener('click', () => performBulkDelete()); bulkDeleteConfirm?.addEventListener('click', () => performBulkDelete());
document.getElementById('object-search')?.addEventListener('input', (event) => { document.getElementById('object-search')?.addEventListener('input', (event) => {
const term = event.target.value.toLowerCase(); const term = event.target.value.toLowerCase();
rows.forEach((row) => {
const key = row.dataset.key.toLowerCase(); if (hasFolders) {
row.style.display = key.includes(term) ? '' : 'none'; // 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', () => { refreshVersionsButton?.addEventListener('click', () => {
@@ -2263,20 +2619,235 @@
}); });
if (uploadForm && uploadFileInput) { 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 = () => { const refreshUploadDropLabel = () => {
if (!uploadDropZoneLabel) return; if (!uploadDropZoneLabel) 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';
if (singleFileOptions) singleFileOptions.classList.remove('d-none');
return; return;
} }
uploadDropZoneLabel.textContent = files.length === 1 ? files[0].name : `${files.length} files selected`; 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 => `<li><strong>${escapeHtml(f.name)}</strong>: ${escapeHtml(f.error)}</li>`)
.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(); refreshUploadDropLabel();
uploadFileInput.addEventListener('change', refreshUploadDropLabel); uploadFileInput.addEventListener('change', () => {
refreshUploadDropLabel();
updateUploadBtnText();
resetUploadUI();
});
uploadDropZone?.addEventListener('click', () => uploadFileInput?.click()); 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) => { const preventDefaults = (event) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
@@ -2344,14 +2915,34 @@
selectAllCheckbox?.addEventListener('change', (event) => { selectAllCheckbox?.addEventListener('change', (event) => {
const shouldSelect = Boolean(event.target?.checked); const shouldSelect = Boolean(event.target?.checked);
rows.forEach((row) => {
const checkbox = row.querySelector('[data-object-select]'); if (hasFolders) {
if (!checkbox || checkbox.disabled) { // Select all objects that start with current prefix (including inside subfolders)
return; const objectsInCurrentView = allObjects.filter(obj => obj.key.startsWith(currentPrefix));
} objectsInCurrentView.forEach(obj => {
checkbox.checked = shouldSelect; const checkbox = obj.element.querySelector('[data-object-select]');
toggleRowSelection(row, shouldSelect); 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); setTimeout(updateBulkDownloadState, 0);
}); });