feat: Implement sidebar collapse and expand functionality
- Add CSS for collapsed sidebar state and transitions - Introduce SidebarToggle class for managing collapse/expand logic - Integrate SidebarToggle initialization in main script - Add toggle button to navbar and make mini sidebar clickable - Store sidebar collapsed state in localStorage - Filter image files and directories in view mode via FileTree - Make navbar brand clickable to navigate to collection root or home
This commit is contained in:
		| @@ -124,6 +124,63 @@ body { | ||||
|     height: 100%; | ||||
|     overflow: hidden; | ||||
|     /* Prevent pane scrolling */ | ||||
|     transition: flex 0.3s ease, min-width 0.3s ease, max-width 0.3s ease; | ||||
| } | ||||
|  | ||||
| /* Collapsed sidebar state - mini sidebar */ | ||||
| #sidebarPane.collapsed { | ||||
|     flex: 0 0 50px; | ||||
|     min-width: 50px; | ||||
|     max-width: 50px; | ||||
|     border-right: 1px solid var(--border-color); | ||||
|     position: relative; | ||||
|     cursor: pointer; | ||||
| } | ||||
|  | ||||
| /* Hide file tree content when collapsed */ | ||||
| #sidebarPane.collapsed #fileTree { | ||||
|     display: none; | ||||
| } | ||||
|  | ||||
| /* Hide collection selector when collapsed */ | ||||
| #sidebarPane.collapsed .collection-selector { | ||||
|     display: none; | ||||
| } | ||||
|  | ||||
| /* Visual indicator in the mini sidebar */ | ||||
| #sidebarPane.collapsed::before { | ||||
|     content: ''; | ||||
|     display: block; | ||||
|     width: 100%; | ||||
|     height: 100%; | ||||
|     background: var(--bg-secondary); | ||||
|     transition: background 0.2s ease; | ||||
| } | ||||
|  | ||||
| /* Hover effect on mini sidebar */ | ||||
| #sidebarPane.collapsed:hover::before { | ||||
|     background: var(--hover-bg); | ||||
| } | ||||
|  | ||||
| /* Right arrow icon in the center of mini sidebar */ | ||||
| #sidebarPane.collapsed::after { | ||||
|     content: '\F285'; | ||||
|     /* Bootstrap icon chevron-right */ | ||||
|     font-family: 'bootstrap-icons'; | ||||
|     position: absolute; | ||||
|     top: 50%; | ||||
|     left: 50%; | ||||
|     transform: translate(-50%, -50%); | ||||
|     font-size: 20px; | ||||
|     color: var(--text-secondary); | ||||
|     pointer-events: none; | ||||
|     opacity: 0.5; | ||||
|     transition: opacity 0.2s ease; | ||||
|     cursor: pointer; | ||||
| } | ||||
|  | ||||
| #sidebarPane.collapsed:hover::after { | ||||
|     opacity: 1; | ||||
| } | ||||
|  | ||||
| #editorPane { | ||||
|   | ||||
| @@ -277,6 +277,9 @@ document.addEventListener('DOMContentLoaded', async () => { | ||||
|         darkMode.toggle(); | ||||
|     }); | ||||
|  | ||||
|     // Initialize sidebar toggle | ||||
|     const sidebarToggle = new SidebarToggle('sidebarPane', 'sidebarToggleBtn'); | ||||
|  | ||||
|     // Initialize collection selector (always needed) | ||||
|     collectionSelector = new CollectionSelector('collectionSelect', webdavClient); | ||||
|     await collectionSelector.load(); | ||||
| @@ -321,7 +324,8 @@ document.addEventListener('DOMContentLoaded', async () => { | ||||
|     editor.setWebDAVClient(webdavClient); | ||||
|  | ||||
|     // Initialize file tree (needed in both modes) | ||||
|     fileTree = new FileTree('fileTree', webdavClient); | ||||
|     // Pass isEditMode to control image filtering (hide images only in view mode) | ||||
|     fileTree = new FileTree('fileTree', webdavClient, isEditMode); | ||||
|     fileTree.onFileSelect = async (item) => { | ||||
|         try { | ||||
|             const currentCollection = collectionSelector.getCurrentCollection(); | ||||
| @@ -579,6 +583,22 @@ document.addEventListener('DOMContentLoaded', async () => { | ||||
|         await autoLoadPageInViewMode(); | ||||
|     } | ||||
|  | ||||
|     // Setup clickable navbar brand (logo/title) | ||||
|     const navbarBrand = document.getElementById('navbarBrand'); | ||||
|     if (navbarBrand) { | ||||
|         navbarBrand.addEventListener('click', (e) => { | ||||
|             e.preventDefault(); | ||||
|             const currentCollection = collectionSelector ? collectionSelector.getCurrentCollection() : null; | ||||
|             if (currentCollection) { | ||||
|                 // Navigate to collection root | ||||
|                 window.location.href = `/${currentCollection}/`; | ||||
|             } else { | ||||
|                 // Navigate to home page | ||||
|                 window.location.href = '/'; | ||||
|             } | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     // Initialize mermaid (always needed) | ||||
|     mermaid.initialize({ startOnLoad: true, theme: darkMode.isDark ? 'dark' : 'default' }); | ||||
|     // Listen for file-saved event to reload file tree | ||||
|   | ||||
| @@ -5,86 +5,86 @@ | ||||
|  | ||||
| const Config = { | ||||
|     // ===== TIMING CONFIGURATION ===== | ||||
|      | ||||
|  | ||||
|     /** | ||||
|      * Long-press threshold in milliseconds | ||||
|      * Used for drag-and-drop detection in file tree | ||||
|      */ | ||||
|     LONG_PRESS_THRESHOLD: 400, | ||||
|      | ||||
|  | ||||
|     /** | ||||
|      * Debounce delay in milliseconds | ||||
|      * Used for editor preview updates | ||||
|      */ | ||||
|     DEBOUNCE_DELAY: 300, | ||||
|      | ||||
|  | ||||
|     /** | ||||
|      * Toast notification duration in milliseconds | ||||
|      */ | ||||
|     TOAST_DURATION: 3000, | ||||
|      | ||||
|  | ||||
|     /** | ||||
|      * Mouse move threshold in pixels | ||||
|      * Used to detect if user is dragging vs clicking | ||||
|      */ | ||||
|     MOUSE_MOVE_THRESHOLD: 5, | ||||
|      | ||||
|  | ||||
|     // ===== UI CONFIGURATION ===== | ||||
|      | ||||
|  | ||||
|     /** | ||||
|      * Drag preview width in pixels | ||||
|      * Width of the drag ghost image during drag-and-drop | ||||
|      */ | ||||
|     DRAG_PREVIEW_WIDTH: 200, | ||||
|      | ||||
|  | ||||
|     /** | ||||
|      * Tree indentation in pixels | ||||
|      * Indentation per level in the file tree | ||||
|      */ | ||||
|     TREE_INDENT_PX: 12, | ||||
|      | ||||
|  | ||||
|     /** | ||||
|      * Toast container z-index | ||||
|      * Ensures toasts appear above other elements | ||||
|      */ | ||||
|     TOAST_Z_INDEX: 9999, | ||||
|      | ||||
|  | ||||
|     /** | ||||
|      * Minimum sidebar width in pixels | ||||
|      */ | ||||
|     MIN_SIDEBAR_WIDTH: 150, | ||||
|      | ||||
|  | ||||
|     /** | ||||
|      * Maximum sidebar width as percentage of container | ||||
|      */ | ||||
|     MAX_SIDEBAR_WIDTH_PERCENT: 40, | ||||
|      | ||||
|  | ||||
|     /** | ||||
|      * Minimum editor width in pixels | ||||
|      */ | ||||
|     MIN_EDITOR_WIDTH: 250, | ||||
|      | ||||
|  | ||||
|     /** | ||||
|      * Maximum editor width as percentage of container | ||||
|      */ | ||||
|     MAX_EDITOR_WIDTH_PERCENT: 70, | ||||
|      | ||||
|  | ||||
|     // ===== VALIDATION CONFIGURATION ===== | ||||
|      | ||||
|  | ||||
|     /** | ||||
|      * Valid filename pattern | ||||
|      * Only lowercase letters, numbers, underscores, and dots allowed | ||||
|      */ | ||||
|     FILENAME_PATTERN: /^[a-z0-9_]+(\.[a-z0-9_]+)*$/, | ||||
|      | ||||
|  | ||||
|     /** | ||||
|      * Characters to replace in filenames | ||||
|      * All invalid characters will be replaced with underscore | ||||
|      */ | ||||
|     FILENAME_INVALID_CHARS: /[^a-z0-9_.]/g, | ||||
|      | ||||
|  | ||||
|     // ===== STORAGE KEYS ===== | ||||
|      | ||||
|  | ||||
|     /** | ||||
|      * LocalStorage keys used throughout the application | ||||
|      */ | ||||
| @@ -93,99 +93,104 @@ const Config = { | ||||
|          * Dark mode preference | ||||
|          */ | ||||
|         DARK_MODE: 'darkMode', | ||||
|          | ||||
|  | ||||
|         /** | ||||
|          * Currently selected collection | ||||
|          */ | ||||
|         SELECTED_COLLECTION: 'selectedCollection', | ||||
|          | ||||
|  | ||||
|         /** | ||||
|          * Last viewed page (per collection) | ||||
|          * Actual key will be: lastViewedPage:{collection} | ||||
|          */ | ||||
|         LAST_VIEWED_PAGE: 'lastViewedPage', | ||||
|          | ||||
|  | ||||
|         /** | ||||
|          * Column dimensions (sidebar, editor, preview widths) | ||||
|          */ | ||||
|         COLUMN_DIMENSIONS: 'columnDimensions' | ||||
|         COLUMN_DIMENSIONS: 'columnDimensions', | ||||
|  | ||||
|         /** | ||||
|          * Sidebar collapsed state | ||||
|          */ | ||||
|         SIDEBAR_COLLAPSED: 'sidebarCollapsed' | ||||
|     }, | ||||
|      | ||||
|  | ||||
|     // ===== EDITOR CONFIGURATION ===== | ||||
|      | ||||
|  | ||||
|     /** | ||||
|      * CodeMirror theme for light mode | ||||
|      */ | ||||
|     EDITOR_THEME_LIGHT: 'default', | ||||
|      | ||||
|  | ||||
|     /** | ||||
|      * CodeMirror theme for dark mode | ||||
|      */ | ||||
|     EDITOR_THEME_DARK: 'monokai', | ||||
|      | ||||
|  | ||||
|     /** | ||||
|      * Mermaid theme for light mode | ||||
|      */ | ||||
|     MERMAID_THEME_LIGHT: 'default', | ||||
|      | ||||
|  | ||||
|     /** | ||||
|      * Mermaid theme for dark mode | ||||
|      */ | ||||
|     MERMAID_THEME_DARK: 'dark', | ||||
|      | ||||
|  | ||||
|     // ===== FILE TREE CONFIGURATION ===== | ||||
|      | ||||
|  | ||||
|     /** | ||||
|      * Default content for new files | ||||
|      */ | ||||
|     DEFAULT_FILE_CONTENT: '# New File\n\n', | ||||
|      | ||||
|  | ||||
|     /** | ||||
|      * Default filename for new files | ||||
|      */ | ||||
|     DEFAULT_NEW_FILENAME: 'new_file.md', | ||||
|      | ||||
|  | ||||
|     /** | ||||
|      * Default folder name for new folders | ||||
|      */ | ||||
|     DEFAULT_NEW_FOLDERNAME: 'new_folder', | ||||
|      | ||||
|  | ||||
|     // ===== WEBDAV CONFIGURATION ===== | ||||
|      | ||||
|  | ||||
|     /** | ||||
|      * WebDAV base URL | ||||
|      */ | ||||
|     WEBDAV_BASE_URL: '/fs/', | ||||
|      | ||||
|  | ||||
|     /** | ||||
|      * PROPFIND depth for file tree loading | ||||
|      */ | ||||
|     PROPFIND_DEPTH: 'infinity', | ||||
|      | ||||
|  | ||||
|     // ===== DRAG AND DROP CONFIGURATION ===== | ||||
|      | ||||
|  | ||||
|     /** | ||||
|      * Drag preview opacity | ||||
|      */ | ||||
|     DRAG_PREVIEW_OPACITY: 0.8, | ||||
|      | ||||
|  | ||||
|     /** | ||||
|      * Dragging item opacity | ||||
|      */ | ||||
|     DRAGGING_OPACITY: 0.4, | ||||
|      | ||||
|  | ||||
|     /** | ||||
|      * Drag preview offset X in pixels | ||||
|      */ | ||||
|     DRAG_PREVIEW_OFFSET_X: 10, | ||||
|      | ||||
|  | ||||
|     /** | ||||
|      * Drag preview offset Y in pixels | ||||
|      */ | ||||
|     DRAG_PREVIEW_OFFSET_Y: 10, | ||||
|      | ||||
|  | ||||
|     // ===== NOTIFICATION TYPES ===== | ||||
|      | ||||
|  | ||||
|     /** | ||||
|      * Bootstrap notification type mappings | ||||
|      */ | ||||
|   | ||||
| @@ -4,13 +4,14 @@ | ||||
|  */ | ||||
|  | ||||
| class FileTree { | ||||
|     constructor(containerId, webdavClient) { | ||||
|     constructor(containerId, webdavClient, isEditMode = false) { | ||||
|         this.container = document.getElementById(containerId); | ||||
|         this.webdavClient = webdavClient; | ||||
|         this.tree = []; | ||||
|         this.selectedPath = null; | ||||
|         this.onFileSelect = null; | ||||
|         this.onFolderSelect = null; | ||||
|         this.filterImagesInViewMode = !isEditMode; // Track if we should filter images (true in view mode) | ||||
|  | ||||
|         // Drag and drop state | ||||
|         this.draggedNode = null; | ||||
| @@ -426,6 +427,19 @@ class FileTree { | ||||
|  | ||||
|     renderNodes(nodes, parentElement, level) { | ||||
|         nodes.forEach(node => { | ||||
|             // Filter out images and image directories in view mode | ||||
|             if (this.filterImagesInViewMode) { | ||||
|                 // Skip image files | ||||
|                 if (!node.isDirectory && PathUtils.isBinaryFile(node.path)) { | ||||
|                     return; | ||||
|                 } | ||||
|  | ||||
|                 // Skip image directories | ||||
|                 if (node.isDirectory && PathUtils.isImageDirectory(node.path)) { | ||||
|                     return; | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             const nodeWrapper = document.createElement('div'); | ||||
|             nodeWrapper.className = 'tree-node-wrapper'; | ||||
|  | ||||
|   | ||||
							
								
								
									
										114
									
								
								static/js/sidebar-toggle.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										114
									
								
								static/js/sidebar-toggle.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,114 @@ | ||||
| /** | ||||
|  * Sidebar Toggle Module | ||||
|  * Manages sidebar collapse/expand functionality with localStorage persistence | ||||
|  */ | ||||
|  | ||||
| class SidebarToggle { | ||||
|     constructor(sidebarId, toggleButtonId) { | ||||
|         this.sidebar = document.getElementById(sidebarId); | ||||
|         this.toggleButton = document.getElementById(toggleButtonId); | ||||
|         this.storageKey = Config.STORAGE_KEYS.SIDEBAR_COLLAPSED || 'sidebarCollapsed'; | ||||
|         this.isCollapsed = localStorage.getItem(this.storageKey) === 'true'; | ||||
|  | ||||
|         this.init(); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Initialize the sidebar toggle | ||||
|      */ | ||||
|     init() { | ||||
|         // Apply initial state | ||||
|         this.apply(); | ||||
|  | ||||
|         // Setup toggle button click handler | ||||
|         if (this.toggleButton) { | ||||
|             this.toggleButton.addEventListener('click', () => { | ||||
|                 this.toggle(); | ||||
|             }); | ||||
|         } | ||||
|  | ||||
|         // Make mini sidebar clickable to expand | ||||
|         if (this.sidebar) { | ||||
|             this.sidebar.addEventListener('click', (e) => { | ||||
|                 // Only expand if sidebar is collapsed and click is on the mini sidebar itself | ||||
|                 // (not on the file tree content when expanded) | ||||
|                 if (this.isCollapsed) { | ||||
|                     this.expand(); | ||||
|                 } | ||||
|             }); | ||||
|  | ||||
|             // Add cursor pointer when collapsed | ||||
|             this.sidebar.style.cursor = 'default'; | ||||
|         } | ||||
|  | ||||
|         Logger.debug(`Sidebar initialized: ${this.isCollapsed ? 'collapsed' : 'expanded'}`); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Toggle sidebar state | ||||
|      */ | ||||
|     toggle() { | ||||
|         this.isCollapsed = !this.isCollapsed; | ||||
|         localStorage.setItem(this.storageKey, this.isCollapsed); | ||||
|         this.apply(); | ||||
|  | ||||
|         Logger.debug(`Sidebar ${this.isCollapsed ? 'collapsed' : 'expanded'}`); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Apply the current sidebar state | ||||
|      */ | ||||
|     apply() { | ||||
|         if (this.sidebar) { | ||||
|             if (this.isCollapsed) { | ||||
|                 this.sidebar.classList.add('collapsed'); | ||||
|                 this.sidebar.style.cursor = 'pointer'; // Make mini sidebar clickable | ||||
|             } else { | ||||
|                 this.sidebar.classList.remove('collapsed'); | ||||
|                 this.sidebar.style.cursor = 'default'; // Normal cursor when expanded | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         // Update toggle button icon | ||||
|         if (this.toggleButton) { | ||||
|             const icon = this.toggleButton.querySelector('i'); | ||||
|             if (icon) { | ||||
|                 if (this.isCollapsed) { | ||||
|                     icon.className = 'bi bi-layout-sidebar-inset-reverse'; | ||||
|                 } else { | ||||
|                     icon.className = 'bi bi-layout-sidebar'; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Collapse the sidebar | ||||
|      */ | ||||
|     collapse() { | ||||
|         if (!this.isCollapsed) { | ||||
|             this.toggle(); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Expand the sidebar | ||||
|      */ | ||||
|     expand() { | ||||
|         if (this.isCollapsed) { | ||||
|             this.toggle(); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Check if sidebar is currently collapsed | ||||
|      * @returns {boolean} True if sidebar is collapsed | ||||
|      */ | ||||
|     isCollapsedState() { | ||||
|         return this.isCollapsed; | ||||
|     } | ||||
| } | ||||
|  | ||||
| // Make SidebarToggle globally available | ||||
| window.SidebarToggle = SidebarToggle; | ||||
|  | ||||
| @@ -103,6 +103,31 @@ const PathUtils = { | ||||
|         return binaryExtensions.includes(extension); | ||||
|     }, | ||||
|  | ||||
|     /** | ||||
|      * Check if a directory is an image directory based on its name | ||||
|      * @param {string} path - The directory path | ||||
|      * @returns {boolean} True if the directory is for images | ||||
|      * @example PathUtils.isImageDirectory('images') // true | ||||
|      * @example PathUtils.isImageDirectory('assets/images') // true | ||||
|      * @example PathUtils.isImageDirectory('docs') // false | ||||
|      */ | ||||
|     isImageDirectory(path) { | ||||
|         const dirName = PathUtils.getFileName(path).toLowerCase(); | ||||
|         const imageDirectoryNames = [ | ||||
|             'images', | ||||
|             'image', | ||||
|             'img', | ||||
|             'imgs', | ||||
|             'pictures', | ||||
|             'pics', | ||||
|             'photos', | ||||
|             'assets', | ||||
|             'media', | ||||
|             'static' | ||||
|         ]; | ||||
|         return imageDirectoryNames.includes(dirName); | ||||
|     }, | ||||
|  | ||||
|     /** | ||||
|      * Get a human-readable file type description | ||||
|      * @param {string} path - The file path | ||||
|   | ||||
| @@ -30,10 +30,18 @@ | ||||
|     <!-- Navbar --> | ||||
|     <nav class="navbar navbar-expand-lg"> | ||||
|         <div class="container-fluid"> | ||||
|             <!-- Left: Logo and Title --> | ||||
|             <span class="navbar-brand mb-0"> | ||||
|                 <i class="bi bi-markdown"></i> Markdown Editor | ||||
|             </span> | ||||
|             <!-- Left: Sidebar Toggle + Logo and Title --> | ||||
|             <div class="d-flex align-items-center gap-2"> | ||||
|                 <!-- Sidebar Toggle Button --> | ||||
|                 <button id="sidebarToggleBtn" class="btn-flat btn-flat-secondary" title="Toggle Sidebar"> | ||||
|                     <i class="bi bi-layout-sidebar"></i> | ||||
|                 </button> | ||||
|  | ||||
|                 <!-- Logo and Title (Clickable) --> | ||||
|                 <a href="/" class="navbar-brand mb-0" id="navbarBrand" style="cursor: pointer; text-decoration: none;"> | ||||
|                     <i class="bi bi-markdown"></i> Markdown Editor | ||||
|                 </a> | ||||
|             </div> | ||||
|  | ||||
|             <!-- Right: All Buttons --> | ||||
|             <div class="ms-auto d-flex gap-2 align-items-center"> | ||||
| @@ -227,6 +235,7 @@ | ||||
|     <script src="/static/js/context-menu.js" defer></script> | ||||
|     <script src="/static/js/file-upload.js" defer></script> | ||||
|     <script src="/static/js/dark-mode.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/editor-drop-handler.js" defer></script> | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user