feat: Implement collection deletion and loading spinners
- Add API endpoint and handler to delete collections - Introduce LoadingSpinner component for async operations - Show loading spinners during file loading and preview rendering - Enhance modal accessibility by removing aria-hidden attribute - Refactor delete functionality to distinguish between collections and files/folders - Remove unused collection definitions from config
This commit is contained in:
44
collections/documents/docusaurus.md
Normal file
44
collections/documents/docusaurus.md
Normal file
@@ -0,0 +1,44 @@
|
||||
## Using Docusaurus
|
||||
|
||||
Once you've set up Hero, you can use it to develop, manage and publish Docusaurus websites.
|
||||
|
||||
## Launch the Hero Website
|
||||
|
||||
To start a Hero Docusaurus website in development mode:
|
||||
|
||||
- Build the book then close the prompt with `Ctrl+C`
|
||||
|
||||
```bash
|
||||
hero docs -d
|
||||
```
|
||||
|
||||
- See the book on the local browser
|
||||
|
||||
```
|
||||
bash /root/hero/var/docusaurus/develop.sh
|
||||
```
|
||||
|
||||
You can then view the website in your browser at `https://localhost:3100`.
|
||||
|
||||
## Publish a Website
|
||||
|
||||
- To build and publish a Hero website:
|
||||
- Development
|
||||
|
||||
```bash
|
||||
hero docs -bpd
|
||||
```
|
||||
|
||||
- Production
|
||||
|
||||
```bash
|
||||
hero docs -bp
|
||||
```
|
||||
|
||||
If you want to specify a different SSH key, use `-dk`:
|
||||
|
||||
```bash
|
||||
hero docs -bpd -dk ~/.ssh/id_ed25519
|
||||
```
|
||||
|
||||
> Note: The container handles the SSH agent and key management automatically on startup, so in most cases, you won't need to manually specify keys.
|
||||
67
collections/documents/getting_started/hero_docker.md
Normal file
67
collections/documents/getting_started/hero_docker.md
Normal file
@@ -0,0 +1,67 @@
|
||||
You can build Hero as a Docker container.
|
||||
|
||||
The code is availabe at this [open-source repository](https://github.com/mik-tf/hero-container).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker installed on your system (More info [here](https://manual.grid.tf/documentation/system_administrators/computer_it_basics/docker_basics.html#install-docker-desktop-and-docker-engine))
|
||||
- SSH keys for deploying Hero websites (if publishing)
|
||||
|
||||
## Build the Image
|
||||
|
||||
- Clone the repository
|
||||
|
||||
```
|
||||
git clone https://github.com/mik-tf/hero-container
|
||||
cd hero-container
|
||||
```
|
||||
|
||||
- Build the Docker image:
|
||||
|
||||
```bash
|
||||
docker build -t heroc .
|
||||
```
|
||||
|
||||
## Pull the Image from Docker Hub
|
||||
|
||||
If you don't want to build the image, you can pull it from Docker Hub.
|
||||
|
||||
```
|
||||
docker pull logismosis/heroc
|
||||
```
|
||||
|
||||
In this case, use `logismosi/heroc` instead of `heroc` to use the container.
|
||||
|
||||
## Run the Hero Container
|
||||
|
||||
You can run the container with an interactive shell:
|
||||
|
||||
```bash
|
||||
docker run -it heroc
|
||||
```
|
||||
|
||||
You can run the container with an interactive shell, while setting the host as your local network, mounting your current directory as the workspace and adding your SSH keys:
|
||||
|
||||
```bash
|
||||
docker run --network=host \
|
||||
-v $(pwd):/workspace \
|
||||
-v ~/.ssh:/root/ssh \
|
||||
-it heroc
|
||||
```
|
||||
|
||||
By default, the container will:
|
||||
|
||||
- Start Redis server in the background
|
||||
- Copy your SSH keys to the proper location
|
||||
- Initialize the SSH agent
|
||||
- Add your default SSH key (`id_ed25519`)
|
||||
|
||||
To use a different SSH key, specify it with the KEY environment variable (e.g. `KEY=id_ed25519`):
|
||||
|
||||
```bash
|
||||
docker run --network=host \
|
||||
-v $(pwd):/workspace \
|
||||
-v ~/.ssh:/root/ssh \
|
||||
-e KEY=your_custom_key_name \
|
||||
-it heroc
|
||||
```
|
||||
22
collections/documents/getting_started/hero_native.md
Normal file
22
collections/documents/getting_started/hero_native.md
Normal file
@@ -0,0 +1,22 @@
|
||||
## Basic Hero
|
||||
|
||||
You can build Hero natively with the following lines:
|
||||
|
||||
```
|
||||
curl https://raw.githubusercontent.com/freeflowuniverse/herolib/refs/heads/development/install_hero.sh > /tmp/install_hero.sh
|
||||
bash /tmp/install_hero.sh
|
||||
```
|
||||
|
||||
## Hero for Developers
|
||||
|
||||
For developers, use the following commands:
|
||||
|
||||
```
|
||||
curl 'https://raw.githubusercontent.com/freeflowuniverse/herolib/refs/heads/development/install_v.sh' > /tmp/install_v.sh
|
||||
bash /tmp/install_v.sh --analyzer --herolib
|
||||
#DONT FORGET TO START A NEW SHELL (otherwise the paths will not be set)
|
||||
```
|
||||
|
||||
## Hero with Docker
|
||||
|
||||
If you have issues running Hero natively, you can use the [Docker version of Hero](hero_docker.md).
|
||||
5
collections/documents/intro.md
Normal file
5
collections/documents/intro.md
Normal file
@@ -0,0 +1,5 @@
|
||||
This ebook contains the basic information to get you started with the Hero tool.
|
||||
|
||||
## What is Hero?
|
||||
|
||||
Hero is an open-source toolset to work with Git, AI, mdBook, Docusaurus, Starlight and more.
|
||||
1
collections/documents/support.md
Normal file
1
collections/documents/support.md
Normal file
@@ -0,0 +1 @@
|
||||
If you need help with Hero, reach out to the ThreeFold Support team [here](https://threefoldfaq.crisp.help/en/).
|
||||
12
config.yaml
12
config.yaml
@@ -8,18 +8,6 @@ collections:
|
||||
projects:
|
||||
path: ./collections/projects
|
||||
description: Project documentation
|
||||
new_collectionss:
|
||||
path: collections/new_collectionss
|
||||
description: 'User-created collection: new_collectionss'
|
||||
test_collection_new:
|
||||
path: collections/test_collection_new
|
||||
description: 'User-created collection: test_collection_new'
|
||||
dynamic_test:
|
||||
path: collections/dynamic_test
|
||||
description: 'User-created collection: dynamic_test'
|
||||
runtime_collection:
|
||||
path: collections/runtime_collection
|
||||
description: 'User-created collection: runtime_collection'
|
||||
7madah:
|
||||
path: collections/7madah
|
||||
description: 'User-created collection: 7madah'
|
||||
|
||||
@@ -104,6 +104,10 @@ class MarkdownEditorApp:
|
||||
if path == '/fs/' and method == 'POST':
|
||||
return self.handle_create_collection(environ, start_response)
|
||||
|
||||
# API to delete a collection
|
||||
if path.startswith('/api/collections/') and method == 'DELETE':
|
||||
return self.handle_delete_collection(environ, start_response)
|
||||
|
||||
# Check if path starts with a collection name (for SPA routing)
|
||||
# This handles URLs like /notes/ttt or /documents/file.md
|
||||
# MUST be checked BEFORE WebDAV routing to prevent WebDAV from intercepting SPA routes
|
||||
@@ -206,6 +210,67 @@ class MarkdownEditorApp:
|
||||
start_response('500 Internal Server Error', [('Content-Type', 'application/json')])
|
||||
return [json.dumps({'error': str(e)}).encode('utf-8')]
|
||||
|
||||
def handle_delete_collection(self, environ, start_response):
|
||||
"""Delete a collection"""
|
||||
try:
|
||||
# Extract collection name from path: /api/collections/{name}
|
||||
path = environ.get('PATH_INFO', '')
|
||||
collection_name = path.split('/')[-1]
|
||||
|
||||
if not collection_name:
|
||||
start_response('400 Bad Request', [('Content-Type', 'application/json')])
|
||||
return [json.dumps({'error': 'Collection name is required'}).encode('utf-8')]
|
||||
|
||||
# Check if collection exists
|
||||
if collection_name not in self.collections:
|
||||
start_response('404 Not Found', [('Content-Type', 'application/json')])
|
||||
return [json.dumps({'error': f'Collection "{collection_name}" not found'}).encode('utf-8')]
|
||||
|
||||
# Get collection path
|
||||
collection_config = self.collections[collection_name]
|
||||
collection_path = Path(collection_config['path'])
|
||||
|
||||
# Delete the collection directory and all its contents
|
||||
import shutil
|
||||
if collection_path.exists():
|
||||
shutil.rmtree(collection_path)
|
||||
print(f"Deleted collection directory: {collection_path}")
|
||||
|
||||
# Remove from collections dict
|
||||
del self.collections[collection_name]
|
||||
|
||||
# Update config file
|
||||
self.save_config()
|
||||
|
||||
# Remove from WebDAV provider mapping
|
||||
provider_key = f'/fs/{collection_name}'
|
||||
if hasattr(self.webdav_app, 'provider_map') and provider_key in self.webdav_app.provider_map:
|
||||
del self.webdav_app.provider_map[provider_key]
|
||||
print(f"Removed provider from provider_map: {provider_key}")
|
||||
|
||||
# Remove from sorted_share_list if it exists
|
||||
if hasattr(self.webdav_app, 'sorted_share_list') and provider_key in self.webdav_app.sorted_share_list:
|
||||
self.webdav_app.sorted_share_list.remove(provider_key)
|
||||
print(f"Removed from sorted_share_list: {provider_key}")
|
||||
|
||||
print(f"Deleted collection '{collection_name}'")
|
||||
|
||||
response_body = json.dumps({'success': True, 'name': collection_name}).encode('utf-8')
|
||||
start_response('200 OK', [
|
||||
('Content-Type', 'application/json'),
|
||||
('Content-Length', str(len(response_body))),
|
||||
('Access-Control-Allow-Origin', '*')
|
||||
])
|
||||
|
||||
return [response_body]
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error deleting collection: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
start_response('500 Internal Server Error', [('Content-Type', 'application/json')])
|
||||
return [json.dumps({'error': str(e)}).encode('utf-8')]
|
||||
|
||||
def handle_static(self, environ, start_response):
|
||||
"""Serve static files"""
|
||||
path = environ.get('PATH_INFO', '')[1:] # Remove leading /
|
||||
|
||||
@@ -389,3 +389,65 @@ body.dark-mode #darkModeBtn i {
|
||||
color: inherit;
|
||||
/* Inherit hover color from parent */
|
||||
}
|
||||
|
||||
/* ===================================
|
||||
Loading Spinner Component
|
||||
=================================== */
|
||||
|
||||
/* Loading overlay - covers the target container */
|
||||
.loading-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: var(--bg-primary);
|
||||
opacity: 0.95;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
/* Loading spinner */
|
||||
.loading-spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 4px solid var(--border-color);
|
||||
border-top-color: var(--primary-color);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
/* Spinner animation */
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Loading text */
|
||||
.loading-text {
|
||||
margin-top: 16px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Loading container with spinner and text */
|
||||
.loading-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Hide loading overlay by default */
|
||||
.loading-overlay.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.language-bash {
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
@@ -119,7 +119,7 @@ body {
|
||||
#sidebarPane {
|
||||
flex: 0 0 20%;
|
||||
min-width: 150px;
|
||||
max-width: 40%;
|
||||
max-width: 20%;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -337,6 +337,12 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
|
||||
Logger.info(`Previewing binary file: ${item.path}`);
|
||||
|
||||
// Initialize and show loading spinner for binary file preview
|
||||
editor.initLoadingSpinners();
|
||||
if (editor.previewSpinner) {
|
||||
editor.previewSpinner.show(`Loading ${fileType.toLowerCase()}...`);
|
||||
}
|
||||
|
||||
// Set flag to prevent auto-update of preview
|
||||
editor.isShowingCustomPreview = true;
|
||||
|
||||
@@ -403,6 +409,14 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
// Display in preview pane
|
||||
editor.previewElement.innerHTML = previewHtml;
|
||||
|
||||
// Hide loading spinner after content is set
|
||||
// Add small delay for images to start loading
|
||||
setTimeout(() => {
|
||||
if (editor.previewSpinner) {
|
||||
editor.previewSpinner.hide();
|
||||
}
|
||||
}, fileType === 'Image' ? 300 : 100);
|
||||
|
||||
// Highlight the file in the tree
|
||||
fileTree.selectAndExpandPath(item.path);
|
||||
|
||||
|
||||
@@ -70,12 +70,13 @@ class ModalManager {
|
||||
}
|
||||
}, { once: true });
|
||||
|
||||
// Remove aria-hidden before showing to prevent accessibility warning
|
||||
this.modalElement.removeAttribute('aria-hidden');
|
||||
|
||||
this.modal.show();
|
||||
|
||||
// Focus confirm button after modal is shown
|
||||
this.modalElement.addEventListener('shown.bs.modal', () => {
|
||||
// Ensure aria-hidden is removed (Bootstrap should do this, but be explicit)
|
||||
this.modalElement.removeAttribute('aria-hidden');
|
||||
this.confirmButton.focus();
|
||||
}, { once: true });
|
||||
});
|
||||
@@ -130,12 +131,13 @@ class ModalManager {
|
||||
}
|
||||
}, { once: true });
|
||||
|
||||
// Remove aria-hidden before showing to prevent accessibility warning
|
||||
this.modalElement.removeAttribute('aria-hidden');
|
||||
|
||||
this.modal.show();
|
||||
|
||||
// Focus and select input after modal is shown
|
||||
this.modalElement.addEventListener('shown.bs.modal', () => {
|
||||
// Ensure aria-hidden is removed (Bootstrap should do this, but be explicit)
|
||||
this.modalElement.removeAttribute('aria-hidden');
|
||||
this.inputElement.focus();
|
||||
this.inputElement.select();
|
||||
}, { once: true });
|
||||
|
||||
@@ -16,6 +16,10 @@ class MarkdownEditor {
|
||||
this.editor = null; // Will be initialized later
|
||||
this.isShowingCustomPreview = false; // Flag to prevent auto-update when showing binary files
|
||||
|
||||
// Initialize loading spinners (will be created lazily when needed)
|
||||
this.editorSpinner = null;
|
||||
this.previewSpinner = null;
|
||||
|
||||
// Only initialize CodeMirror if not in read-only mode (view mode)
|
||||
if (!readOnly) {
|
||||
this.initCodeMirror();
|
||||
@@ -206,11 +210,34 @@ class MarkdownEditor {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize loading spinners (lazy initialization)
|
||||
*/
|
||||
initLoadingSpinners() {
|
||||
if (!this.editorSpinner && !this.readOnly && this.editorElement) {
|
||||
this.editorSpinner = new LoadingSpinner(this.editorElement, 'Loading file...');
|
||||
}
|
||||
if (!this.previewSpinner && this.previewElement) {
|
||||
this.previewSpinner = new LoadingSpinner(this.previewElement, 'Rendering preview...');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load file
|
||||
*/
|
||||
async loadFile(path) {
|
||||
try {
|
||||
// Initialize loading spinners if not already done
|
||||
this.initLoadingSpinners();
|
||||
|
||||
// Show loading spinners
|
||||
if (this.editorSpinner) {
|
||||
this.editorSpinner.show('Loading file...');
|
||||
}
|
||||
if (this.previewSpinner) {
|
||||
this.previewSpinner.show('Loading preview...');
|
||||
}
|
||||
|
||||
// Reset custom preview flag when loading text files
|
||||
this.isShowingCustomPreview = false;
|
||||
|
||||
@@ -232,8 +259,25 @@ class MarkdownEditor {
|
||||
|
||||
// Save as last viewed page
|
||||
this.saveLastViewedPage(path);
|
||||
|
||||
// Hide loading spinners
|
||||
if (this.editorSpinner) {
|
||||
this.editorSpinner.hide();
|
||||
}
|
||||
if (this.previewSpinner) {
|
||||
this.previewSpinner.hide();
|
||||
}
|
||||
|
||||
// No notification for successful file load - it's not critical
|
||||
} catch (error) {
|
||||
// Hide loading spinners on error
|
||||
if (this.editorSpinner) {
|
||||
this.editorSpinner.hide();
|
||||
}
|
||||
if (this.previewSpinner) {
|
||||
this.previewSpinner.hide();
|
||||
}
|
||||
|
||||
console.error('Failed to load file:', error);
|
||||
if (window.showNotification) {
|
||||
window.showNotification('Failed to load file', 'danger');
|
||||
@@ -409,6 +453,14 @@ class MarkdownEditor {
|
||||
}
|
||||
|
||||
try {
|
||||
// Initialize loading spinners if not already done
|
||||
this.initLoadingSpinners();
|
||||
|
||||
// Show preview loading spinner (only if not already shown by loadFile)
|
||||
if (this.previewSpinner && !this.previewSpinner.isVisible()) {
|
||||
this.previewSpinner.show('Rendering preview...');
|
||||
}
|
||||
|
||||
// Step 0: Convert JSX-style syntax to HTML
|
||||
let processedContent = this.convertJSXToHTML(markdown);
|
||||
|
||||
@@ -422,6 +474,9 @@ class MarkdownEditor {
|
||||
if (!this.marked) {
|
||||
console.error("Markdown parser (marked) not initialized.");
|
||||
previewDiv.innerHTML = `<div class="alert alert-danger">Preview engine not loaded.</div>`;
|
||||
if (this.previewSpinner) {
|
||||
this.previewSpinner.hide();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -459,6 +514,13 @@ class MarkdownEditor {
|
||||
console.warn('Mermaid rendering error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Hide preview loading spinner after a small delay to ensure rendering is complete
|
||||
setTimeout(() => {
|
||||
if (this.previewSpinner) {
|
||||
this.previewSpinner.hide();
|
||||
}
|
||||
}, 100);
|
||||
} catch (error) {
|
||||
console.error('Preview rendering error:', error);
|
||||
previewDiv.innerHTML = `
|
||||
@@ -467,6 +529,11 @@ class MarkdownEditor {
|
||||
${error.message}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Hide loading spinner on error
|
||||
if (this.previewSpinner) {
|
||||
this.previewSpinner.hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -189,9 +189,44 @@ class FileTreeActions {
|
||||
},
|
||||
|
||||
delete: async function (path, isDir) {
|
||||
const name = path.split('/').pop();
|
||||
const name = path.split('/').pop() || this.webdavClient.currentCollection;
|
||||
const type = isDir ? 'folder' : 'file';
|
||||
|
||||
// Check if this is a root-level collection (empty path or single-level path)
|
||||
const pathParts = path.split('/').filter(p => p.length > 0);
|
||||
const isCollection = pathParts.length === 0;
|
||||
|
||||
if (isCollection) {
|
||||
// Deleting a collection - use backend API
|
||||
const confirmed = await window.ModalManager.confirm(
|
||||
`Are you sure you want to delete the collection "${name}"? This will delete all files and folders in it.`,
|
||||
'Delete Collection?',
|
||||
true
|
||||
);
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
// Call backend API to delete collection
|
||||
const response = await fetch(`/api/collections/${name}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(error || 'Failed to delete collection');
|
||||
}
|
||||
|
||||
showNotification(`Collection "${name}" deleted successfully`, 'success');
|
||||
|
||||
// Reload the page to refresh collections list
|
||||
window.location.href = '/';
|
||||
} catch (error) {
|
||||
Logger.error('Failed to delete collection:', error);
|
||||
showNotification(`Failed to delete collection: ${error.message}`, 'error');
|
||||
}
|
||||
} else {
|
||||
// Deleting a regular file/folder - use WebDAV
|
||||
const confirmed = await window.ModalManager.confirm(
|
||||
`Are you sure you want to delete ${name}?`,
|
||||
`Delete this ${type}?`,
|
||||
@@ -209,6 +244,7 @@ class FileTreeActions {
|
||||
|
||||
await this.fileTree.load();
|
||||
showNotification(`Deleted ${name}`, 'success');
|
||||
}
|
||||
},
|
||||
|
||||
download: async function (path, isDir) {
|
||||
|
||||
151
static/js/loading-spinner.js
Normal file
151
static/js/loading-spinner.js
Normal file
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Loading Spinner Component
|
||||
* Displays a loading overlay with spinner for async operations
|
||||
*/
|
||||
|
||||
class LoadingSpinner {
|
||||
/**
|
||||
* Create a loading spinner for a container
|
||||
* @param {string|HTMLElement} container - Container element or ID
|
||||
* @param {string} message - Optional loading message
|
||||
*/
|
||||
constructor(container, message = 'Loading...') {
|
||||
this.container = typeof container === 'string'
|
||||
? document.getElementById(container)
|
||||
: container;
|
||||
|
||||
if (!this.container) {
|
||||
Logger.error('LoadingSpinner: Container not found');
|
||||
return;
|
||||
}
|
||||
|
||||
this.message = message;
|
||||
this.overlay = null;
|
||||
this.isShowing = false;
|
||||
this.showTime = null; // Track when spinner was shown
|
||||
this.minDisplayTime = 300; // Minimum time to show spinner (ms)
|
||||
|
||||
// Ensure container has position relative for absolute positioning
|
||||
const position = window.getComputedStyle(this.container).position;
|
||||
if (position === 'static') {
|
||||
this.container.style.position = 'relative';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the loading spinner
|
||||
* @param {string} message - Optional custom message
|
||||
*/
|
||||
show(message = null) {
|
||||
if (this.isShowing) return;
|
||||
|
||||
// Record when spinner was shown
|
||||
this.showTime = Date.now();
|
||||
|
||||
// Create overlay if it doesn't exist
|
||||
if (!this.overlay) {
|
||||
this.overlay = this.createOverlay(message || this.message);
|
||||
this.container.appendChild(this.overlay);
|
||||
} else {
|
||||
// Update message if provided
|
||||
if (message) {
|
||||
const textElement = this.overlay.querySelector('.loading-text');
|
||||
if (textElement) {
|
||||
textElement.textContent = message;
|
||||
}
|
||||
}
|
||||
this.overlay.classList.remove('hidden');
|
||||
}
|
||||
|
||||
this.isShowing = true;
|
||||
Logger.debug(`Loading spinner shown: ${message || this.message}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the loading spinner
|
||||
* Ensures minimum display time for better UX
|
||||
*/
|
||||
hide() {
|
||||
if (!this.isShowing || !this.overlay) return;
|
||||
|
||||
// Calculate how long the spinner has been showing
|
||||
const elapsed = Date.now() - this.showTime;
|
||||
const remaining = Math.max(0, this.minDisplayTime - elapsed);
|
||||
|
||||
// If minimum time hasn't elapsed, delay hiding
|
||||
if (remaining > 0) {
|
||||
setTimeout(() => {
|
||||
this.overlay.classList.add('hidden');
|
||||
this.isShowing = false;
|
||||
Logger.debug('Loading spinner hidden');
|
||||
}, remaining);
|
||||
} else {
|
||||
this.overlay.classList.add('hidden');
|
||||
this.isShowing = false;
|
||||
Logger.debug('Loading spinner hidden');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the loading spinner from DOM
|
||||
*/
|
||||
destroy() {
|
||||
if (this.overlay && this.overlay.parentNode) {
|
||||
this.overlay.parentNode.removeChild(this.overlay);
|
||||
this.overlay = null;
|
||||
}
|
||||
this.isShowing = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the overlay element
|
||||
* @param {string} message - Loading message
|
||||
* @returns {HTMLElement} The overlay element
|
||||
*/
|
||||
createOverlay(message) {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'loading-overlay';
|
||||
|
||||
const content = document.createElement('div');
|
||||
content.className = 'loading-content';
|
||||
|
||||
const spinner = document.createElement('div');
|
||||
spinner.className = 'loading-spinner';
|
||||
|
||||
const text = document.createElement('div');
|
||||
text.className = 'loading-text';
|
||||
text.textContent = message;
|
||||
|
||||
content.appendChild(spinner);
|
||||
content.appendChild(text);
|
||||
overlay.appendChild(content);
|
||||
|
||||
return overlay;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the loading message
|
||||
* @param {string} message - New message
|
||||
*/
|
||||
updateMessage(message) {
|
||||
this.message = message;
|
||||
if (this.overlay && this.isShowing) {
|
||||
const textElement = this.overlay.querySelector('.loading-text');
|
||||
if (textElement) {
|
||||
textElement.textContent = message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if spinner is currently showing
|
||||
* @returns {boolean} True if showing
|
||||
*/
|
||||
isVisible() {
|
||||
return this.isShowing;
|
||||
}
|
||||
}
|
||||
|
||||
// Make LoadingSpinner globally available
|
||||
window.LoadingSpinner = LoadingSpinner;
|
||||
|
||||
@@ -238,6 +238,7 @@
|
||||
<script src="/static/js/sidebar-toggle.js" defer></script>
|
||||
<script src="/static/js/collection-selector.js" defer></script>
|
||||
<script src="/static/js/editor-drop-handler.js" defer></script>
|
||||
<script src="/static/js/loading-spinner.js" defer></script>
|
||||
|
||||
<!-- Core Application Modules -->
|
||||
<script src="/static/js/webdav-client.js" defer></script>
|
||||
|
||||
Reference in New Issue
Block a user