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:
Mahmoud-Emad
2025-10-27 11:32:20 +03:00
parent afcd074913
commit 3961628b3d
15 changed files with 557 additions and 32 deletions

View 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.

View 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
```

View 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).

View 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.

View File

@@ -0,0 +1 @@
If you need help with Hero, reach out to the ThreeFold Support team [here](https://threefoldfaq.crisp.help/en/).

View File

@@ -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'

View File

@@ -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 /

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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);

View File

@@ -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 });

View File

@@ -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();
}
}
}

View File

@@ -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) {

View 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;

View File

@@ -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>