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 | ||||
| @@ -205,7 +209,68 @@ class MarkdownEditorApp: | ||||
|             print(f"Error creating collection: {e}") | ||||
|             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 / | ||||
|   | ||||
| @@ -388,4 +388,66 @@ body.dark-mode #darkModeBtn i { | ||||
| #darkModeBtn:hover 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,26 +189,62 @@ 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'; | ||||
|  | ||||
|             const confirmed = await window.ModalManager.confirm( | ||||
|                 `Are you sure you want to delete ${name}?`, | ||||
|                 `Delete this ${type}?`, | ||||
|                 true | ||||
|             ); | ||||
|             // 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 (!confirmed) return; | ||||
|             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 | ||||
|                 ); | ||||
|  | ||||
|             await this.webdavClient.delete(path); | ||||
|                 if (!confirmed) return; | ||||
|  | ||||
|             // Clear undo history since manual delete occurred | ||||
|             if (this.fileTree.lastMoveOperation) { | ||||
|                 this.fileTree.lastMoveOperation = null; | ||||
|                 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}?`, | ||||
|                     true | ||||
|                 ); | ||||
|  | ||||
|                 if (!confirmed) return; | ||||
|  | ||||
|                 await this.webdavClient.delete(path); | ||||
|  | ||||
|                 // Clear undo history since manual delete occurred | ||||
|                 if (this.fileTree.lastMoveOperation) { | ||||
|                     this.fileTree.lastMoveOperation = null; | ||||
|                 } | ||||
|  | ||||
|                 await this.fileTree.load(); | ||||
|                 showNotification(`Deleted ${name}`, 'success'); | ||||
|             } | ||||
|  | ||||
|             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