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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user