diff --git a/collections/documents/docusaurus.md b/collections/documents/docusaurus.md new file mode 100644 index 0000000..74a9422 --- /dev/null +++ b/collections/documents/docusaurus.md @@ -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. \ No newline at end of file diff --git a/collections/documents/getting_started/hero_docker.md b/collections/documents/getting_started/hero_docker.md new file mode 100644 index 0000000..6488df8 --- /dev/null +++ b/collections/documents/getting_started/hero_docker.md @@ -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 +``` \ No newline at end of file diff --git a/collections/documents/getting_started/hero_native.md b/collections/documents/getting_started/hero_native.md new file mode 100644 index 0000000..9555bb6 --- /dev/null +++ b/collections/documents/getting_started/hero_native.md @@ -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). \ No newline at end of file diff --git a/collections/documents/intro.md b/collections/documents/intro.md new file mode 100644 index 0000000..4f2e13f --- /dev/null +++ b/collections/documents/intro.md @@ -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. \ No newline at end of file diff --git a/collections/documents/support.md b/collections/documents/support.md new file mode 100644 index 0000000..1dadeae --- /dev/null +++ b/collections/documents/support.md @@ -0,0 +1 @@ +If you need help with Hero, reach out to the ThreeFold Support team [here](https://threefoldfaq.crisp.help/en/). \ No newline at end of file diff --git a/config.yaml b/config.yaml index 4f2e67b..1c0a6da 100644 --- a/config.yaml +++ b/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' diff --git a/server_webdav.py b/server_webdav.py index 8150cb0..4f39c48 100755 --- a/server_webdav.py +++ b/server_webdav.py @@ -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 / diff --git a/static/css/components.css b/static/css/components.css index e98d7cf..f524953 100644 --- a/static/css/components.css +++ b/static/css/components.css @@ -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; } \ No newline at end of file diff --git a/static/css/layout.css b/static/css/layout.css index cb53bc0..192d5ed 100644 --- a/static/css/layout.css +++ b/static/css/layout.css @@ -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; diff --git a/static/js/app.js b/static/js/app.js index 9750bfb..5660ffe 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -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); diff --git a/static/js/confirmation.js b/static/js/confirmation.js index 4c50176..1d412ef 100644 --- a/static/js/confirmation.js +++ b/static/js/confirmation.js @@ -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 }); diff --git a/static/js/editor.js b/static/js/editor.js index 59e2147..d718c48 100644 --- a/static/js/editor.js +++ b/static/js/editor.js @@ -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 = `
Preview engine not loaded.
`; + 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} `; + + // Hide loading spinner on error + if (this.previewSpinner) { + this.previewSpinner.hide(); + } } } diff --git a/static/js/file-tree-actions.js b/static/js/file-tree-actions.js index 39701e8..dac99c4 100644 --- a/static/js/file-tree-actions.js +++ b/static/js/file-tree-actions.js @@ -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) { diff --git a/static/js/loading-spinner.js b/static/js/loading-spinner.js new file mode 100644 index 0000000..8538e6c --- /dev/null +++ b/static/js/loading-spinner.js @@ -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; + diff --git a/templates/index.html b/templates/index.html index b78a9a8..b9bd543 100644 --- a/templates/index.html +++ b/templates/index.html @@ -238,6 +238,7 @@ +