refactor: Modularize UI components and utilities
- Extract UI components into separate JS files - Centralize configuration values in config.js - Introduce a dedicated logger module - Improve file tree drag-and-drop and undo functionality - Refactor modal handling to a single manager - Add URL routing support for SPA navigation - Implement view mode for read-only access
This commit is contained in:
		
							
								
								
									
										484
									
								
								static/js/app.js
									
									
									
									
									
								
							
							
						
						
									
										484
									
								
								static/js/app.js
									
									
									
									
									
								
							| @@ -12,100 +12,430 @@ let collectionSelector; | ||||
| let clipboard = null; | ||||
| let currentFilePath = null; | ||||
|  | ||||
| // Simple event bus | ||||
| const eventBus = { | ||||
|     listeners: {}, | ||||
|     on(event, callback) { | ||||
|         if (!this.listeners[event]) { | ||||
|             this.listeners[event] = []; | ||||
| // Event bus is now loaded from event-bus.js module | ||||
| // No need to define it here - it's available as window.eventBus | ||||
|  | ||||
| /** | ||||
|  * Auto-load page in view mode | ||||
|  * Tries to load the last viewed page, falls back to first file if none saved | ||||
|  */ | ||||
| async function autoLoadPageInViewMode() { | ||||
|     if (!editor || !fileTree) return; | ||||
|  | ||||
|     try { | ||||
|         // Try to get last viewed page | ||||
|         let pageToLoad = editor.getLastViewedPage(); | ||||
|  | ||||
|         // If no last viewed page, get the first markdown file | ||||
|         if (!pageToLoad) { | ||||
|             pageToLoad = fileTree.getFirstMarkdownFile(); | ||||
|         } | ||||
|         this.listeners[event].push(callback); | ||||
|     }, | ||||
|     dispatch(event, data) { | ||||
|         if (this.listeners[event]) { | ||||
|             this.listeners[event].forEach(callback => callback(data)); | ||||
|  | ||||
|         // If we found a page to load, load it | ||||
|         if (pageToLoad) { | ||||
|             await editor.loadFile(pageToLoad); | ||||
|             // Highlight the file in the tree and expand parent directories | ||||
|             fileTree.selectAndExpandPath(pageToLoad); | ||||
|         } else { | ||||
|             // No files found, show empty state message | ||||
|             editor.previewElement.innerHTML = ` | ||||
|                 <div class="text-muted text-center mt-5"> | ||||
|                     <p>No content available</p> | ||||
|                 </div> | ||||
|             `; | ||||
|         } | ||||
|     } catch (error) { | ||||
|         console.error('Failed to auto-load page in view mode:', error); | ||||
|         editor.previewElement.innerHTML = ` | ||||
|             <div class="alert alert-danger"> | ||||
|                 <p>Failed to load content</p> | ||||
|             </div> | ||||
|         `; | ||||
|     } | ||||
| }; | ||||
| window.eventBus = eventBus; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Show directory preview with list of files | ||||
|  * @param {string} dirPath - The directory path | ||||
|  */ | ||||
| async function showDirectoryPreview(dirPath) { | ||||
|     if (!editor || !fileTree || !webdavClient) return; | ||||
|  | ||||
|     try { | ||||
|         const dirName = dirPath.split('/').pop() || dirPath; | ||||
|         const files = fileTree.getDirectoryFiles(dirPath); | ||||
|  | ||||
|         // Start building the preview HTML | ||||
|         let html = `<div class="directory-preview">`; | ||||
|         html += `<h2>${dirName}</h2>`; | ||||
|  | ||||
|         if (files.length === 0) { | ||||
|             html += `<p>This directory is empty</p>`; | ||||
|         } else { | ||||
|             html += `<div class="directory-files">`; | ||||
|  | ||||
|             // Create cards for each file | ||||
|             for (const file of files) { | ||||
|                 const fileName = file.name; | ||||
|                 let fileDescription = ''; | ||||
|  | ||||
|                 // Try to get file description from markdown files | ||||
|                 if (file.name.endsWith('.md')) { | ||||
|                     try { | ||||
|                         const content = await webdavClient.get(file.path); | ||||
|                         // Extract first heading or first line as description | ||||
|                         const lines = content.split('\n'); | ||||
|                         for (const line of lines) { | ||||
|                             if (line.trim().startsWith('#')) { | ||||
|                                 fileDescription = line.replace(/^#+\s*/, '').trim(); | ||||
|                                 break; | ||||
|                             } else if (line.trim() && !line.startsWith('---')) { | ||||
|                                 fileDescription = line.trim().substring(0, 100); | ||||
|                                 break; | ||||
|                             } | ||||
|                         } | ||||
|                     } catch (error) { | ||||
|                         console.error('Failed to read file description:', error); | ||||
|                     } | ||||
|                 } | ||||
|  | ||||
|                 html += ` | ||||
|                     <div class="file-card" data-path="${file.path}"> | ||||
|                         <div class="file-card-header"> | ||||
|                             <i class="bi bi-file-earmark-text"></i> | ||||
|                             <span class="file-card-name">${fileName}</span> | ||||
|                         </div> | ||||
|                         ${fileDescription ? `<div class="file-card-description">${fileDescription}</div>` : ''} | ||||
|                     </div> | ||||
|                 `; | ||||
|             } | ||||
|  | ||||
|             html += `</div>`; | ||||
|         } | ||||
|  | ||||
|         html += `</div>`; | ||||
|  | ||||
|         // Set the preview content | ||||
|         editor.previewElement.innerHTML = html; | ||||
|  | ||||
|         // Add click handlers to file cards | ||||
|         editor.previewElement.querySelectorAll('.file-card').forEach(card => { | ||||
|             card.addEventListener('click', async () => { | ||||
|                 const filePath = card.dataset.path; | ||||
|                 await editor.loadFile(filePath); | ||||
|                 fileTree.selectAndExpandPath(filePath); | ||||
|             }); | ||||
|         }); | ||||
|     } catch (error) { | ||||
|         console.error('Failed to show directory preview:', error); | ||||
|         editor.previewElement.innerHTML = ` | ||||
|             <div class="alert alert-danger"> | ||||
|                 <p>Failed to load directory preview</p> | ||||
|             </div> | ||||
|         `; | ||||
|     } | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Parse URL to extract collection and file path | ||||
|  * URL format: /<collection>/<file_path> or /<collection>/<dir>/<file> | ||||
|  * @returns {Object} {collection, filePath} or {collection, null} if only collection | ||||
|  */ | ||||
| function parseURLPath() { | ||||
|     const pathname = window.location.pathname; | ||||
|     const parts = pathname.split('/').filter(p => p); // Remove empty parts | ||||
|  | ||||
|     if (parts.length === 0) { | ||||
|         return { collection: null, filePath: null }; | ||||
|     } | ||||
|  | ||||
|     const collection = parts[0]; | ||||
|     const filePath = parts.length > 1 ? parts.slice(1).join('/') : null; | ||||
|  | ||||
|     return { collection, filePath }; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Update URL based on current collection and file | ||||
|  * @param {string} collection - The collection name | ||||
|  * @param {string} filePath - The file path (optional) | ||||
|  * @param {boolean} isEditMode - Whether in edit mode | ||||
|  */ | ||||
| function updateURL(collection, filePath, isEditMode) { | ||||
|     let url = `/${collection}`; | ||||
|     if (filePath) { | ||||
|         url += `/${filePath}`; | ||||
|     } | ||||
|     if (isEditMode) { | ||||
|         url += '?edit=true'; | ||||
|     } | ||||
|  | ||||
|     // Use pushState to update URL without reloading | ||||
|     window.history.pushState({ collection, filePath }, '', url); | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Load file from URL path | ||||
|  * Assumes the collection is already set and file tree is loaded | ||||
|  * @param {string} collection - The collection name (for validation) | ||||
|  * @param {string} filePath - The file path | ||||
|  */ | ||||
| async function loadFileFromURL(collection, filePath) { | ||||
|     console.log('[loadFileFromURL] Called with:', { collection, filePath }); | ||||
|  | ||||
|     if (!fileTree || !editor || !collectionSelector) { | ||||
|         console.error('[loadFileFromURL] Missing dependencies:', { fileTree: !!fileTree, editor: !!editor, collectionSelector: !!collectionSelector }); | ||||
|         return; | ||||
|     } | ||||
|  | ||||
|     try { | ||||
|         // Verify we're on the right collection | ||||
|         const currentCollection = collectionSelector.getCurrentCollection(); | ||||
|         if (currentCollection !== collection) { | ||||
|             console.error(`[loadFileFromURL] Collection mismatch: expected ${collection}, got ${currentCollection}`); | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         // Load the file or directory | ||||
|         if (filePath) { | ||||
|             // Check if the path is a directory or a file | ||||
|             const node = fileTree.findNode(filePath); | ||||
|             console.log('[loadFileFromURL] Found node:', node); | ||||
|  | ||||
|             if (node && node.isDirectory) { | ||||
|                 // It's a directory, show directory preview | ||||
|                 console.log('[loadFileFromURL] Loading directory preview'); | ||||
|                 await showDirectoryPreview(filePath); | ||||
|                 fileTree.selectAndExpandPath(filePath); | ||||
|             } else if (node) { | ||||
|                 // It's a file, load it | ||||
|                 console.log('[loadFileFromURL] Loading file'); | ||||
|                 await editor.loadFile(filePath); | ||||
|                 fileTree.selectAndExpandPath(filePath); | ||||
|             } else { | ||||
|                 console.error(`[loadFileFromURL] Path not found in file tree: ${filePath}`); | ||||
|             } | ||||
|         } | ||||
|     } catch (error) { | ||||
|         console.error('[loadFileFromURL] Failed to load file from URL:', error); | ||||
|     } | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Handle browser back/forward navigation | ||||
|  */ | ||||
| function setupPopStateListener() { | ||||
|     window.addEventListener('popstate', async (event) => { | ||||
|         const { collection, filePath } = parseURLPath(); | ||||
|         if (collection) { | ||||
|             // Ensure the collection is set | ||||
|             const currentCollection = collectionSelector.getCurrentCollection(); | ||||
|             if (currentCollection !== collection) { | ||||
|                 await collectionSelector.setCollection(collection); | ||||
|                 await fileTree.load(); | ||||
|             } | ||||
|  | ||||
|             // Load the file/directory | ||||
|             await loadFileFromURL(collection, filePath); | ||||
|         } | ||||
|     }); | ||||
| } | ||||
|  | ||||
| // Initialize application | ||||
| document.addEventListener('DOMContentLoaded', async () => { | ||||
|     // Determine view mode from URL parameter | ||||
|     const urlParams = new URLSearchParams(window.location.search); | ||||
|     const isEditMode = urlParams.get('edit') === 'true'; | ||||
|  | ||||
|     // Set view mode class on body | ||||
|     if (isEditMode) { | ||||
|         document.body.classList.add('edit-mode'); | ||||
|         document.body.classList.remove('view-mode'); | ||||
|     } else { | ||||
|         document.body.classList.add('view-mode'); | ||||
|         document.body.classList.remove('edit-mode'); | ||||
|     } | ||||
|  | ||||
|     // Initialize WebDAV client | ||||
|     webdavClient = new WebDAVClient('/fs/'); | ||||
|      | ||||
|  | ||||
|     // Initialize dark mode | ||||
|     darkMode = new DarkMode(); | ||||
|     document.getElementById('darkModeBtn').addEventListener('click', () => { | ||||
|         darkMode.toggle(); | ||||
|     }); | ||||
|      | ||||
|     // Initialize file tree | ||||
|     fileTree = new FileTree('fileTree', webdavClient); | ||||
|     fileTree.onFileSelect = async (item) => { | ||||
|         await editor.loadFile(item.path); | ||||
|     }; | ||||
|      | ||||
|     // Initialize collection selector | ||||
|  | ||||
|     // Initialize collection selector (always needed) | ||||
|     collectionSelector = new CollectionSelector('collectionSelect', webdavClient); | ||||
|     collectionSelector.onChange = async (collection) => { | ||||
|         await fileTree.load(); | ||||
|     }; | ||||
|     await collectionSelector.load(); | ||||
|     await fileTree.load(); | ||||
|      | ||||
|     // Initialize editor | ||||
|     editor = new MarkdownEditor('editor', 'preview', 'filenameInput'); | ||||
|  | ||||
|     // Setup URL routing | ||||
|     setupPopStateListener(); | ||||
|  | ||||
|     // Initialize editor (always needed for preview) | ||||
|     // In view mode, editor is read-only | ||||
|     editor = new MarkdownEditor('editor', 'preview', 'filenameInput', !isEditMode); | ||||
|     editor.setWebDAVClient(webdavClient); | ||||
|  | ||||
|     // Add test content to verify preview works | ||||
|     setTimeout(() => { | ||||
|         if (!editor.editor.getValue()) { | ||||
|             editor.editor.setValue('# Welcome to Markdown Editor\n\nStart typing to see preview...\n'); | ||||
|             editor.updatePreview(); | ||||
|     // Initialize file tree (needed in both modes) | ||||
|     fileTree = new FileTree('fileTree', webdavClient); | ||||
|     fileTree.onFileSelect = async (item) => { | ||||
|         try { | ||||
|             await editor.loadFile(item.path); | ||||
|             // Highlight the file in the tree and expand parent directories | ||||
|             fileTree.selectAndExpandPath(item.path); | ||||
|             // Update URL to reflect current file | ||||
|             const currentCollection = collectionSelector.getCurrentCollection(); | ||||
|             updateURL(currentCollection, item.path, isEditMode); | ||||
|         } catch (error) { | ||||
|             Logger.error('Failed to select file:', error); | ||||
|             if (window.showNotification) { | ||||
|                 window.showNotification('Failed to load file', 'error'); | ||||
|             } | ||||
|         } | ||||
|     }, 200); | ||||
|      | ||||
|     // Setup editor drop handler | ||||
|     const editorDropHandler = new EditorDropHandler( | ||||
|         document.querySelector('.editor-container'), | ||||
|         async (file) => { | ||||
|             await handleEditorFileDrop(file); | ||||
|         } | ||||
|     ); | ||||
|      | ||||
|     // Setup button handlers | ||||
|     document.getElementById('newBtn').addEventListener('click', () => { | ||||
|         editor.newFile(); | ||||
|     }); | ||||
|      | ||||
|     document.getElementById('saveBtn').addEventListener('click', async () => { | ||||
|         await editor.save(); | ||||
|     }); | ||||
|      | ||||
|     document.getElementById('deleteBtn').addEventListener('click', async () => { | ||||
|         await editor.deleteFile(); | ||||
|     }); | ||||
|      | ||||
|     // Setup context menu handlers | ||||
|     setupContextMenuHandlers(); | ||||
|      | ||||
|     // Initialize mermaid | ||||
|     mermaid.initialize({ startOnLoad: true, theme: darkMode.isDark ? 'dark' : 'default' }); | ||||
|     }; | ||||
|  | ||||
|     // Initialize file tree actions manager | ||||
|     window.fileTreeActions = new FileTreeActions(webdavClient, fileTree, editor); | ||||
|     fileTree.onFolderSelect = async (item) => { | ||||
|         try { | ||||
|             // Show directory preview | ||||
|             await showDirectoryPreview(item.path); | ||||
|             // Highlight the directory in the tree and expand parent directories | ||||
|             fileTree.selectAndExpandPath(item.path); | ||||
|             // Update URL to reflect current directory | ||||
|             const currentCollection = collectionSelector.getCurrentCollection(); | ||||
|             updateURL(currentCollection, item.path, isEditMode); | ||||
|         } catch (error) { | ||||
|             Logger.error('Failed to select folder:', error); | ||||
|             if (window.showNotification) { | ||||
|                 window.showNotification('Failed to load folder', 'error'); | ||||
|             } | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     collectionSelector.onChange = async (collection) => { | ||||
|         try { | ||||
|             await fileTree.load(); | ||||
|             // In view mode, auto-load last viewed page when collection changes | ||||
|             if (!isEditMode) { | ||||
|                 await autoLoadPageInViewMode(); | ||||
|             } | ||||
|         } catch (error) { | ||||
|             Logger.error('Failed to change collection:', error); | ||||
|             if (window.showNotification) { | ||||
|                 window.showNotification('Failed to change collection', 'error'); | ||||
|             } | ||||
|         } | ||||
|     }; | ||||
|     await fileTree.load(); | ||||
|  | ||||
|     // Parse URL to load file if specified | ||||
|     const { collection: urlCollection, filePath: urlFilePath } = parseURLPath(); | ||||
|     console.log('[URL PARSE]', { urlCollection, urlFilePath }); | ||||
|  | ||||
|     if (urlCollection && urlFilePath) { | ||||
|         console.log('[URL LOAD] Loading from URL:', urlCollection, urlFilePath); | ||||
|  | ||||
|         // First ensure the collection is set | ||||
|         const currentCollection = collectionSelector.getCurrentCollection(); | ||||
|         if (currentCollection !== urlCollection) { | ||||
|             console.log('[URL LOAD] Switching collection from', currentCollection, 'to', urlCollection); | ||||
|             await collectionSelector.setCollection(urlCollection); | ||||
|             await fileTree.load(); | ||||
|         } | ||||
|  | ||||
|         // Now load the file from URL | ||||
|         console.log('[URL LOAD] Calling loadFileFromURL'); | ||||
|         await loadFileFromURL(urlCollection, urlFilePath); | ||||
|     } else if (!isEditMode) { | ||||
|         // In view mode, auto-load last viewed page if no URL file specified | ||||
|         await autoLoadPageInViewMode(); | ||||
|     } | ||||
|  | ||||
|     // Initialize file tree and editor-specific features only in edit mode | ||||
|     if (isEditMode) { | ||||
|         // Add test content to verify preview works | ||||
|         setTimeout(() => { | ||||
|             if (!editor.editor.getValue()) { | ||||
|                 editor.editor.setValue('# Welcome to Markdown Editor\n\nStart typing to see preview...\n'); | ||||
|                 editor.updatePreview(); | ||||
|             } | ||||
|         }, 200); | ||||
|  | ||||
|         // Setup editor drop handler | ||||
|         const editorDropHandler = new EditorDropHandler( | ||||
|             document.querySelector('.editor-container'), | ||||
|             async (file) => { | ||||
|                 try { | ||||
|                     await handleEditorFileDrop(file); | ||||
|                 } catch (error) { | ||||
|                     Logger.error('Failed to handle file drop:', error); | ||||
|                 } | ||||
|             } | ||||
|         ); | ||||
|  | ||||
|         // Setup button handlers | ||||
|         document.getElementById('newBtn').addEventListener('click', () => { | ||||
|             editor.newFile(); | ||||
|         }); | ||||
|  | ||||
|         document.getElementById('saveBtn').addEventListener('click', async () => { | ||||
|             try { | ||||
|                 await editor.save(); | ||||
|             } catch (error) { | ||||
|                 Logger.error('Failed to save file:', error); | ||||
|                 if (window.showNotification) { | ||||
|                     window.showNotification('Failed to save file', 'error'); | ||||
|                 } | ||||
|             } | ||||
|         }); | ||||
|  | ||||
|         document.getElementById('deleteBtn').addEventListener('click', async () => { | ||||
|             try { | ||||
|                 await editor.deleteFile(); | ||||
|             } catch (error) { | ||||
|                 Logger.error('Failed to delete file:', error); | ||||
|                 if (window.showNotification) { | ||||
|                     window.showNotification('Failed to delete file', 'error'); | ||||
|                 } | ||||
|             } | ||||
|         }); | ||||
|  | ||||
|         // Setup context menu handlers | ||||
|         setupContextMenuHandlers(); | ||||
|  | ||||
|         // Initialize file tree actions manager | ||||
|         window.fileTreeActions = new FileTreeActions(webdavClient, fileTree, editor); | ||||
|     } else { | ||||
|         // In view mode, hide editor buttons | ||||
|         document.getElementById('newBtn').style.display = 'none'; | ||||
|         document.getElementById('saveBtn').style.display = 'none'; | ||||
|         document.getElementById('deleteBtn').style.display = 'none'; | ||||
|  | ||||
|         // Auto-load last viewed page or first file | ||||
|         await autoLoadPageInViewMode(); | ||||
|     } | ||||
|  | ||||
|     // Initialize mermaid (always needed) | ||||
|     mermaid.initialize({ startOnLoad: true, theme: darkMode.isDark ? 'dark' : 'default' }); | ||||
|     // Listen for file-saved event to reload file tree | ||||
|     window.eventBus.on('file-saved', async (path) => { | ||||
|         if (fileTree) { | ||||
|             await fileTree.load(); | ||||
|             fileTree.selectNode(path); | ||||
|         try { | ||||
|             if (fileTree) { | ||||
|                 await fileTree.load(); | ||||
|                 fileTree.selectNode(path); | ||||
|             } | ||||
|         } catch (error) { | ||||
|             Logger.error('Failed to reload file tree after save:', error); | ||||
|         } | ||||
|     }); | ||||
|  | ||||
|     window.eventBus.on('file-deleted', async () => { | ||||
|         if (fileTree) { | ||||
|             await fileTree.load(); | ||||
|         try { | ||||
|             if (fileTree) { | ||||
|                 await fileTree.load(); | ||||
|             } | ||||
|         } catch (error) { | ||||
|             Logger.error('Failed to reload file tree after delete:', error); | ||||
|         } | ||||
|     }); | ||||
| }); | ||||
| @@ -126,17 +456,17 @@ window.addEventListener('column-resize', () => { | ||||
|  */ | ||||
| function setupContextMenuHandlers() { | ||||
|     const menu = document.getElementById('contextMenu'); | ||||
|      | ||||
|  | ||||
|     menu.addEventListener('click', async (e) => { | ||||
|         const item = e.target.closest('.context-menu-item'); | ||||
|         if (!item) return; | ||||
|          | ||||
|  | ||||
|         const action = item.dataset.action; | ||||
|         const targetPath = menu.dataset.targetPath; | ||||
|         const isDir = menu.dataset.targetIsDir === 'true'; | ||||
|          | ||||
|  | ||||
|         hideContextMenu(); | ||||
|          | ||||
|  | ||||
|         await window.fileTreeActions.execute(action, targetPath, isDir); | ||||
|     }); | ||||
| } | ||||
| @@ -163,16 +493,16 @@ async function handleEditorFileDrop(file) { | ||||
|             parts.pop(); // Remove filename | ||||
|             targetDir = parts.join('/'); | ||||
|         } | ||||
|          | ||||
|  | ||||
|         // Upload file | ||||
|         const uploadedPath = await fileTree.uploadFile(targetDir, file); | ||||
|          | ||||
|  | ||||
|         // Insert markdown link at cursor | ||||
|         const isImage = file.type.startsWith('image/'); | ||||
|         const link = isImage  | ||||
|         const link = isImage | ||||
|             ? `` | ||||
|             : `[${file.name}](/${webdavClient.currentCollection}/${uploadedPath})`; | ||||
|          | ||||
|  | ||||
|         editor.insertAtCursor(link); | ||||
|         showNotification(`Uploaded and inserted link`, 'success'); | ||||
|     } catch (error) { | ||||
|   | ||||
		Reference in New Issue
	
	Block a user