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:
|
projects:
|
||||||
path: ./collections/projects
|
path: ./collections/projects
|
||||||
description: Project documentation
|
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:
|
7madah:
|
||||||
path: collections/7madah
|
path: collections/7madah
|
||||||
description: 'User-created collection: 7madah'
|
description: 'User-created collection: 7madah'
|
||||||
|
|||||||
@@ -104,6 +104,10 @@ class MarkdownEditorApp:
|
|||||||
if path == '/fs/' and method == 'POST':
|
if path == '/fs/' and method == 'POST':
|
||||||
return self.handle_create_collection(environ, start_response)
|
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)
|
# Check if path starts with a collection name (for SPA routing)
|
||||||
# This handles URLs like /notes/ttt or /documents/file.md
|
# This handles URLs like /notes/ttt or /documents/file.md
|
||||||
# MUST be checked BEFORE WebDAV routing to prevent WebDAV from intercepting SPA routes
|
# 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}")
|
print(f"Error creating collection: {e}")
|
||||||
start_response('500 Internal Server Error', [('Content-Type', 'application/json')])
|
start_response('500 Internal Server Error', [('Content-Type', 'application/json')])
|
||||||
return [json.dumps({'error': str(e)}).encode('utf-8')]
|
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):
|
def handle_static(self, environ, start_response):
|
||||||
"""Serve static files"""
|
"""Serve static files"""
|
||||||
path = environ.get('PATH_INFO', '')[1:] # Remove leading /
|
path = environ.get('PATH_INFO', '')[1:] # Remove leading /
|
||||||
|
|||||||
@@ -388,4 +388,66 @@ body.dark-mode #darkModeBtn i {
|
|||||||
#darkModeBtn:hover i {
|
#darkModeBtn:hover i {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
/* Inherit hover color from parent */
|
/* 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 {
|
#sidebarPane {
|
||||||
flex: 0 0 20%;
|
flex: 0 0 20%;
|
||||||
min-width: 150px;
|
min-width: 150px;
|
||||||
max-width: 40%;
|
max-width: 20%;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
@@ -337,6 +337,12 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
|
|
||||||
Logger.info(`Previewing binary file: ${item.path}`);
|
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
|
// Set flag to prevent auto-update of preview
|
||||||
editor.isShowingCustomPreview = true;
|
editor.isShowingCustomPreview = true;
|
||||||
|
|
||||||
@@ -403,6 +409,14 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
// Display in preview pane
|
// Display in preview pane
|
||||||
editor.previewElement.innerHTML = previewHtml;
|
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
|
// Highlight the file in the tree
|
||||||
fileTree.selectAndExpandPath(item.path);
|
fileTree.selectAndExpandPath(item.path);
|
||||||
|
|
||||||
|
|||||||
@@ -70,12 +70,13 @@ class ModalManager {
|
|||||||
}
|
}
|
||||||
}, { once: true });
|
}, { once: true });
|
||||||
|
|
||||||
|
// Remove aria-hidden before showing to prevent accessibility warning
|
||||||
|
this.modalElement.removeAttribute('aria-hidden');
|
||||||
|
|
||||||
this.modal.show();
|
this.modal.show();
|
||||||
|
|
||||||
// Focus confirm button after modal is shown
|
// Focus confirm button after modal is shown
|
||||||
this.modalElement.addEventListener('shown.bs.modal', () => {
|
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();
|
this.confirmButton.focus();
|
||||||
}, { once: true });
|
}, { once: true });
|
||||||
});
|
});
|
||||||
@@ -130,12 +131,13 @@ class ModalManager {
|
|||||||
}
|
}
|
||||||
}, { once: true });
|
}, { once: true });
|
||||||
|
|
||||||
|
// Remove aria-hidden before showing to prevent accessibility warning
|
||||||
|
this.modalElement.removeAttribute('aria-hidden');
|
||||||
|
|
||||||
this.modal.show();
|
this.modal.show();
|
||||||
|
|
||||||
// Focus and select input after modal is shown
|
// Focus and select input after modal is shown
|
||||||
this.modalElement.addEventListener('shown.bs.modal', () => {
|
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.focus();
|
||||||
this.inputElement.select();
|
this.inputElement.select();
|
||||||
}, { once: true });
|
}, { once: true });
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ class MarkdownEditor {
|
|||||||
this.editor = null; // Will be initialized later
|
this.editor = null; // Will be initialized later
|
||||||
this.isShowingCustomPreview = false; // Flag to prevent auto-update when showing binary files
|
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)
|
// Only initialize CodeMirror if not in read-only mode (view mode)
|
||||||
if (!readOnly) {
|
if (!readOnly) {
|
||||||
this.initCodeMirror();
|
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
|
* Load file
|
||||||
*/
|
*/
|
||||||
async loadFile(path) {
|
async loadFile(path) {
|
||||||
try {
|
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
|
// Reset custom preview flag when loading text files
|
||||||
this.isShowingCustomPreview = false;
|
this.isShowingCustomPreview = false;
|
||||||
|
|
||||||
@@ -232,8 +259,25 @@ class MarkdownEditor {
|
|||||||
|
|
||||||
// Save as last viewed page
|
// Save as last viewed page
|
||||||
this.saveLastViewedPage(path);
|
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
|
// No notification for successful file load - it's not critical
|
||||||
} catch (error) {
|
} 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);
|
console.error('Failed to load file:', error);
|
||||||
if (window.showNotification) {
|
if (window.showNotification) {
|
||||||
window.showNotification('Failed to load file', 'danger');
|
window.showNotification('Failed to load file', 'danger');
|
||||||
@@ -409,6 +453,14 @@ class MarkdownEditor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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
|
// Step 0: Convert JSX-style syntax to HTML
|
||||||
let processedContent = this.convertJSXToHTML(markdown);
|
let processedContent = this.convertJSXToHTML(markdown);
|
||||||
|
|
||||||
@@ -422,6 +474,9 @@ class MarkdownEditor {
|
|||||||
if (!this.marked) {
|
if (!this.marked) {
|
||||||
console.error("Markdown parser (marked) not initialized.");
|
console.error("Markdown parser (marked) not initialized.");
|
||||||
previewDiv.innerHTML = `<div class="alert alert-danger">Preview engine not loaded.</div>`;
|
previewDiv.innerHTML = `<div class="alert alert-danger">Preview engine not loaded.</div>`;
|
||||||
|
if (this.previewSpinner) {
|
||||||
|
this.previewSpinner.hide();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -459,6 +514,13 @@ class MarkdownEditor {
|
|||||||
console.warn('Mermaid rendering error:', error);
|
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) {
|
} catch (error) {
|
||||||
console.error('Preview rendering error:', error);
|
console.error('Preview rendering error:', error);
|
||||||
previewDiv.innerHTML = `
|
previewDiv.innerHTML = `
|
||||||
@@ -467,6 +529,11 @@ class MarkdownEditor {
|
|||||||
${error.message}
|
${error.message}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// Hide loading spinner on error
|
||||||
|
if (this.previewSpinner) {
|
||||||
|
this.previewSpinner.hide();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -189,26 +189,62 @@ class FileTreeActions {
|
|||||||
},
|
},
|
||||||
|
|
||||||
delete: async function (path, isDir) {
|
delete: async function (path, isDir) {
|
||||||
const name = path.split('/').pop();
|
const name = path.split('/').pop() || this.webdavClient.currentCollection;
|
||||||
const type = isDir ? 'folder' : 'file';
|
const type = isDir ? 'folder' : 'file';
|
||||||
|
|
||||||
const confirmed = await window.ModalManager.confirm(
|
// Check if this is a root-level collection (empty path or single-level path)
|
||||||
`Are you sure you want to delete ${name}?`,
|
const pathParts = path.split('/').filter(p => p.length > 0);
|
||||||
`Delete this ${type}?`,
|
const isCollection = pathParts.length === 0;
|
||||||
true
|
|
||||||
);
|
|
||||||
|
|
||||||
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
|
try {
|
||||||
if (this.fileTree.lastMoveOperation) {
|
// Call backend API to delete collection
|
||||||
this.fileTree.lastMoveOperation = null;
|
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) {
|
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/sidebar-toggle.js" defer></script>
|
||||||
<script src="/static/js/collection-selector.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/editor-drop-handler.js" defer></script>
|
||||||
|
<script src="/static/js/loading-spinner.js" defer></script>
|
||||||
|
|
||||||
<!-- Core Application Modules -->
|
<!-- Core Application Modules -->
|
||||||
<script src="/static/js/webdav-client.js" defer></script>
|
<script src="/static/js/webdav-client.js" defer></script>
|
||||||
|
|||||||
Reference in New Issue
Block a user