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

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