Compare commits
	
		
			16 Commits
		
	
	
		
			e41e49f7ea
			...
			master
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 16aef59298 | |||
| 
						 | 
					3961628b3d | ||
| 
						 | 
					afcd074913 | ||
| 
						 | 
					7a9efd3542 | ||
| 
						 | 
					f319f29d4c | ||
| 
						 | 
					0ed6bcf1f2 | ||
| 23a24d42e2 | |||
| d48e25ce90 | |||
| 11038e0bcd | |||
| cae90ec3dc | |||
| b9349425d7 | |||
| cdc753e72d | |||
| 98a529a3cc | |||
| 5c9e07eee0 | |||
| 12b4685457 | |||
| 3fc8329303 | 
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1 @@
 | 
				
			|||||||
 | 
					.venv
 | 
				
			||||||
							
								
								
									
										22
									
								
								collections/7madah/tests/sub_tests/file1.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,22 @@
 | 
				
			|||||||
 | 
					# Start to end file
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Graph
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					---
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					This is just for testing
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					---
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**See what i did?**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					---
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```mermaid
 | 
				
			||||||
 | 
					graph TD
 | 
				
			||||||
 | 
					    A[Start] --> B{Process};
 | 
				
			||||||
 | 
					    B --> C{Decision};
 | 
				
			||||||
 | 
					    C -- Yes --> D[End Yes];
 | 
				
			||||||
 | 
					    C -- No --> E[End No];
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
							
								
								
									
										9
									
								
								collections/7madah/tests/test.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,9 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
 | 
					# test
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- 1
 | 
				
			||||||
 | 
					- 2
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					!!include path:test2.md
 | 
				
			||||||
							
								
								
									
										12
									
								
								collections/7madah/tests/test2.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,12 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
 | 
					## test2
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- something
 | 
				
			||||||
 | 
					- another thing
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
							
								
								
									
										426
									
								
								collections/7madah/tests/test3.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,426 @@
 | 
				
			|||||||
 | 
					# UI Code Refactoring Plan
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Project:** Markdown Editor  
 | 
				
			||||||
 | 
					**Date:** 2025-10-26  
 | 
				
			||||||
 | 
					**Status:** In Progress
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					---
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Executive Summary
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					This document outlines a comprehensive refactoring plan for the UI codebase to improve maintainability, remove dead code, extract utilities, and standardize patterns. The refactoring is organized into 6 phases with 14 tasks, prioritized by risk and impact.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Key Metrics:**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Total Lines of Code: ~3,587
 | 
				
			||||||
 | 
					- Dead Code to Remove: 213 lines (6%)
 | 
				
			||||||
 | 
					- Estimated Effort: 5-8 days
 | 
				
			||||||
 | 
					- Risk Level: Mostly LOW to MEDIUM
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					---
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Phase 1: Analysis Summary
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Files Reviewed
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**JavaScript Files (10):**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- `/static/js/app.js` (484 lines)
 | 
				
			||||||
 | 
					- `/static/js/column-resizer.js` (100 lines)
 | 
				
			||||||
 | 
					- `/static/js/confirmation.js` (170 lines)
 | 
				
			||||||
 | 
					- `/static/js/editor.js` (420 lines)
 | 
				
			||||||
 | 
					- `/static/js/file-tree-actions.js` (482 lines)
 | 
				
			||||||
 | 
					- `/static/js/file-tree.js` (865 lines)
 | 
				
			||||||
 | 
					- `/static/js/macro-parser.js` (103 lines)
 | 
				
			||||||
 | 
					- `/static/js/macro-processor.js` (157 lines)
 | 
				
			||||||
 | 
					- `/static/js/ui-utils.js` (305 lines)
 | 
				
			||||||
 | 
					- `/static/js/webdav-client.js` (266 lines)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**CSS Files (6):**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- `/static/css/variables.css` (32 lines)
 | 
				
			||||||
 | 
					- `/static/css/layout.css`
 | 
				
			||||||
 | 
					- `/static/css/file-tree.css`
 | 
				
			||||||
 | 
					- `/static/css/editor.css`
 | 
				
			||||||
 | 
					- `/static/css/components.css`
 | 
				
			||||||
 | 
					- `/static/css/modal.css`
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**HTML Templates (1):**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- `/templates/index.html` (203 lines)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					---
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Issues Found
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### 🔴 HIGH PRIORITY
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					1. **Deprecated Modal Code (Dead Code)**
 | 
				
			||||||
 | 
					   - Location: `/static/js/file-tree-actions.js` lines 262-474
 | 
				
			||||||
 | 
					   - Impact: 213 lines of unused code (44% of file)
 | 
				
			||||||
 | 
					   - Risk: LOW to remove
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					2. **Duplicated Event Bus Implementation**
 | 
				
			||||||
 | 
					   - Location: `/static/js/app.js` lines 16-30
 | 
				
			||||||
 | 
					   - Should be extracted to reusable module
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					3. **Duplicated Debounce Function**
 | 
				
			||||||
 | 
					   - Location: `/static/js/editor.js` lines 404-414
 | 
				
			||||||
 | 
					   - Should be shared utility
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					4. **Inconsistent Notification Usage**
 | 
				
			||||||
 | 
					   - Mixed usage of `window.showNotification` vs `showNotification`
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					5. **Duplicated File Download Logic**
 | 
				
			||||||
 | 
					   - Location: `/static/js/file-tree.js` lines 829-839
 | 
				
			||||||
 | 
					   - Should be shared utility
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					6. **Hard-coded Values**
 | 
				
			||||||
 | 
					   - Long-press threshold: 400ms
 | 
				
			||||||
 | 
					   - Debounce delay: 300ms
 | 
				
			||||||
 | 
					   - Drag preview width: 200px
 | 
				
			||||||
 | 
					   - Toast delay: 3000ms
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### 🟡 MEDIUM PRIORITY
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					7. **Global State Management**
 | 
				
			||||||
 | 
					   - Location: `/static/js/app.js` lines 6-13
 | 
				
			||||||
 | 
					   - Makes testing difficult
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					8. **Duplicated Path Manipulation**
 | 
				
			||||||
 | 
					   - `path.split('/').pop()` appears 10+ times
 | 
				
			||||||
 | 
					   - `path.substring(0, path.lastIndexOf('/'))` appears 5+ times
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					9. **Mixed Responsibility in ui-utils.js**
 | 
				
			||||||
 | 
					   - Contains 6 different classes/utilities
 | 
				
			||||||
 | 
					   - Should be split into separate modules
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					10. **Deprecated Event Handler**
 | 
				
			||||||
 | 
					    - Location: `/static/js/file-tree-actions.js` line 329
 | 
				
			||||||
 | 
					    - Uses deprecated `onkeypress`
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### 🟢 LOW PRIORITY
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					11. **Unused Function Parameters**
 | 
				
			||||||
 | 
					12. **Magic Numbers in Styling**
 | 
				
			||||||
 | 
					13. **Inconsistent Comment Styles**
 | 
				
			||||||
 | 
					14. **Console.log Statements**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					---
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Phase 2: Proposed Reusable Components
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### 1. Config Module (`/static/js/config.js`)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Centralize all configuration values:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```javascript
 | 
				
			||||||
 | 
					export const Config = {
 | 
				
			||||||
 | 
					    // Timing
 | 
				
			||||||
 | 
					    LONG_PRESS_THRESHOLD: 400,
 | 
				
			||||||
 | 
					    DEBOUNCE_DELAY: 300,
 | 
				
			||||||
 | 
					    TOAST_DURATION: 3000,
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    // UI
 | 
				
			||||||
 | 
					    DRAG_PREVIEW_WIDTH: 200,
 | 
				
			||||||
 | 
					    TREE_INDENT_PX: 12,
 | 
				
			||||||
 | 
					    MOUSE_MOVE_THRESHOLD: 5,
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    // Validation
 | 
				
			||||||
 | 
					    FILENAME_PATTERN: /^[a-z0-9_]+(\.[a-z0-9_]+)*$/,
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    // Storage Keys
 | 
				
			||||||
 | 
					    STORAGE_KEYS: {
 | 
				
			||||||
 | 
					        DARK_MODE: 'darkMode',
 | 
				
			||||||
 | 
					        SELECTED_COLLECTION: 'selectedCollection',
 | 
				
			||||||
 | 
					        LAST_VIEWED_PAGE: 'lastViewedPage',
 | 
				
			||||||
 | 
					        COLUMN_DIMENSIONS: 'columnDimensions'
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### 2. Logger Module (`/static/js/logger.js`)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Structured logging with levels:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```javascript
 | 
				
			||||||
 | 
					export class Logger {
 | 
				
			||||||
 | 
					    static debug(message, ...args)
 | 
				
			||||||
 | 
					    static info(message, ...args)
 | 
				
			||||||
 | 
					    static warn(message, ...args)
 | 
				
			||||||
 | 
					    static error(message, ...args)
 | 
				
			||||||
 | 
					    static setLevel(level)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### 3. Event Bus Module (`/static/js/event-bus.js`)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Centralized event system:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```javascript
 | 
				
			||||||
 | 
					export class EventBus {
 | 
				
			||||||
 | 
					    on(event, callback)
 | 
				
			||||||
 | 
					    off(event, callback)
 | 
				
			||||||
 | 
					    once(event, callback)
 | 
				
			||||||
 | 
					    dispatch(event, data)
 | 
				
			||||||
 | 
					    clear(event)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### 4. Utilities Module (`/static/js/utils.js`)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Common utility functions:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```javascript
 | 
				
			||||||
 | 
					export const PathUtils = {
 | 
				
			||||||
 | 
					    getFileName(path),
 | 
				
			||||||
 | 
					    getParentPath(path),
 | 
				
			||||||
 | 
					    normalizePath(path),
 | 
				
			||||||
 | 
					    joinPaths(...paths),
 | 
				
			||||||
 | 
					    getExtension(path)
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const TimingUtils = {
 | 
				
			||||||
 | 
					    debounce(func, wait),
 | 
				
			||||||
 | 
					    throttle(func, wait)
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const DownloadUtils = {
 | 
				
			||||||
 | 
					    triggerDownload(content, filename),
 | 
				
			||||||
 | 
					    downloadAsBlob(blob, filename)
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const ValidationUtils = {
 | 
				
			||||||
 | 
					    validateFileName(name, isFolder),
 | 
				
			||||||
 | 
					    sanitizeFileName(name)
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### 5. Notification Service (`/static/js/notification-service.js`)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Standardized notifications:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```javascript
 | 
				
			||||||
 | 
					export class NotificationService {
 | 
				
			||||||
 | 
					    static success(message)
 | 
				
			||||||
 | 
					    static error(message)
 | 
				
			||||||
 | 
					    static warning(message)
 | 
				
			||||||
 | 
					    static info(message)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					---
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Phase 3: Refactoring Tasks
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### 🔴 HIGH PRIORITY
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Task 1: Remove Dead Code**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Files: `/static/js/file-tree-actions.js`
 | 
				
			||||||
 | 
					- Lines: 262-474 (213 lines)
 | 
				
			||||||
 | 
					- Risk: LOW
 | 
				
			||||||
 | 
					- Dependencies: None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Task 2: Extract Event Bus**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Files: NEW `/static/js/event-bus.js`, MODIFY `app.js`, `editor.js`
 | 
				
			||||||
 | 
					- Risk: MEDIUM
 | 
				
			||||||
 | 
					- Dependencies: None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Task 3: Create Utilities Module**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Files: NEW `/static/js/utils.js`, MODIFY multiple files
 | 
				
			||||||
 | 
					- Risk: MEDIUM
 | 
				
			||||||
 | 
					- Dependencies: None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Task 4: Create Config Module**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Files: NEW `/static/js/config.js`, MODIFY multiple files
 | 
				
			||||||
 | 
					- Risk: LOW
 | 
				
			||||||
 | 
					- Dependencies: None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Task 5: Standardize Notification Usage**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Files: NEW `/static/js/notification-service.js`, MODIFY multiple files
 | 
				
			||||||
 | 
					- Risk: LOW
 | 
				
			||||||
 | 
					- Dependencies: None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### 🟡 MEDIUM PRIORITY
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Task 6: Fix Deprecated Event Handler**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Files: `/static/js/file-tree-actions.js` line 329
 | 
				
			||||||
 | 
					- Risk: LOW
 | 
				
			||||||
 | 
					- Dependencies: None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Task 7: Refactor ui-utils.js**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Files: DELETE `ui-utils.js`, CREATE 5 new modules
 | 
				
			||||||
 | 
					- Risk: HIGH
 | 
				
			||||||
 | 
					- Dependencies: Task 5
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Task 8: Standardize Class Export Pattern**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Files: All class files
 | 
				
			||||||
 | 
					- Risk: MEDIUM
 | 
				
			||||||
 | 
					- Dependencies: None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Task 9: Create Logger Module**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Files: NEW `/static/js/logger.js`, MODIFY multiple files
 | 
				
			||||||
 | 
					- Risk: LOW
 | 
				
			||||||
 | 
					- Dependencies: None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Task 10: Implement Download Action**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Files: `/static/js/file-tree-actions.js`
 | 
				
			||||||
 | 
					- Risk: LOW
 | 
				
			||||||
 | 
					- Dependencies: Task 3
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### 🟢 LOW PRIORITY
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Task 11: Standardize JSDoc Comments**
 | 
				
			||||||
 | 
					**Task 12: Extract Magic Numbers to CSS**
 | 
				
			||||||
 | 
					**Task 13: Add Error Boundaries**
 | 
				
			||||||
 | 
					**Task 14: Cache DOM Elements**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					---
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Phase 4: Implementation Order
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Step 1: Foundation (Do First)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					1. Create Config Module (Task 4)
 | 
				
			||||||
 | 
					2. Create Logger Module (Task 9)
 | 
				
			||||||
 | 
					3. Create Event Bus Module (Task 2)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Step 2: Utilities (Do Second)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					4. Create Utilities Module (Task 3)
 | 
				
			||||||
 | 
					5. Create Notification Service (Task 5)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Step 3: Cleanup (Do Third)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					6. Remove Dead Code (Task 1)
 | 
				
			||||||
 | 
					7. Fix Deprecated Event Handler (Task 6)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Step 4: Restructuring (Do Fourth)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					8. Refactor ui-utils.js (Task 7)
 | 
				
			||||||
 | 
					9. Standardize Class Export Pattern (Task 8)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Step 5: Enhancements (Do Fifth)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					10. Implement Download Action (Task 10)
 | 
				
			||||||
 | 
					11. Add Error Boundaries (Task 13)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Step 6: Polish (Do Last)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					12. Standardize JSDoc Comments (Task 11)
 | 
				
			||||||
 | 
					13. Extract Magic Numbers to CSS (Task 12)
 | 
				
			||||||
 | 
					14. Cache DOM Elements (Task 14)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					---
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Phase 5: Testing Checklist
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Core Functionality
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- [ ] File tree loads and displays correctly
 | 
				
			||||||
 | 
					- [ ] Files can be selected and opened
 | 
				
			||||||
 | 
					- [ ] Folders can be expanded/collapsed
 | 
				
			||||||
 | 
					- [ ] Editor loads file content
 | 
				
			||||||
 | 
					- [ ] Preview renders markdown correctly
 | 
				
			||||||
 | 
					- [ ] Save button saves files
 | 
				
			||||||
 | 
					- [ ] Delete button deletes files
 | 
				
			||||||
 | 
					- [ ] New button creates new files
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Context Menu Actions
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- [ ] Right-click shows context menu
 | 
				
			||||||
 | 
					- [ ] New file action works
 | 
				
			||||||
 | 
					- [ ] New folder action works
 | 
				
			||||||
 | 
					- [ ] Rename action works
 | 
				
			||||||
 | 
					- [ ] Delete action works
 | 
				
			||||||
 | 
					- [ ] Copy/Cut/Paste actions work
 | 
				
			||||||
 | 
					- [ ] Upload action works
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Drag and Drop
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- [ ] Long-press detection works
 | 
				
			||||||
 | 
					- [ ] Drag preview appears correctly
 | 
				
			||||||
 | 
					- [ ] Drop targets highlight properly
 | 
				
			||||||
 | 
					- [ ] Files can be moved
 | 
				
			||||||
 | 
					- [ ] Undo (Ctrl+Z) works
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Modals
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- [ ] Confirmation modals appear
 | 
				
			||||||
 | 
					- [ ] Prompt modals appear
 | 
				
			||||||
 | 
					- [ ] Modals don't double-open
 | 
				
			||||||
 | 
					- [ ] Enter/Escape keys work
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### UI Features
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- [ ] Dark mode toggle works
 | 
				
			||||||
 | 
					- [ ] Collection selector works
 | 
				
			||||||
 | 
					- [ ] Column resizers work
 | 
				
			||||||
 | 
					- [ ] Notifications appear
 | 
				
			||||||
 | 
					- [ ] URL routing works
 | 
				
			||||||
 | 
					- [ ] View/Edit modes work
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					---
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Recommendations
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Immediate Actions (Before Production)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					1. Remove dead code (Task 1)
 | 
				
			||||||
 | 
					2. Fix deprecated event handler (Task 6)
 | 
				
			||||||
 | 
					3. Create config module (Task 4)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Short-term Actions (Next Sprint)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					4. Extract utilities (Task 3)
 | 
				
			||||||
 | 
					5. Standardize notifications (Task 5)
 | 
				
			||||||
 | 
					6. Create event bus (Task 2)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Medium-term Actions (Future Sprints)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					7. Refactor ui-utils.js (Task 7)
 | 
				
			||||||
 | 
					8. Add logger (Task 9)
 | 
				
			||||||
 | 
					9. Standardize exports (Task 8)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					---
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Success Metrics
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Before Refactoring:**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Total Lines: ~3,587
 | 
				
			||||||
 | 
					- Dead Code: 213 lines (6%)
 | 
				
			||||||
 | 
					- Duplicated Code: ~50 lines
 | 
				
			||||||
 | 
					- Hard-coded Values: 15+
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**After Refactoring:**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Total Lines: ~3,400 (-5%)
 | 
				
			||||||
 | 
					- Dead Code: 0 lines
 | 
				
			||||||
 | 
					- Duplicated Code: 0 lines
 | 
				
			||||||
 | 
					- Hard-coded Values: 0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Estimated Effort:** 5-8 days
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					---
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Conclusion
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					The UI codebase is generally well-structured. Main improvements needed:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					1. Remove dead code
 | 
				
			||||||
 | 
					2. Extract duplicated utilities
 | 
				
			||||||
 | 
					3. Centralize configuration
 | 
				
			||||||
 | 
					4. Standardize patterns
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Start with high-impact, low-risk changes first to ensure production readiness.
 | 
				
			||||||
							
								
								
									
										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
									
								
							
							
						
						@@ -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
									
								
							
							
						
						@@ -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
									
								
							
							
						
						@@ -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
									
								
							
							
						
						@@ -0,0 +1 @@
 | 
				
			|||||||
 | 
					If you need help with Hero, reach out to the ThreeFold Support team [here](https://threefoldfaq.crisp.help/en/).
 | 
				
			||||||
@@ -1,58 +0,0 @@
 | 
				
			|||||||
# Welcome to Markdown Editor
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
This is a **WebDAV-based** markdown editor with modular architecture.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
```mermaid
 | 
					 | 
				
			||||||
%%{init: {'theme':'dark'}}%%
 | 
					 | 
				
			||||||
graph TD
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    %% User side
 | 
					 | 
				
			||||||
    H1[Human A] --> PA1[Personal Agent A]
 | 
					 | 
				
			||||||
    H2[Human B] --> PA2[Personal Agent B]
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    %% Local mail nodes
 | 
					 | 
				
			||||||
    PA1 --> M1[MyMail Node A]
 | 
					 | 
				
			||||||
    PA2 --> M2[MyMail Node B]
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    %% Proxy coordination layer
 | 
					 | 
				
			||||||
    M1 --> Proxy1A[Proxy Agent L1]
 | 
					 | 
				
			||||||
    Proxy1A --> Proxy2A[Proxy Agent L2]
 | 
					 | 
				
			||||||
    Proxy2A --> Proxy2B[Proxy Agent L2]
 | 
					 | 
				
			||||||
    Proxy2B --> Proxy1B[Proxy Agent L1]
 | 
					 | 
				
			||||||
    Proxy1B --> M2
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    %% Blockchain anchoring
 | 
					 | 
				
			||||||
    M1 --> Chain[Dynamic Blockchain]
 | 
					 | 
				
			||||||
    M2 --> Chain
 | 
					 | 
				
			||||||
```
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
## Features
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
- ✅ Standards-compliant WebDAV backend
 | 
					 | 
				
			||||||
- ✅ Multiple document collections
 | 
					 | 
				
			||||||
- ✅ Modular JavaScript/CSS
 | 
					 | 
				
			||||||
- ✅ Live preview
 | 
					 | 
				
			||||||
- ✅ Syntax highlighting
 | 
					 | 
				
			||||||
- ✅ Mermaid diagrams
 | 
					 | 
				
			||||||
- ✅ Dark mode
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
## WebDAV Methods
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
This editor uses standard WebDAV methods:
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
- `PROPFIND` - List files
 | 
					 | 
				
			||||||
- `GET` - Read files
 | 
					 | 
				
			||||||
- `PUT` - Create/update files
 | 
					 | 
				
			||||||
- `DELETE` - Delete files
 | 
					 | 
				
			||||||
- `COPY` - Copy files
 | 
					 | 
				
			||||||
- `MOVE` - Move/rename files
 | 
					 | 
				
			||||||
- `MKCOL` - Create directories
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
## Try It Out
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
1. Create a new file
 | 
					 | 
				
			||||||
2. Edit markdown
 | 
					 | 
				
			||||||
3. See live preview
 | 
					 | 
				
			||||||
4. Save with WebDAV PUT
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
Enjoy!
 | 
					 | 
				
			||||||
							
								
								
									
										
											BIN
										
									
								
								collections/notes/images/logo-blue.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 98 KiB  | 
							
								
								
									
										18
									
								
								collections/notes/introduction.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,18 @@
 | 
				
			|||||||
 | 
					# Introduction
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### This is an introduction
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					* **This is an internal image**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					---
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					---
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					* **This is an external image**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					---
 | 
				
			||||||
							
								
								
									
										2
									
								
								collections/notes/new_folder/zeko.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,2 @@
 | 
				
			|||||||
 | 
					# New File
 | 
				
			||||||
 | 
					
 | 
				
			||||||
							
								
								
									
										40
									
								
								collections/notes/presentation.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,40 @@
 | 
				
			|||||||
 | 
					## Mycelium Product Presentation
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					This document provides an overview of the Mycelium technology stack (as commercially sold my our company GeoMind).
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<div style={{
 | 
				
			||||||
 | 
					  position: 'relative',
 | 
				
			||||||
 | 
					  width: '100%',
 | 
				
			||||||
 | 
					  height: 0,
 | 
				
			||||||
 | 
					  paddingTop: '56.25%',
 | 
				
			||||||
 | 
					  marginTop: '1.6em',
 | 
				
			||||||
 | 
					  marginBottom: '0.9em',
 | 
				
			||||||
 | 
					  overflow: 'hidden',
 | 
				
			||||||
 | 
					  borderRadius: '8px',
 | 
				
			||||||
 | 
					  willChange: 'transform'
 | 
				
			||||||
 | 
					}}>
 | 
				
			||||||
 | 
					  <iframe
 | 
				
			||||||
 | 
					    src="https://www.canva.com/design/DAG0UtzICsk/rqXpn5f3ibo2OpX-yDWmPQ/view?embed"
 | 
				
			||||||
 | 
					    style={{
 | 
				
			||||||
 | 
					      position: 'absolute',
 | 
				
			||||||
 | 
					      width: '100%',
 | 
				
			||||||
 | 
					      height: '100%',
 | 
				
			||||||
 | 
					      top: 0,
 | 
				
			||||||
 | 
					      left: 0,
 | 
				
			||||||
 | 
					      border: 'none',
 | 
				
			||||||
 | 
					      padding: 0,
 | 
				
			||||||
 | 
					      margin: 0
 | 
				
			||||||
 | 
					    }}
 | 
				
			||||||
 | 
					    allowFullScreen={true}
 | 
				
			||||||
 | 
					    allow="fullscreen">
 | 
				
			||||||
 | 
					  </iframe>
 | 
				
			||||||
 | 
					</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<div style={{ marginTop: '10px' }}>
 | 
				
			||||||
 | 
					  <a href="https://www.canva.com/design/DAG0UtzICsk/rqXpn5f3ibo2OpX-yDWmPQ/view"
 | 
				
			||||||
 | 
					     target="_blank"
 | 
				
			||||||
 | 
					     rel="noopener"
 | 
				
			||||||
 | 
					     style={{ textDecoration: 'none' }}>
 | 
				
			||||||
 | 
					    Geomind Product Intro 2025 (based on mycelium technology)
 | 
				
			||||||
 | 
					  </a>
 | 
				
			||||||
 | 
					</div>
 | 
				
			||||||
							
								
								
									
										9
									
								
								collections/notes/tests/test.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,9 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
 | 
					# test
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- 1
 | 
				
			||||||
 | 
					- 2
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					!!include path:test2.md
 | 
				
			||||||
							
								
								
									
										12
									
								
								collections/notes/tests/test2.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,12 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
 | 
					## test2
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- something
 | 
				
			||||||
 | 
					- another thing
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
							
								
								
									
										426
									
								
								collections/notes/tests/test3.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,426 @@
 | 
				
			|||||||
 | 
					# UI Code Refactoring Plan
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Project:** Markdown Editor  
 | 
				
			||||||
 | 
					**Date:** 2025-10-26  
 | 
				
			||||||
 | 
					**Status:** In Progress
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					---
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Executive Summary
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					This document outlines a comprehensive refactoring plan for the UI codebase to improve maintainability, remove dead code, extract utilities, and standardize patterns. The refactoring is organized into 6 phases with 14 tasks, prioritized by risk and impact.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Key Metrics:**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Total Lines of Code: ~3,587
 | 
				
			||||||
 | 
					- Dead Code to Remove: 213 lines (6%)
 | 
				
			||||||
 | 
					- Estimated Effort: 5-8 days
 | 
				
			||||||
 | 
					- Risk Level: Mostly LOW to MEDIUM
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					---
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Phase 1: Analysis Summary
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Files Reviewed
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**JavaScript Files (10):**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- `/static/js/app.js` (484 lines)
 | 
				
			||||||
 | 
					- `/static/js/column-resizer.js` (100 lines)
 | 
				
			||||||
 | 
					- `/static/js/confirmation.js` (170 lines)
 | 
				
			||||||
 | 
					- `/static/js/editor.js` (420 lines)
 | 
				
			||||||
 | 
					- `/static/js/file-tree-actions.js` (482 lines)
 | 
				
			||||||
 | 
					- `/static/js/file-tree.js` (865 lines)
 | 
				
			||||||
 | 
					- `/static/js/macro-parser.js` (103 lines)
 | 
				
			||||||
 | 
					- `/static/js/macro-processor.js` (157 lines)
 | 
				
			||||||
 | 
					- `/static/js/ui-utils.js` (305 lines)
 | 
				
			||||||
 | 
					- `/static/js/webdav-client.js` (266 lines)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**CSS Files (6):**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- `/static/css/variables.css` (32 lines)
 | 
				
			||||||
 | 
					- `/static/css/layout.css`
 | 
				
			||||||
 | 
					- `/static/css/file-tree.css`
 | 
				
			||||||
 | 
					- `/static/css/editor.css`
 | 
				
			||||||
 | 
					- `/static/css/components.css`
 | 
				
			||||||
 | 
					- `/static/css/modal.css`
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**HTML Templates (1):**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- `/templates/index.html` (203 lines)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					---
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Issues Found
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### 🔴 HIGH PRIORITY
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					1. **Deprecated Modal Code (Dead Code)**
 | 
				
			||||||
 | 
					   - Location: `/static/js/file-tree-actions.js` lines 262-474
 | 
				
			||||||
 | 
					   - Impact: 213 lines of unused code (44% of file)
 | 
				
			||||||
 | 
					   - Risk: LOW to remove
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					2. **Duplicated Event Bus Implementation**
 | 
				
			||||||
 | 
					   - Location: `/static/js/app.js` lines 16-30
 | 
				
			||||||
 | 
					   - Should be extracted to reusable module
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					3. **Duplicated Debounce Function**
 | 
				
			||||||
 | 
					   - Location: `/static/js/editor.js` lines 404-414
 | 
				
			||||||
 | 
					   - Should be shared utility
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					4. **Inconsistent Notification Usage**
 | 
				
			||||||
 | 
					   - Mixed usage of `window.showNotification` vs `showNotification`
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					5. **Duplicated File Download Logic**
 | 
				
			||||||
 | 
					   - Location: `/static/js/file-tree.js` lines 829-839
 | 
				
			||||||
 | 
					   - Should be shared utility
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					6. **Hard-coded Values**
 | 
				
			||||||
 | 
					   - Long-press threshold: 400ms
 | 
				
			||||||
 | 
					   - Debounce delay: 300ms
 | 
				
			||||||
 | 
					   - Drag preview width: 200px
 | 
				
			||||||
 | 
					   - Toast delay: 3000ms
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### 🟡 MEDIUM PRIORITY
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					7. **Global State Management**
 | 
				
			||||||
 | 
					   - Location: `/static/js/app.js` lines 6-13
 | 
				
			||||||
 | 
					   - Makes testing difficult
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					8. **Duplicated Path Manipulation**
 | 
				
			||||||
 | 
					   - `path.split('/').pop()` appears 10+ times
 | 
				
			||||||
 | 
					   - `path.substring(0, path.lastIndexOf('/'))` appears 5+ times
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					9. **Mixed Responsibility in ui-utils.js**
 | 
				
			||||||
 | 
					   - Contains 6 different classes/utilities
 | 
				
			||||||
 | 
					   - Should be split into separate modules
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					10. **Deprecated Event Handler**
 | 
				
			||||||
 | 
					    - Location: `/static/js/file-tree-actions.js` line 329
 | 
				
			||||||
 | 
					    - Uses deprecated `onkeypress`
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### 🟢 LOW PRIORITY
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					11. **Unused Function Parameters**
 | 
				
			||||||
 | 
					12. **Magic Numbers in Styling**
 | 
				
			||||||
 | 
					13. **Inconsistent Comment Styles**
 | 
				
			||||||
 | 
					14. **Console.log Statements**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					---
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Phase 2: Proposed Reusable Components
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### 1. Config Module (`/static/js/config.js`)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Centralize all configuration values:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```javascript
 | 
				
			||||||
 | 
					export const Config = {
 | 
				
			||||||
 | 
					    // Timing
 | 
				
			||||||
 | 
					    LONG_PRESS_THRESHOLD: 400,
 | 
				
			||||||
 | 
					    DEBOUNCE_DELAY: 300,
 | 
				
			||||||
 | 
					    TOAST_DURATION: 3000,
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    // UI
 | 
				
			||||||
 | 
					    DRAG_PREVIEW_WIDTH: 200,
 | 
				
			||||||
 | 
					    TREE_INDENT_PX: 12,
 | 
				
			||||||
 | 
					    MOUSE_MOVE_THRESHOLD: 5,
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    // Validation
 | 
				
			||||||
 | 
					    FILENAME_PATTERN: /^[a-z0-9_]+(\.[a-z0-9_]+)*$/,
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    // Storage Keys
 | 
				
			||||||
 | 
					    STORAGE_KEYS: {
 | 
				
			||||||
 | 
					        DARK_MODE: 'darkMode',
 | 
				
			||||||
 | 
					        SELECTED_COLLECTION: 'selectedCollection',
 | 
				
			||||||
 | 
					        LAST_VIEWED_PAGE: 'lastViewedPage',
 | 
				
			||||||
 | 
					        COLUMN_DIMENSIONS: 'columnDimensions'
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### 2. Logger Module (`/static/js/logger.js`)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Structured logging with levels:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```javascript
 | 
				
			||||||
 | 
					export class Logger {
 | 
				
			||||||
 | 
					    static debug(message, ...args)
 | 
				
			||||||
 | 
					    static info(message, ...args)
 | 
				
			||||||
 | 
					    static warn(message, ...args)
 | 
				
			||||||
 | 
					    static error(message, ...args)
 | 
				
			||||||
 | 
					    static setLevel(level)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### 3. Event Bus Module (`/static/js/event-bus.js`)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Centralized event system:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```javascript
 | 
				
			||||||
 | 
					export class EventBus {
 | 
				
			||||||
 | 
					    on(event, callback)
 | 
				
			||||||
 | 
					    off(event, callback)
 | 
				
			||||||
 | 
					    once(event, callback)
 | 
				
			||||||
 | 
					    dispatch(event, data)
 | 
				
			||||||
 | 
					    clear(event)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### 4. Utilities Module (`/static/js/utils.js`)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Common utility functions:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```javascript
 | 
				
			||||||
 | 
					export const PathUtils = {
 | 
				
			||||||
 | 
					    getFileName(path),
 | 
				
			||||||
 | 
					    getParentPath(path),
 | 
				
			||||||
 | 
					    normalizePath(path),
 | 
				
			||||||
 | 
					    joinPaths(...paths),
 | 
				
			||||||
 | 
					    getExtension(path)
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const TimingUtils = {
 | 
				
			||||||
 | 
					    debounce(func, wait),
 | 
				
			||||||
 | 
					    throttle(func, wait)
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const DownloadUtils = {
 | 
				
			||||||
 | 
					    triggerDownload(content, filename),
 | 
				
			||||||
 | 
					    downloadAsBlob(blob, filename)
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const ValidationUtils = {
 | 
				
			||||||
 | 
					    validateFileName(name, isFolder),
 | 
				
			||||||
 | 
					    sanitizeFileName(name)
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### 5. Notification Service (`/static/js/notification-service.js`)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Standardized notifications:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```javascript
 | 
				
			||||||
 | 
					export class NotificationService {
 | 
				
			||||||
 | 
					    static success(message)
 | 
				
			||||||
 | 
					    static error(message)
 | 
				
			||||||
 | 
					    static warning(message)
 | 
				
			||||||
 | 
					    static info(message)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					---
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Phase 3: Refactoring Tasks
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### 🔴 HIGH PRIORITY
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Task 1: Remove Dead Code**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Files: `/static/js/file-tree-actions.js`
 | 
				
			||||||
 | 
					- Lines: 262-474 (213 lines)
 | 
				
			||||||
 | 
					- Risk: LOW
 | 
				
			||||||
 | 
					- Dependencies: None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Task 2: Extract Event Bus**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Files: NEW `/static/js/event-bus.js`, MODIFY `app.js`, `editor.js`
 | 
				
			||||||
 | 
					- Risk: MEDIUM
 | 
				
			||||||
 | 
					- Dependencies: None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Task 3: Create Utilities Module**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Files: NEW `/static/js/utils.js`, MODIFY multiple files
 | 
				
			||||||
 | 
					- Risk: MEDIUM
 | 
				
			||||||
 | 
					- Dependencies: None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Task 4: Create Config Module**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Files: NEW `/static/js/config.js`, MODIFY multiple files
 | 
				
			||||||
 | 
					- Risk: LOW
 | 
				
			||||||
 | 
					- Dependencies: None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Task 5: Standardize Notification Usage**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Files: NEW `/static/js/notification-service.js`, MODIFY multiple files
 | 
				
			||||||
 | 
					- Risk: LOW
 | 
				
			||||||
 | 
					- Dependencies: None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### 🟡 MEDIUM PRIORITY
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Task 6: Fix Deprecated Event Handler**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Files: `/static/js/file-tree-actions.js` line 329
 | 
				
			||||||
 | 
					- Risk: LOW
 | 
				
			||||||
 | 
					- Dependencies: None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Task 7: Refactor ui-utils.js**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Files: DELETE `ui-utils.js`, CREATE 5 new modules
 | 
				
			||||||
 | 
					- Risk: HIGH
 | 
				
			||||||
 | 
					- Dependencies: Task 5
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Task 8: Standardize Class Export Pattern**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Files: All class files
 | 
				
			||||||
 | 
					- Risk: MEDIUM
 | 
				
			||||||
 | 
					- Dependencies: None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Task 9: Create Logger Module**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Files: NEW `/static/js/logger.js`, MODIFY multiple files
 | 
				
			||||||
 | 
					- Risk: LOW
 | 
				
			||||||
 | 
					- Dependencies: None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Task 10: Implement Download Action**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Files: `/static/js/file-tree-actions.js`
 | 
				
			||||||
 | 
					- Risk: LOW
 | 
				
			||||||
 | 
					- Dependencies: Task 3
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### 🟢 LOW PRIORITY
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Task 11: Standardize JSDoc Comments**
 | 
				
			||||||
 | 
					**Task 12: Extract Magic Numbers to CSS**
 | 
				
			||||||
 | 
					**Task 13: Add Error Boundaries**
 | 
				
			||||||
 | 
					**Task 14: Cache DOM Elements**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					---
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Phase 4: Implementation Order
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Step 1: Foundation (Do First)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					1. Create Config Module (Task 4)
 | 
				
			||||||
 | 
					2. Create Logger Module (Task 9)
 | 
				
			||||||
 | 
					3. Create Event Bus Module (Task 2)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Step 2: Utilities (Do Second)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					4. Create Utilities Module (Task 3)
 | 
				
			||||||
 | 
					5. Create Notification Service (Task 5)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Step 3: Cleanup (Do Third)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					6. Remove Dead Code (Task 1)
 | 
				
			||||||
 | 
					7. Fix Deprecated Event Handler (Task 6)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Step 4: Restructuring (Do Fourth)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					8. Refactor ui-utils.js (Task 7)
 | 
				
			||||||
 | 
					9. Standardize Class Export Pattern (Task 8)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Step 5: Enhancements (Do Fifth)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					10. Implement Download Action (Task 10)
 | 
				
			||||||
 | 
					11. Add Error Boundaries (Task 13)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Step 6: Polish (Do Last)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					12. Standardize JSDoc Comments (Task 11)
 | 
				
			||||||
 | 
					13. Extract Magic Numbers to CSS (Task 12)
 | 
				
			||||||
 | 
					14. Cache DOM Elements (Task 14)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					---
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Phase 5: Testing Checklist
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Core Functionality
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- [ ] File tree loads and displays correctly
 | 
				
			||||||
 | 
					- [ ] Files can be selected and opened
 | 
				
			||||||
 | 
					- [ ] Folders can be expanded/collapsed
 | 
				
			||||||
 | 
					- [ ] Editor loads file content
 | 
				
			||||||
 | 
					- [ ] Preview renders markdown correctly
 | 
				
			||||||
 | 
					- [ ] Save button saves files
 | 
				
			||||||
 | 
					- [ ] Delete button deletes files
 | 
				
			||||||
 | 
					- [ ] New button creates new files
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Context Menu Actions
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- [ ] Right-click shows context menu
 | 
				
			||||||
 | 
					- [ ] New file action works
 | 
				
			||||||
 | 
					- [ ] New folder action works
 | 
				
			||||||
 | 
					- [ ] Rename action works
 | 
				
			||||||
 | 
					- [ ] Delete action works
 | 
				
			||||||
 | 
					- [ ] Copy/Cut/Paste actions work
 | 
				
			||||||
 | 
					- [ ] Upload action works
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Drag and Drop
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- [ ] Long-press detection works
 | 
				
			||||||
 | 
					- [ ] Drag preview appears correctly
 | 
				
			||||||
 | 
					- [ ] Drop targets highlight properly
 | 
				
			||||||
 | 
					- [ ] Files can be moved
 | 
				
			||||||
 | 
					- [ ] Undo (Ctrl+Z) works
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Modals
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- [ ] Confirmation modals appear
 | 
				
			||||||
 | 
					- [ ] Prompt modals appear
 | 
				
			||||||
 | 
					- [ ] Modals don't double-open
 | 
				
			||||||
 | 
					- [ ] Enter/Escape keys work
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### UI Features
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- [ ] Dark mode toggle works
 | 
				
			||||||
 | 
					- [ ] Collection selector works
 | 
				
			||||||
 | 
					- [ ] Column resizers work
 | 
				
			||||||
 | 
					- [ ] Notifications appear
 | 
				
			||||||
 | 
					- [ ] URL routing works
 | 
				
			||||||
 | 
					- [ ] View/Edit modes work
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					---
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Recommendations
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Immediate Actions (Before Production)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					1. Remove dead code (Task 1)
 | 
				
			||||||
 | 
					2. Fix deprecated event handler (Task 6)
 | 
				
			||||||
 | 
					3. Create config module (Task 4)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Short-term Actions (Next Sprint)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					4. Extract utilities (Task 3)
 | 
				
			||||||
 | 
					5. Standardize notifications (Task 5)
 | 
				
			||||||
 | 
					6. Create event bus (Task 2)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Medium-term Actions (Future Sprints)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					7. Refactor ui-utils.js (Task 7)
 | 
				
			||||||
 | 
					8. Add logger (Task 9)
 | 
				
			||||||
 | 
					9. Standardize exports (Task 8)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					---
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Success Metrics
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Before Refactoring:**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Total Lines: ~3,587
 | 
				
			||||||
 | 
					- Dead Code: 213 lines (6%)
 | 
				
			||||||
 | 
					- Duplicated Code: ~50 lines
 | 
				
			||||||
 | 
					- Hard-coded Values: 15+
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**After Refactoring:**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Total Lines: ~3,400 (-5%)
 | 
				
			||||||
 | 
					- Dead Code: 0 lines
 | 
				
			||||||
 | 
					- Duplicated Code: 0 lines
 | 
				
			||||||
 | 
					- Hard-coded Values: 0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Estimated Effort:** 5-8 days
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					---
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Conclusion
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					The UI codebase is generally well-structured. Main improvements needed:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					1. Remove dead code
 | 
				
			||||||
 | 
					2. Extract duplicated utilities
 | 
				
			||||||
 | 
					3. Centralize configuration
 | 
				
			||||||
 | 
					4. Standardize patterns
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Start with high-impact, low-risk changes first to ensure production readiness.
 | 
				
			||||||
@@ -1,4 +0,0 @@
 | 
				
			|||||||
 | 
					 | 
				
			||||||
test
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
							
								
								
									
										78
									
								
								collections/notes/why.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,78 @@
 | 
				
			|||||||
 | 
					**Decentralized Infrastructure Technology for Everyone, Everywhere**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Mycelium is a comprehensive DePIN (Decentralized Physical Infrastructure) system designed to scale to planetary level, capable of providing resilient services with end-to-end encryption, and enabling any machine and human to communicate efficiently over optimal paths.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Mycelium is Compatible with Kubernetes, Docker, VMs, Web2, Web3 – and building towards Web4.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Terminology Clarification
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- **Mycelium Tech**: The core technology stack (ZOS, QSS, Mycelium Network)
 | 
				
			||||||
 | 
					- **ThreeFold Grid**: The decentralized infrastructure offering built on Mycelium Tech
 | 
				
			||||||
 | 
					- **GeoMind**: The commercial tech company operating tier-S/H datacenters with Mycelium
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Why Decentralized Infrastructure Matters
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Traditional internet infrastructure is burdened with inefficiencies, risks, and growing dependency on centralization.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### **The Challenges We Face**  
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- **Centralization Risks**: Digital infrastructure is controlled by a few corporations, compromising autonomy and creating single points of failure.  
 | 
				
			||||||
 | 
					- **Economic Inefficiency**: Current infrastructure models extract value from local economies, funneling resources to centralized providers.
 | 
				
			||||||
 | 
					- **Outdated Protocols**: TCP/IP, the internet's core protocol, was never designed for modern needs like dynamic networks, security, and session management.
 | 
				
			||||||
 | 
					- **Geographic Inefficiency**: Over 70% of the world relies on distant infrastructure, making access expensive, unreliable, and dependent on fragile global systems.
 | 
				
			||||||
 | 
					- **Limited Access**: Over 50% of the world lacks decent internet access, widening opportunity gaps.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Mycelium addresses these challenges through a complete, integrated technology stack designed from first principles.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## What Mycelium Provides
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Mycelium is unique in its ability to deliver an integrated platform covering all three fundamental layers of internet infrastructure:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### **Compute Layer** - ZOS
 | 
				
			||||||
 | 
					- Autonomous, stateless operating system
 | 
				
			||||||
 | 
					- MyImage architecture (up to 100x faster deployment)
 | 
				
			||||||
 | 
					- Deterministic, cryptographically verified deployment
 | 
				
			||||||
 | 
					- Supports Kubernetes, containers, VMs, and Linux workloads
 | 
				
			||||||
 | 
					- Self-healing with no manual maintenance required
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### **Storage Layer** - Quantum Safe Storage (QSS)
 | 
				
			||||||
 | 
					- Mathematical encoding with forward error correction
 | 
				
			||||||
 | 
					- 20% overhead vs 400% for traditional replication
 | 
				
			||||||
 | 
					- Zero-knowledge design: storage nodes can't access data
 | 
				
			||||||
 | 
					- Petabyte-to-zetabyte scalability
 | 
				
			||||||
 | 
					- Self-healing bitrot protection
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### **Network Layer** - Mycelium Network
 | 
				
			||||||
 | 
					- End-to-end encrypted IPv6 overlay
 | 
				
			||||||
 | 
					- Shortest-path optimization
 | 
				
			||||||
 | 
					- Multi-protocol support (TCP, QUIC, UDP, satellite, wireless)
 | 
				
			||||||
 | 
					- Peer-to-peer architecture with no central points of failure
 | 
				
			||||||
 | 
					- Distributed secure name services
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Key Differentiators
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					| Feature                  | Mycelium                                     | Traditional Cloud                          |
 | 
				
			||||||
 | 
					| ------------------------ | -------------------------------------------- | ------------------------------------------ |
 | 
				
			||||||
 | 
					| **Architecture**         | Distributed peer-to-peer, no central control | Centralized control planes                 |
 | 
				
			||||||
 | 
					| **Deployment**           | Stateless network boot, zero-install         | Local image installation                   |
 | 
				
			||||||
 | 
					| **Storage Efficiency**   | 20% overhead                                 | 300-400% overhead                          |
 | 
				
			||||||
 | 
					| **Security**             | End-to-end encrypted, zero-knowledge design  | Perimeter-based, trust intermediaries      |
 | 
				
			||||||
 | 
					| **Energy**               | Up to 10x more efficient                     | Higher consumption                         |
 | 
				
			||||||
 | 
					| **Autonomy**             | Self-healing, autonomous agents              | Requires active management                 |
 | 
				
			||||||
 | 
					| **Geographic Awareness** | Shortest path routing, location-aware        | Static routing, no geographic optimization |
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Current Status
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- **Deployed**: 20+ countries, 30,000+ vCPU
 | 
				
			||||||
 | 
					- **Proof of Concept**: Technology validated in production
 | 
				
			||||||
 | 
					- **Commercialization**: Beginning phase with enterprise roadmap
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Technology Maturity
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- **All our core cloud technology**: Production
 | 
				
			||||||
 | 
					- **Quantum Safe Storage**: Production (6+ years)
 | 
				
			||||||
 | 
					- **Mycelium Network**: Beta 
 | 
				
			||||||
 | 
					- **Deterministic Deployment**: OEM only
 | 
				
			||||||
 | 
					- **FungiStor**: H1 2026
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Mycelium represents not just an upgrade to existing infrastructure, but a fundamental rethinking of how internet infrastructure should be built—distributed, autonomous, secure, and efficient.%   
 | 
				
			||||||
							
								
								
									
										
											BIN
										
									
								
								collections/tech/images/arch.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 1.8 MiB  | 
							
								
								
									
										
											BIN
										
									
								
								collections/tech/images/dashboard.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 403 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								collections/tech/images/letsfix.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 409 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								collections/tech/images/opportunity.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 254 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								collections/tech/images/status.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 388 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								collections/tech/images/unique.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 1.4 MiB  | 
							
								
								
									
										
											BIN
										
									
								
								collections/tech/images/usable_by_all.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 248 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								collections/tech/images/web4.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 61 KiB  | 
							
								
								
									
										79
									
								
								collections/tech/introduction.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,79 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
 | 
					**Decentralized Infrastructure Technology for Everyone, Everywhere**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Mycelium is a comprehensive DePIN (Decentralized Physical Infrastructure) system designed to scale to planetary level, capable of providing resilient services with end-to-end encryption, and enabling any machine and human to communicate efficiently over optimal paths.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Mycelium is Compatible with Kubernetes, Docker, VMs, Web2, Web3 – and building towards Web4.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Terminology Clarification
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- **Mycelium Tech**: The core technology stack (ZOS, QSS, Mycelium Network)
 | 
				
			||||||
 | 
					- **ThreeFold Grid**: The decentralized infrastructure offering built on Mycelium Tech
 | 
				
			||||||
 | 
					- **GeoMind**: The commercial tech company operating tier-S/H datacenters with Mycelium
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Why Decentralized Infrastructure Matters
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Traditional internet infrastructure is burdened with inefficiencies, risks, and growing dependency on centralization.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### **The Challenges We Face**  
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- **Centralization Risks**: Digital infrastructure is controlled by a few corporations, compromising autonomy and creating single points of failure.  
 | 
				
			||||||
 | 
					- **Economic Inefficiency**: Current infrastructure models extract value from local economies, funneling resources to centralized providers.
 | 
				
			||||||
 | 
					- **Outdated Protocols**: TCP/IP, the internet's core protocol, was never designed for modern needs like dynamic networks, security, and session management.
 | 
				
			||||||
 | 
					- **Geographic Inefficiency**: Over 70% of the world relies on distant infrastructure, making access expensive, unreliable, and dependent on fragile global systems.
 | 
				
			||||||
 | 
					- **Limited Access**: Over 50% of the world lacks decent internet access, widening opportunity gaps.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Mycelium addresses these challenges through a complete, integrated technology stack designed from first principles.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## What Mycelium Provides
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Mycelium is unique in its ability to deliver an integrated platform covering all three fundamental layers of internet infrastructure:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### **Compute Layer** - ZOS
 | 
				
			||||||
 | 
					- Autonomous, stateless operating system
 | 
				
			||||||
 | 
					- MyImage architecture (up to 100x faster deployment)
 | 
				
			||||||
 | 
					- Deterministic, cryptographically verified deployment
 | 
				
			||||||
 | 
					- Supports Kubernetes, containers, VMs, and Linux workloads
 | 
				
			||||||
 | 
					- Self-healing with no manual maintenance required
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### **Storage Layer** - Quantum Safe Storage (QSS)
 | 
				
			||||||
 | 
					- Mathematical encoding with forward error correction
 | 
				
			||||||
 | 
					- 20% overhead vs 400% for traditional replication
 | 
				
			||||||
 | 
					- Zero-knowledge design: storage nodes can't access data
 | 
				
			||||||
 | 
					- Petabyte-to-zetabyte scalability
 | 
				
			||||||
 | 
					- Self-healing bitrot protection
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### **Network Layer** - Mycelium Network
 | 
				
			||||||
 | 
					- End-to-end encrypted IPv6 overlay
 | 
				
			||||||
 | 
					- Shortest-path optimization
 | 
				
			||||||
 | 
					- Multi-protocol support (TCP, QUIC, UDP, satellite, wireless)
 | 
				
			||||||
 | 
					- Peer-to-peer architecture with no central points of failure
 | 
				
			||||||
 | 
					- Distributed secure name services
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Key Differentiators
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					| Feature                  | Mycelium                                     | Traditional Cloud                          |
 | 
				
			||||||
 | 
					| ------------------------ | -------------------------------------------- | ------------------------------------------ |
 | 
				
			||||||
 | 
					| **Architecture**         | Distributed peer-to-peer, no central control | Centralized control planes                 |
 | 
				
			||||||
 | 
					| **Deployment**           | Stateless network boot, zero-install         | Local image installation                   |
 | 
				
			||||||
 | 
					| **Storage Efficiency**   | 20% overhead                                 | 300-400% overhead                          |
 | 
				
			||||||
 | 
					| **Security**             | End-to-end encrypted, zero-knowledge design  | Perimeter-based, trust intermediaries      |
 | 
				
			||||||
 | 
					| **Energy**               | Up to 10x more efficient                     | Higher consumption                         |
 | 
				
			||||||
 | 
					| **Autonomy**             | Self-healing, autonomous agents              | Requires active management                 |
 | 
				
			||||||
 | 
					| **Geographic Awareness** | Shortest path routing, location-aware        | Static routing, no geographic optimization |
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Current Status
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- **Deployed**: 20+ countries, 30,000+ vCPU
 | 
				
			||||||
 | 
					- **Proof of Concept**: Technology validated in production
 | 
				
			||||||
 | 
					- **Commercialization**: Beginning phase with enterprise roadmap
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Technology Maturity
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- **All our core cloud technology**: Production
 | 
				
			||||||
 | 
					- **Quantum Safe Storage**: Production (6+ years)
 | 
				
			||||||
 | 
					- **Mycelium Network**: Beta 
 | 
				
			||||||
 | 
					- **Deterministic Deployment**: OEM only
 | 
				
			||||||
 | 
					- **FungiStor**: H1 2026
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Mycelium represents not just an upgrade to existing infrastructure, but a fundamental rethinking of how internet infrastructure should be built—distributed, autonomous, secure, and efficient.
 | 
				
			||||||
							
								
								
									
										42
									
								
								collections/tech/presentation.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,42 @@
 | 
				
			|||||||
 | 
					## Mycelium Product Presentation
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					This document provides an overview of the Mycelium technology stack (as commercially sold my our company GeoMind).
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<div style={{
 | 
				
			||||||
 | 
					  position: 'relative',
 | 
				
			||||||
 | 
					  width: '100%',
 | 
				
			||||||
 | 
					  height: 0,
 | 
				
			||||||
 | 
					  paddingTop: '56.25%',
 | 
				
			||||||
 | 
					  marginTop: '1.6em',
 | 
				
			||||||
 | 
					  marginBottom: '0.9em',
 | 
				
			||||||
 | 
					  overflow: 'hidden',
 | 
				
			||||||
 | 
					  borderRadius: '8px',
 | 
				
			||||||
 | 
					  willChange: 'transform'
 | 
				
			||||||
 | 
					}}>
 | 
				
			||||||
 | 
					  <iframe
 | 
				
			||||||
 | 
					    src="https://www.canva.com/design/DAG0UtzICsk/rqXpn5f3ibo2OpX-yDWmPQ/view?embed"
 | 
				
			||||||
 | 
					    style={{
 | 
				
			||||||
 | 
					      position: 'absolute',
 | 
				
			||||||
 | 
					      width: '100%',
 | 
				
			||||||
 | 
					      height: '100%',
 | 
				
			||||||
 | 
					      top: 0,
 | 
				
			||||||
 | 
					      left: 0,
 | 
				
			||||||
 | 
					      border: 'none',
 | 
				
			||||||
 | 
					      padding: 0,
 | 
				
			||||||
 | 
					      margin: 0
 | 
				
			||||||
 | 
					    }}
 | 
				
			||||||
 | 
					    allowFullScreen={true}
 | 
				
			||||||
 | 
					    allow="fullscreen">
 | 
				
			||||||
 | 
					  </iframe>
 | 
				
			||||||
 | 
					</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<div style={{ marginTop: '10px' }}>
 | 
				
			||||||
 | 
					  <a href="https://www.canva.com/design/DAG0UtzICsk/rqXpn5f3ibo2OpX-yDWmPQ/view"
 | 
				
			||||||
 | 
					     target="_blank"
 | 
				
			||||||
 | 
					     rel="noopener"
 | 
				
			||||||
 | 
					     style={{ textDecoration: 'none' }}>
 | 
				
			||||||
 | 
					    Geomind Product Intro 2025 (based on mycelium technology)
 | 
				
			||||||
 | 
					  </a>
 | 
				
			||||||
 | 
					</div>
 | 
				
			||||||
							
								
								
									
										50
									
								
								collections/tech/roadmap/enterprise_roadmap.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,50 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
 | 
					# Government, Commercial Hosters, Telco and Enterprise Roadmap
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					We are working on the government, commercial hosters, telco and enterprise releases of our technology.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					> 90% of the work has been done as part of our base offering but we need additional features for enterprises.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Enterprise User Interface
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					The current user interface is designed for an open-source tech audience. For enterprise use, we need a different approach to meet the unique needs of enterprise environments:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- **Private or Hybrid Context**: All operations should be conducted within a private or hybrid cloud context to ensure security and compliance.
 | 
				
			||||||
 | 
					- **Enhanced Monitoring**: We need more comprehensive monitoring dashboard screens to provide real-time insights and analytics.
 | 
				
			||||||
 | 
					- **Identity Management Integration**: Integration with enterprise-grade Identity Management solutions, such as LDAP, Active Directory, and SSO (Single Sign-On), is essential.
 | 
				
			||||||
 | 
					- **Enterprise-Friendly UI**: The user interface needs to be redesigned to be more intuitive and tailored to enterprise users, focusing on usability and efficiency.
 | 
				
			||||||
 | 
					- **Token Irrelevance**: Tokens are not a priority in this context and should be de-emphasized in the solution.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Windows Support
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					The virtual Machine technology we use does support Windows, but we need to do some further integration.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## High Performance Network Integration
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- **Local Network Integration**: ZOS is designed to support a wide range of technologies, though additional integration work is required to optimize performance.
 | 
				
			||||||
 | 
					- **High-Speed Backbones**: We aim to support high-speed Ethernet and RDMA (Infiniband) based backbones.
 | 
				
			||||||
 | 
					- **Instrumentation Enhancements**: Additional instrumentation needs to be incorporated into ZOS to achieve optimal performance.
 | 
				
			||||||
 | 
					- **Target Performance**: Our goal is to achieve network speeds exceeding 100 Gbps.
 | 
				
			||||||
 | 
					- **Custom Integration**: We offer integration with selected network equipment from our customers, accommodating custom integration requirements.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## High Performance Storage Block Device Integration
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Next to the existing already integrated storage backends we want to support a high performance redundant storage block device.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- High performance redundant storage network
 | 
				
			||||||
 | 
					- Supports high-speed backbones as defined above
 | 
				
			||||||
 | 
					- Scalable to thousands of machines per cluster.
 | 
				
			||||||
 | 
					- Replication capability between zones.
 | 
				
			||||||
 | 
					- Custom Integration
 | 
				
			||||||
 | 
					  - We offer integration with selected storage equipment from our customers, accommodating custom integration requirements.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Service Level Management
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- The system will have hooks and visualization for achievement of Service levels.
 | 
				
			||||||
 | 
					- This will allow a commercial service provider to get to higher revenue and better uptime management.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Support for Liquid Cooling Tanks
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Do a test setup in liquid cooling rack or node.
 | 
				
			||||||
 | 
					  - We can use our self-healing capabilities to manage in a better way.
 | 
				
			||||||
 | 
					- This is an integration effort, and not much code changes are needed.
 | 
				
			||||||
							
								
								
									
										24
									
								
								collections/tech/roadmap/hero_roadmap.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,24 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
 | 
					## AI Agent High Level Roadmap
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					MyAgent is our private AI agent.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					The first version of our MyAgent enables the management of core services such as an innovative database backend, a autonomous decentralized git system, and the automatic integration and deployment of our workloads.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					This stack allows everyone to deploy scalable Web 2,3 and 4 apps on top of the TFGrid in a fully automated way.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					|                                     | Roadmap                                                                                                      | Timing |
 | 
				
			||||||
 | 
					| ----------------------------------- | ------------------------------------------------------------------------------------------------------------ | ------ |
 | 
				
			||||||
 | 
					| MyAgent Publisher                   | Publish websites, e-books, and more on top of the ThreeFold Grid                                             | H2 25  |
 | 
				
			||||||
 | 
					| MyAgent CI = Continuous Integration | Easier to use Continuous Integration/Development, very powerfull, with multinode support                     | H2 25  |
 | 
				
			||||||
 | 
					| MyAgent Play                        | Integrate declarative automation and configuration management as part of wiki approach in MyAgent Publisher  | H2 25  |
 | 
				
			||||||
 | 
					| MyAgent Git                         | Alternative to centralized Github (based on Gitea), fully integrated on top of TFGrid                        | H2 25  |
 | 
				
			||||||
 | 
					| MyAgent DB                          | Flexible ultra redundant database stor with indexing, queries, stored procedures, super scalable replication | H2 25  |
 | 
				
			||||||
 | 
					| MyAgent OSIS                        | Object Storage and Index system                                                                              | H2 25  |
 | 
				
			||||||
 | 
					| MyAgent WEB                         | Web framework, deployable globally on TFGrid, integrated with Mycelium Net and Names                         | H2 25  |
 | 
				
			||||||
 | 
					| MyAgent Monitor                     | Monitor all your different components on redundant monitoring stack                                          | H2 25  |
 | 
				
			||||||
 | 
					| MyAgent Happs                       | MyAgent natively supports Holochain HAPPS                                                                    | Q4 25  |
 | 
				
			||||||
 | 
					| MyAgent Actors                      | MyAgent can serve actors which respond and act on OpenRPC calls ideal as backend for web or other apps       | Q4 25  |
 | 
				
			||||||
 | 
					| MyAgent Web 3 Gateway               | MyAgent aims to have native support for chosen Web3 partner solutions (Bitcoin, Ethereum, and more)          | Q4 25  |
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					All of the specs above are fully integrated with the Mycelium Network and the ThreeFold Grid.
 | 
				
			||||||
							
								
								
									
										40
									
								
								collections/tech/roadmap/high_level.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,40 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Roadmap in Phases
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Phase 1: Wave 1 of Companies, Leading to Our expertise (DONE)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Technology creation
 | 
				
			||||||
 | 
					  - This was result of 20 years of evolution
 | 
				
			||||||
 | 
					- 7 startups acquired as part of this process
 | 
				
			||||||
 | 
					- Technology used globally by big vendors
 | 
				
			||||||
 | 
					- +600m USD in exits
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Phase 2: Proof of Tech (DONE)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Open source technology launched globally
 | 
				
			||||||
 | 
					- +60,000,000 active vCPU
 | 
				
			||||||
 | 
					- Large scale proof of core technology
 | 
				
			||||||
 | 
					- Focus on early adoptors in tech space (Cloud, Web2, Web3, etc.)
 | 
				
			||||||
 | 
					- 50m USD funded by founders, community and hosters (people providing capacity)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Phase 3: Commercialization & Global Expansion (START)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Phase 3.1: Commercial Partners
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Mycelium Launches with commercial strategic partners
 | 
				
			||||||
 | 
					  - Telco Operatators
 | 
				
			||||||
 | 
					  - IT Integrators
 | 
				
			||||||
 | 
					- Enterprise roadmap delivered within 6 months
 | 
				
			||||||
 | 
					  - This is mainly about integration, documentation and UI work
 | 
				
			||||||
 | 
					- Together with partners we deliver on the many projects which are in our funnel today, e.g., East Africa, Brazil
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Phase 3.2: Large Scale Financancing for Infrastructure
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Large Scaling Financing Round**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Financing for infrastructure projects (trillions available right now for infrastructures in emerging countries)
 | 
				
			||||||
 | 
					- Public STO (security token offering)
 | 
				
			||||||
 | 
					  - This lets people around the world to co-own the infrastructure for their internet
 | 
				
			||||||
 | 
					- Large partnerships drive alternative to Tier 3 and 4 datacenters
 | 
				
			||||||
							
								
								
									
										
											BIN
										
									
								
								collections/tech/roadmap/img/roadmap.jpg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 97 KiB  | 
							
								
								
									
										50
									
								
								collections/tech/roadmap/tfgrid_roadmap.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,50 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
 | 
					## High Level Roadmap
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Status Today
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					The core offering is functioning effectively, maintained through a community-driven, best-effort approach. Currently,
 | 
				
			||||||
 | 
					there are no Service Level Agreements (SLAs) in place, and there should be increased visibility for users regarding their expectations for uptime, performance, and other service related requirements.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					The uptime and stability of ZOS are very good.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Additionally, hardware compatibility is excellent, with most machines now supported out of the box.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					|                        | Status today                                                                                                            | SDK/API | Web UI |
 | 
				
			||||||
 | 
					| ---------------------- | ----------------------------------------------------------------------------------------------------------------------- | ------- | ------ |
 | 
				
			||||||
 | 
					| ZOS                    | Used for management of +30,000 logical CPU cores                                                                        | yes     | yes    |
 | 
				
			||||||
 | 
					| MyImage (flists)       | Basis for ZOS modules as well as replaces images for VM's ...                                                           | yes     | yes    |
 | 
				
			||||||
 | 
					| MyImage from Docker    | convert docker through our Hub                                                                                          | yes     | yes    |
 | 
				
			||||||
 | 
					| MyImage Hub            | Mycelium is hosting some as well as everyone can install their own Hub                                                  | yes     | yes    |
 | 
				
			||||||
 | 
					| Mycelium Core          | Integrated in ZOS for VM's as well s ZDB and monitoring                                                                 | yes     | yes    |
 | 
				
			||||||
 | 
					| Mycelium Message Bus   | Can be used by any developer for their own usecases                                                                     | NA      | NA     |
 | 
				
			||||||
 | 
					| Quantum Safe Storage   | Usable for experts only, is reliably working for +6 years, +100 MB/sec per stream                                       | yes     | no     |
 | 
				
			||||||
 | 
					| Unbreakable Filesystem | Quantum Safe FS= usable for experts, is a fuse based filesystem on top of the QSS Core                                  | yes     | no     |
 | 
				
			||||||
 | 
					| ZOS Kubernetes         | Working very well, Integrated in ZOS, uses our overlay networks based on Wireguard, can use Quantum Safe FS underneith. | yes     | yes    |
 | 
				
			||||||
 | 
					| ZOS VM's               | The base of our service portfolio, missing is better service level management                                           | yes     | yes    |
 | 
				
			||||||
 | 
					| ZOS Monitoring         | Working well                                                                                                            | yes     | yes    |
 | 
				
			||||||
 | 
					| ZOS VM Monitoring      | Working well, can be retrieved through SDK                                                                              | yes     | yes    |
 | 
				
			||||||
 | 
					| ZOS Web Gateway        | Working well, but documentation not good enough, and not enough of them deployed                                        | yes     | yes    |
 | 
				
			||||||
 | 
					| Zero-Boot              | There are multiple ways active on how to deploy ZOS all are stateless and capable for full secure boot                  | yes     | yes    |
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Planned new features
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Considerable effort is being made to enable our partners to go into production;
 | 
				
			||||||
 | 
					however, for this initiative to truly succeed on planetary level, we need many more nodes deployed in the field.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Below you can find some of the planned features of Mycelium Network 4.0 mainly to achieve ability to scale to hundred of thousand of nodes.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					|                                 | Roadmap                                                             | Timing  |
 | 
				
			||||||
 | 
					| ------------------------------- | ------------------------------------------------------------------- | ------- |
 | 
				
			||||||
 | 
					| ZOS v4 (our next major release) | V4, without Mycelium Chain, mutual credit, marketplace              | Q2/3 25 |
 | 
				
			||||||
 | 
					| MyImage from Docker             | CI/CD integration (See MyAgent CI/CD)                               | Q1 25   |
 | 
				
			||||||
 | 
					| MyImage Hub Integration         | CI/CD integration (See MyAgent CI/CD) no more need for separate Hub | Q1 25   |
 | 
				
			||||||
 | 
					| Mycelium Core                   | Just more hardening and testing                                     | Q1 25   |
 | 
				
			||||||
 | 
					| Mycelium Message Bus            | Replace our current RMB, all our own RPC over Mycelium              | Q1 25   |
 | 
				
			||||||
 | 
					| ZOS VM's Cloud Slices           | Integration MyAgent CI, use cloud slices to manage                  | Q2 25   |
 | 
				
			||||||
 | 
					| ZOS Monitoring Docu             | More docu and easier API                                            | Q2 25   |
 | 
				
			||||||
 | 
					| ZOS Web Gateway Expansion       | Need more deployed, better integration with new Mycelium            | Q2 25   |
 | 
				
			||||||
 | 
					| Mycelium Names                  | In V4, name services                                                | Q2 25   |
 | 
				
			||||||
 | 
					| ZOS Cloud,Storage,AI Slices     | As part of marketplace for V4, flexible billing mutual credit       | Q3 25   |
 | 
				
			||||||
 | 
					| FungiStor                       | A revolutionary different way how to deliver content                | Q3 25   |
 | 
				
			||||||
 | 
					| MyImage on FungiStor            | Can be stored on FungiStor                                          | Q3 25   |
 | 
				
			||||||
							
								
								
									
										21
									
								
								collections/tech/status.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,21 @@
 | 
				
			|||||||
 | 
					## Technology Status
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					The Mycelium technology stack is proven and operational in production environments globally.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Ongoing deployment and enhancement activities continue across the platform, with expanding adoption and application scope.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Usable for Any Infrastructure Use Case
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Mycelium is designed to support any infrastructure workload - from traditional cloud applications to edge computing, AI services, and decentralized applications.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Differentiated Architecture
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Mycelium's unique value lies in its integrated approach: autonomous infrastructure, deterministic deployment, zero-knowledge storage, and optimized networking - delivered as a cohesive platform rather than point solutions.
 | 
				
			||||||
							
								
								
									
										21
									
								
								collections/tech/vision.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,21 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Vision
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Building the foundational internet infrastructure layer that is more reliable, safe, private, scalable, and sustainable.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Our technology enables anyone to become an infrastructure provider while maintaining autonomous, self-healing services covering all three fundamental layers of internet architecture.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Our system is unique in its ability to deliver integrated services across compute (ZOS), storage (Quantum Safe Storage), and networking (Mycelium Network) within a single, coherent platform.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Lets Fix Our Internet
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**We are a grounded project:**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Already deployed in 30+ countries with 30,000+ vCPUs live
 | 
				
			||||||
 | 
					- Proven technology in production for multiple years
 | 
				
			||||||
 | 
					- Complete stack: OS, storage, networking, AI agents
 | 
				
			||||||
 | 
					- Focused on building and proving technology
 | 
				
			||||||
 | 
					- Commercial phase launching with enterprise roadmap
 | 
				
			||||||
							
								
								
									
										43
									
								
								collections/tech/what.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,43 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
 | 
					## What do we do?
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					A truly reliable Internet requires fundamentally better systems for networking (communication), storage (data), and compute. 
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Mycelium has built these core technologies from the ground up, enabling anyone to become an infrastructure provider while maintaining autonomous, self-healing services covering all three fundamental layers of internet architecture.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Authentic, Secure & Globally Scalable Network Technology
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Our Mycelium Network technology enables seamless, private communication between people and machines, anywhere in the world, using the most efficient path available.
 | 
				
			||||||
 | 
					- It integrates with a global edge network of ultra-connected, low-latency supernodes to deliver superior performance and resilience.
 | 
				
			||||||
 | 
					- Mycelium is designed to overcome the limitations of the traditional Internet, such as unreliability, poor performance, and security risks.
 | 
				
			||||||
 | 
					- It provides core services including Naming, Shortest Path Routing, End-to-End Encryption, Authentication, a Secure Message Bus, and Content Delivery.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Data Storage & Distribution
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Our Quantum-Safe Storage system enables users to store unlimited amounts of data with full ownership and control.
 | 
				
			||||||
 | 
					- As soon as data leaves the application or compute layer, it is encoded in a way that is resistant even to quantum-level attacks.
 | 
				
			||||||
 | 
					- Users have full control over data availability, redundancy, and geographic placement.
 | 
				
			||||||
 | 
					- The system supports multiple interfaces, including IPFS, S3, WebDAV, HTTP, and standard file system access.
 | 
				
			||||||
 | 
					- Data can never be corrupted, and the storage system is self-healing by design.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Secure Compute
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Self-Managing & Stateless: Requires no manual interactions, enabling fully autonomous operation across global infrastructure.
 | 
				
			||||||
 | 
					- Secure & Deterministic Deployments: Every workload is cryptographically verified and deployed with guaranteed consistency—no room for tampering or drift.
 | 
				
			||||||
 | 
					- Efficient Deployment Storage System (Zero-Image): Achieves up to 100x reduction in image size and transfer using a unique metadata-driven architecture.
 | 
				
			||||||
 | 
					- Compatible: Runs Docker containers, virtual machines, and Linux workloads seamlessly.
 | 
				
			||||||
 | 
					- Smart Contract-Based Deployment: Workloads are governed by cryptographically signed contracts, ensuring transparent, tamper-proof deployment and execution.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Compare
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					| Feature                                                                                                                                                       | Others | Mycelium Tech |
 | 
				
			||||||
 | 
					| ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ------------- |
 | 
				
			||||||
 | 
					| Deterministic Deployments Possible, no one (hacker) can alter state.                                                                                          | NO     | YES           |
 | 
				
			||||||
 | 
					| Autonomous/Self Healing Infrastructure which can scale to the planet.                                                                                         | NO     | YES           |
 | 
				
			||||||
 | 
					| Usable for any web2, web3 workload, compatible with now & future.                                                                                             | NO     | YES           |
 | 
				
			||||||
 | 
					| Data is geo-aware, war & disaster proof.                                                                                                                      | NO     | YES           |
 | 
				
			||||||
 | 
					| Can work in hyperscale datacenters as well as at edge.                                                                                                        | NO     | YES           |
 | 
				
			||||||
 | 
					| Cost effective, can be 3x less                                                                                                                                | NO     | YES           |
 | 
				
			||||||
 | 
					| Networks can always find the shortest path and work over multiple media e.g. satellite, std internet, meshed wireless, lorawan, etc all end to end encrypted. | NO     | YES           |
 | 
				
			||||||
							
								
								
									
										29
									
								
								config.yaml
									
									
									
									
									
								
							
							
						
						@@ -1,25 +1,22 @@
 | 
				
			|||||||
# WsgiDAV Configuration
 | 
					 | 
				
			||||||
# Collections define WebDAV-accessible directories
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
collections:
 | 
					collections:
 | 
				
			||||||
  documents:
 | 
					  documents:
 | 
				
			||||||
    path: "./collections/documents"
 | 
					    path: ./collections/documents
 | 
				
			||||||
    description: "General documents and notes"
 | 
					    description: General documents and notes
 | 
				
			||||||
 | 
					 | 
				
			||||||
  notes:
 | 
					  notes:
 | 
				
			||||||
    path: "./collections/notes"
 | 
					    path: ./collections/notes
 | 
				
			||||||
    description: "Personal notes and drafts"
 | 
					    description: Personal notes and drafts
 | 
				
			||||||
 | 
					 | 
				
			||||||
  projects:
 | 
					  projects:
 | 
				
			||||||
    path: "./collections/projects"
 | 
					    path: ./collections/projects
 | 
				
			||||||
    description: "Project documentation"
 | 
					    description: Project documentation
 | 
				
			||||||
 | 
					  7madah:
 | 
				
			||||||
# Server settings
 | 
					    path: collections/7madah
 | 
				
			||||||
 | 
					    description: 'User-created collection: 7madah'
 | 
				
			||||||
 | 
					  tech:
 | 
				
			||||||
 | 
					    path: collections/tech
 | 
				
			||||||
 | 
					    description: 'User-created collection: tech'
 | 
				
			||||||
server:
 | 
					server:
 | 
				
			||||||
  host: "0.0.0.0"
 | 
					  host: localhost
 | 
				
			||||||
  port: 8004
 | 
					  port: 8004
 | 
				
			||||||
 | 
					 | 
				
			||||||
# WebDAV settings
 | 
					 | 
				
			||||||
webdav:
 | 
					webdav:
 | 
				
			||||||
  verbose: 1
 | 
					  verbose: 1
 | 
				
			||||||
  enable_loggers: []
 | 
					  enable_loggers: []
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										426
									
								
								refactor-plan.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,426 @@
 | 
				
			|||||||
 | 
					# UI Code Refactoring Plan
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Project:** Markdown Editor  
 | 
				
			||||||
 | 
					**Date:** 2025-10-26  
 | 
				
			||||||
 | 
					**Status:** In Progress
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					---
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Executive Summary
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					This document outlines a comprehensive refactoring plan for the UI codebase to improve maintainability, remove dead code, extract utilities, and standardize patterns. The refactoring is organized into 6 phases with 14 tasks, prioritized by risk and impact.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Key Metrics:**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Total Lines of Code: ~3,587
 | 
				
			||||||
 | 
					- Dead Code to Remove: 213 lines (6%)
 | 
				
			||||||
 | 
					- Estimated Effort: 5-8 days
 | 
				
			||||||
 | 
					- Risk Level: Mostly LOW to MEDIUM
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					---
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Phase 1: Analysis Summary
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Files Reviewed
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**JavaScript Files (10):**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- `/static/js/app.js` (484 lines)
 | 
				
			||||||
 | 
					- `/static/js/column-resizer.js` (100 lines)
 | 
				
			||||||
 | 
					- `/static/js/confirmation.js` (170 lines)
 | 
				
			||||||
 | 
					- `/static/js/editor.js` (420 lines)
 | 
				
			||||||
 | 
					- `/static/js/file-tree-actions.js` (482 lines)
 | 
				
			||||||
 | 
					- `/static/js/file-tree.js` (865 lines)
 | 
				
			||||||
 | 
					- `/static/js/macro-parser.js` (103 lines)
 | 
				
			||||||
 | 
					- `/static/js/macro-processor.js` (157 lines)
 | 
				
			||||||
 | 
					- `/static/js/ui-utils.js` (305 lines)
 | 
				
			||||||
 | 
					- `/static/js/webdav-client.js` (266 lines)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**CSS Files (6):**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- `/static/css/variables.css` (32 lines)
 | 
				
			||||||
 | 
					- `/static/css/layout.css`
 | 
				
			||||||
 | 
					- `/static/css/file-tree.css`
 | 
				
			||||||
 | 
					- `/static/css/editor.css`
 | 
				
			||||||
 | 
					- `/static/css/components.css`
 | 
				
			||||||
 | 
					- `/static/css/modal.css`
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**HTML Templates (1):**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- `/templates/index.html` (203 lines)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					---
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Issues Found
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### 🔴 HIGH PRIORITY
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					1. **Deprecated Modal Code (Dead Code)**
 | 
				
			||||||
 | 
					   - Location: `/static/js/file-tree-actions.js` lines 262-474
 | 
				
			||||||
 | 
					   - Impact: 213 lines of unused code (44% of file)
 | 
				
			||||||
 | 
					   - Risk: LOW to remove
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					2. **Duplicated Event Bus Implementation**
 | 
				
			||||||
 | 
					   - Location: `/static/js/app.js` lines 16-30
 | 
				
			||||||
 | 
					   - Should be extracted to reusable module
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					3. **Duplicated Debounce Function**
 | 
				
			||||||
 | 
					   - Location: `/static/js/editor.js` lines 404-414
 | 
				
			||||||
 | 
					   - Should be shared utility
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					4. **Inconsistent Notification Usage**
 | 
				
			||||||
 | 
					   - Mixed usage of `window.showNotification` vs `showNotification`
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					5. **Duplicated File Download Logic**
 | 
				
			||||||
 | 
					   - Location: `/static/js/file-tree.js` lines 829-839
 | 
				
			||||||
 | 
					   - Should be shared utility
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					6. **Hard-coded Values**
 | 
				
			||||||
 | 
					   - Long-press threshold: 400ms
 | 
				
			||||||
 | 
					   - Debounce delay: 300ms
 | 
				
			||||||
 | 
					   - Drag preview width: 200px
 | 
				
			||||||
 | 
					   - Toast delay: 3000ms
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### 🟡 MEDIUM PRIORITY
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					7. **Global State Management**
 | 
				
			||||||
 | 
					   - Location: `/static/js/app.js` lines 6-13
 | 
				
			||||||
 | 
					   - Makes testing difficult
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					8. **Duplicated Path Manipulation**
 | 
				
			||||||
 | 
					   - `path.split('/').pop()` appears 10+ times
 | 
				
			||||||
 | 
					   - `path.substring(0, path.lastIndexOf('/'))` appears 5+ times
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					9. **Mixed Responsibility in ui-utils.js**
 | 
				
			||||||
 | 
					   - Contains 6 different classes/utilities
 | 
				
			||||||
 | 
					   - Should be split into separate modules
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					10. **Deprecated Event Handler**
 | 
				
			||||||
 | 
					    - Location: `/static/js/file-tree-actions.js` line 329
 | 
				
			||||||
 | 
					    - Uses deprecated `onkeypress`
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### 🟢 LOW PRIORITY
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					11. **Unused Function Parameters**
 | 
				
			||||||
 | 
					12. **Magic Numbers in Styling**
 | 
				
			||||||
 | 
					13. **Inconsistent Comment Styles**
 | 
				
			||||||
 | 
					14. **Console.log Statements**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					---
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Phase 2: Proposed Reusable Components
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### 1. Config Module (`/static/js/config.js`)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Centralize all configuration values:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```javascript
 | 
				
			||||||
 | 
					export const Config = {
 | 
				
			||||||
 | 
					    // Timing
 | 
				
			||||||
 | 
					    LONG_PRESS_THRESHOLD: 400,
 | 
				
			||||||
 | 
					    DEBOUNCE_DELAY: 300,
 | 
				
			||||||
 | 
					    TOAST_DURATION: 3000,
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    // UI
 | 
				
			||||||
 | 
					    DRAG_PREVIEW_WIDTH: 200,
 | 
				
			||||||
 | 
					    TREE_INDENT_PX: 12,
 | 
				
			||||||
 | 
					    MOUSE_MOVE_THRESHOLD: 5,
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    // Validation
 | 
				
			||||||
 | 
					    FILENAME_PATTERN: /^[a-z0-9_]+(\.[a-z0-9_]+)*$/,
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    // Storage Keys
 | 
				
			||||||
 | 
					    STORAGE_KEYS: {
 | 
				
			||||||
 | 
					        DARK_MODE: 'darkMode',
 | 
				
			||||||
 | 
					        SELECTED_COLLECTION: 'selectedCollection',
 | 
				
			||||||
 | 
					        LAST_VIEWED_PAGE: 'lastViewedPage',
 | 
				
			||||||
 | 
					        COLUMN_DIMENSIONS: 'columnDimensions'
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### 2. Logger Module (`/static/js/logger.js`)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Structured logging with levels:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```javascript
 | 
				
			||||||
 | 
					export class Logger {
 | 
				
			||||||
 | 
					    static debug(message, ...args)
 | 
				
			||||||
 | 
					    static info(message, ...args)
 | 
				
			||||||
 | 
					    static warn(message, ...args)
 | 
				
			||||||
 | 
					    static error(message, ...args)
 | 
				
			||||||
 | 
					    static setLevel(level)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### 3. Event Bus Module (`/static/js/event-bus.js`)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Centralized event system:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```javascript
 | 
				
			||||||
 | 
					export class EventBus {
 | 
				
			||||||
 | 
					    on(event, callback)
 | 
				
			||||||
 | 
					    off(event, callback)
 | 
				
			||||||
 | 
					    once(event, callback)
 | 
				
			||||||
 | 
					    dispatch(event, data)
 | 
				
			||||||
 | 
					    clear(event)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### 4. Utilities Module (`/static/js/utils.js`)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Common utility functions:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```javascript
 | 
				
			||||||
 | 
					export const PathUtils = {
 | 
				
			||||||
 | 
					    getFileName(path),
 | 
				
			||||||
 | 
					    getParentPath(path),
 | 
				
			||||||
 | 
					    normalizePath(path),
 | 
				
			||||||
 | 
					    joinPaths(...paths),
 | 
				
			||||||
 | 
					    getExtension(path)
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const TimingUtils = {
 | 
				
			||||||
 | 
					    debounce(func, wait),
 | 
				
			||||||
 | 
					    throttle(func, wait)
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const DownloadUtils = {
 | 
				
			||||||
 | 
					    triggerDownload(content, filename),
 | 
				
			||||||
 | 
					    downloadAsBlob(blob, filename)
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const ValidationUtils = {
 | 
				
			||||||
 | 
					    validateFileName(name, isFolder),
 | 
				
			||||||
 | 
					    sanitizeFileName(name)
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### 5. Notification Service (`/static/js/notification-service.js`)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Standardized notifications:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```javascript
 | 
				
			||||||
 | 
					export class NotificationService {
 | 
				
			||||||
 | 
					    static success(message)
 | 
				
			||||||
 | 
					    static error(message)
 | 
				
			||||||
 | 
					    static warning(message)
 | 
				
			||||||
 | 
					    static info(message)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					---
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Phase 3: Refactoring Tasks
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### 🔴 HIGH PRIORITY
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Task 1: Remove Dead Code**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Files: `/static/js/file-tree-actions.js`
 | 
				
			||||||
 | 
					- Lines: 262-474 (213 lines)
 | 
				
			||||||
 | 
					- Risk: LOW
 | 
				
			||||||
 | 
					- Dependencies: None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Task 2: Extract Event Bus**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Files: NEW `/static/js/event-bus.js`, MODIFY `app.js`, `editor.js`
 | 
				
			||||||
 | 
					- Risk: MEDIUM
 | 
				
			||||||
 | 
					- Dependencies: None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Task 3: Create Utilities Module**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Files: NEW `/static/js/utils.js`, MODIFY multiple files
 | 
				
			||||||
 | 
					- Risk: MEDIUM
 | 
				
			||||||
 | 
					- Dependencies: None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Task 4: Create Config Module**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Files: NEW `/static/js/config.js`, MODIFY multiple files
 | 
				
			||||||
 | 
					- Risk: LOW
 | 
				
			||||||
 | 
					- Dependencies: None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Task 5: Standardize Notification Usage**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Files: NEW `/static/js/notification-service.js`, MODIFY multiple files
 | 
				
			||||||
 | 
					- Risk: LOW
 | 
				
			||||||
 | 
					- Dependencies: None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### 🟡 MEDIUM PRIORITY
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Task 6: Fix Deprecated Event Handler**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Files: `/static/js/file-tree-actions.js` line 329
 | 
				
			||||||
 | 
					- Risk: LOW
 | 
				
			||||||
 | 
					- Dependencies: None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Task 7: Refactor ui-utils.js**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Files: DELETE `ui-utils.js`, CREATE 5 new modules
 | 
				
			||||||
 | 
					- Risk: HIGH
 | 
				
			||||||
 | 
					- Dependencies: Task 5
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Task 8: Standardize Class Export Pattern**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Files: All class files
 | 
				
			||||||
 | 
					- Risk: MEDIUM
 | 
				
			||||||
 | 
					- Dependencies: None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Task 9: Create Logger Module**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Files: NEW `/static/js/logger.js`, MODIFY multiple files
 | 
				
			||||||
 | 
					- Risk: LOW
 | 
				
			||||||
 | 
					- Dependencies: None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Task 10: Implement Download Action**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Files: `/static/js/file-tree-actions.js`
 | 
				
			||||||
 | 
					- Risk: LOW
 | 
				
			||||||
 | 
					- Dependencies: Task 3
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### 🟢 LOW PRIORITY
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Task 11: Standardize JSDoc Comments**
 | 
				
			||||||
 | 
					**Task 12: Extract Magic Numbers to CSS**
 | 
				
			||||||
 | 
					**Task 13: Add Error Boundaries**
 | 
				
			||||||
 | 
					**Task 14: Cache DOM Elements**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					---
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Phase 4: Implementation Order
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Step 1: Foundation (Do First)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					1. Create Config Module (Task 4)
 | 
				
			||||||
 | 
					2. Create Logger Module (Task 9)
 | 
				
			||||||
 | 
					3. Create Event Bus Module (Task 2)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Step 2: Utilities (Do Second)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					4. Create Utilities Module (Task 3)
 | 
				
			||||||
 | 
					5. Create Notification Service (Task 5)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Step 3: Cleanup (Do Third)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					6. Remove Dead Code (Task 1)
 | 
				
			||||||
 | 
					7. Fix Deprecated Event Handler (Task 6)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Step 4: Restructuring (Do Fourth)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					8. Refactor ui-utils.js (Task 7)
 | 
				
			||||||
 | 
					9. Standardize Class Export Pattern (Task 8)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Step 5: Enhancements (Do Fifth)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					10. Implement Download Action (Task 10)
 | 
				
			||||||
 | 
					11. Add Error Boundaries (Task 13)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Step 6: Polish (Do Last)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					12. Standardize JSDoc Comments (Task 11)
 | 
				
			||||||
 | 
					13. Extract Magic Numbers to CSS (Task 12)
 | 
				
			||||||
 | 
					14. Cache DOM Elements (Task 14)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					---
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Phase 5: Testing Checklist
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Core Functionality
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- [ ] File tree loads and displays correctly
 | 
				
			||||||
 | 
					- [ ] Files can be selected and opened
 | 
				
			||||||
 | 
					- [ ] Folders can be expanded/collapsed
 | 
				
			||||||
 | 
					- [ ] Editor loads file content
 | 
				
			||||||
 | 
					- [ ] Preview renders markdown correctly
 | 
				
			||||||
 | 
					- [ ] Save button saves files
 | 
				
			||||||
 | 
					- [ ] Delete button deletes files
 | 
				
			||||||
 | 
					- [ ] New button creates new files
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Context Menu Actions
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- [ ] Right-click shows context menu
 | 
				
			||||||
 | 
					- [ ] New file action works
 | 
				
			||||||
 | 
					- [ ] New folder action works
 | 
				
			||||||
 | 
					- [ ] Rename action works
 | 
				
			||||||
 | 
					- [ ] Delete action works
 | 
				
			||||||
 | 
					- [ ] Copy/Cut/Paste actions work
 | 
				
			||||||
 | 
					- [ ] Upload action works
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Drag and Drop
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- [ ] Long-press detection works
 | 
				
			||||||
 | 
					- [ ] Drag preview appears correctly
 | 
				
			||||||
 | 
					- [ ] Drop targets highlight properly
 | 
				
			||||||
 | 
					- [ ] Files can be moved
 | 
				
			||||||
 | 
					- [ ] Undo (Ctrl+Z) works
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Modals
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- [ ] Confirmation modals appear
 | 
				
			||||||
 | 
					- [ ] Prompt modals appear
 | 
				
			||||||
 | 
					- [ ] Modals don't double-open
 | 
				
			||||||
 | 
					- [ ] Enter/Escape keys work
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### UI Features
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- [ ] Dark mode toggle works
 | 
				
			||||||
 | 
					- [ ] Collection selector works
 | 
				
			||||||
 | 
					- [ ] Column resizers work
 | 
				
			||||||
 | 
					- [ ] Notifications appear
 | 
				
			||||||
 | 
					- [ ] URL routing works
 | 
				
			||||||
 | 
					- [ ] View/Edit modes work
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					---
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Recommendations
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Immediate Actions (Before Production)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					1. Remove dead code (Task 1)
 | 
				
			||||||
 | 
					2. Fix deprecated event handler (Task 6)
 | 
				
			||||||
 | 
					3. Create config module (Task 4)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Short-term Actions (Next Sprint)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					4. Extract utilities (Task 3)
 | 
				
			||||||
 | 
					5. Standardize notifications (Task 5)
 | 
				
			||||||
 | 
					6. Create event bus (Task 2)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Medium-term Actions (Future Sprints)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					7. Refactor ui-utils.js (Task 7)
 | 
				
			||||||
 | 
					8. Add logger (Task 9)
 | 
				
			||||||
 | 
					9. Standardize exports (Task 8)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					---
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Success Metrics
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Before Refactoring:**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Total Lines: ~3,587
 | 
				
			||||||
 | 
					- Dead Code: 213 lines (6%)
 | 
				
			||||||
 | 
					- Duplicated Code: ~50 lines
 | 
				
			||||||
 | 
					- Hard-coded Values: 15+
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**After Refactoring:**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Total Lines: ~3,400 (-5%)
 | 
				
			||||||
 | 
					- Dead Code: 0 lines
 | 
				
			||||||
 | 
					- Duplicated Code: 0 lines
 | 
				
			||||||
 | 
					- Hard-coded Values: 0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Estimated Effort:** 5-8 days
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					---
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Conclusion
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					The UI codebase is generally well-structured. Main improvements needed:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					1. Remove dead code
 | 
				
			||||||
 | 
					2. Extract duplicated utilities
 | 
				
			||||||
 | 
					3. Centralize configuration
 | 
				
			||||||
 | 
					4. Standardize patterns
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Start with high-impact, low-risk changes first to ensure production readiness.
 | 
				
			||||||
							
								
								
									
										8
									
								
								server_debug.log
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,8 @@
 | 
				
			|||||||
 | 
					==============================================
 | 
				
			||||||
 | 
					Markdown Editor v3.0 - WebDAV Server
 | 
				
			||||||
 | 
					==============================================
 | 
				
			||||||
 | 
					Activating virtual environment...
 | 
				
			||||||
 | 
					Installing dependencies...
 | 
				
			||||||
 | 
					Audited 3 packages in 29ms
 | 
				
			||||||
 | 
					Checking for process on port 8004...
 | 
				
			||||||
 | 
					Starting WebDAV server...
 | 
				
			||||||
							
								
								
									
										203
									
								
								server_webdav.py
									
									
									
									
									
								
							
							
						
						@@ -19,6 +19,8 @@ class MarkdownEditorApp:
 | 
				
			|||||||
    """Main application that wraps WsgiDAV and adds custom endpoints"""
 | 
					    """Main application that wraps WsgiDAV and adds custom endpoints"""
 | 
				
			||||||
    
 | 
					    
 | 
				
			||||||
    def __init__(self, config_path="config.yaml"):
 | 
					    def __init__(self, config_path="config.yaml"):
 | 
				
			||||||
 | 
					        self.root_path = Path(__file__).parent.resolve()
 | 
				
			||||||
 | 
					        os.chdir(self.root_path)
 | 
				
			||||||
        self.config = self.load_config(config_path)
 | 
					        self.config = self.load_config(config_path)
 | 
				
			||||||
        self.collections = self.config.get('collections', {})
 | 
					        self.collections = self.config.get('collections', {})
 | 
				
			||||||
        self.setup_collections()
 | 
					        self.setup_collections()
 | 
				
			||||||
@@ -26,9 +28,17 @@ class MarkdownEditorApp:
 | 
				
			|||||||
    
 | 
					    
 | 
				
			||||||
    def load_config(self, config_path):
 | 
					    def load_config(self, config_path):
 | 
				
			||||||
        """Load configuration from YAML file"""
 | 
					        """Load configuration from YAML file"""
 | 
				
			||||||
 | 
					        self.config_path = config_path
 | 
				
			||||||
        with open(config_path, 'r') as f:
 | 
					        with open(config_path, 'r') as f:
 | 
				
			||||||
            return yaml.safe_load(f)
 | 
					            return yaml.safe_load(f)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def save_config(self):
 | 
				
			||||||
 | 
					        """Save configuration to YAML file"""
 | 
				
			||||||
 | 
					        # Update config with current collections
 | 
				
			||||||
 | 
					        self.config['collections'] = self.collections
 | 
				
			||||||
 | 
					        with open(self.config_path, 'w') as f:
 | 
				
			||||||
 | 
					            yaml.dump(self.config, f, default_flow_style=False, sort_keys=False)
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
    def setup_collections(self):
 | 
					    def setup_collections(self):
 | 
				
			||||||
        """Create collection directories if they don't exist"""
 | 
					        """Create collection directories if they don't exist"""
 | 
				
			||||||
        for name, config in self.collections.items():
 | 
					        for name, config in self.collections.items():
 | 
				
			||||||
@@ -51,7 +61,7 @@ class MarkdownEditorApp:
 | 
				
			|||||||
        
 | 
					        
 | 
				
			||||||
        config = {
 | 
					        config = {
 | 
				
			||||||
            'host': self.config['server']['host'],
 | 
					            'host': self.config['server']['host'],
 | 
				
			||||||
            'port': self.config['server']['port'],
 | 
					            'port': int(os.environ.get('PORT', self.config['server']['port'])),
 | 
				
			||||||
            'provider_mapping': provider_mapping,
 | 
					            'provider_mapping': provider_mapping,
 | 
				
			||||||
            'verbose': self.config['webdav'].get('verbose', 1),
 | 
					            'verbose': self.config['webdav'].get('verbose', 1),
 | 
				
			||||||
            'logging': {
 | 
					            'logging': {
 | 
				
			||||||
@@ -73,21 +83,48 @@ class MarkdownEditorApp:
 | 
				
			|||||||
        path = environ.get('PATH_INFO', '')
 | 
					        path = environ.get('PATH_INFO', '')
 | 
				
			||||||
        method = environ.get('REQUEST_METHOD', '')
 | 
					        method = environ.get('REQUEST_METHOD', '')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        # Handle collection list endpoint
 | 
					        # Root and index.html
 | 
				
			||||||
        if path == '/fs/' and method == 'GET':
 | 
					 | 
				
			||||||
            return self.handle_collections_list(environ, start_response)
 | 
					 | 
				
			||||||
        
 | 
					 | 
				
			||||||
        # Handle static files
 | 
					 | 
				
			||||||
        if path.startswith('/static/'):
 | 
					 | 
				
			||||||
            return self.handle_static(environ, start_response)
 | 
					 | 
				
			||||||
        
 | 
					 | 
				
			||||||
        # Handle root - serve index.html
 | 
					 | 
				
			||||||
        if path == '/' or path == '/index.html':
 | 
					        if path == '/' or path == '/index.html':
 | 
				
			||||||
            return self.handle_index(environ, start_response)
 | 
					            return self.handle_index(environ, start_response)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        # All other requests go to WebDAV
 | 
					        # Static files
 | 
				
			||||||
 | 
					        if path.startswith('/static/'):
 | 
				
			||||||
 | 
					            return self.handle_static(environ, start_response)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Health check
 | 
				
			||||||
 | 
					        if path == '/health' and method == 'GET':
 | 
				
			||||||
 | 
					            start_response('200 OK', [('Content-Type', 'text/plain')])
 | 
				
			||||||
 | 
					            return [b'OK']
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # API for collections
 | 
				
			||||||
 | 
					        if path == '/fs/' and method == 'GET':
 | 
				
			||||||
 | 
					            return self.handle_collections_list(environ, start_response)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # API to create new collection
 | 
				
			||||||
 | 
					        if path == '/fs/' and method == 'POST':
 | 
				
			||||||
 | 
					            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)
 | 
				
			||||||
 | 
					        # This handles URLs like /notes/ttt or /documents/file.md
 | 
				
			||||||
 | 
					        # MUST be checked BEFORE WebDAV routing to prevent WebDAV from intercepting SPA routes
 | 
				
			||||||
 | 
					        path_parts = path.strip('/').split('/')
 | 
				
			||||||
 | 
					        if path_parts and path_parts[0] in self.collections:
 | 
				
			||||||
 | 
					            # This is a SPA route for a collection, serve index.html
 | 
				
			||||||
 | 
					            # The client-side router will handle the path
 | 
				
			||||||
 | 
					            return self.handle_index(environ, start_response)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # All other /fs/ requests go to WebDAV
 | 
				
			||||||
 | 
					        if path.startswith('/fs/'):
 | 
				
			||||||
            return self.webdav_app(environ, start_response)
 | 
					            return self.webdav_app(environ, start_response)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Fallback: Serve index.html for all other routes (SPA routing)
 | 
				
			||||||
 | 
					        # This allows client-side routing to handle any other paths
 | 
				
			||||||
 | 
					        return self.handle_index(environ, start_response)
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
    def handle_collections_list(self, environ, start_response):
 | 
					    def handle_collections_list(self, environ, start_response):
 | 
				
			||||||
        """Return list of available collections"""
 | 
					        """Return list of available collections"""
 | 
				
			||||||
        collections = list(self.collections.keys())
 | 
					        collections = list(self.collections.keys())
 | 
				
			||||||
@@ -101,12 +138,145 @@ class MarkdownEditorApp:
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        return [response_body]
 | 
					        return [response_body]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def handle_create_collection(self, environ, start_response):
 | 
				
			||||||
 | 
					        """Create a new collection"""
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            # Read request body
 | 
				
			||||||
 | 
					            content_length = int(environ.get('CONTENT_LENGTH', 0))
 | 
				
			||||||
 | 
					            request_body = environ['wsgi.input'].read(content_length)
 | 
				
			||||||
 | 
					            data = json.loads(request_body.decode('utf-8'))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            collection_name = data.get('name')
 | 
				
			||||||
 | 
					            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 already exists
 | 
				
			||||||
 | 
					            if collection_name in self.collections:
 | 
				
			||||||
 | 
					                start_response('409 Conflict', [('Content-Type', 'application/json')])
 | 
				
			||||||
 | 
					                return [json.dumps({'error': f'Collection "{collection_name}" already exists'}).encode('utf-8')]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            # Create collection directory
 | 
				
			||||||
 | 
					            collection_path = Path(f'./collections/{collection_name}')
 | 
				
			||||||
 | 
					            collection_path.mkdir(parents=True, exist_ok=True)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            # Create images subdirectory
 | 
				
			||||||
 | 
					            images_path = collection_path / 'images'
 | 
				
			||||||
 | 
					            images_path.mkdir(exist_ok=True)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            # Add to collections dict
 | 
				
			||||||
 | 
					            self.collections[collection_name] = {
 | 
				
			||||||
 | 
					                'path': str(collection_path),
 | 
				
			||||||
 | 
					                'description': f'User-created collection: {collection_name}'
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            # Update config file
 | 
				
			||||||
 | 
					            self.save_config()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            # Add to WebDAV provider mapping
 | 
				
			||||||
 | 
					            from wsgidav.fs_dav_provider import FilesystemProvider
 | 
				
			||||||
 | 
					            provider_path = os.path.abspath(str(collection_path))
 | 
				
			||||||
 | 
					            provider_key = f'/fs/{collection_name}'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            # Use the add_provider method if available, otherwise add directly to provider_map
 | 
				
			||||||
 | 
					            provider = FilesystemProvider(provider_path)
 | 
				
			||||||
 | 
					            if hasattr(self.webdav_app, 'add_provider'):
 | 
				
			||||||
 | 
					                self.webdav_app.add_provider(provider_key, provider)
 | 
				
			||||||
 | 
					                print(f"Added provider using add_provider(): {provider_key}")
 | 
				
			||||||
 | 
					            else:
 | 
				
			||||||
 | 
					                self.webdav_app.provider_map[provider_key] = provider
 | 
				
			||||||
 | 
					                print(f"Added provider to provider_map: {provider_key}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            # Also update sorted_share_list if it exists
 | 
				
			||||||
 | 
					            if hasattr(self.webdav_app, 'sorted_share_list'):
 | 
				
			||||||
 | 
					                if provider_key not in self.webdav_app.sorted_share_list:
 | 
				
			||||||
 | 
					                    self.webdav_app.sorted_share_list.append(provider_key)
 | 
				
			||||||
 | 
					                    self.webdav_app.sorted_share_list.sort(reverse=True)
 | 
				
			||||||
 | 
					                    print(f"Updated sorted_share_list")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            print(f"Created collection '{collection_name}' at {provider_path}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            response_body = json.dumps({'success': True, 'name': collection_name}).encode('utf-8')
 | 
				
			||||||
 | 
					            start_response('201 Created', [
 | 
				
			||||||
 | 
					                ('Content-Type', 'application/json'),
 | 
				
			||||||
 | 
					                ('Content-Length', str(len(response_body))),
 | 
				
			||||||
 | 
					                ('Access-Control-Allow-Origin', '*')
 | 
				
			||||||
 | 
					            ])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            return [response_body]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        except Exception as e:
 | 
				
			||||||
 | 
					            print(f"Error creating collection: {e}")
 | 
				
			||||||
 | 
					            start_response('500 Internal Server Error', [('Content-Type', 'application/json')])
 | 
				
			||||||
 | 
					            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 /
 | 
				
			||||||
        file_path = Path(path)
 | 
					        file_path = self.root_path / path
 | 
				
			||||||
        
 | 
					        
 | 
				
			||||||
        if not file_path.exists() or not file_path.is_file():
 | 
					        if not file_path.is_file():
 | 
				
			||||||
            start_response('404 Not Found', [('Content-Type', 'text/plain')])
 | 
					            start_response('404 Not Found', [('Content-Type', 'text/plain')])
 | 
				
			||||||
            return [b'File not found']
 | 
					            return [b'File not found']
 | 
				
			||||||
        
 | 
					        
 | 
				
			||||||
@@ -139,9 +309,9 @@ class MarkdownEditorApp:
 | 
				
			|||||||
    
 | 
					    
 | 
				
			||||||
    def handle_index(self, environ, start_response):
 | 
					    def handle_index(self, environ, start_response):
 | 
				
			||||||
        """Serve index.html"""
 | 
					        """Serve index.html"""
 | 
				
			||||||
        index_path = Path('templates/index.html')
 | 
					        index_path = self.root_path / 'templates' / 'index.html'
 | 
				
			||||||
        
 | 
					        
 | 
				
			||||||
        if not index_path.exists():
 | 
					        if not index_path.is_file():
 | 
				
			||||||
            start_response('404 Not Found', [('Content-Type', 'text/plain')])
 | 
					            start_response('404 Not Found', [('Content-Type', 'text/plain')])
 | 
				
			||||||
            return [b'index.html not found']
 | 
					            return [b'index.html not found']
 | 
				
			||||||
        
 | 
					        
 | 
				
			||||||
@@ -167,7 +337,7 @@ def main():
 | 
				
			|||||||
    
 | 
					    
 | 
				
			||||||
    # Get server config
 | 
					    # Get server config
 | 
				
			||||||
    host = app.config['server']['host']
 | 
					    host = app.config['server']['host']
 | 
				
			||||||
    port = app.config['server']['port']
 | 
					    port = int(os.environ.get('PORT', app.config['server']['port']))
 | 
				
			||||||
    
 | 
					    
 | 
				
			||||||
    print(f"\nServer starting on http://{host}:{port}")
 | 
					    print(f"\nServer starting on http://{host}:{port}")
 | 
				
			||||||
    print(f"\nAvailable collections:")
 | 
					    print(f"\nAvailable collections:")
 | 
				
			||||||
@@ -187,6 +357,7 @@ def main():
 | 
				
			|||||||
    
 | 
					    
 | 
				
			||||||
    try:
 | 
					    try:
 | 
				
			||||||
        server.start()
 | 
					        server.start()
 | 
				
			||||||
 | 
					        server.wait()
 | 
				
			||||||
    except KeyboardInterrupt:
 | 
					    except KeyboardInterrupt:
 | 
				
			||||||
        print("\n\nShutting down...")
 | 
					        print("\n\nShutting down...")
 | 
				
			||||||
        server.stop()
 | 
					        server.stop()
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										6
									
								
								start.sh
									
									
									
									
									
								
							
							
						
						@@ -1,5 +1,8 @@
 | 
				
			|||||||
#!/bin/bash
 | 
					#!/bin/bash
 | 
				
			||||||
set -e
 | 
					set -e
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Change to the script's directory to ensure relative paths work
 | 
				
			||||||
 | 
					cd "$(dirname "$0")"
 | 
				
			||||||
echo "=============================================="
 | 
					echo "=============================================="
 | 
				
			||||||
echo "Markdown Editor v3.0 - WebDAV Server"
 | 
					echo "Markdown Editor v3.0 - WebDAV Server"
 | 
				
			||||||
echo "=============================================="
 | 
					echo "=============================================="
 | 
				
			||||||
@@ -16,5 +19,8 @@ echo "Activating virtual environment..."
 | 
				
			|||||||
source .venv/bin/activate
 | 
					source .venv/bin/activate
 | 
				
			||||||
echo "Installing dependencies..."
 | 
					echo "Installing dependencies..."
 | 
				
			||||||
uv pip install wsgidav cheroot pyyaml
 | 
					uv pip install wsgidav cheroot pyyaml
 | 
				
			||||||
 | 
					PORT=8004
 | 
				
			||||||
 | 
					echo "Checking for process on port $PORT..."
 | 
				
			||||||
 | 
					lsof -ti:$PORT | xargs -r kill -9
 | 
				
			||||||
echo "Starting WebDAV server..."
 | 
					echo "Starting WebDAV server..."
 | 
				
			||||||
python server_webdav.py
 | 
					python server_webdav.py
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,5 +1,5 @@
 | 
				
			|||||||
// Markdown Editor Application with File Tree
 | 
					// Markdown Editor Application with File Tree
 | 
				
			||||||
(function() {
 | 
					(function () {
 | 
				
			||||||
    'use strict';
 | 
					    'use strict';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // State management
 | 
					    // State management
 | 
				
			||||||
@@ -24,7 +24,7 @@
 | 
				
			|||||||
    function enableDarkMode() {
 | 
					    function enableDarkMode() {
 | 
				
			||||||
        isDarkMode = true;
 | 
					        isDarkMode = true;
 | 
				
			||||||
        document.body.classList.add('dark-mode');
 | 
					        document.body.classList.add('dark-mode');
 | 
				
			||||||
        document.getElementById('darkModeIcon').textContent = '☀️';
 | 
					        document.getElementById('darkModeIcon').innerHTML = '<i class="bi bi-sun-fill"></i>';
 | 
				
			||||||
        localStorage.setItem('darkMode', 'true');
 | 
					        localStorage.setItem('darkMode', 'true');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        mermaid.initialize({
 | 
					        mermaid.initialize({
 | 
				
			||||||
@@ -41,7 +41,7 @@
 | 
				
			|||||||
    function disableDarkMode() {
 | 
					    function disableDarkMode() {
 | 
				
			||||||
        isDarkMode = false;
 | 
					        isDarkMode = false;
 | 
				
			||||||
        document.body.classList.remove('dark-mode');
 | 
					        document.body.classList.remove('dark-mode');
 | 
				
			||||||
        document.getElementById('darkModeIcon').textContent = '🌙';
 | 
					        // document.getElementById('darkModeIcon').textContent = '🌙';
 | 
				
			||||||
        localStorage.setItem('darkMode', 'false');
 | 
					        localStorage.setItem('darkMode', 'false');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        mermaid.initialize({
 | 
					        mermaid.initialize({
 | 
				
			||||||
@@ -189,8 +189,8 @@
 | 
				
			|||||||
            lineWrapping: true,
 | 
					            lineWrapping: true,
 | 
				
			||||||
            autofocus: true,
 | 
					            autofocus: true,
 | 
				
			||||||
            extraKeys: {
 | 
					            extraKeys: {
 | 
				
			||||||
                'Ctrl-S': function() { saveFile(); },
 | 
					                'Ctrl-S': function () { saveFile(); },
 | 
				
			||||||
                'Cmd-S': function() { saveFile(); }
 | 
					                'Cmd-S': function () { saveFile(); }
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -338,7 +338,6 @@
 | 
				
			|||||||
        if (node.type === 'directory') {
 | 
					        if (node.type === 'directory') {
 | 
				
			||||||
            const toggle = document.createElement('span');
 | 
					            const toggle = document.createElement('span');
 | 
				
			||||||
            toggle.className = 'tree-node-toggle';
 | 
					            toggle.className = 'tree-node-toggle';
 | 
				
			||||||
            toggle.innerHTML = '▶';
 | 
					 | 
				
			||||||
            toggle.addEventListener('click', (e) => {
 | 
					            toggle.addEventListener('click', (e) => {
 | 
				
			||||||
                e.stopPropagation();
 | 
					                e.stopPropagation();
 | 
				
			||||||
                toggleNode(nodeDiv);
 | 
					                toggleNode(nodeDiv);
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,5 +1,5 @@
 | 
				
			|||||||
// Markdown Editor Application
 | 
					// Markdown Editor Application
 | 
				
			||||||
(function() {
 | 
					(function () {
 | 
				
			||||||
    'use strict';
 | 
					    'use strict';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // State management
 | 
					    // State management
 | 
				
			||||||
@@ -21,7 +21,7 @@
 | 
				
			|||||||
    function enableDarkMode() {
 | 
					    function enableDarkMode() {
 | 
				
			||||||
        isDarkMode = true;
 | 
					        isDarkMode = true;
 | 
				
			||||||
        document.body.classList.add('dark-mode');
 | 
					        document.body.classList.add('dark-mode');
 | 
				
			||||||
        document.getElementById('darkModeIcon').textContent = '☀️';
 | 
					        document.getElementById('darkModeIcon').innerHTML = '<i class="bi bi-sun-fill"></i>';
 | 
				
			||||||
        localStorage.setItem('darkMode', 'true');
 | 
					        localStorage.setItem('darkMode', 'true');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        // Update mermaid theme
 | 
					        // Update mermaid theme
 | 
				
			||||||
@@ -40,7 +40,7 @@
 | 
				
			|||||||
    function disableDarkMode() {
 | 
					    function disableDarkMode() {
 | 
				
			||||||
        isDarkMode = false;
 | 
					        isDarkMode = false;
 | 
				
			||||||
        document.body.classList.remove('dark-mode');
 | 
					        document.body.classList.remove('dark-mode');
 | 
				
			||||||
        document.getElementById('darkModeIcon').textContent = '🌙';
 | 
					        // document.getElementById('darkModeIcon').textContent = '🌙';
 | 
				
			||||||
        localStorage.setItem('darkMode', 'false');
 | 
					        localStorage.setItem('darkMode', 'false');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        // Update mermaid theme
 | 
					        // Update mermaid theme
 | 
				
			||||||
@@ -198,8 +198,8 @@
 | 
				
			|||||||
            lineWrapping: true,
 | 
					            lineWrapping: true,
 | 
				
			||||||
            autofocus: true,
 | 
					            autofocus: true,
 | 
				
			||||||
            extraKeys: {
 | 
					            extraKeys: {
 | 
				
			||||||
                'Ctrl-S': function() { saveFile(); },
 | 
					                'Ctrl-S': function () { saveFile(); },
 | 
				
			||||||
                'Cmd-S': function() { saveFile(); }
 | 
					                'Cmd-S': function () { saveFile(); }
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -2,10 +2,21 @@
 | 
				
			|||||||
.preview-pane {
 | 
					.preview-pane {
 | 
				
			||||||
    font-size: 16px;
 | 
					    font-size: 16px;
 | 
				
			||||||
    line-height: 1.6;
 | 
					    line-height: 1.6;
 | 
				
			||||||
 | 
					    color: var(--text-primary);
 | 
				
			||||||
 | 
					    background-color: var(--bg-primary);
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.preview-pane h1, .preview-pane h2, .preview-pane h3,
 | 
					#preview {
 | 
				
			||||||
.preview-pane h4, .preview-pane h5, .preview-pane h6 {
 | 
					    color: var(--text-primary);
 | 
				
			||||||
 | 
					    background-color: var(--bg-primary);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.preview-pane h1,
 | 
				
			||||||
 | 
					.preview-pane h2,
 | 
				
			||||||
 | 
					.preview-pane h3,
 | 
				
			||||||
 | 
					.preview-pane h4,
 | 
				
			||||||
 | 
					.preview-pane h5,
 | 
				
			||||||
 | 
					.preview-pane h6 {
 | 
				
			||||||
    margin-top: 24px;
 | 
					    margin-top: 24px;
 | 
				
			||||||
    margin-bottom: 16px;
 | 
					    margin-bottom: 16px;
 | 
				
			||||||
    font-weight: 600;
 | 
					    font-weight: 600;
 | 
				
			||||||
@@ -132,11 +143,21 @@ body.dark-mode .context-menu {
 | 
				
			|||||||
    animation: slideIn 0.3s ease;
 | 
					    animation: slideIn 0.3s ease;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Override Bootstrap warning background to be darker for better text contrast */
 | 
				
			||||||
 | 
					.toast.bg-warning {
 | 
				
			||||||
 | 
					    background-color: #cc9a06 !important;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					body.dark-mode .toast.bg-warning {
 | 
				
			||||||
 | 
					    background-color: #b8860b !important;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@keyframes slideIn {
 | 
					@keyframes slideIn {
 | 
				
			||||||
    from {
 | 
					    from {
 | 
				
			||||||
        transform: translateX(400px);
 | 
					        transform: translateX(400px);
 | 
				
			||||||
        opacity: 0;
 | 
					        opacity: 0;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    to {
 | 
					    to {
 | 
				
			||||||
        transform: translateX(0);
 | 
					        transform: translateX(0);
 | 
				
			||||||
        opacity: 1;
 | 
					        opacity: 1;
 | 
				
			||||||
@@ -152,9 +173,281 @@ body.dark-mode .context-menu {
 | 
				
			|||||||
        transform: translateX(0);
 | 
					        transform: translateX(0);
 | 
				
			||||||
        opacity: 1;
 | 
					        opacity: 1;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    to {
 | 
					    to {
 | 
				
			||||||
        transform: translateX(400px);
 | 
					        transform: translateX(400px);
 | 
				
			||||||
        opacity: 0;
 | 
					        opacity: 0;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Modal Dialogs */
 | 
				
			||||||
 | 
					.modal {
 | 
				
			||||||
 | 
					    z-index: 10000;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.modal-backdrop {
 | 
				
			||||||
 | 
					    z-index: 9999;
 | 
				
			||||||
 | 
					    background-color: rgba(0, 0, 0, 0.5);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					body.dark-mode .modal-content {
 | 
				
			||||||
 | 
					    background-color: var(--bg-secondary);
 | 
				
			||||||
 | 
					    color: var(--text-primary);
 | 
				
			||||||
 | 
					    border: 1px solid var(--border-color);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					body.dark-mode .modal-header {
 | 
				
			||||||
 | 
					    border-bottom-color: var(--border-color);
 | 
				
			||||||
 | 
					    background-color: var(--bg-tertiary);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					body.dark-mode .modal-footer {
 | 
				
			||||||
 | 
					    border-top-color: var(--border-color);
 | 
				
			||||||
 | 
					    background-color: var(--bg-tertiary);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.modal-header.border-danger {
 | 
				
			||||||
 | 
					    border-bottom: 2px solid var(--danger-color) !important;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.modal-open {
 | 
				
			||||||
 | 
					    overflow: hidden;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Input in modal */
 | 
				
			||||||
 | 
					.modal-body input.form-control {
 | 
				
			||||||
 | 
					    background-color: var(--bg-primary);
 | 
				
			||||||
 | 
					    color: var(--text-primary);
 | 
				
			||||||
 | 
					    border-color: var(--border-color);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.modal-body input.form-control:focus {
 | 
				
			||||||
 | 
					    background-color: var(--bg-primary);
 | 
				
			||||||
 | 
					    color: var(--text-primary);
 | 
				
			||||||
 | 
					    border-color: var(--link-color);
 | 
				
			||||||
 | 
					    box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Directory Preview Styles */
 | 
				
			||||||
 | 
					.directory-preview {
 | 
				
			||||||
 | 
					    padding: 20px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.directory-preview h2 {
 | 
				
			||||||
 | 
					    margin-bottom: 20px;
 | 
				
			||||||
 | 
					    /* color: var(--text-primary); */
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.directory-files {
 | 
				
			||||||
 | 
					    display: grid;
 | 
				
			||||||
 | 
					    grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
 | 
				
			||||||
 | 
					    gap: 16px;
 | 
				
			||||||
 | 
					    margin-top: 20px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.file-card {
 | 
				
			||||||
 | 
					    background-color: var(--bg-tertiary);
 | 
				
			||||||
 | 
					    border: 1px solid var(--border-color);
 | 
				
			||||||
 | 
					    border-radius: 8px;
 | 
				
			||||||
 | 
					    padding: 16px;
 | 
				
			||||||
 | 
					    cursor: pointer;
 | 
				
			||||||
 | 
					    transition: all 0.2s ease;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.file-card:hover {
 | 
				
			||||||
 | 
					    background-color: var(--bg-secondary);
 | 
				
			||||||
 | 
					    border-color: var(--link-color);
 | 
				
			||||||
 | 
					    transform: translateY(-2px);
 | 
				
			||||||
 | 
					    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.file-card-header {
 | 
				
			||||||
 | 
					    display: flex;
 | 
				
			||||||
 | 
					    align-items: center;
 | 
				
			||||||
 | 
					    gap: 8px;
 | 
				
			||||||
 | 
					    margin-bottom: 8px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.file-card-header i {
 | 
				
			||||||
 | 
					    color: var(--link-color);
 | 
				
			||||||
 | 
					    font-size: 18px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.file-card-name {
 | 
				
			||||||
 | 
					    font-weight: 500;
 | 
				
			||||||
 | 
					    color: var(--text-primary);
 | 
				
			||||||
 | 
					    word-break: break-word;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.file-card-description {
 | 
				
			||||||
 | 
					    font-size: 13px;
 | 
				
			||||||
 | 
					    color: var(--text-secondary);
 | 
				
			||||||
 | 
					    line-height: 1.4;
 | 
				
			||||||
 | 
					    margin-top: 8px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Flat Button Styles */
 | 
				
			||||||
 | 
					.btn-flat {
 | 
				
			||||||
 | 
					    border: none;
 | 
				
			||||||
 | 
					    border-radius: 0;
 | 
				
			||||||
 | 
					    padding: 6px 12px;
 | 
				
			||||||
 | 
					    font-size: 14px;
 | 
				
			||||||
 | 
					    font-weight: 500;
 | 
				
			||||||
 | 
					    transition: all 0.2s ease;
 | 
				
			||||||
 | 
					    background-color: transparent;
 | 
				
			||||||
 | 
					    color: var(--text-primary);
 | 
				
			||||||
 | 
					    position: relative;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.btn-flat:hover {
 | 
				
			||||||
 | 
					    background-color: var(--bg-tertiary);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.btn-flat:active {
 | 
				
			||||||
 | 
					    transform: scale(0.95);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Flat button variants */
 | 
				
			||||||
 | 
					.btn-flat-primary {
 | 
				
			||||||
 | 
					    color: #0d6efd;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.btn-flat-primary:hover {
 | 
				
			||||||
 | 
					    background-color: rgba(13, 110, 253, 0.1);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.btn-flat-success {
 | 
				
			||||||
 | 
					    color: #198754;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.btn-flat-success:hover {
 | 
				
			||||||
 | 
					    background-color: rgba(25, 135, 84, 0.1);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.btn-flat-danger {
 | 
				
			||||||
 | 
					    color: #dc3545;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.btn-flat-danger:hover {
 | 
				
			||||||
 | 
					    background-color: rgba(220, 53, 69, 0.1);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.btn-flat-warning {
 | 
				
			||||||
 | 
					    color: #ffc107;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.btn-flat-warning:hover {
 | 
				
			||||||
 | 
					    background-color: rgba(255, 193, 7, 0.1);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.btn-flat-secondary {
 | 
				
			||||||
 | 
					    color: var(--text-secondary);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.btn-flat-secondary:hover {
 | 
				
			||||||
 | 
					    background-color: var(--bg-tertiary);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Dark mode adjustments */
 | 
				
			||||||
 | 
					body.dark-mode .btn-flat-primary {
 | 
				
			||||||
 | 
					    color: #6ea8fe;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					body.dark-mode .btn-flat-success {
 | 
				
			||||||
 | 
					    color: #75b798;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					body.dark-mode .btn-flat-danger {
 | 
				
			||||||
 | 
					    color: #ea868f;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					body.dark-mode .btn-flat-warning {
 | 
				
			||||||
 | 
					    color: #ffda6a;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Dark Mode Button Icon Styles */
 | 
				
			||||||
 | 
					#darkModeBtn i {
 | 
				
			||||||
 | 
					    font-size: 16px;
 | 
				
			||||||
 | 
					    color: inherit;
 | 
				
			||||||
 | 
					    /* Inherit color from parent button */
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Light mode: moon icon */
 | 
				
			||||||
 | 
					body:not(.dark-mode) #darkModeBtn i {
 | 
				
			||||||
 | 
					    color: var(--text-secondary);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Dark mode: sun icon */
 | 
				
			||||||
 | 
					body.dark-mode #darkModeBtn i {
 | 
				
			||||||
 | 
					    color: #ffc107;
 | 
				
			||||||
 | 
					    /* Warm sun color */
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Hover effects */
 | 
				
			||||||
 | 
					#darkModeBtn:hover i {
 | 
				
			||||||
 | 
					    color: inherit;
 | 
				
			||||||
 | 
					    /* 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;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -6,6 +6,8 @@
 | 
				
			|||||||
    display: flex;
 | 
					    display: flex;
 | 
				
			||||||
    gap: 10px;
 | 
					    gap: 10px;
 | 
				
			||||||
    align-items: center;
 | 
					    align-items: center;
 | 
				
			||||||
 | 
					    flex-shrink: 0;
 | 
				
			||||||
 | 
					    /* Prevent header from shrinking */
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.editor-header input {
 | 
					.editor-header input {
 | 
				
			||||||
@@ -19,18 +21,42 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
.editor-container {
 | 
					.editor-container {
 | 
				
			||||||
    flex: 1;
 | 
					    flex: 1;
 | 
				
			||||||
 | 
					    /* Take remaining space */
 | 
				
			||||||
    overflow: hidden;
 | 
					    overflow: hidden;
 | 
				
			||||||
 | 
					    /* Prevent container overflow, CodeMirror handles its own scrolling */
 | 
				
			||||||
 | 
					    display: flex;
 | 
				
			||||||
 | 
					    flex-direction: column;
 | 
				
			||||||
 | 
					    min-height: 0;
 | 
				
			||||||
 | 
					    /* Important: allows flex child to shrink below content size */
 | 
				
			||||||
 | 
					    position: relative;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#editor {
 | 
				
			||||||
 | 
					    flex: 1;
 | 
				
			||||||
 | 
					    /* Take all available space */
 | 
				
			||||||
 | 
					    min-height: 0;
 | 
				
			||||||
 | 
					    /* Allow shrinking */
 | 
				
			||||||
 | 
					    overflow: hidden;
 | 
				
			||||||
 | 
					    /* CodeMirror will handle scrolling */
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/* CodeMirror customization */
 | 
					/* CodeMirror customization */
 | 
				
			||||||
.CodeMirror {
 | 
					.CodeMirror {
 | 
				
			||||||
    height: 100%;
 | 
					    height: 100% !important;
 | 
				
			||||||
 | 
					    /* Force full height */
 | 
				
			||||||
    font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
 | 
					    font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
 | 
				
			||||||
    font-size: 14px;
 | 
					    font-size: 14px;
 | 
				
			||||||
    background-color: var(--bg-primary);
 | 
					    background-color: var(--bg-primary);
 | 
				
			||||||
    color: var(--text-primary);
 | 
					    color: var(--text-primary);
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.CodeMirror-scroll {
 | 
				
			||||||
 | 
					    overflow-y: auto !important;
 | 
				
			||||||
 | 
					    /* Ensure vertical scrolling is enabled */
 | 
				
			||||||
 | 
					    overflow-x: auto !important;
 | 
				
			||||||
 | 
					    /* Ensure horizontal scrolling is enabled */
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
body.dark-mode .CodeMirror {
 | 
					body.dark-mode .CodeMirror {
 | 
				
			||||||
    background-color: #1c2128;
 | 
					    background-color: #1c2128;
 | 
				
			||||||
    color: #e6edf3;
 | 
					    color: #e6edf3;
 | 
				
			||||||
@@ -72,4 +98,3 @@ body.dark-mode .CodeMirror-gutters {
 | 
				
			|||||||
    pointer-events: none;
 | 
					    pointer-events: none;
 | 
				
			||||||
    z-index: 1000;
 | 
					    z-index: 1000;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,7 +1,12 @@
 | 
				
			|||||||
/* File tree styles */
 | 
					/* Bootstrap-styled File Tree */
 | 
				
			||||||
.file-tree {
 | 
					.file-tree {
 | 
				
			||||||
    font-size: 14px;
 | 
					    font-size: 13px;
 | 
				
			||||||
    user-select: none;
 | 
					    user-select: none;
 | 
				
			||||||
 | 
					    padding: 8px 0;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.tree-node-wrapper {
 | 
				
			||||||
 | 
					    margin: 0;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.tree-node {
 | 
					.tree-node {
 | 
				
			||||||
@@ -9,11 +14,15 @@
 | 
				
			|||||||
    cursor: pointer;
 | 
					    cursor: pointer;
 | 
				
			||||||
    display: flex;
 | 
					    display: flex;
 | 
				
			||||||
    align-items: center;
 | 
					    align-items: center;
 | 
				
			||||||
    gap: 8px;
 | 
					    gap: 6px;
 | 
				
			||||||
    border-radius: 4px;
 | 
					    border-radius: 4px;
 | 
				
			||||||
    margin: 2px 0;
 | 
					    margin: 1px 4px;
 | 
				
			||||||
    color: var(--text-primary);
 | 
					    color: var(--text-primary);
 | 
				
			||||||
    transition: background-color 0.15s ease;
 | 
					    transition: all 0.15s ease;
 | 
				
			||||||
 | 
					    white-space: nowrap;
 | 
				
			||||||
 | 
					    overflow: visible;
 | 
				
			||||||
 | 
					    text-overflow: ellipsis;
 | 
				
			||||||
 | 
					    min-height: 28px;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.tree-node:hover {
 | 
					.tree-node:hover {
 | 
				
			||||||
@@ -21,20 +30,56 @@
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.tree-node.active {
 | 
					.tree-node.active {
 | 
				
			||||||
    background-color: #0969da;
 | 
					    color: var(--link-color);
 | 
				
			||||||
    color: white;
 | 
					    font-weight: 500;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
body.dark-mode .tree-node.active {
 | 
					.tree-node.active:hover {
 | 
				
			||||||
    background-color: #1f6feb;
 | 
					    filter: brightness(1.2);
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.tree-node.active .tree-node-icon {
 | 
				
			||||||
 | 
					    color: var(--link-color);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Toggle arrow */
 | 
				
			||||||
 | 
					.tree-node-toggle {
 | 
				
			||||||
 | 
					    display: inline-flex;
 | 
				
			||||||
 | 
					    align-items: center;
 | 
				
			||||||
 | 
					    justify-content: center;
 | 
				
			||||||
 | 
					    width: 16px;
 | 
				
			||||||
 | 
					    height: 16px;
 | 
				
			||||||
 | 
					    min-width: 16px;
 | 
				
			||||||
 | 
					    min-height: 16px;
 | 
				
			||||||
 | 
					    color: var(--text-secondary);
 | 
				
			||||||
 | 
					    flex-shrink: 0;
 | 
				
			||||||
 | 
					    transition: transform 0.2s ease;
 | 
				
			||||||
 | 
					    position: relative;
 | 
				
			||||||
 | 
					    z-index: 1;
 | 
				
			||||||
 | 
					    overflow: visible;
 | 
				
			||||||
 | 
					    cursor: pointer;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.tree-node-toggle.expanded {
 | 
				
			||||||
 | 
					    transform: rotate(90deg);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.tree-node-toggle:hover {
 | 
				
			||||||
 | 
					    color: var(--link-color);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Icon styling */
 | 
				
			||||||
.tree-node-icon {
 | 
					.tree-node-icon {
 | 
				
			||||||
    width: 16px;
 | 
					    width: 16px;
 | 
				
			||||||
    text-align: center;
 | 
					    height: 16px;
 | 
				
			||||||
 | 
					    display: flex;
 | 
				
			||||||
 | 
					    align-items: center;
 | 
				
			||||||
 | 
					    justify-content: center;
 | 
				
			||||||
    flex-shrink: 0;
 | 
					    flex-shrink: 0;
 | 
				
			||||||
 | 
					    color: var(--text-secondary);
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Content wrapper */
 | 
				
			||||||
.tree-node-content {
 | 
					.tree-node-content {
 | 
				
			||||||
    display: flex;
 | 
					    display: flex;
 | 
				
			||||||
    align-items: center;
 | 
					    align-items: center;
 | 
				
			||||||
@@ -48,41 +93,161 @@ body.dark-mode .tree-node.active {
 | 
				
			|||||||
    overflow: hidden;
 | 
					    overflow: hidden;
 | 
				
			||||||
    text-overflow: ellipsis;
 | 
					    text-overflow: ellipsis;
 | 
				
			||||||
    white-space: nowrap;
 | 
					    white-space: nowrap;
 | 
				
			||||||
 | 
					    font-size: 13px;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.tree-node-size {
 | 
					.file-size-badge {
 | 
				
			||||||
    font-size: 11px;
 | 
					    font-size: 10px;
 | 
				
			||||||
    color: var(--text-secondary);
 | 
					    color: var(--text-secondary);
 | 
				
			||||||
 | 
					    margin-left: auto;
 | 
				
			||||||
    flex-shrink: 0;
 | 
					    flex-shrink: 0;
 | 
				
			||||||
 | 
					    padding: 2px 6px;
 | 
				
			||||||
 | 
					    background-color: var(--bg-tertiary);
 | 
				
			||||||
 | 
					    border-radius: 3px;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Children container */
 | 
				
			||||||
.tree-children {
 | 
					.tree-children {
 | 
				
			||||||
    margin-left: 16px;
 | 
					    margin-left: 8px;
 | 
				
			||||||
 | 
					    border-left: 1px solid var(--border-light);
 | 
				
			||||||
 | 
					    padding-left: 4px;
 | 
				
			||||||
 | 
					    max-height: 100%;
 | 
				
			||||||
 | 
					    overflow: visible;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.tree-children.collapsed {
 | 
				
			||||||
 | 
					    display: none;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Drag and drop */
 | 
				
			||||||
 | 
					/* Default cursor is pointer, not grab (only show grab after long-press) */
 | 
				
			||||||
 | 
					.tree-node {
 | 
				
			||||||
 | 
					    cursor: pointer;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Show grab cursor only when drag is ready (after long-press) */
 | 
				
			||||||
 | 
					.tree-node.drag-ready {
 | 
				
			||||||
 | 
					    cursor: grab !important;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.tree-node.drag-ready:active {
 | 
				
			||||||
 | 
					    cursor: grabbing !important;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.tree-node.dragging {
 | 
					.tree-node.dragging {
 | 
				
			||||||
    opacity: 0.5;
 | 
					    opacity: 0.4;
 | 
				
			||||||
 | 
					    background-color: var(--bg-tertiary);
 | 
				
			||||||
 | 
					    cursor: grabbing !important;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.tree-node.drag-over {
 | 
					.tree-node.drag-over {
 | 
				
			||||||
    background-color: var(--info-color);
 | 
					    background-color: rgba(13, 110, 253, 0.15) !important;
 | 
				
			||||||
    color: white;
 | 
					    border: 2px dashed var(--link-color) !important;
 | 
				
			||||||
 | 
					    box-shadow: 0 0 8px rgba(13, 110, 253, 0.3);
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/* Collection selector */
 | 
					/* Root-level drop target highlighting */
 | 
				
			||||||
 | 
					.file-tree.drag-over-root {
 | 
				
			||||||
 | 
					    background-color: rgba(13, 110, 253, 0.08);
 | 
				
			||||||
 | 
					    border: 2px dashed var(--link-color);
 | 
				
			||||||
 | 
					    border-radius: 6px;
 | 
				
			||||||
 | 
					    box-shadow: inset 0 0 12px rgba(13, 110, 253, 0.2);
 | 
				
			||||||
 | 
					    margin: 4px;
 | 
				
			||||||
 | 
					    padding: 4px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Only show drag cursor on directories when dragging */
 | 
				
			||||||
 | 
					body.dragging-active .tree-node[data-isdir="true"] {
 | 
				
			||||||
 | 
					    cursor: copy;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					body.dragging-active .tree-node[data-isdir="false"] {
 | 
				
			||||||
 | 
					    cursor: no-drop;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Show move cursor when hovering over root-level empty space */
 | 
				
			||||||
 | 
					body.dragging-active .file-tree.drag-over-root {
 | 
				
			||||||
 | 
					    cursor: move;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Collection selector - Bootstrap styled */
 | 
				
			||||||
.collection-selector {
 | 
					.collection-selector {
 | 
				
			||||||
    margin-bottom: 10px;
 | 
					    margin: 12px 8px;
 | 
				
			||||||
    padding: 8px;
 | 
					    padding: 8px 12px;
 | 
				
			||||||
    background-color: var(--bg-tertiary);
 | 
					    background-color: var(--bg-tertiary);
 | 
				
			||||||
    border-radius: 4px;
 | 
					    border-radius: 6px;
 | 
				
			||||||
 | 
					    border: 1px solid var(--border-color);
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.collection-selector select {
 | 
					.collection-selector .form-label {
 | 
				
			||||||
    width: 100%;
 | 
					    margin-bottom: 6px;
 | 
				
			||||||
    padding: 6px;
 | 
					    font-weight: 500;
 | 
				
			||||||
 | 
					    font-size: 12px;
 | 
				
			||||||
 | 
					    color: var(--text-secondary);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.collection-selector .form-select-sm {
 | 
				
			||||||
 | 
					    padding: 4px 8px;
 | 
				
			||||||
 | 
					    font-size: 13px;
 | 
				
			||||||
    background-color: var(--bg-primary);
 | 
					    background-color: var(--bg-primary);
 | 
				
			||||||
    color: var(--text-primary);
 | 
					    color: var(--text-primary);
 | 
				
			||||||
    border: 1px solid var(--border-color);
 | 
					    border: 1px solid var(--border-color);
 | 
				
			||||||
    border-radius: 4px;
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.collection-selector .form-select-sm:focus {
 | 
				
			||||||
 | 
					    border-color: var(--link-color);
 | 
				
			||||||
 | 
					    box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Dark mode adjustments */
 | 
				
			||||||
 | 
					body.dark-mode .tree-node:hover {
 | 
				
			||||||
 | 
					    background-color: var(--bg-tertiary);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					body.dark-mode .tree-node.active {
 | 
				
			||||||
 | 
					    color: var(--link-color);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					body.dark-mode .tree-node.active .tree-node-icon {
 | 
				
			||||||
 | 
					    color: var(--link-color);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					body.dark-mode .tree-node.active .tree-node-icon .tree-node-toggle {
 | 
				
			||||||
 | 
					    color: var(--link-color);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					body.dark-mode .tree-children {
 | 
				
			||||||
 | 
					    border-left-color: var(--border-color);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Empty directory message */
 | 
				
			||||||
 | 
					.tree-empty-message {
 | 
				
			||||||
 | 
					    padding: 8px 12px;
 | 
				
			||||||
 | 
					    color: var(--text-secondary);
 | 
				
			||||||
 | 
					    font-size: 12px;
 | 
				
			||||||
 | 
					    font-style: italic;
 | 
				
			||||||
 | 
					    user-select: none;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					body.dark-mode .tree-empty-message {
 | 
				
			||||||
 | 
					    color: var(--text-secondary);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Scrollbar in sidebar */
 | 
				
			||||||
 | 
					.sidebar::-webkit-scrollbar-thumb {
 | 
				
			||||||
 | 
					    background-color: var(--border-color);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.sidebar::-webkit-scrollbar-thumb:hover {
 | 
				
			||||||
 | 
					    background-color: var(--text-secondary);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.new-collection-btn {
 | 
				
			||||||
 | 
					    padding: 0.375rem 0.75rem;
 | 
				
			||||||
 | 
					    font-size: 1rem;
 | 
				
			||||||
 | 
					    border-radius: 0.25rem;
 | 
				
			||||||
 | 
					    transition: all 0.2s ease;
 | 
				
			||||||
 | 
					    color: var(--text-primary);
 | 
				
			||||||
 | 
					    border: 1px solid var(--border-color);
 | 
				
			||||||
 | 
					    background-color: transparent;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -1,39 +1,54 @@
 | 
				
			|||||||
/* Base layout styles */
 | 
					/* Base layout styles */
 | 
				
			||||||
html, body {
 | 
					html,
 | 
				
			||||||
    height: 100%;
 | 
					body {
 | 
				
			||||||
 | 
					    height: 100vh;
 | 
				
			||||||
    margin: 0;
 | 
					    margin: 0;
 | 
				
			||||||
    padding: 0;
 | 
					    padding: 0;
 | 
				
			||||||
 | 
					    overflow: hidden;
 | 
				
			||||||
 | 
					    /* Prevent page-level scrolling */
 | 
				
			||||||
    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif;
 | 
					    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif;
 | 
				
			||||||
    background-color: var(--bg-primary);
 | 
					    background-color: var(--bg-primary);
 | 
				
			||||||
    color: var(--text-primary);
 | 
					    color: var(--text-primary);
 | 
				
			||||||
    transition: background-color 0.3s ease, color 0.3s ease;
 | 
					    transition: background-color 0.3s ease, color 0.3s ease;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.container-fluid {
 | 
					body {
 | 
				
			||||||
    height: calc(100% - 56px);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.sidebar {
 | 
					 | 
				
			||||||
    background-color: var(--bg-secondary);
 | 
					 | 
				
			||||||
    border-right: 1px solid var(--border-color);
 | 
					 | 
				
			||||||
    overflow-y: auto;
 | 
					 | 
				
			||||||
    height: 100%;
 | 
					 | 
				
			||||||
    transition: background-color 0.3s ease;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.editor-pane {
 | 
					 | 
				
			||||||
    background-color: var(--bg-primary);
 | 
					 | 
				
			||||||
    height: 100%;
 | 
					 | 
				
			||||||
    display: flex;
 | 
					    display: flex;
 | 
				
			||||||
    flex-direction: column;
 | 
					    flex-direction: column;
 | 
				
			||||||
    border-right: 1px solid var(--border-color);
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.preview-pane {
 | 
					/* Column Resizer */
 | 
				
			||||||
    background-color: var(--bg-primary);
 | 
					.column-resizer {
 | 
				
			||||||
 | 
					    width: 1px;
 | 
				
			||||||
 | 
					    background-color: var(--border-color);
 | 
				
			||||||
 | 
					    cursor: col-resize;
 | 
				
			||||||
 | 
					    transition: background-color 0.2s ease, width 0.2s ease, box-shadow 0.2s ease;
 | 
				
			||||||
 | 
					    user-select: none;
 | 
				
			||||||
 | 
					    flex-shrink: 0;
 | 
				
			||||||
 | 
					    padding: 0 3px;
 | 
				
			||||||
 | 
					    /* Add invisible padding for easier grab */
 | 
				
			||||||
 | 
					    margin: 0 -3px;
 | 
				
			||||||
 | 
					    /* Compensate for padding */
 | 
				
			||||||
    height: 100%;
 | 
					    height: 100%;
 | 
				
			||||||
    overflow-y: auto;
 | 
					    /* Take full height of parent */
 | 
				
			||||||
    padding: 20px;
 | 
					    align-self: stretch;
 | 
				
			||||||
 | 
					    /* Ensure it stretches to full height */
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.column-resizer:hover {
 | 
				
			||||||
 | 
					    background-color: var(--link-color);
 | 
				
			||||||
 | 
					    width: 1px;
 | 
				
			||||||
 | 
					    box-shadow: 0 0 6px rgba(13, 110, 253, 0.3);
 | 
				
			||||||
 | 
					    /* Visual feedback instead of width change */
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.column-resizer.dragging {
 | 
				
			||||||
 | 
					    background-color: var(--link-color);
 | 
				
			||||||
 | 
					    box-shadow: 0 0 8px rgba(13, 110, 253, 0.5);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.column-resizer.dragging {
 | 
				
			||||||
 | 
					    background-color: var(--link-color);
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/* Navbar */
 | 
					/* Navbar */
 | 
				
			||||||
@@ -41,11 +56,185 @@ html, body {
 | 
				
			|||||||
    background-color: var(--bg-secondary);
 | 
					    background-color: var(--bg-secondary);
 | 
				
			||||||
    border-bottom: 1px solid var(--border-color);
 | 
					    border-bottom: 1px solid var(--border-color);
 | 
				
			||||||
    transition: background-color 0.3s ease;
 | 
					    transition: background-color 0.3s ease;
 | 
				
			||||||
 | 
					    flex-shrink: 0;
 | 
				
			||||||
 | 
					    /* Prevent navbar from shrinking */
 | 
				
			||||||
 | 
					    padding: 0.5rem 1rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.navbar .container-fluid {
 | 
				
			||||||
 | 
					    display: flex;
 | 
				
			||||||
 | 
					    flex-direction: row;
 | 
				
			||||||
 | 
					    align-items: center;
 | 
				
			||||||
 | 
					    justify-content: space-between;
 | 
				
			||||||
 | 
					    padding: 0;
 | 
				
			||||||
 | 
					    overflow: visible;
 | 
				
			||||||
 | 
					    /* Override the hidden overflow for navbar */
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.navbar-brand {
 | 
					.navbar-brand {
 | 
				
			||||||
    color: var(--text-primary) !important;
 | 
					    color: var(--text-primary) !important;
 | 
				
			||||||
    font-weight: 600;
 | 
					    font-weight: 600;
 | 
				
			||||||
 | 
					    font-size: 1.1rem;
 | 
				
			||||||
 | 
					    margin: 0;
 | 
				
			||||||
 | 
					    flex-shrink: 0;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.navbar-brand i {
 | 
				
			||||||
 | 
					    font-size: 1.2rem;
 | 
				
			||||||
 | 
					    margin-right: 0.5rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.navbar-center {
 | 
				
			||||||
 | 
					    flex: 1;
 | 
				
			||||||
 | 
					    display: flex;
 | 
				
			||||||
 | 
					    justify-content: center;
 | 
				
			||||||
 | 
					    align-items: center;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.navbar-right {
 | 
				
			||||||
 | 
					    flex-shrink: 0;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Adjust container for flex layout */
 | 
				
			||||||
 | 
					.container-fluid {
 | 
				
			||||||
 | 
					    flex: 1;
 | 
				
			||||||
 | 
					    /* Take remaining space after navbar */
 | 
				
			||||||
 | 
					    padding: 0;
 | 
				
			||||||
 | 
					    overflow: hidden;
 | 
				
			||||||
 | 
					    /* Prevent container scrolling */
 | 
				
			||||||
 | 
					    display: flex;
 | 
				
			||||||
 | 
					    flex-direction: column;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.row {
 | 
				
			||||||
 | 
					    width: 100%;
 | 
				
			||||||
 | 
					    display: flex;
 | 
				
			||||||
 | 
					    flex-direction: row;
 | 
				
			||||||
 | 
					    margin: 0;
 | 
				
			||||||
 | 
					    height: 100%;
 | 
				
			||||||
 | 
					    overflow: hidden;
 | 
				
			||||||
 | 
					    /* Prevent row scrolling */
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#sidebarPane {
 | 
				
			||||||
 | 
					    flex: 0 0 20%;
 | 
				
			||||||
 | 
					    min-width: 150px;
 | 
				
			||||||
 | 
					    max-width: 20%;
 | 
				
			||||||
 | 
					    padding: 0;
 | 
				
			||||||
 | 
					    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 {
 | 
				
			||||||
 | 
					    flex: 1 1 40%;
 | 
				
			||||||
 | 
					    min-width: 250px;
 | 
				
			||||||
 | 
					    max-width: 70%;
 | 
				
			||||||
 | 
					    padding: 0;
 | 
				
			||||||
 | 
					    display: flex;
 | 
				
			||||||
 | 
					    flex-direction: column;
 | 
				
			||||||
 | 
					    height: 100%;
 | 
				
			||||||
 | 
					    overflow: hidden;
 | 
				
			||||||
 | 
					    /* Prevent pane scrolling */
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Sidebar - improved */
 | 
				
			||||||
 | 
					.sidebar {
 | 
				
			||||||
 | 
					    background-color: var(--bg-secondary);
 | 
				
			||||||
 | 
					    border-right: 1px solid var(--border-color);
 | 
				
			||||||
 | 
					    height: 100%;
 | 
				
			||||||
 | 
					    transition: background-color 0.3s ease;
 | 
				
			||||||
 | 
					    display: flex;
 | 
				
			||||||
 | 
					    flex-direction: column;
 | 
				
			||||||
 | 
					    overflow: hidden;
 | 
				
			||||||
 | 
					    /* Prevent sidebar container scrolling */
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.sidebar h6 {
 | 
				
			||||||
 | 
					    margin: 12px 8px 6px;
 | 
				
			||||||
 | 
					    font-size: 11px;
 | 
				
			||||||
 | 
					    font-weight: 600;
 | 
				
			||||||
 | 
					    color: var(--text-secondary);
 | 
				
			||||||
 | 
					    text-transform: uppercase;
 | 
				
			||||||
 | 
					    letter-spacing: 0.5px;
 | 
				
			||||||
 | 
					    flex-shrink: 0;
 | 
				
			||||||
 | 
					    /* Prevent header from shrinking */
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Collection selector - fixed height */
 | 
				
			||||||
 | 
					.collection-selector {
 | 
				
			||||||
 | 
					    flex-shrink: 0;
 | 
				
			||||||
 | 
					    /* Prevent selector from shrinking */
 | 
				
			||||||
 | 
					    padding: 12px 10px;
 | 
				
			||||||
 | 
					    background-color: var(--bg-secondary);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#fileTree {
 | 
				
			||||||
 | 
					    flex: 1;
 | 
				
			||||||
 | 
					    /* Take remaining space */
 | 
				
			||||||
 | 
					    overflow-y: auto;
 | 
				
			||||||
 | 
					    /* Enable vertical scrolling */
 | 
				
			||||||
 | 
					    overflow-x: hidden;
 | 
				
			||||||
 | 
					    padding: 4px 10px;
 | 
				
			||||||
 | 
					    min-height: 0;
 | 
				
			||||||
 | 
					    /* Important: allows flex child to shrink below content size */
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/* Scrollbar styling */
 | 
					/* Scrollbar styling */
 | 
				
			||||||
@@ -67,3 +256,89 @@ html, body {
 | 
				
			|||||||
    background: var(--text-secondary);
 | 
					    background: var(--text-secondary);
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Preview Pane Styling */
 | 
				
			||||||
 | 
					#previewPane {
 | 
				
			||||||
 | 
					    min-width: 250px;
 | 
				
			||||||
 | 
					    max-width: 70%;
 | 
				
			||||||
 | 
					    padding: 0;
 | 
				
			||||||
 | 
					    background-color: var(--bg-primary);
 | 
				
			||||||
 | 
					    border-left: 1px solid var(--border-color);
 | 
				
			||||||
 | 
					    flex: 1;
 | 
				
			||||||
 | 
					    height: 100%;
 | 
				
			||||||
 | 
					    overflow-y: auto;
 | 
				
			||||||
 | 
					    /* Enable vertical scrolling for preview pane */
 | 
				
			||||||
 | 
					    overflow-x: hidden;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#preview {
 | 
				
			||||||
 | 
					    padding: 20px;
 | 
				
			||||||
 | 
					    overflow-wrap: break-word;
 | 
				
			||||||
 | 
					    word-wrap: break-word;
 | 
				
			||||||
 | 
					    color: var(--text-primary);
 | 
				
			||||||
 | 
					    min-height: 100%;
 | 
				
			||||||
 | 
					    /* Ensure content fills at least the full height */
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#preview>p:first-child {
 | 
				
			||||||
 | 
					    margin-top: 0;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#preview>h1:first-child,
 | 
				
			||||||
 | 
					#preview>h2:first-child {
 | 
				
			||||||
 | 
					    margin-top: 0;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Iframe styles in preview - minimal defaults that can be overridden */
 | 
				
			||||||
 | 
					#preview iframe {
 | 
				
			||||||
 | 
					    border: none;
 | 
				
			||||||
 | 
					    /* Default to no border, can be overridden by inline styles */
 | 
				
			||||||
 | 
					    display: block;
 | 
				
			||||||
 | 
					    /* Prevent inline spacing issues */
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* View Mode Styles */
 | 
				
			||||||
 | 
					body.view-mode #editorPane {
 | 
				
			||||||
 | 
					    display: none;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					body.view-mode #resizer1 {
 | 
				
			||||||
 | 
					    display: none;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					body.view-mode #resizer2 {
 | 
				
			||||||
 | 
					    display: none;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					body.view-mode #previewPane {
 | 
				
			||||||
 | 
					    max-width: 100%;
 | 
				
			||||||
 | 
					    min-width: auto;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					body.view-mode #sidebarPane {
 | 
				
			||||||
 | 
					    display: flex;
 | 
				
			||||||
 | 
					    flex: 0 0 20%;
 | 
				
			||||||
 | 
					    height: 100%;
 | 
				
			||||||
 | 
					    /* Keep sidebar at 20% width in view mode */
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					body.edit-mode #editorPane {
 | 
				
			||||||
 | 
					    display: flex;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					body.edit-mode #resizer1 {
 | 
				
			||||||
 | 
					    display: block;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					body.edit-mode #resizer2 {
 | 
				
			||||||
 | 
					    display: block;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					body.edit-mode #previewPane {
 | 
				
			||||||
 | 
					    max-width: 70%;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					body.edit-mode #sidebarPane {
 | 
				
			||||||
 | 
					    display: flex;
 | 
				
			||||||
 | 
					    height: 100%;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										3
									
								
								static/css/modal.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,3 @@
 | 
				
			|||||||
 | 
					.modal-header .btn-close {
 | 
				
			||||||
 | 
					    filter: var(--bs-btn-close-white-filter);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										779
									
								
								static/js/app.js
									
									
									
									
									
								
							
							
						
						@@ -12,8 +12,262 @@ let collectionSelector;
 | 
				
			|||||||
let clipboard = null;
 | 
					let clipboard = null;
 | 
				
			||||||
let currentFilePath = null;
 | 
					let currentFilePath = null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// 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();
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // If we found a page to load, load it
 | 
				
			||||||
 | 
					        if (pageToLoad) {
 | 
				
			||||||
 | 
					            // Use fileTree.onFileSelect to handle both text and binary files
 | 
				
			||||||
 | 
					            if (fileTree.onFileSelect) {
 | 
				
			||||||
 | 
					                fileTree.onFileSelect({ path: pageToLoad, isDirectory: false });
 | 
				
			||||||
 | 
					            } else {
 | 
				
			||||||
 | 
					                // Fallback to direct loading (for text files only)
 | 
				
			||||||
 | 
					                await editor.loadFile(pageToLoad);
 | 
				
			||||||
 | 
					                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>
 | 
				
			||||||
 | 
					        `;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * 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, check if it's binary
 | 
				
			||||||
 | 
					                console.log('[loadFileFromURL] Loading file');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                // Use the fileTree.onFileSelect callback to handle both text and binary files
 | 
				
			||||||
 | 
					                if (fileTree.onFileSelect) {
 | 
				
			||||||
 | 
					                    fileTree.onFileSelect({ path: filePath, isDirectory: false });
 | 
				
			||||||
 | 
					                } else {
 | 
				
			||||||
 | 
					                    // Fallback to direct loading
 | 
				
			||||||
 | 
					                    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
 | 
					// Initialize application
 | 
				
			||||||
document.addEventListener('DOMContentLoaded', async () => {
 | 
					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
 | 
					    // Initialize WebDAV client
 | 
				
			||||||
    webdavClient = new WebDAVClient('/fs/');
 | 
					    webdavClient = new WebDAVClient('/fs/');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -23,114 +277,377 @@ document.addEventListener('DOMContentLoaded', async () => {
 | 
				
			|||||||
        darkMode.toggle();
 | 
					        darkMode.toggle();
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // Initialize collection selector
 | 
					    // Initialize sidebar toggle
 | 
				
			||||||
 | 
					    const sidebarToggle = new SidebarToggle('sidebarPane', 'sidebarToggleBtn');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Initialize collection selector (always needed)
 | 
				
			||||||
    collectionSelector = new CollectionSelector('collectionSelect', webdavClient);
 | 
					    collectionSelector = new CollectionSelector('collectionSelect', webdavClient);
 | 
				
			||||||
    collectionSelector.onChange = async (collection) => {
 | 
					 | 
				
			||||||
        await fileTree.load();
 | 
					 | 
				
			||||||
    };
 | 
					 | 
				
			||||||
    await collectionSelector.load();
 | 
					    await collectionSelector.load();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // Initialize file tree
 | 
					    // Setup New Collection button
 | 
				
			||||||
    fileTree = new FileTree('fileTree', webdavClient);
 | 
					    document.getElementById('newCollectionBtn').addEventListener('click', async () => {
 | 
				
			||||||
 | 
					        try {
 | 
				
			||||||
 | 
					            const collectionName = await window.ModalManager.prompt(
 | 
				
			||||||
 | 
					                'Enter new collection name (lowercase, underscore only):',
 | 
				
			||||||
 | 
					                'new_collection'
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            if (!collectionName) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Validate collection name
 | 
				
			||||||
 | 
					            const validation = ValidationUtils.validateFileName(collectionName, true);
 | 
				
			||||||
 | 
					            if (!validation.valid) {
 | 
				
			||||||
 | 
					                window.showNotification(validation.message, 'warning');
 | 
				
			||||||
 | 
					                return;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Create the collection
 | 
				
			||||||
 | 
					            await webdavClient.createCollection(validation.sanitized);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Reload collections and switch to the new one
 | 
				
			||||||
 | 
					            await collectionSelector.load();
 | 
				
			||||||
 | 
					            await collectionSelector.setCollection(validation.sanitized);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            window.showNotification(`Collection "${validation.sanitized}" created`, 'success');
 | 
				
			||||||
 | 
					        } catch (error) {
 | 
				
			||||||
 | 
					            Logger.error('Failed to create collection:', error);
 | 
				
			||||||
 | 
					            window.showNotification('Failed to create collection', 'error');
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // 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);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Initialize file tree (needed in both modes)
 | 
				
			||||||
 | 
					    // Pass isEditMode to control image filtering (hide images only in view mode)
 | 
				
			||||||
 | 
					    fileTree = new FileTree('fileTree', webdavClient, isEditMode);
 | 
				
			||||||
    fileTree.onFileSelect = async (item) => {
 | 
					    fileTree.onFileSelect = async (item) => {
 | 
				
			||||||
        await loadFile(item.path);
 | 
					        try {
 | 
				
			||||||
 | 
					            const currentCollection = collectionSelector.getCurrentCollection();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Check if the file is a binary/non-editable file
 | 
				
			||||||
 | 
					            if (PathUtils.isBinaryFile(item.path)) {
 | 
				
			||||||
 | 
					                const fileType = PathUtils.getFileType(item.path);
 | 
				
			||||||
 | 
					                const fileName = PathUtils.getFileName(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
 | 
				
			||||||
 | 
					                editor.isShowingCustomPreview = true;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                // In edit mode, show a warning notification
 | 
				
			||||||
 | 
					                if (isEditMode) {
 | 
				
			||||||
 | 
					                    if (window.showNotification) {
 | 
				
			||||||
 | 
					                        window.showNotification(
 | 
				
			||||||
 | 
					                            `"${fileName}" is read-only. Showing preview only.`,
 | 
				
			||||||
 | 
					                            'warning'
 | 
				
			||||||
 | 
					                        );
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    // Hide the editor pane temporarily
 | 
				
			||||||
 | 
					                    const editorPane = document.getElementById('editorPane');
 | 
				
			||||||
 | 
					                    const resizer1 = document.getElementById('resizer1');
 | 
				
			||||||
 | 
					                    if (editorPane) editorPane.style.display = 'none';
 | 
				
			||||||
 | 
					                    if (resizer1) resizer1.style.display = 'none';
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                // Clear the editor (but don't trigger preview update due to flag)
 | 
				
			||||||
 | 
					                if (editor.editor) {
 | 
				
			||||||
 | 
					                    editor.editor.setValue('');
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					                editor.filenameInput.value = item.path;
 | 
				
			||||||
 | 
					                editor.currentFile = item.path;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                // Build the file URL using the WebDAV client's method
 | 
				
			||||||
 | 
					                const fileUrl = webdavClient.getFullUrl(item.path);
 | 
				
			||||||
 | 
					                Logger.debug(`Binary file URL: ${fileUrl}`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                // Generate preview HTML based on file type
 | 
				
			||||||
 | 
					                let previewHtml = '';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                if (fileType === 'Image') {
 | 
				
			||||||
 | 
					                    // Preview images
 | 
				
			||||||
 | 
					                    previewHtml = `
 | 
				
			||||||
 | 
					                        <div style="padding: 20px; text-align: center;">
 | 
				
			||||||
 | 
					                            <h3>${fileName}</h3>
 | 
				
			||||||
 | 
					                            <p style="color: var(--text-secondary); margin-bottom: 20px;">Image Preview (Read-only)</p>
 | 
				
			||||||
 | 
					                            <img src="${fileUrl}" alt="${fileName}" style="max-width: 100%; height: auto; border: 1px solid var(--border-color); border-radius: 4px;">
 | 
				
			||||||
 | 
					                        </div>
 | 
				
			||||||
 | 
					                    `;
 | 
				
			||||||
 | 
					                } else if (fileType === 'PDF') {
 | 
				
			||||||
 | 
					                    // Preview PDFs
 | 
				
			||||||
 | 
					                    previewHtml = `
 | 
				
			||||||
 | 
					                        <div style="padding: 20px;">
 | 
				
			||||||
 | 
					                            <h3>${fileName}</h3>
 | 
				
			||||||
 | 
					                            <p style="color: var(--text-secondary); margin-bottom: 20px;">PDF Preview (Read-only)</p>
 | 
				
			||||||
 | 
					                            <iframe src="${fileUrl}" style="width: 100%; height: 80vh; border: 1px solid var(--border-color); border-radius: 4px;"></iframe>
 | 
				
			||||||
 | 
					                        </div>
 | 
				
			||||||
 | 
					                    `;
 | 
				
			||||||
 | 
					                } else {
 | 
				
			||||||
 | 
					                    // For other binary files, show download link
 | 
				
			||||||
 | 
					                    previewHtml = `
 | 
				
			||||||
 | 
					                        <div style="padding: 20px;">
 | 
				
			||||||
 | 
					                            <h3>${fileName}</h3>
 | 
				
			||||||
 | 
					                            <p style="color: var(--text-secondary); margin-bottom: 20px;">${fileType} File (Read-only)</p>
 | 
				
			||||||
 | 
					                            <p>This file cannot be previewed in the browser.</p>
 | 
				
			||||||
 | 
					                            <a href="${fileUrl}" download="${fileName}" class="btn btn-primary">Download ${fileName}</a>
 | 
				
			||||||
 | 
					                        </div>
 | 
				
			||||||
 | 
					                    `;
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                // 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);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                // Save as last viewed page (for binary files too)
 | 
				
			||||||
 | 
					                editor.saveLastViewedPage(item.path);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                // Update URL to reflect current file
 | 
				
			||||||
 | 
					                updateURL(currentCollection, item.path, isEditMode);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                return;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // For text files, restore the editor pane if it was hidden
 | 
				
			||||||
 | 
					            if (isEditMode) {
 | 
				
			||||||
 | 
					                const editorPane = document.getElementById('editorPane');
 | 
				
			||||||
 | 
					                const resizer1 = document.getElementById('resizer1');
 | 
				
			||||||
 | 
					                if (editorPane) editorPane.style.display = '';
 | 
				
			||||||
 | 
					                if (resizer1) resizer1.style.display = '';
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            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
 | 
				
			||||||
 | 
					            updateURL(currentCollection, item.path, isEditMode);
 | 
				
			||||||
 | 
					        } catch (error) {
 | 
				
			||||||
 | 
					            Logger.error('Failed to select file:', error);
 | 
				
			||||||
 | 
					            if (window.showNotification) {
 | 
				
			||||||
 | 
					                window.showNotification('Failed to load file', 'error');
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    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();
 | 
					    await fileTree.load();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // Initialize editor
 | 
					    // Parse URL to load file if specified
 | 
				
			||||||
    editor = new MarkdownEditor('editor', 'preview');
 | 
					    const { collection: urlCollection, filePath: urlFilePath } = parseURLPath();
 | 
				
			||||||
 | 
					    console.log('[URL PARSE]', { urlCollection, urlFilePath });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (urlCollection) {
 | 
				
			||||||
 | 
					        // 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();
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // If there's a file path in the URL, load it
 | 
				
			||||||
 | 
					        if (urlFilePath) {
 | 
				
			||||||
 | 
					            console.log('[URL LOAD] Loading file from URL:', urlCollection, urlFilePath);
 | 
				
			||||||
 | 
					            await loadFileFromURL(urlCollection, urlFilePath);
 | 
				
			||||||
 | 
					        } else if (!isEditMode) {
 | 
				
			||||||
 | 
					            // Collection-only URL in view mode: auto-load last viewed page
 | 
				
			||||||
 | 
					            console.log('[URL LOAD] Collection-only URL, auto-loading page');
 | 
				
			||||||
 | 
					            await autoLoadPageInViewMode();
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    } else if (!isEditMode) {
 | 
				
			||||||
 | 
					        // No URL collection specified, in view mode: auto-load last viewed page
 | 
				
			||||||
 | 
					        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
 | 
					        // Setup editor drop handler
 | 
				
			||||||
        const editorDropHandler = new EditorDropHandler(
 | 
					        const editorDropHandler = new EditorDropHandler(
 | 
				
			||||||
            document.querySelector('.editor-container'),
 | 
					            document.querySelector('.editor-container'),
 | 
				
			||||||
            async (file) => {
 | 
					            async (file) => {
 | 
				
			||||||
 | 
					                try {
 | 
				
			||||||
                    await handleEditorFileDrop(file);
 | 
					                    await handleEditorFileDrop(file);
 | 
				
			||||||
 | 
					                } catch (error) {
 | 
				
			||||||
 | 
					                    Logger.error('Failed to handle file drop:', error);
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
        );
 | 
					        );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        // Setup button handlers
 | 
					        // Setup button handlers
 | 
				
			||||||
        document.getElementById('newBtn').addEventListener('click', () => {
 | 
					        document.getElementById('newBtn').addEventListener('click', () => {
 | 
				
			||||||
        newFile();
 | 
					            editor.newFile();
 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        document.getElementById('saveBtn').addEventListener('click', async () => {
 | 
					        document.getElementById('saveBtn').addEventListener('click', async () => {
 | 
				
			||||||
        await saveFile();
 | 
					            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 () => {
 | 
					        document.getElementById('deleteBtn').addEventListener('click', async () => {
 | 
				
			||||||
        await deleteCurrentFile();
 | 
					            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
 | 
					        // Setup context menu handlers
 | 
				
			||||||
        setupContextMenuHandlers();
 | 
					        setupContextMenuHandlers();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // Initialize mermaid
 | 
					        // Initialize file tree actions manager
 | 
				
			||||||
 | 
					        window.fileTreeActions = new FileTreeActions(webdavClient, fileTree, editor);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Setup Exit Edit Mode button
 | 
				
			||||||
 | 
					        document.getElementById('exitEditModeBtn').addEventListener('click', () => {
 | 
				
			||||||
 | 
					            // Switch to view mode by removing edit=true from URL
 | 
				
			||||||
 | 
					            const url = new URL(window.location.href);
 | 
				
			||||||
 | 
					            url.searchParams.delete('edit');
 | 
				
			||||||
 | 
					            window.location.href = url.toString();
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Hide Edit Mode button in edit mode
 | 
				
			||||||
 | 
					        document.getElementById('editModeBtn').style.display = 'none';
 | 
				
			||||||
 | 
					    } 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';
 | 
				
			||||||
 | 
					        document.getElementById('exitEditModeBtn').style.display = 'none';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Show Edit Mode button in view mode
 | 
				
			||||||
 | 
					        document.getElementById('editModeBtn').style.display = 'block';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Setup Edit Mode button
 | 
				
			||||||
 | 
					        document.getElementById('editModeBtn').addEventListener('click', () => {
 | 
				
			||||||
 | 
					            // Switch to edit mode by adding edit=true to URL
 | 
				
			||||||
 | 
					            const url = new URL(window.location.href);
 | 
				
			||||||
 | 
					            url.searchParams.set('edit', 'true');
 | 
				
			||||||
 | 
					            window.location.href = url.toString();
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Auto-load last viewed page or first file
 | 
				
			||||||
 | 
					        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' });
 | 
					    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) => {
 | 
				
			||||||
 | 
					        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 () => {
 | 
				
			||||||
 | 
					        try {
 | 
				
			||||||
 | 
					            if (fileTree) {
 | 
				
			||||||
 | 
					                await fileTree.load();
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        } catch (error) {
 | 
				
			||||||
 | 
					            Logger.error('Failed to reload file tree after delete:', error);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Listen for column resize events to refresh editor
 | 
				
			||||||
 | 
					window.addEventListener('column-resize', () => {
 | 
				
			||||||
 | 
					    if (editor && editor.editor) {
 | 
				
			||||||
 | 
					        editor.editor.refresh();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
 * File Operations
 | 
					 * File Operations
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
async function loadFile(path) {
 | 
					 | 
				
			||||||
    try {
 | 
					 | 
				
			||||||
        const content = await webdavClient.get(path);
 | 
					 | 
				
			||||||
        editor.setValue(content);
 | 
					 | 
				
			||||||
        document.getElementById('filenameInput').value = path;
 | 
					 | 
				
			||||||
        currentFilePath = path;
 | 
					 | 
				
			||||||
        showNotification('File loaded', 'success');
 | 
					 | 
				
			||||||
    } catch (error) {
 | 
					 | 
				
			||||||
        console.error('Failed to load file:', error);
 | 
					 | 
				
			||||||
        showNotification('Failed to load file', 'error');
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
function newFile() {
 | 
					 | 
				
			||||||
    editor.setValue('# New File\n\nStart typing...\n');
 | 
					 | 
				
			||||||
    document.getElementById('filenameInput').value = '';
 | 
					 | 
				
			||||||
    document.getElementById('filenameInput').focus();
 | 
					 | 
				
			||||||
    currentFilePath = null;
 | 
					 | 
				
			||||||
    showNotification('New file', 'info');
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
async function saveFile() {
 | 
					 | 
				
			||||||
    const filename = document.getElementById('filenameInput').value.trim();
 | 
					 | 
				
			||||||
    if (!filename) {
 | 
					 | 
				
			||||||
        showNotification('Please enter a filename', 'warning');
 | 
					 | 
				
			||||||
        return;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    try {
 | 
					 | 
				
			||||||
        const content = editor.getValue();
 | 
					 | 
				
			||||||
        await webdavClient.put(filename, content);
 | 
					 | 
				
			||||||
        currentFilePath = filename;
 | 
					 | 
				
			||||||
        await fileTree.load();
 | 
					 | 
				
			||||||
        showNotification('Saved', 'success');
 | 
					 | 
				
			||||||
    } catch (error) {
 | 
					 | 
				
			||||||
        console.error('Failed to save file:', error);
 | 
					 | 
				
			||||||
        showNotification('Failed to save file', 'error');
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
async function deleteCurrentFile() {
 | 
					 | 
				
			||||||
    if (!currentFilePath) {
 | 
					 | 
				
			||||||
        showNotification('No file selected', 'warning');
 | 
					 | 
				
			||||||
        return;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    if (!confirm(`Delete ${currentFilePath}?`)) {
 | 
					 | 
				
			||||||
        return;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    try {
 | 
					 | 
				
			||||||
        await webdavClient.delete(currentFilePath);
 | 
					 | 
				
			||||||
        await fileTree.load();
 | 
					 | 
				
			||||||
        newFile();
 | 
					 | 
				
			||||||
        showNotification('Deleted', 'success');
 | 
					 | 
				
			||||||
    } catch (error) {
 | 
					 | 
				
			||||||
        console.error('Failed to delete file:', error);
 | 
					 | 
				
			||||||
        showNotification('Failed to delete file', 'error');
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
 * Context Menu Handlers
 | 
					 * Context Menu Handlers
 | 
				
			||||||
@@ -148,117 +665,12 @@ function setupContextMenuHandlers() {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        hideContextMenu();
 | 
					        hideContextMenu();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        await handleContextAction(action, targetPath, isDir);
 | 
					        await window.fileTreeActions.execute(action, targetPath, isDir);
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
async function handleContextAction(action, targetPath, isDir) {
 | 
					// All context actions are now handled by FileTreeActions, so this function is no longer needed.
 | 
				
			||||||
    switch (action) {
 | 
					// async function handleContextAction(action, targetPath, isDir) { ... }
 | 
				
			||||||
        case 'open':
 | 
					 | 
				
			||||||
            if (!isDir) {
 | 
					 | 
				
			||||||
                await loadFile(targetPath);
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
            break;
 | 
					 | 
				
			||||||
            
 | 
					 | 
				
			||||||
        case 'new-file':
 | 
					 | 
				
			||||||
            if (isDir) {
 | 
					 | 
				
			||||||
                const filename = prompt('Enter filename:');
 | 
					 | 
				
			||||||
                if (filename) {
 | 
					 | 
				
			||||||
                    await fileTree.createFile(targetPath, filename);
 | 
					 | 
				
			||||||
                }
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
            break;
 | 
					 | 
				
			||||||
            
 | 
					 | 
				
			||||||
        case 'new-folder':
 | 
					 | 
				
			||||||
            if (isDir) {
 | 
					 | 
				
			||||||
                const foldername = prompt('Enter folder name:');
 | 
					 | 
				
			||||||
                if (foldername) {
 | 
					 | 
				
			||||||
                    await fileTree.createFolder(targetPath, foldername);
 | 
					 | 
				
			||||||
                }
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
            break;
 | 
					 | 
				
			||||||
            
 | 
					 | 
				
			||||||
        case 'upload':
 | 
					 | 
				
			||||||
            if (isDir) {
 | 
					 | 
				
			||||||
                showFileUploadDialog(targetPath, async (path, file) => {
 | 
					 | 
				
			||||||
                    await fileTree.uploadFile(path, file);
 | 
					 | 
				
			||||||
                });
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
            break;
 | 
					 | 
				
			||||||
            
 | 
					 | 
				
			||||||
        case 'download':
 | 
					 | 
				
			||||||
            if (isDir) {
 | 
					 | 
				
			||||||
                await fileTree.downloadFolder(targetPath);
 | 
					 | 
				
			||||||
            } else {
 | 
					 | 
				
			||||||
                await fileTree.downloadFile(targetPath);
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
            break;
 | 
					 | 
				
			||||||
            
 | 
					 | 
				
			||||||
        case 'rename':
 | 
					 | 
				
			||||||
            const newName = prompt('Enter new name:', targetPath.split('/').pop());
 | 
					 | 
				
			||||||
            if (newName) {
 | 
					 | 
				
			||||||
                const parentPath = targetPath.split('/').slice(0, -1).join('/');
 | 
					 | 
				
			||||||
                const newPath = parentPath ? `${parentPath}/${newName}` : newName;
 | 
					 | 
				
			||||||
                try {
 | 
					 | 
				
			||||||
                    await webdavClient.move(targetPath, newPath);
 | 
					 | 
				
			||||||
                    await fileTree.load();
 | 
					 | 
				
			||||||
                    showNotification('Renamed', 'success');
 | 
					 | 
				
			||||||
                } catch (error) {
 | 
					 | 
				
			||||||
                    console.error('Failed to rename:', error);
 | 
					 | 
				
			||||||
                    showNotification('Failed to rename', 'error');
 | 
					 | 
				
			||||||
                }
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
            break;
 | 
					 | 
				
			||||||
            
 | 
					 | 
				
			||||||
        case 'copy':
 | 
					 | 
				
			||||||
            clipboard = { path: targetPath, operation: 'copy' };
 | 
					 | 
				
			||||||
            showNotification('Copied to clipboard', 'info');
 | 
					 | 
				
			||||||
            updatePasteVisibility();
 | 
					 | 
				
			||||||
            break;
 | 
					 | 
				
			||||||
            
 | 
					 | 
				
			||||||
        case 'cut':
 | 
					 | 
				
			||||||
            clipboard = { path: targetPath, operation: 'cut' };
 | 
					 | 
				
			||||||
            showNotification('Cut to clipboard', 'info');
 | 
					 | 
				
			||||||
            updatePasteVisibility();
 | 
					 | 
				
			||||||
            break;
 | 
					 | 
				
			||||||
            
 | 
					 | 
				
			||||||
        case 'paste':
 | 
					 | 
				
			||||||
            if (clipboard && isDir) {
 | 
					 | 
				
			||||||
                const filename = clipboard.path.split('/').pop();
 | 
					 | 
				
			||||||
                const destPath = `${targetPath}/${filename}`;
 | 
					 | 
				
			||||||
                
 | 
					 | 
				
			||||||
                try {
 | 
					 | 
				
			||||||
                    if (clipboard.operation === 'copy') {
 | 
					 | 
				
			||||||
                        await webdavClient.copy(clipboard.path, destPath);
 | 
					 | 
				
			||||||
                        showNotification('Copied', 'success');
 | 
					 | 
				
			||||||
                    } else {
 | 
					 | 
				
			||||||
                        await webdavClient.move(clipboard.path, destPath);
 | 
					 | 
				
			||||||
                        showNotification('Moved', 'success');
 | 
					 | 
				
			||||||
                        clipboard = null;
 | 
					 | 
				
			||||||
                        updatePasteVisibility();
 | 
					 | 
				
			||||||
                    }
 | 
					 | 
				
			||||||
                    await fileTree.load();
 | 
					 | 
				
			||||||
                } catch (error) {
 | 
					 | 
				
			||||||
                    console.error('Failed to paste:', error);
 | 
					 | 
				
			||||||
                    showNotification('Failed to paste', 'error');
 | 
					 | 
				
			||||||
                }
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
            break;
 | 
					 | 
				
			||||||
            
 | 
					 | 
				
			||||||
        case 'delete':
 | 
					 | 
				
			||||||
            if (confirm(`Delete ${targetPath}?`)) {
 | 
					 | 
				
			||||||
                try {
 | 
					 | 
				
			||||||
                    await webdavClient.delete(targetPath);
 | 
					 | 
				
			||||||
                    await fileTree.load();
 | 
					 | 
				
			||||||
                    showNotification('Deleted', 'success');
 | 
					 | 
				
			||||||
                } catch (error) {
 | 
					 | 
				
			||||||
                    console.error('Failed to delete:', error);
 | 
					 | 
				
			||||||
                    showNotification('Failed to delete', 'error');
 | 
					 | 
				
			||||||
                }
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
            break;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
function updatePasteVisibility() {
 | 
					function updatePasteVisibility() {
 | 
				
			||||||
    const pasteItem = document.getElementById('pasteMenuItem');
 | 
					    const pasteItem = document.getElementById('pasteMenuItem');
 | 
				
			||||||
@@ -284,10 +696,11 @@ async function handleEditorFileDrop(file) {
 | 
				
			|||||||
        const uploadedPath = await fileTree.uploadFile(targetDir, file);
 | 
					        const uploadedPath = await fileTree.uploadFile(targetDir, file);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        // Insert markdown link at cursor
 | 
					        // Insert markdown link at cursor
 | 
				
			||||||
 | 
					        // Use relative path (without collection name) so the image renderer can resolve it correctly
 | 
				
			||||||
        const isImage = file.type.startsWith('image/');
 | 
					        const isImage = file.type.startsWith('image/');
 | 
				
			||||||
        const link = isImage
 | 
					        const link = isImage
 | 
				
			||||||
            ? ``
 | 
					            ? ``
 | 
				
			||||||
            : `[${file.name}](/${webdavClient.currentCollection}/${uploadedPath})`;
 | 
					            : `[${file.name}](${uploadedPath})`;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        editor.insertAtCursor(link);
 | 
					        editor.insertAtCursor(link);
 | 
				
			||||||
        showNotification(`Uploaded and inserted link`, 'success');
 | 
					        showNotification(`Uploaded and inserted link`, 'success');
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										152
									
								
								static/js/collection-selector.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,152 @@
 | 
				
			|||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Collection Selector Module
 | 
				
			||||||
 | 
					 * Manages the collection dropdown selector and persistence
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class CollectionSelector {
 | 
				
			||||||
 | 
					    constructor(selectId, webdavClient) {
 | 
				
			||||||
 | 
					        this.select = document.getElementById(selectId);
 | 
				
			||||||
 | 
					        this.webdavClient = webdavClient;
 | 
				
			||||||
 | 
					        this.onChange = null;
 | 
				
			||||||
 | 
					        this.storageKey = Config.STORAGE_KEYS.SELECTED_COLLECTION;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Load collections from WebDAV and populate the selector
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    async load() {
 | 
				
			||||||
 | 
					        try {
 | 
				
			||||||
 | 
					            const collections = await this.webdavClient.getCollections();
 | 
				
			||||||
 | 
					            this.select.innerHTML = '';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            collections.forEach(collection => {
 | 
				
			||||||
 | 
					                const option = document.createElement('option');
 | 
				
			||||||
 | 
					                option.value = collection;
 | 
				
			||||||
 | 
					                option.textContent = collection;
 | 
				
			||||||
 | 
					                this.select.appendChild(option);
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Determine which collection to select (priority: URL > localStorage > first)
 | 
				
			||||||
 | 
					            let collectionToSelect = collections[0]; // Default to first
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Check URL first (highest priority)
 | 
				
			||||||
 | 
					            const urlCollection = this.getCollectionFromURL();
 | 
				
			||||||
 | 
					            if (urlCollection && collections.includes(urlCollection)) {
 | 
				
			||||||
 | 
					                collectionToSelect = urlCollection;
 | 
				
			||||||
 | 
					                Logger.info(`Using collection from URL: ${urlCollection}`);
 | 
				
			||||||
 | 
					            } else {
 | 
				
			||||||
 | 
					                // Fall back to localStorage
 | 
				
			||||||
 | 
					                const savedCollection = localStorage.getItem(this.storageKey);
 | 
				
			||||||
 | 
					                if (savedCollection && collections.includes(savedCollection)) {
 | 
				
			||||||
 | 
					                    collectionToSelect = savedCollection;
 | 
				
			||||||
 | 
					                    Logger.info(`Using collection from localStorage: ${savedCollection}`);
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            if (collections.length > 0) {
 | 
				
			||||||
 | 
					                this.select.value = collectionToSelect;
 | 
				
			||||||
 | 
					                this.webdavClient.setCollection(collectionToSelect);
 | 
				
			||||||
 | 
					                if (this.onChange) {
 | 
				
			||||||
 | 
					                    this.onChange(collectionToSelect);
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Add change listener
 | 
				
			||||||
 | 
					            this.select.addEventListener('change', () => {
 | 
				
			||||||
 | 
					                const collection = this.select.value;
 | 
				
			||||||
 | 
					                // Save to localStorage
 | 
				
			||||||
 | 
					                localStorage.setItem(this.storageKey, collection);
 | 
				
			||||||
 | 
					                this.webdavClient.setCollection(collection);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                Logger.info(`Collection changed to: ${collection}`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                // Update URL to reflect collection change
 | 
				
			||||||
 | 
					                this.updateURLForCollection(collection);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                if (this.onChange) {
 | 
				
			||||||
 | 
					                    this.onChange(collection);
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            Logger.debug(`Loaded ${collections.length} collections`);
 | 
				
			||||||
 | 
					        } catch (error) {
 | 
				
			||||||
 | 
					            Logger.error('Failed to load collections:', error);
 | 
				
			||||||
 | 
					            if (window.showNotification) {
 | 
				
			||||||
 | 
					                window.showNotification('Failed to load collections', 'error');
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Get the currently selected collection
 | 
				
			||||||
 | 
					     * @returns {string} The collection name
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    getCurrentCollection() {
 | 
				
			||||||
 | 
					        return this.select.value;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Set the collection to a specific value
 | 
				
			||||||
 | 
					     * @param {string} collection - The collection name to set
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    async setCollection(collection) {
 | 
				
			||||||
 | 
					        const collections = Array.from(this.select.options).map(opt => opt.value);
 | 
				
			||||||
 | 
					        if (collections.includes(collection)) {
 | 
				
			||||||
 | 
					            this.select.value = collection;
 | 
				
			||||||
 | 
					            localStorage.setItem(this.storageKey, collection);
 | 
				
			||||||
 | 
					            this.webdavClient.setCollection(collection);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            Logger.info(`Collection set to: ${collection}`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Update URL to reflect collection change
 | 
				
			||||||
 | 
					            this.updateURLForCollection(collection);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            if (this.onChange) {
 | 
				
			||||||
 | 
					                this.onChange(collection);
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					            Logger.warn(`Collection "${collection}" not found in available collections`);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Update the browser URL to reflect the current collection
 | 
				
			||||||
 | 
					     * @param {string} collection - The collection name
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    updateURLForCollection(collection) {
 | 
				
			||||||
 | 
					        // Get current URL parameters
 | 
				
			||||||
 | 
					        const urlParams = new URLSearchParams(window.location.search);
 | 
				
			||||||
 | 
					        const isEditMode = urlParams.get('edit') === 'true';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Build new URL with collection
 | 
				
			||||||
 | 
					        let url = `/${collection}/`;
 | 
				
			||||||
 | 
					        if (isEditMode) {
 | 
				
			||||||
 | 
					            url += '?edit=true';
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Use pushState to update URL without reloading
 | 
				
			||||||
 | 
					        window.history.pushState({ collection, filePath: null }, '', url);
 | 
				
			||||||
 | 
					        Logger.debug(`Updated URL to: ${url}`);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Extract collection name from current URL
 | 
				
			||||||
 | 
					     * URL format: /<collection>/ or /<collection>/<file_path>
 | 
				
			||||||
 | 
					     * @returns {string|null} The collection name or null if not found
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    getCollectionFromURL() {
 | 
				
			||||||
 | 
					        const pathname = window.location.pathname;
 | 
				
			||||||
 | 
					        const parts = pathname.split('/').filter(p => p); // Remove empty parts
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (parts.length === 0) {
 | 
				
			||||||
 | 
					            return null;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // First part is the collection
 | 
				
			||||||
 | 
					        return parts[0];
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Make CollectionSelector globally available
 | 
				
			||||||
 | 
					window.CollectionSelector = CollectionSelector;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
							
								
								
									
										100
									
								
								static/js/column-resizer.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,100 @@
 | 
				
			|||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Column Resizer Module
 | 
				
			||||||
 | 
					 * Handles draggable column dividers
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class ColumnResizer {
 | 
				
			||||||
 | 
					    constructor() {
 | 
				
			||||||
 | 
					        this.resizer1 = document.getElementById('resizer1');
 | 
				
			||||||
 | 
					        this.resizer2 = document.getElementById('resizer2');
 | 
				
			||||||
 | 
					        this.sidebarPane = document.getElementById('sidebarPane');
 | 
				
			||||||
 | 
					        this.editorPane = document.getElementById('editorPane');
 | 
				
			||||||
 | 
					        this.previewPane = document.getElementById('previewPane');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Load saved dimensions
 | 
				
			||||||
 | 
					        this.loadDimensions();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Setup listeners
 | 
				
			||||||
 | 
					        this.setupResizers();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    setupResizers() {
 | 
				
			||||||
 | 
					        this.resizer1.addEventListener('mousedown', (e) => this.startResize(e, 1));
 | 
				
			||||||
 | 
					        this.resizer2.addEventListener('mousedown', (e) => this.startResize(e, 2));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    startResize(e, resizerId) {
 | 
				
			||||||
 | 
					        e.preventDefault();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        const startX = e.clientX;
 | 
				
			||||||
 | 
					        const startWidth1 = this.sidebarPane.offsetWidth;
 | 
				
			||||||
 | 
					        const startWidth2 = this.editorPane.offsetWidth;
 | 
				
			||||||
 | 
					        const containerWidth = this.sidebarPane.parentElement.offsetWidth;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        const resizer = resizerId === 1 ? this.resizer1 : this.resizer2;
 | 
				
			||||||
 | 
					        resizer.classList.add('dragging');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        const handleMouseMove = (moveEvent) => {
 | 
				
			||||||
 | 
					            const deltaX = moveEvent.clientX - startX;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            if (resizerId === 1) {
 | 
				
			||||||
 | 
					                // Resize sidebar and editor
 | 
				
			||||||
 | 
					                const newWidth1 = Math.max(150, Math.min(40 * containerWidth / 100, startWidth1 + deltaX));
 | 
				
			||||||
 | 
					                const newWidth2 = startWidth2 - (newWidth1 - startWidth1);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                this.sidebarPane.style.flex = `0 0 ${newWidth1}px`;
 | 
				
			||||||
 | 
					                this.editorPane.style.flex = `1 1 ${newWidth2}px`;
 | 
				
			||||||
 | 
					            } else if (resizerId === 2) {
 | 
				
			||||||
 | 
					                // Resize editor and preview
 | 
				
			||||||
 | 
					                const newWidth2 = Math.max(250, Math.min(70 * containerWidth / 100, startWidth2 + deltaX));
 | 
				
			||||||
 | 
					                const containerFlex = this.sidebarPane.offsetWidth;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                this.editorPane.style.flex = `0 0 ${newWidth2}px`;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        const handleMouseUp = () => {
 | 
				
			||||||
 | 
					            resizer.classList.remove('dragging');
 | 
				
			||||||
 | 
					            document.removeEventListener('mousemove', handleMouseMove);
 | 
				
			||||||
 | 
					            document.removeEventListener('mouseup', handleMouseUp);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Save dimensions
 | 
				
			||||||
 | 
					            this.saveDimensions();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Trigger editor resize
 | 
				
			||||||
 | 
					            if (window.editor && window.editor.editor) {
 | 
				
			||||||
 | 
					                window.editor.editor.refresh();
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        document.addEventListener('mousemove', handleMouseMove);
 | 
				
			||||||
 | 
					        document.addEventListener('mouseup', handleMouseUp);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    saveDimensions() {
 | 
				
			||||||
 | 
					        const dimensions = {
 | 
				
			||||||
 | 
					            sidebar: this.sidebarPane.offsetWidth,
 | 
				
			||||||
 | 
					            editor: this.editorPane.offsetWidth,
 | 
				
			||||||
 | 
					            preview: this.previewPane.offsetWidth
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					        localStorage.setItem('columnDimensions', JSON.stringify(dimensions));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    loadDimensions() {
 | 
				
			||||||
 | 
					        const saved = localStorage.getItem('columnDimensions');
 | 
				
			||||||
 | 
					        if (!saved) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        try {
 | 
				
			||||||
 | 
					            const { sidebar, editor, preview } = JSON.parse(saved);
 | 
				
			||||||
 | 
					            this.sidebarPane.style.flex = `0 0 ${sidebar}px`;
 | 
				
			||||||
 | 
					            this.editorPane.style.flex = `0 0 ${editor}px`;
 | 
				
			||||||
 | 
					        } catch (error) {
 | 
				
			||||||
 | 
					            console.error('Failed to load column dimensions:', error);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Initialize on DOM ready
 | 
				
			||||||
 | 
					document.addEventListener('DOMContentLoaded', () => {
 | 
				
			||||||
 | 
					    window.columnResizer = new ColumnResizer();
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
							
								
								
									
										207
									
								
								static/js/config.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,207 @@
 | 
				
			|||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Application Configuration
 | 
				
			||||||
 | 
					 * Centralized configuration values for the markdown editor
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					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
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    STORAGE_KEYS: {
 | 
				
			||||||
 | 
					        /**
 | 
				
			||||||
 | 
					         * 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',
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        /**
 | 
				
			||||||
 | 
					         * 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
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    NOTIFICATION_TYPES: {
 | 
				
			||||||
 | 
					        SUCCESS: 'success',
 | 
				
			||||||
 | 
					        ERROR: 'danger',
 | 
				
			||||||
 | 
					        WARNING: 'warning',
 | 
				
			||||||
 | 
					        INFO: 'primary'
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Make Config globally available
 | 
				
			||||||
 | 
					window.Config = Config;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
							
								
								
									
										180
									
								
								static/js/confirmation.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,180 @@
 | 
				
			|||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Unified Modal Manager
 | 
				
			||||||
 | 
					 * Handles showing and hiding a Bootstrap modal for confirmations and prompts.
 | 
				
			||||||
 | 
					 * Uses a single reusable modal element to prevent double-opening issues.
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					class ModalManager {
 | 
				
			||||||
 | 
					    constructor(modalId) {
 | 
				
			||||||
 | 
					        this.modalElement = document.getElementById(modalId);
 | 
				
			||||||
 | 
					        if (!this.modalElement) {
 | 
				
			||||||
 | 
					            console.error(`Modal element with id "${modalId}" not found`);
 | 
				
			||||||
 | 
					            return;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        this.modal = new bootstrap.Modal(this.modalElement, {
 | 
				
			||||||
 | 
					            backdrop: 'static',
 | 
				
			||||||
 | 
					            keyboard: true
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        this.messageElement = this.modalElement.querySelector('#confirmationMessage');
 | 
				
			||||||
 | 
					        this.inputElement = this.modalElement.querySelector('#confirmationInput');
 | 
				
			||||||
 | 
					        this.confirmButton = this.modalElement.querySelector('#confirmButton');
 | 
				
			||||||
 | 
					        this.cancelButton = this.modalElement.querySelector('[data-bs-dismiss="modal"]');
 | 
				
			||||||
 | 
					        this.titleElement = this.modalElement.querySelector('.modal-title');
 | 
				
			||||||
 | 
					        this.currentResolver = null;
 | 
				
			||||||
 | 
					        this.isShowing = false;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Show a confirmation dialog
 | 
				
			||||||
 | 
					     * @param {string} message - The message to display
 | 
				
			||||||
 | 
					     * @param {string} title - The dialog title
 | 
				
			||||||
 | 
					     * @param {boolean} isDangerous - Whether this is a dangerous action (shows red button)
 | 
				
			||||||
 | 
					     * @returns {Promise<boolean>} - Resolves to true if confirmed, false/null if cancelled
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    confirm(message, title = 'Confirmation', isDangerous = false) {
 | 
				
			||||||
 | 
					        return new Promise((resolve) => {
 | 
				
			||||||
 | 
					            // Prevent double-opening
 | 
				
			||||||
 | 
					            if (this.isShowing) {
 | 
				
			||||||
 | 
					                console.warn('Modal is already showing, ignoring duplicate request');
 | 
				
			||||||
 | 
					                resolve(null);
 | 
				
			||||||
 | 
					                return;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            this.isShowing = true;
 | 
				
			||||||
 | 
					            this.currentResolver = resolve;
 | 
				
			||||||
 | 
					            this.titleElement.textContent = title;
 | 
				
			||||||
 | 
					            this.messageElement.textContent = message;
 | 
				
			||||||
 | 
					            this.inputElement.style.display = 'none';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Update button styling based on danger level
 | 
				
			||||||
 | 
					            if (isDangerous) {
 | 
				
			||||||
 | 
					                this.confirmButton.className = 'btn-flat btn-flat-danger';
 | 
				
			||||||
 | 
					                this.confirmButton.innerHTML = '<i class="bi bi-trash"></i> Delete';
 | 
				
			||||||
 | 
					            } else {
 | 
				
			||||||
 | 
					                this.confirmButton.className = 'btn-flat btn-flat-primary';
 | 
				
			||||||
 | 
					                this.confirmButton.innerHTML = '<i class="bi bi-check-circle"></i> OK';
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Set up event handlers
 | 
				
			||||||
 | 
					            this.confirmButton.onclick = (e) => {
 | 
				
			||||||
 | 
					                e.preventDefault();
 | 
				
			||||||
 | 
					                e.stopPropagation();
 | 
				
			||||||
 | 
					                this._handleConfirm(false);
 | 
				
			||||||
 | 
					            };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Handle modal hidden event for cleanup
 | 
				
			||||||
 | 
					            this.modalElement.addEventListener('hidden.bs.modal', () => {
 | 
				
			||||||
 | 
					                if (this.currentResolver) {
 | 
				
			||||||
 | 
					                    this._handleCancel();
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            }, { 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', () => {
 | 
				
			||||||
 | 
					                this.confirmButton.focus();
 | 
				
			||||||
 | 
					            }, { once: true });
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Show a prompt dialog (input dialog)
 | 
				
			||||||
 | 
					     * @param {string} message - The message/label to display
 | 
				
			||||||
 | 
					     * @param {string} defaultValue - The default input value
 | 
				
			||||||
 | 
					     * @param {string} title - The dialog title
 | 
				
			||||||
 | 
					     * @returns {Promise<string|null>} - Resolves to input value if confirmed, null if cancelled
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    prompt(message, defaultValue = '', title = 'Input') {
 | 
				
			||||||
 | 
					        return new Promise((resolve) => {
 | 
				
			||||||
 | 
					            // Prevent double-opening
 | 
				
			||||||
 | 
					            if (this.isShowing) {
 | 
				
			||||||
 | 
					                console.warn('Modal is already showing, ignoring duplicate request');
 | 
				
			||||||
 | 
					                resolve(null);
 | 
				
			||||||
 | 
					                return;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            this.isShowing = true;
 | 
				
			||||||
 | 
					            this.currentResolver = resolve;
 | 
				
			||||||
 | 
					            this.titleElement.textContent = title;
 | 
				
			||||||
 | 
					            this.messageElement.textContent = message;
 | 
				
			||||||
 | 
					            this.inputElement.style.display = 'block';
 | 
				
			||||||
 | 
					            this.inputElement.value = defaultValue;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Reset button to primary style for prompts
 | 
				
			||||||
 | 
					            this.confirmButton.className = 'btn-flat btn-flat-primary';
 | 
				
			||||||
 | 
					            this.confirmButton.innerHTML = '<i class="bi bi-check-circle"></i> OK';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Set up event handlers
 | 
				
			||||||
 | 
					            this.confirmButton.onclick = (e) => {
 | 
				
			||||||
 | 
					                e.preventDefault();
 | 
				
			||||||
 | 
					                e.stopPropagation();
 | 
				
			||||||
 | 
					                this._handleConfirm(true);
 | 
				
			||||||
 | 
					            };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Handle Enter key in input
 | 
				
			||||||
 | 
					            this.inputElement.onkeydown = (e) => {
 | 
				
			||||||
 | 
					                if (e.key === 'Enter') {
 | 
				
			||||||
 | 
					                    e.preventDefault();
 | 
				
			||||||
 | 
					                    this._handleConfirm(true);
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Handle modal hidden event for cleanup
 | 
				
			||||||
 | 
					            this.modalElement.addEventListener('hidden.bs.modal', () => {
 | 
				
			||||||
 | 
					                if (this.currentResolver) {
 | 
				
			||||||
 | 
					                    this._handleCancel();
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            }, { 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', () => {
 | 
				
			||||||
 | 
					                this.inputElement.focus();
 | 
				
			||||||
 | 
					                this.inputElement.select();
 | 
				
			||||||
 | 
					            }, { once: true });
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    _handleConfirm(isPrompt) {
 | 
				
			||||||
 | 
					        if (this.currentResolver) {
 | 
				
			||||||
 | 
					            const value = isPrompt ? this.inputElement.value.trim() : true;
 | 
				
			||||||
 | 
					            const resolver = this.currentResolver;
 | 
				
			||||||
 | 
					            this._cleanup();
 | 
				
			||||||
 | 
					            resolver(value);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    _handleCancel() {
 | 
				
			||||||
 | 
					        if (this.currentResolver) {
 | 
				
			||||||
 | 
					            const resolver = this.currentResolver;
 | 
				
			||||||
 | 
					            this._cleanup();
 | 
				
			||||||
 | 
					            resolver(null);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    _cleanup() {
 | 
				
			||||||
 | 
					        this.confirmButton.onclick = null;
 | 
				
			||||||
 | 
					        this.inputElement.onkeydown = null;
 | 
				
			||||||
 | 
					        this.currentResolver = null;
 | 
				
			||||||
 | 
					        this.isShowing = false;
 | 
				
			||||||
 | 
					        this.modal.hide();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Restore aria-hidden after modal is hidden
 | 
				
			||||||
 | 
					        this.modalElement.addEventListener('hidden.bs.modal', () => {
 | 
				
			||||||
 | 
					            this.modalElement.setAttribute('aria-hidden', 'true');
 | 
				
			||||||
 | 
					        }, { once: true });
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Make it globally available
 | 
				
			||||||
 | 
					window.ConfirmationManager = new ModalManager('confirmationModal');
 | 
				
			||||||
 | 
					window.ModalManager = window.ConfirmationManager; // Alias for clarity
 | 
				
			||||||
							
								
								
									
										89
									
								
								static/js/context-menu.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,89 @@
 | 
				
			|||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Context Menu Module
 | 
				
			||||||
 | 
					 * Handles the right-click context menu for file tree items
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Show context menu at specified position
 | 
				
			||||||
 | 
					 * @param {number} x - X coordinate
 | 
				
			||||||
 | 
					 * @param {number} y - Y coordinate
 | 
				
			||||||
 | 
					 * @param {Object} target - Target object with path and isDir properties
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					function showContextMenu(x, y, target) {
 | 
				
			||||||
 | 
					    const menu = document.getElementById('contextMenu');
 | 
				
			||||||
 | 
					    if (!menu) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Store target data
 | 
				
			||||||
 | 
					    menu.dataset.targetPath = target.path;
 | 
				
			||||||
 | 
					    menu.dataset.targetIsDir = target.isDir;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Show/hide menu items based on target type
 | 
				
			||||||
 | 
					    const items = {
 | 
				
			||||||
 | 
					        'new-file': target.isDir,
 | 
				
			||||||
 | 
					        'new-folder': target.isDir,
 | 
				
			||||||
 | 
					        'upload': target.isDir,
 | 
				
			||||||
 | 
					        'download': true,
 | 
				
			||||||
 | 
					        'paste': target.isDir && window.fileTreeActions?.clipboard,
 | 
				
			||||||
 | 
					        'open': !target.isDir
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Object.entries(items).forEach(([action, show]) => {
 | 
				
			||||||
 | 
					        const item = menu.querySelector(`[data-action="${action}"]`);
 | 
				
			||||||
 | 
					        if (item) {
 | 
				
			||||||
 | 
					            item.style.display = show ? 'flex' : 'none';
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Position menu
 | 
				
			||||||
 | 
					    menu.style.display = 'block';
 | 
				
			||||||
 | 
					    menu.style.left = x + 'px';
 | 
				
			||||||
 | 
					    menu.style.top = y + 'px';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Adjust if off-screen
 | 
				
			||||||
 | 
					    setTimeout(() => {
 | 
				
			||||||
 | 
					        const rect = menu.getBoundingClientRect();
 | 
				
			||||||
 | 
					        if (rect.right > window.innerWidth) {
 | 
				
			||||||
 | 
					            menu.style.left = (window.innerWidth - rect.width - 10) + 'px';
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        if (rect.bottom > window.innerHeight) {
 | 
				
			||||||
 | 
					            menu.style.top = (window.innerHeight - rect.height - 10) + 'px';
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }, 0);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Hide the context menu
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					function hideContextMenu() {
 | 
				
			||||||
 | 
					    const menu = document.getElementById('contextMenu');
 | 
				
			||||||
 | 
					    if (menu) {
 | 
				
			||||||
 | 
					        menu.style.display = 'none';
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Combined click handler for context menu and outside clicks
 | 
				
			||||||
 | 
					document.addEventListener('click', async (e) => {
 | 
				
			||||||
 | 
					    const menuItem = e.target.closest('.context-menu-item');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (menuItem) {
 | 
				
			||||||
 | 
					        // Handle context menu item click
 | 
				
			||||||
 | 
					        const action = menuItem.dataset.action;
 | 
				
			||||||
 | 
					        const menu = document.getElementById('contextMenu');
 | 
				
			||||||
 | 
					        const targetPath = menu.dataset.targetPath;
 | 
				
			||||||
 | 
					        const isDir = menu.dataset.targetIsDir === 'true';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        hideContextMenu();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (window.fileTreeActions) {
 | 
				
			||||||
 | 
					            await window.fileTreeActions.execute(action, targetPath, isDir);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    } else if (!e.target.closest('#contextMenu') && !e.target.closest('.tree-node')) {
 | 
				
			||||||
 | 
					        // Hide on outside click
 | 
				
			||||||
 | 
					        hideContextMenu();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Make functions globally available
 | 
				
			||||||
 | 
					window.showContextMenu = showContextMenu;
 | 
				
			||||||
 | 
					window.hideContextMenu = hideContextMenu;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
							
								
								
									
										77
									
								
								static/js/dark-mode.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,77 @@
 | 
				
			|||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Dark Mode Module
 | 
				
			||||||
 | 
					 * Manages dark mode theme switching and persistence
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class DarkMode {
 | 
				
			||||||
 | 
					    constructor() {
 | 
				
			||||||
 | 
					        this.isDark = localStorage.getItem(Config.STORAGE_KEYS.DARK_MODE) === 'true';
 | 
				
			||||||
 | 
					        this.apply();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Toggle dark mode on/off
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    toggle() {
 | 
				
			||||||
 | 
					        this.isDark = !this.isDark;
 | 
				
			||||||
 | 
					        localStorage.setItem(Config.STORAGE_KEYS.DARK_MODE, this.isDark);
 | 
				
			||||||
 | 
					        this.apply();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        Logger.debug(`Dark mode ${this.isDark ? 'enabled' : 'disabled'}`);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Apply the current dark mode state
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    apply() {
 | 
				
			||||||
 | 
					        if (this.isDark) {
 | 
				
			||||||
 | 
					            document.body.classList.add('dark-mode');
 | 
				
			||||||
 | 
					            const btn = document.getElementById('darkModeBtn');
 | 
				
			||||||
 | 
					            if (btn) btn.innerHTML = '<i class="bi bi-sun-fill"></i>';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Update mermaid theme
 | 
				
			||||||
 | 
					            if (window.mermaid) {
 | 
				
			||||||
 | 
					                mermaid.initialize({ theme: Config.MERMAID_THEME_DARK });
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					            document.body.classList.remove('dark-mode');
 | 
				
			||||||
 | 
					            const btn = document.getElementById('darkModeBtn');
 | 
				
			||||||
 | 
					            if (btn) btn.innerHTML = '<i class="bi bi-moon-fill"></i>';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Update mermaid theme
 | 
				
			||||||
 | 
					            if (window.mermaid) {
 | 
				
			||||||
 | 
					                mermaid.initialize({ theme: Config.MERMAID_THEME_LIGHT });
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Check if dark mode is currently enabled
 | 
				
			||||||
 | 
					     * @returns {boolean} True if dark mode is enabled
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    isEnabled() {
 | 
				
			||||||
 | 
					        return this.isDark;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Enable dark mode
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    enable() {
 | 
				
			||||||
 | 
					        if (!this.isDark) {
 | 
				
			||||||
 | 
					            this.toggle();
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Disable dark mode
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    disable() {
 | 
				
			||||||
 | 
					        if (this.isDark) {
 | 
				
			||||||
 | 
					            this.toggle();
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Make DarkMode globally available
 | 
				
			||||||
 | 
					window.DarkMode = DarkMode;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
							
								
								
									
										67
									
								
								static/js/editor-drop-handler.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,67 @@
 | 
				
			|||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Editor Drop Handler Module
 | 
				
			||||||
 | 
					 * Handles file drops into the editor for uploading
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class EditorDropHandler {
 | 
				
			||||||
 | 
					    constructor(editorElement, onFileDrop) {
 | 
				
			||||||
 | 
					        this.editorElement = editorElement;
 | 
				
			||||||
 | 
					        this.onFileDrop = onFileDrop;
 | 
				
			||||||
 | 
					        this.setupHandlers();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Setup drag and drop event handlers
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    setupHandlers() {
 | 
				
			||||||
 | 
					        this.editorElement.addEventListener('dragover', (e) => {
 | 
				
			||||||
 | 
					            e.preventDefault();
 | 
				
			||||||
 | 
					            e.stopPropagation();
 | 
				
			||||||
 | 
					            this.editorElement.classList.add('drag-over');
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        this.editorElement.addEventListener('dragleave', (e) => {
 | 
				
			||||||
 | 
					            e.preventDefault();
 | 
				
			||||||
 | 
					            e.stopPropagation();
 | 
				
			||||||
 | 
					            this.editorElement.classList.remove('drag-over');
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        this.editorElement.addEventListener('drop', async (e) => {
 | 
				
			||||||
 | 
					            e.preventDefault();
 | 
				
			||||||
 | 
					            e.stopPropagation();
 | 
				
			||||||
 | 
					            this.editorElement.classList.remove('drag-over');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            const files = Array.from(e.dataTransfer.files);
 | 
				
			||||||
 | 
					            if (files.length === 0) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            Logger.debug(`Dropped ${files.length} file(s) into editor`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            for (const file of files) {
 | 
				
			||||||
 | 
					                try {
 | 
				
			||||||
 | 
					                    if (this.onFileDrop) {
 | 
				
			||||||
 | 
					                        await this.onFileDrop(file);
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					                } catch (error) {
 | 
				
			||||||
 | 
					                    Logger.error('Drop failed:', error);
 | 
				
			||||||
 | 
					                    if (window.showNotification) {
 | 
				
			||||||
 | 
					                        window.showNotification(`Failed to upload ${file.name}`, 'error');
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Remove event handlers
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    destroy() {
 | 
				
			||||||
 | 
					        // Note: We can't easily remove the event listeners without keeping references
 | 
				
			||||||
 | 
					        // This is a limitation of the current implementation
 | 
				
			||||||
 | 
					        // In a future refactor, we could store the bound handlers
 | 
				
			||||||
 | 
					        Logger.debug('EditorDropHandler destroyed');
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Make EditorDropHandler globally available
 | 
				
			||||||
 | 
					window.EditorDropHandler = EditorDropHandler;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -4,14 +4,26 @@
 | 
				
			|||||||
 */
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class MarkdownEditor {
 | 
					class MarkdownEditor {
 | 
				
			||||||
    constructor(editorId, previewId, filenameInputId) {
 | 
					    constructor(editorId, previewId, filenameInputId, readOnly = false) {
 | 
				
			||||||
        this.editorElement = document.getElementById(editorId);
 | 
					        this.editorElement = document.getElementById(editorId);
 | 
				
			||||||
        this.previewElement = document.getElementById(previewId);
 | 
					        this.previewElement = document.getElementById(previewId);
 | 
				
			||||||
        this.filenameInput = document.getElementById(filenameInputId);
 | 
					        this.filenameInput = document.getElementById(filenameInputId);
 | 
				
			||||||
        this.currentFile = null;
 | 
					        this.currentFile = null;
 | 
				
			||||||
        this.webdavClient = null;
 | 
					        this.webdavClient = null;
 | 
				
			||||||
 | 
					        this.macroProcessor = new MacroProcessor(null); // Will be set later
 | 
				
			||||||
 | 
					        this.lastViewedStorageKey = 'lastViewedPage'; // localStorage key for tracking last viewed page
 | 
				
			||||||
 | 
					        this.readOnly = readOnly; // Whether editor is in read-only mode
 | 
				
			||||||
 | 
					        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();
 | 
					            this.initCodeMirror();
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
        this.initMarkdown();
 | 
					        this.initMarkdown();
 | 
				
			||||||
        this.initMermaid();
 | 
					        this.initMermaid();
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
@@ -20,37 +32,148 @@ class MarkdownEditor {
 | 
				
			|||||||
     * Initialize CodeMirror
 | 
					     * Initialize CodeMirror
 | 
				
			||||||
     */
 | 
					     */
 | 
				
			||||||
    initCodeMirror() {
 | 
					    initCodeMirror() {
 | 
				
			||||||
 | 
					        // Determine theme based on dark mode
 | 
				
			||||||
 | 
					        const isDarkMode = document.body.classList.contains('dark-mode');
 | 
				
			||||||
 | 
					        const theme = isDarkMode ? 'monokai' : 'default';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        this.editor = CodeMirror(this.editorElement, {
 | 
					        this.editor = CodeMirror(this.editorElement, {
 | 
				
			||||||
            mode: 'markdown',
 | 
					            mode: 'markdown',
 | 
				
			||||||
            theme: 'monokai',
 | 
					            theme: theme,
 | 
				
			||||||
            lineNumbers: true,
 | 
					            lineNumbers: true,
 | 
				
			||||||
            lineWrapping: true,
 | 
					            lineWrapping: true,
 | 
				
			||||||
            autofocus: true,
 | 
					            autofocus: !this.readOnly, // Don't autofocus in read-only mode
 | 
				
			||||||
            extraKeys: {
 | 
					            readOnly: this.readOnly, // Set read-only mode
 | 
				
			||||||
 | 
					            extraKeys: this.readOnly ? {} : {
 | 
				
			||||||
                'Ctrl-S': () => this.save(),
 | 
					                'Ctrl-S': () => this.save(),
 | 
				
			||||||
                'Cmd-S': () => this.save()
 | 
					                'Cmd-S': () => this.save()
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        // Update preview on change
 | 
					        // Update preview on change with debouncing
 | 
				
			||||||
        this.editor.on('change', () => {
 | 
					        this.editor.on('change', TimingUtils.debounce(() => {
 | 
				
			||||||
            this.updatePreview();
 | 
					            this.updatePreview();
 | 
				
			||||||
        });
 | 
					        }, Config.DEBOUNCE_DELAY));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Initial preview render
 | 
				
			||||||
 | 
					        setTimeout(() => {
 | 
				
			||||||
 | 
					            this.updatePreview();
 | 
				
			||||||
 | 
					        }, 100);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        // Sync scroll
 | 
					        // Sync scroll
 | 
				
			||||||
        this.editor.on('scroll', () => {
 | 
					        this.editor.on('scroll', () => {
 | 
				
			||||||
            this.syncScroll();
 | 
					            this.syncScroll();
 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Listen for dark mode changes
 | 
				
			||||||
 | 
					        this.setupThemeListener();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Setup listener for dark mode changes
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    setupThemeListener() {
 | 
				
			||||||
 | 
					        // Watch for dark mode class changes
 | 
				
			||||||
 | 
					        const observer = new MutationObserver((mutations) => {
 | 
				
			||||||
 | 
					            mutations.forEach((mutation) => {
 | 
				
			||||||
 | 
					                if (mutation.attributeName === 'class') {
 | 
				
			||||||
 | 
					                    const isDarkMode = document.body.classList.contains('dark-mode');
 | 
				
			||||||
 | 
					                    const newTheme = isDarkMode ? 'monokai' : 'default';
 | 
				
			||||||
 | 
					                    this.editor.setOption('theme', newTheme);
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        observer.observe(document.body, { attributes: true });
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /**
 | 
					    /**
 | 
				
			||||||
     * Initialize markdown parser
 | 
					     * Initialize markdown parser
 | 
				
			||||||
     */
 | 
					     */
 | 
				
			||||||
    initMarkdown() {
 | 
					    initMarkdown() {
 | 
				
			||||||
 | 
					        if (window.marked) {
 | 
				
			||||||
            this.marked = window.marked;
 | 
					            this.marked = window.marked;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Create custom renderer for images
 | 
				
			||||||
 | 
					            const renderer = new marked.Renderer();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            renderer.image = (token) => {
 | 
				
			||||||
 | 
					                // Handle both old API (string params) and new API (token object)
 | 
				
			||||||
 | 
					                let href, title, text;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                if (typeof token === 'object' && token !== null) {
 | 
				
			||||||
 | 
					                    // New API: token is an object
 | 
				
			||||||
 | 
					                    href = token.href || '';
 | 
				
			||||||
 | 
					                    title = token.title || '';
 | 
				
			||||||
 | 
					                    text = token.text || '';
 | 
				
			||||||
 | 
					                } else {
 | 
				
			||||||
 | 
					                    // Old API: separate parameters (href, title, text)
 | 
				
			||||||
 | 
					                    href = arguments[0] || '';
 | 
				
			||||||
 | 
					                    title = arguments[1] || '';
 | 
				
			||||||
 | 
					                    text = arguments[2] || '';
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                // Ensure all are strings
 | 
				
			||||||
 | 
					                href = String(href || '');
 | 
				
			||||||
 | 
					                title = String(title || '');
 | 
				
			||||||
 | 
					                text = String(text || '');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                Logger.debug(`Image renderer called with href="${href}", title="${title}", text="${text}"`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                // Check if href contains binary data (starts with non-printable characters)
 | 
				
			||||||
 | 
					                if (href && href.length > 100 && /^[\x00-\x1F\x7F-\xFF]/.test(href)) {
 | 
				
			||||||
 | 
					                    Logger.error('Image href contains binary data - this should not happen!');
 | 
				
			||||||
 | 
					                    Logger.error('First 50 chars:', href.substring(0, 50));
 | 
				
			||||||
 | 
					                    // Return a placeholder image
 | 
				
			||||||
 | 
					                    return `<div class="alert alert-warning">⚠️ Invalid image data detected. Please re-upload the image.</div>`;
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                // Fix relative image paths to use WebDAV base URL
 | 
				
			||||||
 | 
					                if (href && !href.startsWith('http://') && !href.startsWith('https://') && !href.startsWith('data:')) {
 | 
				
			||||||
 | 
					                    // Get the directory of the current file
 | 
				
			||||||
 | 
					                    const currentDir = this.currentFile ? PathUtils.getParentPath(this.currentFile) : '';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    // Resolve relative path
 | 
				
			||||||
 | 
					                    let imagePath = href;
 | 
				
			||||||
 | 
					                    if (href.startsWith('./')) {
 | 
				
			||||||
 | 
					                        // Relative to current directory
 | 
				
			||||||
 | 
					                        imagePath = PathUtils.joinPaths(currentDir, href.substring(2));
 | 
				
			||||||
 | 
					                    } else if (href.startsWith('../')) {
 | 
				
			||||||
 | 
					                        // Relative to parent directory
 | 
				
			||||||
 | 
					                        imagePath = PathUtils.joinPaths(currentDir, href);
 | 
				
			||||||
 | 
					                    } else if (!href.startsWith('/')) {
 | 
				
			||||||
 | 
					                        // Relative to current directory (no ./)
 | 
				
			||||||
 | 
					                        imagePath = PathUtils.joinPaths(currentDir, href);
 | 
				
			||||||
 | 
					                    } else {
 | 
				
			||||||
 | 
					                        // Absolute path from collection root
 | 
				
			||||||
 | 
					                        imagePath = href.substring(1); // Remove leading /
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    // Build WebDAV URL - ensure no double slashes
 | 
				
			||||||
 | 
					                    if (this.webdavClient && this.webdavClient.currentCollection) {
 | 
				
			||||||
 | 
					                        // Remove trailing slash from baseUrl if present
 | 
				
			||||||
 | 
					                        const baseUrl = this.webdavClient.baseUrl.endsWith('/')
 | 
				
			||||||
 | 
					                            ? this.webdavClient.baseUrl.slice(0, -1)
 | 
				
			||||||
 | 
					                            : this.webdavClient.baseUrl;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                        // Ensure imagePath doesn't start with /
 | 
				
			||||||
 | 
					                        const cleanImagePath = imagePath.startsWith('/') ? imagePath.substring(1) : imagePath;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                        href = `${baseUrl}/${this.webdavClient.currentCollection}/${cleanImagePath}`;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                        Logger.debug(`Resolved image URL: ${href}`);
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                // Generate HTML directly
 | 
				
			||||||
 | 
					                const titleAttr = title ? ` title="${title}"` : '';
 | 
				
			||||||
 | 
					                const altAttr = text ? ` alt="${text}"` : '';
 | 
				
			||||||
 | 
					                return `<img src="${href}"${altAttr}${titleAttr}>`;
 | 
				
			||||||
 | 
					            };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            this.marked.setOptions({
 | 
					            this.marked.setOptions({
 | 
				
			||||||
                breaks: true,
 | 
					                breaks: true,
 | 
				
			||||||
                gfm: true,
 | 
					                gfm: true,
 | 
				
			||||||
 | 
					                renderer: renderer,
 | 
				
			||||||
                highlight: (code, lang) => {
 | 
					                highlight: (code, lang) => {
 | 
				
			||||||
                    if (lang && window.Prism.languages[lang]) {
 | 
					                    if (lang && window.Prism.languages[lang]) {
 | 
				
			||||||
                        return window.Prism.highlight(code, window.Prism.languages[lang], lang);
 | 
					                        return window.Prism.highlight(code, window.Prism.languages[lang], lang);
 | 
				
			||||||
@@ -58,6 +181,9 @@ class MarkdownEditor {
 | 
				
			|||||||
                    return code;
 | 
					                    return code;
 | 
				
			||||||
                }
 | 
					                }
 | 
				
			||||||
            });
 | 
					            });
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					            console.error('Marked library not found.');
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /**
 | 
					    /**
 | 
				
			||||||
@@ -77,6 +203,23 @@ class MarkdownEditor {
 | 
				
			|||||||
     */
 | 
					     */
 | 
				
			||||||
    setWebDAVClient(client) {
 | 
					    setWebDAVClient(client) {
 | 
				
			||||||
        this.webdavClient = client;
 | 
					        this.webdavClient = client;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Update macro processor with client
 | 
				
			||||||
 | 
					        if (this.macroProcessor) {
 | 
				
			||||||
 | 
					            this.macroProcessor.webdavClient = client;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * 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...');
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /**
 | 
					    /**
 | 
				
			||||||
@@ -84,16 +227,57 @@ class MarkdownEditor {
 | 
				
			|||||||
     */
 | 
					     */
 | 
				
			||||||
    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
 | 
				
			||||||
 | 
					            this.isShowingCustomPreview = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            const content = await this.webdavClient.get(path);
 | 
					            const content = await this.webdavClient.get(path);
 | 
				
			||||||
            this.currentFile = path;
 | 
					            this.currentFile = path;
 | 
				
			||||||
            this.filenameInput.value = path;
 | 
					 | 
				
			||||||
            this.editor.setValue(content);
 | 
					 | 
				
			||||||
            this.updatePreview();
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
            if (window.showNotification) {
 | 
					            // Update filename input if it exists
 | 
				
			||||||
                window.showNotification(`Loaded ${path}`, 'info');
 | 
					            if (this.filenameInput) {
 | 
				
			||||||
 | 
					                this.filenameInput.value = path;
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Update editor if it exists (edit mode)
 | 
				
			||||||
 | 
					            if (this.editor) {
 | 
				
			||||||
 | 
					                this.editor.setValue(content);
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Update preview with the loaded content
 | 
				
			||||||
 | 
					            await this.renderPreview(content);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // 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) {
 | 
					        } 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');
 | 
				
			||||||
@@ -101,6 +285,32 @@ class MarkdownEditor {
 | 
				
			|||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Save the last viewed page to localStorage
 | 
				
			||||||
 | 
					     * Stores per collection so different collections can have different last viewed pages
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    saveLastViewedPage(path) {
 | 
				
			||||||
 | 
					        if (!this.webdavClient || !this.webdavClient.currentCollection) {
 | 
				
			||||||
 | 
					            return;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        const collection = this.webdavClient.currentCollection;
 | 
				
			||||||
 | 
					        const storageKey = `${this.lastViewedStorageKey}:${collection}`;
 | 
				
			||||||
 | 
					        localStorage.setItem(storageKey, path);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Get the last viewed page from localStorage
 | 
				
			||||||
 | 
					     * Returns null if no page was previously viewed
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    getLastViewedPage() {
 | 
				
			||||||
 | 
					        if (!this.webdavClient || !this.webdavClient.currentCollection) {
 | 
				
			||||||
 | 
					            return null;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        const collection = this.webdavClient.currentCollection;
 | 
				
			||||||
 | 
					        const storageKey = `${this.lastViewedStorageKey}:${collection}`;
 | 
				
			||||||
 | 
					        return localStorage.getItem(storageKey);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /**
 | 
					    /**
 | 
				
			||||||
     * Save file
 | 
					     * Save file
 | 
				
			||||||
     */
 | 
					     */
 | 
				
			||||||
@@ -123,10 +333,9 @@ class MarkdownEditor {
 | 
				
			|||||||
                window.showNotification('✅ Saved', 'success');
 | 
					                window.showNotification('✅ Saved', 'success');
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            // Trigger file tree reload
 | 
					            // Dispatch event to reload file tree
 | 
				
			||||||
            if (window.fileTree) {
 | 
					            if (window.eventBus) {
 | 
				
			||||||
                await window.fileTree.load();
 | 
					                window.eventBus.dispatch('file-saved', path);
 | 
				
			||||||
                window.fileTree.selectNode(path);
 | 
					 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
        } catch (error) {
 | 
					        } catch (error) {
 | 
				
			||||||
            console.error('Failed to save file:', error);
 | 
					            console.error('Failed to save file:', error);
 | 
				
			||||||
@@ -143,12 +352,9 @@ class MarkdownEditor {
 | 
				
			|||||||
        this.currentFile = null;
 | 
					        this.currentFile = null;
 | 
				
			||||||
        this.filenameInput.value = '';
 | 
					        this.filenameInput.value = '';
 | 
				
			||||||
        this.filenameInput.focus();
 | 
					        this.filenameInput.focus();
 | 
				
			||||||
        this.editor.setValue('');
 | 
					        this.editor.setValue('# New File\n\nStart typing...\n');
 | 
				
			||||||
        this.updatePreview();
 | 
					        this.updatePreview();
 | 
				
			||||||
 | 
					        // No notification needed - UI is self-explanatory
 | 
				
			||||||
        if (window.showNotification) {
 | 
					 | 
				
			||||||
            window.showNotification('Enter filename and start typing', 'info');
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /**
 | 
					    /**
 | 
				
			||||||
@@ -156,60 +362,194 @@ class MarkdownEditor {
 | 
				
			|||||||
     */
 | 
					     */
 | 
				
			||||||
    async deleteFile() {
 | 
					    async deleteFile() {
 | 
				
			||||||
        if (!this.currentFile) {
 | 
					        if (!this.currentFile) {
 | 
				
			||||||
            if (window.showNotification) {
 | 
					 | 
				
			||||||
            window.showNotification('No file selected', 'warning');
 | 
					            window.showNotification('No file selected', 'warning');
 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
            return;
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        if (!confirm(`Delete ${this.currentFile}?`)) {
 | 
					 | 
				
			||||||
            return;
 | 
					            return;
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        const confirmed = await window.ConfirmationManager.confirm(`Are you sure you want to delete ${this.currentFile}?`, 'Delete File', true);
 | 
				
			||||||
 | 
					        if (confirmed) {
 | 
				
			||||||
            try {
 | 
					            try {
 | 
				
			||||||
                await this.webdavClient.delete(this.currentFile);
 | 
					                await this.webdavClient.delete(this.currentFile);
 | 
				
			||||||
            
 | 
					 | 
				
			||||||
            if (window.showNotification) {
 | 
					 | 
				
			||||||
                window.showNotification(`Deleted ${this.currentFile}`, 'success');
 | 
					                window.showNotification(`Deleted ${this.currentFile}`, 'success');
 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                this.newFile();
 | 
					                this.newFile();
 | 
				
			||||||
 | 
					                window.eventBus.dispatch('file-deleted');
 | 
				
			||||||
            // Trigger file tree reload
 | 
					 | 
				
			||||||
            if (window.fileTree) {
 | 
					 | 
				
			||||||
                await window.fileTree.load();
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
            } catch (error) {
 | 
					            } catch (error) {
 | 
				
			||||||
                console.error('Failed to delete file:', error);
 | 
					                console.error('Failed to delete file:', error);
 | 
				
			||||||
            if (window.showNotification) {
 | 
					 | 
				
			||||||
                window.showNotification('Failed to delete file', 'danger');
 | 
					                window.showNotification('Failed to delete file', 'danger');
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /**
 | 
					    /**
 | 
				
			||||||
     * Update preview
 | 
					     * Convert JSX-style attributes to HTML attributes
 | 
				
			||||||
 | 
					     * Handles style={{...}} and boolean attributes like allowFullScreen={true}
 | 
				
			||||||
     */
 | 
					     */
 | 
				
			||||||
    updatePreview() {
 | 
					    convertJSXToHTML(content) {
 | 
				
			||||||
        const markdown = this.editor.getValue();
 | 
					        Logger.debug('Converting JSX to HTML...');
 | 
				
			||||||
        let html = this.marked.parse(markdown);
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
        // Process mermaid diagrams
 | 
					        // Convert style={{...}} to style="..."
 | 
				
			||||||
        html = html.replace(/<pre><code class="language-mermaid">([\s\S]*?)<\/code><\/pre>/g, (match, code) => {
 | 
					        // This regex finds style={{...}} and converts the object notation to CSS string
 | 
				
			||||||
            const id = 'mermaid-' + Math.random().toString(36).substr(2, 9);
 | 
					        content = content.replace(/style=\{\{([^}]+)\}\}/g, (match, styleContent) => {
 | 
				
			||||||
            return `<div class="mermaid" id="${id}">${code}</div>`;
 | 
					            Logger.debug(`Found JSX style: ${match}`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Parse the object-like syntax and convert to CSS
 | 
				
			||||||
 | 
					            const cssRules = styleContent
 | 
				
			||||||
 | 
					                .split(',')
 | 
				
			||||||
 | 
					                .map(rule => {
 | 
				
			||||||
 | 
					                    const colonIndex = rule.indexOf(':');
 | 
				
			||||||
 | 
					                    if (colonIndex === -1) return '';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    const key = rule.substring(0, colonIndex).trim();
 | 
				
			||||||
 | 
					                    const value = rule.substring(colonIndex + 1).trim();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    if (!key || !value) return '';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    // Convert camelCase to kebab-case (e.g., paddingTop -> padding-top)
 | 
				
			||||||
 | 
					                    const cssKey = key.replace(/([A-Z])/g, '-$1').toLowerCase();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    // Remove quotes from value
 | 
				
			||||||
 | 
					                    let cssValue = value.replace(/^['"]|['"]$/g, '');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    return `${cssKey}: ${cssValue}`;
 | 
				
			||||||
 | 
					                })
 | 
				
			||||||
 | 
					                .filter(rule => rule)
 | 
				
			||||||
 | 
					                .join('; ');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            Logger.debug(`Converted to CSS: style="${cssRules}"`);
 | 
				
			||||||
 | 
					            return `style="${cssRules}"`;
 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        this.previewElement.innerHTML = html;
 | 
					        // Convert boolean attributes like allowFullScreen={true} to allowfullscreen
 | 
				
			||||||
 | 
					        content = content.replace(/(\w+)=\{true\}/g, (match, attrName) => {
 | 
				
			||||||
 | 
					            Logger.debug(`Found boolean attribute: ${match}`);
 | 
				
			||||||
 | 
					            // Convert camelCase to lowercase for HTML attributes
 | 
				
			||||||
 | 
					            const htmlAttr = attrName.toLowerCase();
 | 
				
			||||||
 | 
					            Logger.debug(`Converted to: ${htmlAttr}`);
 | 
				
			||||||
 | 
					            return htmlAttr;
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        // Render mermaid diagrams
 | 
					        // Remove attributes set to {false}
 | 
				
			||||||
        if (window.mermaid) {
 | 
					        content = content.replace(/\s+\w+=\{false\}/g, '');
 | 
				
			||||||
            window.mermaid.init(undefined, this.previewElement.querySelectorAll('.mermaid'));
 | 
					
 | 
				
			||||||
 | 
					        return content;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        // Highlight code blocks
 | 
					    /**
 | 
				
			||||||
 | 
					     * Render preview from markdown content
 | 
				
			||||||
 | 
					     * Can be called with explicit content (for view mode) or from editor (for edit mode)
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    async renderPreview(markdownContent = null) {
 | 
				
			||||||
 | 
					        // Get markdown content from editor if not provided
 | 
				
			||||||
 | 
					        const markdown = markdownContent !== null ? markdownContent : (this.editor ? this.editor.getValue() : '');
 | 
				
			||||||
 | 
					        const previewDiv = this.previewElement;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (!markdown || !markdown.trim()) {
 | 
				
			||||||
 | 
					            previewDiv.innerHTML = `
 | 
				
			||||||
 | 
					                <div class="text-muted text-center mt-5">
 | 
				
			||||||
 | 
					                    <p>Start typing to see preview...</p>
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
 | 
					            `;
 | 
				
			||||||
 | 
					            return;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        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);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Step 1: Process macros
 | 
				
			||||||
 | 
					            if (this.macroProcessor) {
 | 
				
			||||||
 | 
					                const processingResult = await this.macroProcessor.processMacros(processedContent);
 | 
				
			||||||
 | 
					                processedContent = processingResult.content;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Step 2: Parse markdown to HTML
 | 
				
			||||||
 | 
					            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;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            let html = this.marked.parse(processedContent);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Replace mermaid code blocks
 | 
				
			||||||
 | 
					            html = html.replace(
 | 
				
			||||||
 | 
					                /<pre><code class="language-mermaid">([\s\S]*?)<\/code><\/pre>/g,
 | 
				
			||||||
 | 
					                (match, code) => {
 | 
				
			||||||
 | 
					                    const id = 'mermaid-' + Math.random().toString(36).substr(2, 9);
 | 
				
			||||||
 | 
					                    return `<div class="mermaid" id="${id}">${code.trim()}</div>`;
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            previewDiv.innerHTML = html;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Apply syntax highlighting
 | 
				
			||||||
 | 
					            const codeBlocks = previewDiv.querySelectorAll('pre code');
 | 
				
			||||||
 | 
					            codeBlocks.forEach(block => {
 | 
				
			||||||
 | 
					                const languageClass = Array.from(block.classList)
 | 
				
			||||||
 | 
					                    .find(cls => cls.startsWith('language-'));
 | 
				
			||||||
 | 
					                if (languageClass && languageClass !== 'language-mermaid') {
 | 
				
			||||||
                    if (window.Prism) {
 | 
					                    if (window.Prism) {
 | 
				
			||||||
            window.Prism.highlightAllUnder(this.previewElement);
 | 
					                        window.Prism.highlightElement(block);
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Render mermaid diagrams
 | 
				
			||||||
 | 
					            const mermaidElements = previewDiv.querySelectorAll('.mermaid');
 | 
				
			||||||
 | 
					            if (mermaidElements.length > 0 && window.mermaid) {
 | 
				
			||||||
 | 
					                try {
 | 
				
			||||||
 | 
					                    window.mermaid.contentLoaded();
 | 
				
			||||||
 | 
					                } catch (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) {
 | 
				
			||||||
 | 
					            console.error('Preview rendering error:', error);
 | 
				
			||||||
 | 
					            previewDiv.innerHTML = `
 | 
				
			||||||
 | 
					                <div class="alert alert-danger" role="alert">
 | 
				
			||||||
 | 
					                    <strong>Error rendering preview:</strong><br>
 | 
				
			||||||
 | 
					                    ${error.message}
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
 | 
					            `;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Hide loading spinner on error
 | 
				
			||||||
 | 
					            if (this.previewSpinner) {
 | 
				
			||||||
 | 
					                this.previewSpinner.hide();
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Update preview (backward compatibility wrapper)
 | 
				
			||||||
 | 
					     * Calls renderPreview with content from editor
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    async updatePreview() {
 | 
				
			||||||
 | 
					        // Skip auto-update if showing custom preview (e.g., binary files)
 | 
				
			||||||
 | 
					        if (this.isShowingCustomPreview) {
 | 
				
			||||||
 | 
					            Logger.debug('Skipping auto-update: showing custom preview');
 | 
				
			||||||
 | 
					            return;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (this.editor) {
 | 
				
			||||||
 | 
					            await this.renderPreview();
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -217,6 +557,8 @@ class MarkdownEditor {
 | 
				
			|||||||
     * Sync scroll between editor and preview
 | 
					     * Sync scroll between editor and preview
 | 
				
			||||||
     */
 | 
					     */
 | 
				
			||||||
    syncScroll() {
 | 
					    syncScroll() {
 | 
				
			||||||
 | 
					        if (!this.editor) return; // Skip if no editor (view mode)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        const scrollInfo = this.editor.getScrollInfo();
 | 
					        const scrollInfo = this.editor.getScrollInfo();
 | 
				
			||||||
        const scrollPercent = scrollInfo.top / (scrollInfo.height - scrollInfo.clientHeight);
 | 
					        const scrollPercent = scrollInfo.top / (scrollInfo.height - scrollInfo.clientHeight);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -266,6 +608,8 @@ class MarkdownEditor {
 | 
				
			|||||||
    setValue(content) {
 | 
					    setValue(content) {
 | 
				
			||||||
        this.editor.setValue(content);
 | 
					        this.editor.setValue(content);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Debounce function moved to TimingUtils in utils.js
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Export for use in other modules
 | 
					// Export for use in other modules
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										126
									
								
								static/js/event-bus.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,126 @@
 | 
				
			|||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Event Bus Module
 | 
				
			||||||
 | 
					 * Provides a centralized event system for application-wide communication
 | 
				
			||||||
 | 
					 * Allows components to communicate without tight coupling
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class EventBus {
 | 
				
			||||||
 | 
					    constructor() {
 | 
				
			||||||
 | 
					        /**
 | 
				
			||||||
 | 
					         * Map of event names to arrays of listener functions
 | 
				
			||||||
 | 
					         * @type {Object.<string, Function[]>}
 | 
				
			||||||
 | 
					         */
 | 
				
			||||||
 | 
					        this.listeners = {};
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Register an event listener
 | 
				
			||||||
 | 
					     * @param {string} event - The event name to listen for
 | 
				
			||||||
 | 
					     * @param {Function} callback - The function to call when the event is dispatched
 | 
				
			||||||
 | 
					     * @returns {Function} A function to unregister this listener
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    on(event, callback) {
 | 
				
			||||||
 | 
					        if (!this.listeners[event]) {
 | 
				
			||||||
 | 
					            this.listeners[event] = [];
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        this.listeners[event].push(callback);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Return unsubscribe function
 | 
				
			||||||
 | 
					        return () => this.off(event, callback);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Register a one-time event listener
 | 
				
			||||||
 | 
					     * The listener will be automatically removed after being called once
 | 
				
			||||||
 | 
					     * @param {string} event - The event name to listen for
 | 
				
			||||||
 | 
					     * @param {Function} callback - The function to call when the event is dispatched
 | 
				
			||||||
 | 
					     * @returns {Function} A function to unregister this listener
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    once(event, callback) {
 | 
				
			||||||
 | 
					        const onceWrapper = (data) => {
 | 
				
			||||||
 | 
					            callback(data);
 | 
				
			||||||
 | 
					            this.off(event, onceWrapper);
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					        return this.on(event, onceWrapper);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Unregister an event listener
 | 
				
			||||||
 | 
					     * @param {string} event - The event name
 | 
				
			||||||
 | 
					     * @param {Function} callback - The callback function to remove
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    off(event, callback) {
 | 
				
			||||||
 | 
					        if (!this.listeners[event]) {
 | 
				
			||||||
 | 
					            return;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        this.listeners[event] = this.listeners[event].filter(
 | 
				
			||||||
 | 
					            listener => listener !== callback
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Clean up empty listener arrays
 | 
				
			||||||
 | 
					        if (this.listeners[event].length === 0) {
 | 
				
			||||||
 | 
					            delete this.listeners[event];
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Dispatch an event to all registered listeners
 | 
				
			||||||
 | 
					     * @param {string} event - The event name to dispatch
 | 
				
			||||||
 | 
					     * @param {any} data - The data to pass to the listeners
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    dispatch(event, data) {
 | 
				
			||||||
 | 
					        if (!this.listeners[event]) {
 | 
				
			||||||
 | 
					            return;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Create a copy of the listeners array to avoid issues if listeners are added/removed during dispatch
 | 
				
			||||||
 | 
					        const listeners = [...this.listeners[event]];
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        listeners.forEach(callback => {
 | 
				
			||||||
 | 
					            try {
 | 
				
			||||||
 | 
					                callback(data);
 | 
				
			||||||
 | 
					            } catch (error) {
 | 
				
			||||||
 | 
					                Logger.error(`Error in event listener for "${event}":`, error);
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Remove all listeners for a specific event
 | 
				
			||||||
 | 
					     * If no event is specified, removes all listeners for all events
 | 
				
			||||||
 | 
					     * @param {string} [event] - The event name (optional)
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    clear(event) {
 | 
				
			||||||
 | 
					        if (event) {
 | 
				
			||||||
 | 
					            delete this.listeners[event];
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					            this.listeners = {};
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Get the number of listeners for an event
 | 
				
			||||||
 | 
					     * @param {string} event - The event name
 | 
				
			||||||
 | 
					     * @returns {number} The number of listeners
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    listenerCount(event) {
 | 
				
			||||||
 | 
					        return this.listeners[event] ? this.listeners[event].length : 0;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Get all event names that have listeners
 | 
				
			||||||
 | 
					     * @returns {string[]} Array of event names
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    eventNames() {
 | 
				
			||||||
 | 
					        return Object.keys(this.listeners);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Create and export the global event bus instance
 | 
				
			||||||
 | 
					const eventBus = new EventBus();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Make it globally available
 | 
				
			||||||
 | 
					window.eventBus = eventBus;
 | 
				
			||||||
 | 
					window.EventBus = EventBus;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
							
								
								
									
										604
									
								
								static/js/file-tree-actions.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,604 @@
 | 
				
			|||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * File Tree Actions Manager
 | 
				
			||||||
 | 
					 * Centralized handling of all tree operations
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class FileTreeActions {
 | 
				
			||||||
 | 
					    constructor(webdavClient, fileTree, editor) {
 | 
				
			||||||
 | 
					        this.webdavClient = webdavClient;
 | 
				
			||||||
 | 
					        this.fileTree = fileTree;
 | 
				
			||||||
 | 
					        this.editor = editor;
 | 
				
			||||||
 | 
					        this.clipboard = null;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Validate and sanitize filename/folder name
 | 
				
			||||||
 | 
					     * Returns { valid: boolean, sanitized: string, message: string }
 | 
				
			||||||
 | 
					     * Now uses ValidationUtils from utils.js
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    validateFileName(name, isFolder = false) {
 | 
				
			||||||
 | 
					        return ValidationUtils.validateFileName(name, isFolder);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    async execute(action, targetPath, isDirectory) {
 | 
				
			||||||
 | 
					        const handler = this.actions[action];
 | 
				
			||||||
 | 
					        if (!handler) {
 | 
				
			||||||
 | 
					            console.error(`Unknown action: ${action}`);
 | 
				
			||||||
 | 
					            return;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        try {
 | 
				
			||||||
 | 
					            await handler.call(this, targetPath, isDirectory);
 | 
				
			||||||
 | 
					        } catch (error) {
 | 
				
			||||||
 | 
					            console.error(`Action failed: ${action}`, error);
 | 
				
			||||||
 | 
					            showNotification(`Failed to ${action}`, 'error');
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    actions = {
 | 
				
			||||||
 | 
					        open: async function (path, isDir) {
 | 
				
			||||||
 | 
					            if (!isDir) {
 | 
				
			||||||
 | 
					                await this.editor.loadFile(path);
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        'new-file': async function (path, isDir) {
 | 
				
			||||||
 | 
					            if (!isDir) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            const filename = await window.ModalManager.prompt(
 | 
				
			||||||
 | 
					                'Enter filename (lowercase, underscore only):',
 | 
				
			||||||
 | 
					                'new_file.md',
 | 
				
			||||||
 | 
					                'New File'
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            if (!filename) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            let finalFilename = filename;
 | 
				
			||||||
 | 
					            const validation = this.validateFileName(filename, false);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            if (!validation.valid) {
 | 
				
			||||||
 | 
					                showNotification(validation.message, 'warning');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                // Ask if user wants to use sanitized version
 | 
				
			||||||
 | 
					                if (validation.sanitized) {
 | 
				
			||||||
 | 
					                    const useSanitized = await window.ModalManager.confirm(
 | 
				
			||||||
 | 
					                        `${filename} → ${validation.sanitized}`,
 | 
				
			||||||
 | 
					                        'Use sanitized name?',
 | 
				
			||||||
 | 
					                        false
 | 
				
			||||||
 | 
					                    );
 | 
				
			||||||
 | 
					                    if (useSanitized) {
 | 
				
			||||||
 | 
					                        finalFilename = validation.sanitized;
 | 
				
			||||||
 | 
					                    } else {
 | 
				
			||||||
 | 
					                        return;
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					                } else {
 | 
				
			||||||
 | 
					                    return;
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            const fullPath = `${path}/${finalFilename}`.replace(/\/+/g, '/');
 | 
				
			||||||
 | 
					            await this.webdavClient.put(fullPath, '# New File\n\n');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Clear undo history since new file was created
 | 
				
			||||||
 | 
					            if (this.fileTree.lastMoveOperation) {
 | 
				
			||||||
 | 
					                this.fileTree.lastMoveOperation = null;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            await this.fileTree.load();
 | 
				
			||||||
 | 
					            showNotification(`Created ${finalFilename}`, 'success');
 | 
				
			||||||
 | 
					            await this.editor.loadFile(fullPath);
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        'new-folder': async function (path, isDir) {
 | 
				
			||||||
 | 
					            if (!isDir) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            const foldername = await window.ModalManager.prompt(
 | 
				
			||||||
 | 
					                'Enter folder name (lowercase, underscore only):',
 | 
				
			||||||
 | 
					                'new_folder',
 | 
				
			||||||
 | 
					                'New Folder'
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            if (!foldername) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            let finalFoldername = foldername;
 | 
				
			||||||
 | 
					            const validation = this.validateFileName(foldername, true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            if (!validation.valid) {
 | 
				
			||||||
 | 
					                showNotification(validation.message, 'warning');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                if (validation.sanitized) {
 | 
				
			||||||
 | 
					                    const useSanitized = await window.ModalManager.confirm(
 | 
				
			||||||
 | 
					                        `${foldername} → ${validation.sanitized}`,
 | 
				
			||||||
 | 
					                        'Use sanitized name?',
 | 
				
			||||||
 | 
					                        false
 | 
				
			||||||
 | 
					                    );
 | 
				
			||||||
 | 
					                    if (useSanitized) {
 | 
				
			||||||
 | 
					                        finalFoldername = validation.sanitized;
 | 
				
			||||||
 | 
					                    } else {
 | 
				
			||||||
 | 
					                        return;
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					                } else {
 | 
				
			||||||
 | 
					                    return;
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            const fullPath = `${path}/${finalFoldername}`.replace(/\/+/g, '/');
 | 
				
			||||||
 | 
					            await this.webdavClient.mkcol(fullPath);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Clear undo history since new folder was created
 | 
				
			||||||
 | 
					            if (this.fileTree.lastMoveOperation) {
 | 
				
			||||||
 | 
					                this.fileTree.lastMoveOperation = null;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            await this.fileTree.load();
 | 
				
			||||||
 | 
					            showNotification(`Created folder ${finalFoldername}`, 'success');
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        rename: async function (path, isDir) {
 | 
				
			||||||
 | 
					            const oldName = path.split('/').pop();
 | 
				
			||||||
 | 
					            const newName = await window.ModalManager.prompt(
 | 
				
			||||||
 | 
					                'Rename to:',
 | 
				
			||||||
 | 
					                oldName,
 | 
				
			||||||
 | 
					                'Rename'
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            if (newName && newName !== oldName) {
 | 
				
			||||||
 | 
					                const parentPath = path.substring(0, path.lastIndexOf('/'));
 | 
				
			||||||
 | 
					                const newPath = parentPath ? `${parentPath}/${newName}` : newName;
 | 
				
			||||||
 | 
					                await this.webdavClient.move(path, newPath);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                // Clear undo history since manual rename occurred
 | 
				
			||||||
 | 
					                if (this.fileTree.lastMoveOperation) {
 | 
				
			||||||
 | 
					                    this.fileTree.lastMoveOperation = null;
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                await this.fileTree.load();
 | 
				
			||||||
 | 
					                showNotification('Renamed', 'success');
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        copy: async function (path, isDir) {
 | 
				
			||||||
 | 
					            this.clipboard = { path, operation: 'copy', isDirectory: isDir };
 | 
				
			||||||
 | 
					            // No notification for copy - it's a quick operation
 | 
				
			||||||
 | 
					            this.updatePasteMenuItem();
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        cut: async function (path, isDir) {
 | 
				
			||||||
 | 
					            this.clipboard = { path, operation: 'cut', isDirectory: isDir };
 | 
				
			||||||
 | 
					            // No notification for cut - it's a quick operation
 | 
				
			||||||
 | 
					            this.updatePasteMenuItem();
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        paste: async function (targetPath, isDir) {
 | 
				
			||||||
 | 
					            if (!this.clipboard || !isDir) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            const itemName = this.clipboard.path.split('/').pop();
 | 
				
			||||||
 | 
					            const destPath = `${targetPath}/${itemName}`.replace(/\/+/g, '/');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            if (this.clipboard.operation === 'copy') {
 | 
				
			||||||
 | 
					                await this.webdavClient.copy(this.clipboard.path, destPath);
 | 
				
			||||||
 | 
					                // No notification for paste - file tree updates show the result
 | 
				
			||||||
 | 
					            } else {
 | 
				
			||||||
 | 
					                await this.webdavClient.move(this.clipboard.path, destPath);
 | 
				
			||||||
 | 
					                this.clipboard = null;
 | 
				
			||||||
 | 
					                this.updatePasteMenuItem();
 | 
				
			||||||
 | 
					                // No notification for move - file tree updates show the result
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            await this.fileTree.load();
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        delete: async function (path, isDir) {
 | 
				
			||||||
 | 
					            const name = path.split('/').pop() || this.webdavClient.currentCollection;
 | 
				
			||||||
 | 
					            const type = isDir ? 'folder' : 'file';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // 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 (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
 | 
				
			||||||
 | 
					                );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                if (!confirmed) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                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');
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        download: async function (path, isDir) {
 | 
				
			||||||
 | 
					            Logger.info(`Downloading ${isDir ? 'folder' : 'file'}: ${path}`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            if (isDir) {
 | 
				
			||||||
 | 
					                await this.fileTree.downloadFolder(path);
 | 
				
			||||||
 | 
					            } else {
 | 
				
			||||||
 | 
					                await this.fileTree.downloadFile(path);
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        upload: async function (path, isDir) {
 | 
				
			||||||
 | 
					            if (!isDir) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            const input = document.createElement('input');
 | 
				
			||||||
 | 
					            input.type = 'file';
 | 
				
			||||||
 | 
					            input.multiple = true;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            input.onchange = async (e) => {
 | 
				
			||||||
 | 
					                const files = Array.from(e.target.files);
 | 
				
			||||||
 | 
					                for (const file of files) {
 | 
				
			||||||
 | 
					                    const fullPath = `${path}/${file.name}`.replace(/\/+/g, '/');
 | 
				
			||||||
 | 
					                    const content = await file.arrayBuffer();
 | 
				
			||||||
 | 
					                    await this.webdavClient.putBinary(fullPath, content);
 | 
				
			||||||
 | 
					                    showNotification(`Uploaded ${file.name}`, 'success');
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					                await this.fileTree.load();
 | 
				
			||||||
 | 
					            };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            input.click();
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        'copy-to-collection': async function (path, isDir) {
 | 
				
			||||||
 | 
					            // Get list of available collections
 | 
				
			||||||
 | 
					            const collections = await this.webdavClient.getCollections();
 | 
				
			||||||
 | 
					            const currentCollection = this.webdavClient.currentCollection;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Filter out current collection
 | 
				
			||||||
 | 
					            const otherCollections = collections.filter(c => c !== currentCollection);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            if (otherCollections.length === 0) {
 | 
				
			||||||
 | 
					                showNotification('No other collections available', 'warning');
 | 
				
			||||||
 | 
					                return;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Show collection selection dialog
 | 
				
			||||||
 | 
					            const targetCollection = await this.showCollectionSelectionDialog(
 | 
				
			||||||
 | 
					                otherCollections,
 | 
				
			||||||
 | 
					                `Copy ${PathUtils.getFileName(path)} to collection:`
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            if (!targetCollection) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Copy the file/folder
 | 
				
			||||||
 | 
					            await this.copyToCollection(path, isDir, currentCollection, targetCollection);
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        'move-to-collection': async function (path, isDir) {
 | 
				
			||||||
 | 
					            // Get list of available collections
 | 
				
			||||||
 | 
					            const collections = await this.webdavClient.getCollections();
 | 
				
			||||||
 | 
					            const currentCollection = this.webdavClient.currentCollection;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Filter out current collection
 | 
				
			||||||
 | 
					            const otherCollections = collections.filter(c => c !== currentCollection);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            if (otherCollections.length === 0) {
 | 
				
			||||||
 | 
					                showNotification('No other collections available', 'warning');
 | 
				
			||||||
 | 
					                return;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Show collection selection dialog
 | 
				
			||||||
 | 
					            const targetCollection = await this.showCollectionSelectionDialog(
 | 
				
			||||||
 | 
					                otherCollections,
 | 
				
			||||||
 | 
					                `Move ${PathUtils.getFileName(path)} to collection:`
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            if (!targetCollection) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Move the file/folder
 | 
				
			||||||
 | 
					            await this.moveToCollection(path, isDir, currentCollection, targetCollection);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Old deprecated modal methods removed - all modals now use window.ModalManager
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    updatePasteMenuItem() {
 | 
				
			||||||
 | 
					        const pasteItem = document.getElementById('pasteMenuItem');
 | 
				
			||||||
 | 
					        if (pasteItem) {
 | 
				
			||||||
 | 
					            pasteItem.style.display = this.clipboard ? 'flex' : 'none';
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Show a dialog to select a collection
 | 
				
			||||||
 | 
					     * @param {Array<string>} collections - List of collection names
 | 
				
			||||||
 | 
					     * @param {string} message - Dialog message
 | 
				
			||||||
 | 
					     * @returns {Promise<string|null>} Selected collection or null if cancelled
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    async showCollectionSelectionDialog(collections, message) {
 | 
				
			||||||
 | 
					        // Prevent duplicate modals
 | 
				
			||||||
 | 
					        if (this._collectionModalShowing) {
 | 
				
			||||||
 | 
					            Logger.warn('Collection selection modal is already showing');
 | 
				
			||||||
 | 
					            return null;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        this._collectionModalShowing = true;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Create a custom modal with radio buttons for collection selection
 | 
				
			||||||
 | 
					        const modal = document.createElement('div');
 | 
				
			||||||
 | 
					        modal.className = 'modal fade';
 | 
				
			||||||
 | 
					        modal.innerHTML = `
 | 
				
			||||||
 | 
					            <div class="modal-dialog modal-dialog-centered">
 | 
				
			||||||
 | 
					                <div class="modal-content">
 | 
				
			||||||
 | 
					                    <div class="modal-header">
 | 
				
			||||||
 | 
					                        <h5 class="modal-title"><i class="bi bi-folder-symlink"></i> Select Collection</h5>
 | 
				
			||||||
 | 
					                        <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
 | 
				
			||||||
 | 
					                    </div>
 | 
				
			||||||
 | 
					                    <div class="modal-body">
 | 
				
			||||||
 | 
					                        <p class="mb-3">${message}</p>
 | 
				
			||||||
 | 
					                        <div class="collection-list" style="max-height: 300px; overflow-y: auto;">
 | 
				
			||||||
 | 
					                            ${collections.map((c, i) => `
 | 
				
			||||||
 | 
					                                <div class="form-check p-2 mb-2 rounded border collection-option" style="cursor: pointer; transition: all 0.2s;">
 | 
				
			||||||
 | 
					                                    <input class="form-check-input" type="radio" name="collection" id="collection-${i}" value="${c}" ${i === 0 ? 'checked' : ''}>
 | 
				
			||||||
 | 
					                                    <label class="form-check-label w-100" for="collection-${i}" style="cursor: pointer;">
 | 
				
			||||||
 | 
					                                        <i class="bi bi-folder"></i> <strong>${c}</strong>
 | 
				
			||||||
 | 
					                                    </label>
 | 
				
			||||||
 | 
					                                </div>
 | 
				
			||||||
 | 
					                            `).join('')}
 | 
				
			||||||
 | 
					                        </div>
 | 
				
			||||||
 | 
					                        <div id="confirmationPreview" class="alert alert-info mt-3" style="display: none;">
 | 
				
			||||||
 | 
					                            <i class="bi bi-info-circle"></i> <span id="confirmationText"></span>
 | 
				
			||||||
 | 
					                        </div>
 | 
				
			||||||
 | 
					                    </div>
 | 
				
			||||||
 | 
					                    <div class="modal-footer">
 | 
				
			||||||
 | 
					                        <button type="button" class="btn-flat btn-flat-secondary" data-bs-dismiss="modal">
 | 
				
			||||||
 | 
					                            <i class="bi bi-x-circle"></i> Cancel
 | 
				
			||||||
 | 
					                        </button>
 | 
				
			||||||
 | 
					                        <button type="button" class="btn-flat btn-flat-primary" id="confirmCollectionBtn">
 | 
				
			||||||
 | 
					                            <i class="bi bi-check-circle"></i> OK
 | 
				
			||||||
 | 
					                        </button>
 | 
				
			||||||
 | 
					                    </div>
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					        `;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        document.body.appendChild(modal);
 | 
				
			||||||
 | 
					        const bsModal = new bootstrap.Modal(modal);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Extract file name and action from message
 | 
				
			||||||
 | 
					        // Message format: "Copy filename to collection:" or "Move filename to collection:"
 | 
				
			||||||
 | 
					        const messageMatch = message.match(/(Copy|Move)\s+(.+?)\s+to collection:/);
 | 
				
			||||||
 | 
					        const action = messageMatch ? messageMatch[1].toLowerCase() : 'copy';
 | 
				
			||||||
 | 
					        const fileName = messageMatch ? messageMatch[2] : 'item';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Get confirmation preview elements
 | 
				
			||||||
 | 
					        const confirmationPreview = modal.querySelector('#confirmationPreview');
 | 
				
			||||||
 | 
					        const confirmationText = modal.querySelector('#confirmationText');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Function to update confirmation message
 | 
				
			||||||
 | 
					        const updateConfirmation = (collectionName) => {
 | 
				
			||||||
 | 
					            confirmationText.textContent = `"${fileName}" will be ${action}d to "${collectionName}"`;
 | 
				
			||||||
 | 
					            confirmationPreview.style.display = 'block';
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Add hover effects and click handlers for collection options
 | 
				
			||||||
 | 
					        const collectionOptions = modal.querySelectorAll('.collection-option');
 | 
				
			||||||
 | 
					        collectionOptions.forEach(option => {
 | 
				
			||||||
 | 
					            // Hover effect
 | 
				
			||||||
 | 
					            option.addEventListener('mouseenter', () => {
 | 
				
			||||||
 | 
					                option.style.backgroundColor = 'var(--bs-light)';
 | 
				
			||||||
 | 
					                option.style.borderColor = 'var(--bs-primary)';
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					            option.addEventListener('mouseleave', () => {
 | 
				
			||||||
 | 
					                const radio = option.querySelector('input[type="radio"]');
 | 
				
			||||||
 | 
					                if (!radio.checked) {
 | 
				
			||||||
 | 
					                    option.style.backgroundColor = '';
 | 
				
			||||||
 | 
					                    option.style.borderColor = '';
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Click on the whole div to select
 | 
				
			||||||
 | 
					            option.addEventListener('click', () => {
 | 
				
			||||||
 | 
					                const radio = option.querySelector('input[type="radio"]');
 | 
				
			||||||
 | 
					                radio.checked = true;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                // Update confirmation message
 | 
				
			||||||
 | 
					                updateConfirmation(radio.value);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                // Update all options styling
 | 
				
			||||||
 | 
					                collectionOptions.forEach(opt => {
 | 
				
			||||||
 | 
					                    const r = opt.querySelector('input[type="radio"]');
 | 
				
			||||||
 | 
					                    if (r.checked) {
 | 
				
			||||||
 | 
					                        opt.style.backgroundColor = 'var(--bs-primary-bg-subtle)';
 | 
				
			||||||
 | 
					                        opt.style.borderColor = 'var(--bs-primary)';
 | 
				
			||||||
 | 
					                    } else {
 | 
				
			||||||
 | 
					                        opt.style.backgroundColor = '';
 | 
				
			||||||
 | 
					                        opt.style.borderColor = '';
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Set initial styling for checked option
 | 
				
			||||||
 | 
					            const radio = option.querySelector('input[type="radio"]');
 | 
				
			||||||
 | 
					            if (radio.checked) {
 | 
				
			||||||
 | 
					                option.style.backgroundColor = 'var(--bs-primary-bg-subtle)';
 | 
				
			||||||
 | 
					                option.style.borderColor = 'var(--bs-primary)';
 | 
				
			||||||
 | 
					                // Show initial confirmation
 | 
				
			||||||
 | 
					                updateConfirmation(radio.value);
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return new Promise((resolve) => {
 | 
				
			||||||
 | 
					            const confirmBtn = modal.querySelector('#confirmCollectionBtn');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            confirmBtn.addEventListener('click', () => {
 | 
				
			||||||
 | 
					                const selected = modal.querySelector('input[name="collection"]:checked');
 | 
				
			||||||
 | 
					                this._collectionModalShowing = false;
 | 
				
			||||||
 | 
					                bsModal.hide();
 | 
				
			||||||
 | 
					                resolve(selected ? selected.value : null);
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modal.addEventListener('hidden.bs.modal', () => {
 | 
				
			||||||
 | 
					                modal.remove();
 | 
				
			||||||
 | 
					                this._collectionModalShowing = false;
 | 
				
			||||||
 | 
					                resolve(null);
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            bsModal.show();
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Copy a file or folder to another collection
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    async copyToCollection(path, isDir, sourceCollection, targetCollection) {
 | 
				
			||||||
 | 
					        try {
 | 
				
			||||||
 | 
					            Logger.info(`Copying ${path} from ${sourceCollection} to ${targetCollection}`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            if (isDir) {
 | 
				
			||||||
 | 
					                // Copy folder recursively
 | 
				
			||||||
 | 
					                await this.copyFolderToCollection(path, sourceCollection, targetCollection);
 | 
				
			||||||
 | 
					            } else {
 | 
				
			||||||
 | 
					                // Copy single file
 | 
				
			||||||
 | 
					                await this.copyFileToCollection(path, sourceCollection, targetCollection);
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            showNotification(`Copied to ${targetCollection}`, 'success');
 | 
				
			||||||
 | 
					        } catch (error) {
 | 
				
			||||||
 | 
					            Logger.error('Failed to copy to collection:', error);
 | 
				
			||||||
 | 
					            showNotification('Failed to copy to collection', 'error');
 | 
				
			||||||
 | 
					            throw error;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Move a file or folder to another collection
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    async moveToCollection(path, isDir, sourceCollection, targetCollection) {
 | 
				
			||||||
 | 
					        try {
 | 
				
			||||||
 | 
					            Logger.info(`Moving ${path} from ${sourceCollection} to ${targetCollection}`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // First copy
 | 
				
			||||||
 | 
					            await this.copyToCollection(path, isDir, sourceCollection, targetCollection);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Then delete from source
 | 
				
			||||||
 | 
					            await this.webdavClient.delete(path);
 | 
				
			||||||
 | 
					            await this.fileTree.load();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            showNotification(`Moved to ${targetCollection}`, 'success');
 | 
				
			||||||
 | 
					        } catch (error) {
 | 
				
			||||||
 | 
					            Logger.error('Failed to move to collection:', error);
 | 
				
			||||||
 | 
					            showNotification('Failed to move to collection', 'error');
 | 
				
			||||||
 | 
					            throw error;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Copy a single file to another collection
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    async copyFileToCollection(path, sourceCollection, targetCollection) {
 | 
				
			||||||
 | 
					        // Read file from source collection
 | 
				
			||||||
 | 
					        const content = await this.webdavClient.get(path);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Write to target collection
 | 
				
			||||||
 | 
					        const originalCollection = this.webdavClient.currentCollection;
 | 
				
			||||||
 | 
					        this.webdavClient.setCollection(targetCollection);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Ensure parent directories exist in target collection
 | 
				
			||||||
 | 
					        await this.webdavClient.ensureParentDirectories(path);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        await this.webdavClient.put(path, content);
 | 
				
			||||||
 | 
					        this.webdavClient.setCollection(originalCollection);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Copy a folder recursively to another collection
 | 
				
			||||||
 | 
					     * @param {string} folderPath - Path of the folder to copy
 | 
				
			||||||
 | 
					     * @param {string} sourceCollection - Source collection name
 | 
				
			||||||
 | 
					     * @param {string} targetCollection - Target collection name
 | 
				
			||||||
 | 
					     * @param {Set} visitedPaths - Set of already visited paths to prevent infinite loops
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    async copyFolderToCollection(folderPath, sourceCollection, targetCollection, visitedPaths = new Set()) {
 | 
				
			||||||
 | 
					        // Prevent infinite loops by tracking visited paths
 | 
				
			||||||
 | 
					        if (visitedPaths.has(folderPath)) {
 | 
				
			||||||
 | 
					            Logger.warn(`Skipping already visited path: ${folderPath}`);
 | 
				
			||||||
 | 
					            return;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        visitedPaths.add(folderPath);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        Logger.info(`Copying folder: ${folderPath} from ${sourceCollection} to ${targetCollection}`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Set to source collection to list items
 | 
				
			||||||
 | 
					        const originalCollection = this.webdavClient.currentCollection;
 | 
				
			||||||
 | 
					        this.webdavClient.setCollection(sourceCollection);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Get only direct children (not recursive to avoid infinite loop)
 | 
				
			||||||
 | 
					        const items = await this.webdavClient.list(folderPath, false);
 | 
				
			||||||
 | 
					        Logger.debug(`Found ${items.length} items in ${folderPath}:`, items.map(i => i.path));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Create the folder in target collection
 | 
				
			||||||
 | 
					        this.webdavClient.setCollection(targetCollection);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        try {
 | 
				
			||||||
 | 
					            // Ensure parent directories exist first
 | 
				
			||||||
 | 
					            await this.webdavClient.ensureParentDirectories(folderPath + '/dummy.txt');
 | 
				
			||||||
 | 
					            // Then create the folder itself
 | 
				
			||||||
 | 
					            await this.webdavClient.createFolder(folderPath);
 | 
				
			||||||
 | 
					            Logger.debug(`Created folder: ${folderPath}`);
 | 
				
			||||||
 | 
					        } catch (error) {
 | 
				
			||||||
 | 
					            // Folder might already exist (405 Method Not Allowed), ignore error
 | 
				
			||||||
 | 
					            if (error.message && error.message.includes('405')) {
 | 
				
			||||||
 | 
					                Logger.debug(`Folder ${folderPath} already exists (405)`);
 | 
				
			||||||
 | 
					            } else {
 | 
				
			||||||
 | 
					                Logger.debug('Folder might already exist:', error);
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Copy all items
 | 
				
			||||||
 | 
					        for (const item of items) {
 | 
				
			||||||
 | 
					            if (item.isDirectory) {
 | 
				
			||||||
 | 
					                // Recursively copy subdirectory
 | 
				
			||||||
 | 
					                await this.copyFolderToCollection(item.path, sourceCollection, targetCollection, visitedPaths);
 | 
				
			||||||
 | 
					            } else {
 | 
				
			||||||
 | 
					                // Copy file
 | 
				
			||||||
 | 
					                this.webdavClient.setCollection(sourceCollection);
 | 
				
			||||||
 | 
					                const content = await this.webdavClient.get(item.path);
 | 
				
			||||||
 | 
					                this.webdavClient.setCollection(targetCollection);
 | 
				
			||||||
 | 
					                // Ensure parent directories exist before copying file
 | 
				
			||||||
 | 
					                await this.webdavClient.ensureParentDirectories(item.path);
 | 
				
			||||||
 | 
					                await this.webdavClient.put(item.path, content);
 | 
				
			||||||
 | 
					                Logger.debug(`Copied file: ${item.path}`);
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        this.webdavClient.setCollection(originalCollection);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -4,15 +4,31 @@
 | 
				
			|||||||
 */
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class FileTree {
 | 
					class FileTree {
 | 
				
			||||||
    constructor(containerId, webdavClient) {
 | 
					    constructor(containerId, webdavClient, isEditMode = false) {
 | 
				
			||||||
        this.container = document.getElementById(containerId);
 | 
					        this.container = document.getElementById(containerId);
 | 
				
			||||||
        this.webdavClient = webdavClient;
 | 
					        this.webdavClient = webdavClient;
 | 
				
			||||||
        this.tree = [];
 | 
					        this.tree = [];
 | 
				
			||||||
        this.selectedPath = null;
 | 
					        this.selectedPath = null;
 | 
				
			||||||
        this.onFileSelect = null;
 | 
					        this.onFileSelect = null;
 | 
				
			||||||
        this.onFolderSelect = null;
 | 
					        this.onFolderSelect = null;
 | 
				
			||||||
 | 
					        this.filterImagesInViewMode = !isEditMode; // Track if we should filter images (true in view mode)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Drag and drop state
 | 
				
			||||||
 | 
					        this.draggedNode = null;
 | 
				
			||||||
 | 
					        this.draggedPath = null;
 | 
				
			||||||
 | 
					        this.draggedIsDir = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Long-press detection
 | 
				
			||||||
 | 
					        this.longPressTimer = null;
 | 
				
			||||||
 | 
					        this.longPressThreshold = Config.LONG_PRESS_THRESHOLD;
 | 
				
			||||||
 | 
					        this.isDraggingEnabled = false;
 | 
				
			||||||
 | 
					        this.mouseDownNode = null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Undo functionality
 | 
				
			||||||
 | 
					        this.lastMoveOperation = null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        this.setupEventListeners();
 | 
					        this.setupEventListeners();
 | 
				
			||||||
 | 
					        this.setupUndoListener();
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    setupEventListeners() {
 | 
					    setupEventListeners() {
 | 
				
			||||||
@@ -24,9 +40,10 @@ class FileTree {
 | 
				
			|||||||
            const path = node.dataset.path;
 | 
					            const path = node.dataset.path;
 | 
				
			||||||
            const isDir = node.dataset.isdir === 'true';
 | 
					            const isDir = node.dataset.isdir === 'true';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            // Toggle folder
 | 
					            // Check if toggle was clicked (icon or toggle button)
 | 
				
			||||||
            if (e.target.closest('.tree-toggle')) {
 | 
					            const toggle = e.target.closest('.tree-node-toggle');
 | 
				
			||||||
                this.toggleFolder(node);
 | 
					            if (toggle) {
 | 
				
			||||||
 | 
					                // Toggle is handled by its own click listener in renderNodes
 | 
				
			||||||
                return;
 | 
					                return;
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -38,17 +55,358 @@ class FileTree {
 | 
				
			|||||||
            }
 | 
					            }
 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        // Context menu
 | 
					        // Context menu (only in edit mode)
 | 
				
			||||||
        this.container.addEventListener('contextmenu', (e) => {
 | 
					        this.container.addEventListener('contextmenu', (e) => {
 | 
				
			||||||
            const node = e.target.closest('.tree-node');
 | 
					            // Check if we're in edit mode
 | 
				
			||||||
            if (!node) return;
 | 
					            const isEditMode = document.body.classList.contains('edit-mode');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // In view mode, disable custom context menu entirely
 | 
				
			||||||
 | 
					            if (!isEditMode) {
 | 
				
			||||||
 | 
					                e.preventDefault(); // Prevent default browser context menu
 | 
				
			||||||
 | 
					                return; // Don't show custom context menu
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Edit mode: show custom context menu
 | 
				
			||||||
 | 
					            const node = e.target.closest('.tree-node');
 | 
				
			||||||
            e.preventDefault();
 | 
					            e.preventDefault();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            if (node) {
 | 
				
			||||||
 | 
					                // Clicked on a node
 | 
				
			||||||
                const path = node.dataset.path;
 | 
					                const path = node.dataset.path;
 | 
				
			||||||
                const isDir = node.dataset.isdir === 'true';
 | 
					                const isDir = node.dataset.isdir === 'true';
 | 
				
			||||||
            
 | 
					 | 
				
			||||||
                window.showContextMenu(e.clientX, e.clientY, { path, isDir });
 | 
					                window.showContextMenu(e.clientX, e.clientY, { path, isDir });
 | 
				
			||||||
 | 
					            } else if (e.target === this.container) {
 | 
				
			||||||
 | 
					                // Clicked on the empty space in the file tree container
 | 
				
			||||||
 | 
					                window.showContextMenu(e.clientX, e.clientY, { path: '', isDir: true });
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Drag and drop event listeners (only in edit mode)
 | 
				
			||||||
 | 
					        this.setupDragAndDrop();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    setupUndoListener() {
 | 
				
			||||||
 | 
					        // Listen for Ctrl+Z (Windows/Linux) or Cmd+Z (Mac)
 | 
				
			||||||
 | 
					        document.addEventListener('keydown', async (e) => {
 | 
				
			||||||
 | 
					            // Check for Ctrl+Z or Cmd+Z
 | 
				
			||||||
 | 
					            const isUndo = (e.ctrlKey || e.metaKey) && e.key === 'z';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            if (isUndo && this.isEditMode() && this.lastMoveOperation) {
 | 
				
			||||||
 | 
					                e.preventDefault();
 | 
				
			||||||
 | 
					                await this.undoLastMove();
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    async undoLastMove() {
 | 
				
			||||||
 | 
					        if (!this.lastMoveOperation) {
 | 
				
			||||||
 | 
					            return;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        const { sourcePath, destPath, fileName, isDirectory } = this.lastMoveOperation;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        try {
 | 
				
			||||||
 | 
					            // Move the item back to its original location
 | 
				
			||||||
 | 
					            await this.webdavClient.move(destPath, sourcePath);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Get the parent folder name for the notification
 | 
				
			||||||
 | 
					            const sourceParent = PathUtils.getParentPath(sourcePath);
 | 
				
			||||||
 | 
					            const parentName = sourceParent ? sourceParent + '/' : 'root';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Clear the undo history
 | 
				
			||||||
 | 
					            this.lastMoveOperation = null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Reload the tree
 | 
				
			||||||
 | 
					            await this.load();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Re-select the moved item
 | 
				
			||||||
 | 
					            this.selectAndExpandPath(sourcePath);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            showNotification(`Undo: Moved ${fileName} back to ${parentName}`, 'success');
 | 
				
			||||||
 | 
					        } catch (error) {
 | 
				
			||||||
 | 
					            console.error('Failed to undo move:', error);
 | 
				
			||||||
 | 
					            showNotification('Failed to undo move: ' + error.message, 'danger');
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    setupDragAndDrop() {
 | 
				
			||||||
 | 
					        // Dragover event on container to allow dropping on root level
 | 
				
			||||||
 | 
					        this.container.addEventListener('dragover', (e) => {
 | 
				
			||||||
 | 
					            if (!this.isEditMode() || !this.draggedPath) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            const node = e.target.closest('.tree-node');
 | 
				
			||||||
 | 
					            if (!node) {
 | 
				
			||||||
 | 
					                // Hovering over empty space (root level)
 | 
				
			||||||
 | 
					                e.preventDefault();
 | 
				
			||||||
 | 
					                e.dataTransfer.dropEffect = 'move';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                // Highlight the entire container as a drop target
 | 
				
			||||||
 | 
					                this.container.classList.add('drag-over-root');
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Dragleave event on container to remove root-level highlighting
 | 
				
			||||||
 | 
					        this.container.addEventListener('dragleave', (e) => {
 | 
				
			||||||
 | 
					            if (!this.isEditMode()) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Only remove if we're actually leaving the container
 | 
				
			||||||
 | 
					            // Check if the related target is outside the container
 | 
				
			||||||
 | 
					            if (!this.container.contains(e.relatedTarget)) {
 | 
				
			||||||
 | 
					                this.container.classList.remove('drag-over-root');
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Dragenter event to manage highlighting
 | 
				
			||||||
 | 
					        this.container.addEventListener('dragenter', (e) => {
 | 
				
			||||||
 | 
					            if (!this.isEditMode() || !this.draggedPath) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            const node = e.target.closest('.tree-node');
 | 
				
			||||||
 | 
					            if (!node) {
 | 
				
			||||||
 | 
					                // Entering empty space
 | 
				
			||||||
 | 
					                this.container.classList.add('drag-over-root');
 | 
				
			||||||
 | 
					            } else {
 | 
				
			||||||
 | 
					                // Entering a node, remove root highlighting
 | 
				
			||||||
 | 
					                this.container.classList.remove('drag-over-root');
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Drop event on container for root level drops
 | 
				
			||||||
 | 
					        this.container.addEventListener('drop', async (e) => {
 | 
				
			||||||
 | 
					            if (!this.isEditMode()) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            const node = e.target.closest('.tree-node');
 | 
				
			||||||
 | 
					            if (!node && this.draggedPath) {
 | 
				
			||||||
 | 
					                // Dropped on root level
 | 
				
			||||||
 | 
					                e.preventDefault();
 | 
				
			||||||
 | 
					                this.container.classList.remove('drag-over-root');
 | 
				
			||||||
 | 
					                await this.handleDrop('', true);
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    isEditMode() {
 | 
				
			||||||
 | 
					        return document.body.classList.contains('edit-mode');
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    setupNodeDragHandlers(nodeElement, node) {
 | 
				
			||||||
 | 
					        // Dragstart - when user starts dragging
 | 
				
			||||||
 | 
					        nodeElement.addEventListener('dragstart', (e) => {
 | 
				
			||||||
 | 
					            this.draggedNode = nodeElement;
 | 
				
			||||||
 | 
					            this.draggedPath = node.path;
 | 
				
			||||||
 | 
					            this.draggedIsDir = node.isDirectory;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            nodeElement.classList.add('dragging');
 | 
				
			||||||
 | 
					            document.body.classList.add('dragging-active');
 | 
				
			||||||
 | 
					            e.dataTransfer.effectAllowed = 'move';
 | 
				
			||||||
 | 
					            e.dataTransfer.setData('text/plain', node.path);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Create a custom drag image with fixed width
 | 
				
			||||||
 | 
					            const dragImage = nodeElement.cloneNode(true);
 | 
				
			||||||
 | 
					            dragImage.style.position = 'absolute';
 | 
				
			||||||
 | 
					            dragImage.style.top = '-9999px';
 | 
				
			||||||
 | 
					            dragImage.style.left = '-9999px';
 | 
				
			||||||
 | 
					            dragImage.style.width = `${Config.DRAG_PREVIEW_WIDTH}px`;
 | 
				
			||||||
 | 
					            dragImage.style.maxWidth = `${Config.DRAG_PREVIEW_WIDTH}px`;
 | 
				
			||||||
 | 
					            dragImage.style.opacity = Config.DRAG_PREVIEW_OPACITY;
 | 
				
			||||||
 | 
					            dragImage.style.backgroundColor = 'var(--bg-secondary)';
 | 
				
			||||||
 | 
					            dragImage.style.border = '1px solid var(--border-color)';
 | 
				
			||||||
 | 
					            dragImage.style.borderRadius = '4px';
 | 
				
			||||||
 | 
					            dragImage.style.padding = '4px 8px';
 | 
				
			||||||
 | 
					            dragImage.style.whiteSpace = 'nowrap';
 | 
				
			||||||
 | 
					            dragImage.style.overflow = 'hidden';
 | 
				
			||||||
 | 
					            dragImage.style.textOverflow = 'ellipsis';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            document.body.appendChild(dragImage);
 | 
				
			||||||
 | 
					            e.dataTransfer.setDragImage(dragImage, 10, 10);
 | 
				
			||||||
 | 
					            setTimeout(() => {
 | 
				
			||||||
 | 
					                if (dragImage.parentNode) {
 | 
				
			||||||
 | 
					                    document.body.removeChild(dragImage);
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            }, 0);
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Dragend - when drag operation ends
 | 
				
			||||||
 | 
					        nodeElement.addEventListener('dragend', () => {
 | 
				
			||||||
 | 
					            nodeElement.classList.remove('dragging');
 | 
				
			||||||
 | 
					            nodeElement.classList.remove('drag-ready');
 | 
				
			||||||
 | 
					            document.body.classList.remove('dragging-active');
 | 
				
			||||||
 | 
					            this.container.classList.remove('drag-over-root');
 | 
				
			||||||
 | 
					            this.clearDragOverStates();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Reset draggable state
 | 
				
			||||||
 | 
					            nodeElement.draggable = false;
 | 
				
			||||||
 | 
					            nodeElement.style.cursor = '';
 | 
				
			||||||
 | 
					            this.isDraggingEnabled = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            this.draggedNode = null;
 | 
				
			||||||
 | 
					            this.draggedPath = null;
 | 
				
			||||||
 | 
					            this.draggedIsDir = false;
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Dragover - when dragging over this node
 | 
				
			||||||
 | 
					        nodeElement.addEventListener('dragover', (e) => {
 | 
				
			||||||
 | 
					            if (!this.draggedPath) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            const targetPath = node.path;
 | 
				
			||||||
 | 
					            const targetIsDir = node.isDirectory;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Only allow dropping on directories
 | 
				
			||||||
 | 
					            if (!targetIsDir) {
 | 
				
			||||||
 | 
					                e.dataTransfer.dropEffect = 'none';
 | 
				
			||||||
 | 
					                return;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Check if this is a valid drop target
 | 
				
			||||||
 | 
					            if (this.isValidDropTarget(this.draggedPath, this.draggedIsDir, targetPath)) {
 | 
				
			||||||
 | 
					                e.preventDefault();
 | 
				
			||||||
 | 
					                e.dataTransfer.dropEffect = 'move';
 | 
				
			||||||
 | 
					                nodeElement.classList.add('drag-over');
 | 
				
			||||||
 | 
					            } else {
 | 
				
			||||||
 | 
					                e.dataTransfer.dropEffect = 'none';
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Dragleave - when drag leaves this node
 | 
				
			||||||
 | 
					        nodeElement.addEventListener('dragleave', (e) => {
 | 
				
			||||||
 | 
					            // Only remove if we're actually leaving the node (not entering a child)
 | 
				
			||||||
 | 
					            if (e.target === nodeElement) {
 | 
				
			||||||
 | 
					                nodeElement.classList.remove('drag-over');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                // If leaving a node and not entering another node, might be going to root
 | 
				
			||||||
 | 
					                const relatedNode = e.relatedTarget?.closest('.tree-node');
 | 
				
			||||||
 | 
					                if (!relatedNode && this.container.contains(e.relatedTarget)) {
 | 
				
			||||||
 | 
					                    // Moving to empty space (root area)
 | 
				
			||||||
 | 
					                    this.container.classList.add('drag-over-root');
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Drop - when item is dropped on this node
 | 
				
			||||||
 | 
					        nodeElement.addEventListener('drop', async (e) => {
 | 
				
			||||||
 | 
					            e.preventDefault();
 | 
				
			||||||
 | 
					            e.stopPropagation();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            nodeElement.classList.remove('drag-over');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            if (!this.draggedPath) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            const targetPath = node.path;
 | 
				
			||||||
 | 
					            const targetIsDir = node.isDirectory;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            if (targetIsDir && this.isValidDropTarget(this.draggedPath, this.draggedIsDir, targetPath)) {
 | 
				
			||||||
 | 
					                await this.handleDrop(targetPath, targetIsDir);
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    clearDragOverStates() {
 | 
				
			||||||
 | 
					        this.container.querySelectorAll('.drag-over').forEach(node => {
 | 
				
			||||||
 | 
					            node.classList.remove('drag-over');
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    isValidDropTarget(sourcePath, sourceIsDir, targetPath) {
 | 
				
			||||||
 | 
					        // Can't drop on itself
 | 
				
			||||||
 | 
					        if (sourcePath === targetPath) {
 | 
				
			||||||
 | 
					            return false;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // If dragging a directory, can't drop into its own descendants
 | 
				
			||||||
 | 
					        if (sourceIsDir) {
 | 
				
			||||||
 | 
					            // Check if target is a descendant of source
 | 
				
			||||||
 | 
					            if (targetPath.startsWith(sourcePath + '/')) {
 | 
				
			||||||
 | 
					                return false;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Can't drop into the same parent directory
 | 
				
			||||||
 | 
					        const sourceParent = PathUtils.getParentPath(sourcePath);
 | 
				
			||||||
 | 
					        if (sourceParent === targetPath) {
 | 
				
			||||||
 | 
					            return false;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return true;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    async handleDrop(targetPath, targetIsDir) {
 | 
				
			||||||
 | 
					        if (!this.draggedPath) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        try {
 | 
				
			||||||
 | 
					            const sourcePath = this.draggedPath;
 | 
				
			||||||
 | 
					            const fileName = PathUtils.getFileName(sourcePath);
 | 
				
			||||||
 | 
					            const isDirectory = this.draggedIsDir;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Construct destination path
 | 
				
			||||||
 | 
					            let destPath;
 | 
				
			||||||
 | 
					            if (targetPath === '') {
 | 
				
			||||||
 | 
					                // Dropping to root
 | 
				
			||||||
 | 
					                destPath = fileName;
 | 
				
			||||||
 | 
					            } else {
 | 
				
			||||||
 | 
					                destPath = `${targetPath}/${fileName}`;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Check if destination already exists
 | 
				
			||||||
 | 
					            const destNode = this.findNode(destPath);
 | 
				
			||||||
 | 
					            if (destNode) {
 | 
				
			||||||
 | 
					                const overwrite = await window.ModalManager.confirm(
 | 
				
			||||||
 | 
					                    `A ${destNode.isDirectory ? 'folder' : 'file'} named "${fileName}" already exists in the destination. Do you want to overwrite it?`,
 | 
				
			||||||
 | 
					                    'Name Conflict',
 | 
				
			||||||
 | 
					                    true
 | 
				
			||||||
 | 
					                );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                if (!overwrite) {
 | 
				
			||||||
 | 
					                    return;
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                // Delete existing item first
 | 
				
			||||||
 | 
					                await this.webdavClient.delete(destPath);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                // Clear undo history since we're overwriting
 | 
				
			||||||
 | 
					                this.lastMoveOperation = null;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Perform the move
 | 
				
			||||||
 | 
					            await this.webdavClient.move(sourcePath, destPath);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Store undo information (only if not overwriting)
 | 
				
			||||||
 | 
					            if (!destNode) {
 | 
				
			||||||
 | 
					                this.lastMoveOperation = {
 | 
				
			||||||
 | 
					                    sourcePath: sourcePath,
 | 
				
			||||||
 | 
					                    destPath: destPath,
 | 
				
			||||||
 | 
					                    fileName: fileName,
 | 
				
			||||||
 | 
					                    isDirectory: isDirectory
 | 
				
			||||||
 | 
					                };
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // If the moved item was the currently selected file, update the selection
 | 
				
			||||||
 | 
					            if (this.selectedPath === sourcePath) {
 | 
				
			||||||
 | 
					                this.selectedPath = destPath;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                // Update editor's current file path if it's the file being moved
 | 
				
			||||||
 | 
					                if (!this.draggedIsDir && window.editor && window.editor.currentFile === sourcePath) {
 | 
				
			||||||
 | 
					                    window.editor.currentFile = destPath;
 | 
				
			||||||
 | 
					                    if (window.editor.filenameInput) {
 | 
				
			||||||
 | 
					                        window.editor.filenameInput.value = destPath;
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                // Notify file select callback if it's a file
 | 
				
			||||||
 | 
					                if (!this.draggedIsDir && this.onFileSelect) {
 | 
				
			||||||
 | 
					                    this.onFileSelect({ path: destPath, isDirectory: false });
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Reload the tree
 | 
				
			||||||
 | 
					            await this.load();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Re-select the moved item
 | 
				
			||||||
 | 
					            this.selectAndExpandPath(destPath);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            showNotification(`Moved ${fileName} successfully`, 'success');
 | 
				
			||||||
 | 
					        } catch (error) {
 | 
				
			||||||
 | 
					            console.error('Failed to move item:', error);
 | 
				
			||||||
 | 
					            showNotification('Failed to move item: ' + error.message, 'danger');
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    async load() {
 | 
					    async load() {
 | 
				
			||||||
@@ -69,83 +427,75 @@ class FileTree {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    renderNodes(nodes, parentElement, level) {
 | 
					    renderNodes(nodes, parentElement, level) {
 | 
				
			||||||
        nodes.forEach(node => {
 | 
					        nodes.forEach(node => {
 | 
				
			||||||
            const nodeElement = this.createNodeElement(node, level);
 | 
					            // Filter out images and image directories in view mode
 | 
				
			||||||
            parentElement.appendChild(nodeElement);
 | 
					            if (this.filterImagesInViewMode) {
 | 
				
			||||||
 | 
					                // Skip image files
 | 
				
			||||||
 | 
					                if (!node.isDirectory && PathUtils.isBinaryFile(node.path)) {
 | 
				
			||||||
 | 
					                    return;
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            if (node.children && node.children.length > 0) {
 | 
					                // Skip image directories
 | 
				
			||||||
 | 
					                if (node.isDirectory && PathUtils.isImageDirectory(node.path)) {
 | 
				
			||||||
 | 
					                    return;
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            const nodeWrapper = document.createElement('div');
 | 
				
			||||||
 | 
					            nodeWrapper.className = 'tree-node-wrapper';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Create node element
 | 
				
			||||||
 | 
					            const nodeElement = this.createNodeElement(node, level);
 | 
				
			||||||
 | 
					            nodeWrapper.appendChild(nodeElement);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Create children container for directories
 | 
				
			||||||
 | 
					            if (node.isDirectory) {
 | 
				
			||||||
                const childContainer = document.createElement('div');
 | 
					                const childContainer = document.createElement('div');
 | 
				
			||||||
                childContainer.className = 'tree-children';
 | 
					                childContainer.className = 'tree-children';
 | 
				
			||||||
                childContainer.style.display = 'none';
 | 
					                childContainer.style.display = 'none';
 | 
				
			||||||
                nodeElement.appendChild(childContainer);
 | 
					                childContainer.dataset.parent = node.path;
 | 
				
			||||||
 | 
					                childContainer.style.marginLeft = `${(level + 1) * 12}px`;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                // Only render children if they exist
 | 
				
			||||||
 | 
					                if (node.children && node.children.length > 0) {
 | 
				
			||||||
                    this.renderNodes(node.children, childContainer, level + 1);
 | 
					                    this.renderNodes(node.children, childContainer, level + 1);
 | 
				
			||||||
 | 
					                } else {
 | 
				
			||||||
 | 
					                    // Empty directory - show empty state message
 | 
				
			||||||
 | 
					                    const emptyMessage = document.createElement('div');
 | 
				
			||||||
 | 
					                    emptyMessage.className = 'tree-empty-message';
 | 
				
			||||||
 | 
					                    emptyMessage.textContent = 'Empty folder';
 | 
				
			||||||
 | 
					                    childContainer.appendChild(emptyMessage);
 | 
				
			||||||
                }
 | 
					                }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                nodeWrapper.appendChild(childContainer);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                // Make toggle functional for ALL directories (including empty ones)
 | 
				
			||||||
 | 
					                const toggle = nodeElement.querySelector('.tree-node-toggle');
 | 
				
			||||||
 | 
					                if (toggle) {
 | 
				
			||||||
 | 
					                    const toggleHandler = (e) => {
 | 
				
			||||||
 | 
					                        e.stopPropagation();
 | 
				
			||||||
 | 
					                        const isHidden = childContainer.style.display === 'none';
 | 
				
			||||||
 | 
					                        childContainer.style.display = isHidden ? 'block' : 'none';
 | 
				
			||||||
 | 
					                        toggle.style.transform = isHidden ? 'rotate(90deg)' : 'rotate(0deg)';
 | 
				
			||||||
 | 
					                        toggle.classList.toggle('expanded');
 | 
				
			||||||
 | 
					                    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    // Add click listener to toggle icon
 | 
				
			||||||
 | 
					                    toggle.addEventListener('click', toggleHandler);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    // Also allow double-click on the node to toggle
 | 
				
			||||||
 | 
					                    nodeElement.addEventListener('dblclick', toggleHandler);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    // Make toggle cursor pointer for all directories
 | 
				
			||||||
 | 
					                    toggle.style.cursor = 'pointer';
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            parentElement.appendChild(nodeWrapper);
 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    createNodeElement(node, level) {
 | 
					 | 
				
			||||||
        const div = document.createElement('div');
 | 
					 | 
				
			||||||
        div.className = 'tree-node';
 | 
					 | 
				
			||||||
        div.dataset.path = node.path;
 | 
					 | 
				
			||||||
        div.dataset.isdir = node.isDirectory;
 | 
					 | 
				
			||||||
        div.style.paddingLeft = `${level * 20 + 10}px`;
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
        // Toggle arrow for folders
 | 
					    // toggleFolder is no longer needed as the event listener is added in renderNodes.
 | 
				
			||||||
        if (node.isDirectory) {
 | 
					 | 
				
			||||||
            const toggle = document.createElement('span');
 | 
					 | 
				
			||||||
            toggle.className = 'tree-toggle';
 | 
					 | 
				
			||||||
            toggle.innerHTML = '<i class="bi bi-chevron-right"></i>';
 | 
					 | 
				
			||||||
            div.appendChild(toggle);
 | 
					 | 
				
			||||||
        } else {
 | 
					 | 
				
			||||||
            const spacer = document.createElement('span');
 | 
					 | 
				
			||||||
            spacer.className = 'tree-spacer';
 | 
					 | 
				
			||||||
            spacer.style.width = '16px';
 | 
					 | 
				
			||||||
            spacer.style.display = 'inline-block';
 | 
					 | 
				
			||||||
            div.appendChild(spacer);
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        
 | 
					 | 
				
			||||||
        // Icon
 | 
					 | 
				
			||||||
        const icon = document.createElement('i');
 | 
					 | 
				
			||||||
        if (node.isDirectory) {
 | 
					 | 
				
			||||||
            icon.className = 'bi bi-folder-fill';
 | 
					 | 
				
			||||||
            icon.style.color = '#dcb67a';
 | 
					 | 
				
			||||||
        } else {
 | 
					 | 
				
			||||||
            icon.className = 'bi bi-file-earmark-text';
 | 
					 | 
				
			||||||
            icon.style.color = '#6a9fb5';
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        div.appendChild(icon);
 | 
					 | 
				
			||||||
        
 | 
					 | 
				
			||||||
        // Name
 | 
					 | 
				
			||||||
        const name = document.createElement('span');
 | 
					 | 
				
			||||||
        name.className = 'tree-name';
 | 
					 | 
				
			||||||
        name.textContent = node.name;
 | 
					 | 
				
			||||||
        div.appendChild(name);
 | 
					 | 
				
			||||||
        
 | 
					 | 
				
			||||||
        // Size for files
 | 
					 | 
				
			||||||
        if (!node.isDirectory && node.size) {
 | 
					 | 
				
			||||||
            const size = document.createElement('span');
 | 
					 | 
				
			||||||
            size.className = 'tree-size';
 | 
					 | 
				
			||||||
            size.textContent = this.formatSize(node.size);
 | 
					 | 
				
			||||||
            div.appendChild(size);
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        
 | 
					 | 
				
			||||||
        return div;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    toggleFolder(nodeElement) {
 | 
					 | 
				
			||||||
        const childContainer = nodeElement.querySelector('.tree-children');
 | 
					 | 
				
			||||||
        if (!childContainer) return;
 | 
					 | 
				
			||||||
        
 | 
					 | 
				
			||||||
        const toggle = nodeElement.querySelector('.tree-toggle i');
 | 
					 | 
				
			||||||
        const isExpanded = childContainer.style.display !== 'none';
 | 
					 | 
				
			||||||
        
 | 
					 | 
				
			||||||
        if (isExpanded) {
 | 
					 | 
				
			||||||
            childContainer.style.display = 'none';
 | 
					 | 
				
			||||||
            toggle.className = 'bi bi-chevron-right';
 | 
					 | 
				
			||||||
        } else {
 | 
					 | 
				
			||||||
            childContainer.style.display = 'block';
 | 
					 | 
				
			||||||
            toggle.className = 'bi bi-chevron-down';
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    selectFile(path) {
 | 
					    selectFile(path) {
 | 
				
			||||||
        this.selectedPath = path;
 | 
					        this.selectedPath = path;
 | 
				
			||||||
@@ -163,20 +513,225 @@ class FileTree {
 | 
				
			|||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Find a node by path
 | 
				
			||||||
 | 
					     * @param {string} path - The path to find
 | 
				
			||||||
 | 
					     * @returns {Object|null} The node or null if not found
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    findNode(path) {
 | 
				
			||||||
 | 
					        const search = (nodes, targetPath) => {
 | 
				
			||||||
 | 
					            for (const node of nodes) {
 | 
				
			||||||
 | 
					                if (node.path === targetPath) {
 | 
				
			||||||
 | 
					                    return node;
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					                if (node.children && node.children.length > 0) {
 | 
				
			||||||
 | 
					                    const found = search(node.children, targetPath);
 | 
				
			||||||
 | 
					                    if (found) return found;
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					            return null;
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return search(this.tree, path);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Get all files in a directory (direct children only)
 | 
				
			||||||
 | 
					     * @param {string} dirPath - The directory path
 | 
				
			||||||
 | 
					     * @returns {Array} Array of file nodes
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    getDirectoryFiles(dirPath) {
 | 
				
			||||||
 | 
					        const dirNode = this.findNode(dirPath);
 | 
				
			||||||
 | 
					        if (dirNode && dirNode.children) {
 | 
				
			||||||
 | 
					            return dirNode.children.filter(child => !child.isDirectory);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        return [];
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    updateSelection() {
 | 
					    updateSelection() {
 | 
				
			||||||
        // Remove previous selection
 | 
					        // Remove previous selection
 | 
				
			||||||
        this.container.querySelectorAll('.tree-node').forEach(node => {
 | 
					        this.container.querySelectorAll('.tree-node').forEach(node => {
 | 
				
			||||||
            node.classList.remove('selected');
 | 
					            node.classList.remove('active');
 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        // Add selection to current
 | 
					        // Add selection to current and all parent directories
 | 
				
			||||||
        if (this.selectedPath) {
 | 
					        if (this.selectedPath) {
 | 
				
			||||||
 | 
					            // Add active class to the selected file/folder
 | 
				
			||||||
            const node = this.container.querySelector(`[data-path="${this.selectedPath}"]`);
 | 
					            const node = this.container.querySelector(`[data-path="${this.selectedPath}"]`);
 | 
				
			||||||
            if (node) {
 | 
					            if (node) {
 | 
				
			||||||
                node.classList.add('selected');
 | 
					                node.classList.add('active');
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Add active class to all parent directories
 | 
				
			||||||
 | 
					            const parts = this.selectedPath.split('/');
 | 
				
			||||||
 | 
					            let currentPath = '';
 | 
				
			||||||
 | 
					            for (let i = 0; i < parts.length - 1; i++) {
 | 
				
			||||||
 | 
					                currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i];
 | 
				
			||||||
 | 
					                const parentNode = this.container.querySelector(`[data-path="${currentPath}"]`);
 | 
				
			||||||
 | 
					                if (parentNode) {
 | 
				
			||||||
 | 
					                    parentNode.classList.add('active');
 | 
				
			||||||
                }
 | 
					                }
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Highlight a file as active and expand all parent directories
 | 
				
			||||||
 | 
					     * @param {string} path - The file path to highlight
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    selectAndExpandPath(path) {
 | 
				
			||||||
 | 
					        this.selectedPath = path;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Expand all parent directories
 | 
				
			||||||
 | 
					        this.expandParentDirectories(path);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Update selection
 | 
				
			||||||
 | 
					        this.updateSelection();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Expand all parent directories of a given path
 | 
				
			||||||
 | 
					     * @param {string} path - The file path
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    expandParentDirectories(path) {
 | 
				
			||||||
 | 
					        // Get all parent paths
 | 
				
			||||||
 | 
					        const parts = path.split('/');
 | 
				
			||||||
 | 
					        let currentPath = '';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        for (let i = 0; i < parts.length - 1; i++) {
 | 
				
			||||||
 | 
					            currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Find the node with this path
 | 
				
			||||||
 | 
					            const parentNode = this.container.querySelector(`[data-path="${currentPath}"]`);
 | 
				
			||||||
 | 
					            if (parentNode && parentNode.dataset.isdir === 'true') {
 | 
				
			||||||
 | 
					                // Find the children container
 | 
				
			||||||
 | 
					                const wrapper = parentNode.closest('.tree-node-wrapper');
 | 
				
			||||||
 | 
					                if (wrapper) {
 | 
				
			||||||
 | 
					                    const childContainer = wrapper.querySelector('.tree-children');
 | 
				
			||||||
 | 
					                    if (childContainer && childContainer.style.display === 'none') {
 | 
				
			||||||
 | 
					                        // Expand it
 | 
				
			||||||
 | 
					                        childContainer.style.display = 'block';
 | 
				
			||||||
 | 
					                        const toggle = parentNode.querySelector('.tree-node-toggle');
 | 
				
			||||||
 | 
					                        if (toggle) {
 | 
				
			||||||
 | 
					                            toggle.classList.add('expanded');
 | 
				
			||||||
 | 
					                        }
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    createNodeElement(node, level) {
 | 
				
			||||||
 | 
					        const nodeElement = document.createElement('div');
 | 
				
			||||||
 | 
					        nodeElement.className = 'tree-node';
 | 
				
			||||||
 | 
					        nodeElement.dataset.path = node.path;
 | 
				
			||||||
 | 
					        nodeElement.dataset.isdir = node.isDirectory;
 | 
				
			||||||
 | 
					        nodeElement.style.paddingLeft = `${level * 12}px`;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Enable drag and drop in edit mode with long-press detection
 | 
				
			||||||
 | 
					        if (this.isEditMode()) {
 | 
				
			||||||
 | 
					            // Start with draggable disabled
 | 
				
			||||||
 | 
					            nodeElement.draggable = false;
 | 
				
			||||||
 | 
					            this.setupNodeDragHandlers(nodeElement, node);
 | 
				
			||||||
 | 
					            this.setupLongPressDetection(nodeElement, node);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Create toggle/icon container
 | 
				
			||||||
 | 
					        const iconContainer = document.createElement('span');
 | 
				
			||||||
 | 
					        iconContainer.className = 'tree-node-icon';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (node.isDirectory) {
 | 
				
			||||||
 | 
					            // Create toggle icon for folders
 | 
				
			||||||
 | 
					            const toggle = document.createElement('i');
 | 
				
			||||||
 | 
					            toggle.className = 'bi bi-chevron-right tree-node-toggle';
 | 
				
			||||||
 | 
					            toggle.style.fontSize = '12px';
 | 
				
			||||||
 | 
					            iconContainer.appendChild(toggle);
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					            // Create file icon
 | 
				
			||||||
 | 
					            const fileIcon = document.createElement('i');
 | 
				
			||||||
 | 
					            fileIcon.className = 'bi bi-file-earmark-text';
 | 
				
			||||||
 | 
					            fileIcon.style.fontSize = '14px';
 | 
				
			||||||
 | 
					            iconContainer.appendChild(fileIcon);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        const title = document.createElement('span');
 | 
				
			||||||
 | 
					        title.className = 'tree-node-title';
 | 
				
			||||||
 | 
					        title.textContent = node.name;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        nodeElement.appendChild(iconContainer);
 | 
				
			||||||
 | 
					        nodeElement.appendChild(title);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return nodeElement;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    setupLongPressDetection(nodeElement, node) {
 | 
				
			||||||
 | 
					        // Mouse down - start long-press timer
 | 
				
			||||||
 | 
					        nodeElement.addEventListener('mousedown', (e) => {
 | 
				
			||||||
 | 
					            // Ignore if clicking on toggle button
 | 
				
			||||||
 | 
					            if (e.target.closest('.tree-node-toggle')) {
 | 
				
			||||||
 | 
					                return;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            this.mouseDownNode = nodeElement;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Start timer for long-press
 | 
				
			||||||
 | 
					            this.longPressTimer = setTimeout(() => {
 | 
				
			||||||
 | 
					                // Long-press threshold met - enable dragging
 | 
				
			||||||
 | 
					                this.isDraggingEnabled = true;
 | 
				
			||||||
 | 
					                nodeElement.draggable = true;
 | 
				
			||||||
 | 
					                nodeElement.classList.add('drag-ready');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                // Change cursor to grab
 | 
				
			||||||
 | 
					                nodeElement.style.cursor = 'grab';
 | 
				
			||||||
 | 
					            }, this.longPressThreshold);
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Mouse up - cancel long-press timer
 | 
				
			||||||
 | 
					        nodeElement.addEventListener('mouseup', () => {
 | 
				
			||||||
 | 
					            this.clearLongPressTimer();
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Mouse leave - cancel long-press timer
 | 
				
			||||||
 | 
					        nodeElement.addEventListener('mouseleave', () => {
 | 
				
			||||||
 | 
					            this.clearLongPressTimer();
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Mouse move - cancel long-press if moved too much
 | 
				
			||||||
 | 
					        let startX, startY;
 | 
				
			||||||
 | 
					        nodeElement.addEventListener('mousedown', (e) => {
 | 
				
			||||||
 | 
					            startX = e.clientX;
 | 
				
			||||||
 | 
					            startY = e.clientY;
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        nodeElement.addEventListener('mousemove', (e) => {
 | 
				
			||||||
 | 
					            if (this.longPressTimer && !this.isDraggingEnabled) {
 | 
				
			||||||
 | 
					                const deltaX = Math.abs(e.clientX - startX);
 | 
				
			||||||
 | 
					                const deltaY = Math.abs(e.clientY - startY);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                // If mouse moved more than threshold, cancel long-press
 | 
				
			||||||
 | 
					                if (deltaX > Config.MOUSE_MOVE_THRESHOLD || deltaY > Config.MOUSE_MOVE_THRESHOLD) {
 | 
				
			||||||
 | 
					                    this.clearLongPressTimer();
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    clearLongPressTimer() {
 | 
				
			||||||
 | 
					        if (this.longPressTimer) {
 | 
				
			||||||
 | 
					            clearTimeout(this.longPressTimer);
 | 
				
			||||||
 | 
					            this.longPressTimer = null;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Reset dragging state if not currently dragging
 | 
				
			||||||
 | 
					        if (!this.draggedPath && this.mouseDownNode) {
 | 
				
			||||||
 | 
					            this.mouseDownNode.draggable = false;
 | 
				
			||||||
 | 
					            this.mouseDownNode.classList.remove('drag-ready');
 | 
				
			||||||
 | 
					            this.mouseDownNode.style.cursor = '';
 | 
				
			||||||
 | 
					            this.isDraggingEnabled = false;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        this.mouseDownNode = null;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    formatSize(bytes) {
 | 
					    formatSize(bytes) {
 | 
				
			||||||
        if (bytes === 0) return '0 B';
 | 
					        if (bytes === 0) return '0 B';
 | 
				
			||||||
@@ -186,11 +741,21 @@ class FileTree {
 | 
				
			|||||||
        return Math.round(bytes / Math.pow(k, i) * 10) / 10 + ' ' + sizes[i];
 | 
					        return Math.round(bytes / Math.pow(k, i) * 10) / 10 + ' ' + sizes[i];
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    newFile() {
 | 
				
			||||||
 | 
					        this.selectedPath = null;
 | 
				
			||||||
 | 
					        this.updateSelection();
 | 
				
			||||||
 | 
					        // Potentially clear editor via callback
 | 
				
			||||||
 | 
					        if (this.onFileSelect) {
 | 
				
			||||||
 | 
					            this.onFileSelect({ path: null, isDirectory: false });
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    async createFile(parentPath, filename) {
 | 
					    async createFile(parentPath, filename) {
 | 
				
			||||||
        try {
 | 
					        try {
 | 
				
			||||||
            const fullPath = parentPath ? `${parentPath}/${filename}` : filename;
 | 
					            const fullPath = parentPath ? `${parentPath}/${filename}` : filename;
 | 
				
			||||||
            await this.webdavClient.put(fullPath, '# New File\n\nStart typing...\n');
 | 
					            await this.webdavClient.put(fullPath, '# New File\n\nStart typing...\n');
 | 
				
			||||||
            await this.load();
 | 
					            await this.load();
 | 
				
			||||||
 | 
					            this.selectFile(fullPath); // Select the new file
 | 
				
			||||||
            showNotification('File created', 'success');
 | 
					            showNotification('File created', 'success');
 | 
				
			||||||
            return fullPath;
 | 
					            return fullPath;
 | 
				
			||||||
        } catch (error) {
 | 
					        } catch (error) {
 | 
				
			||||||
@@ -232,8 +797,8 @@ class FileTree {
 | 
				
			|||||||
    async downloadFile(path) {
 | 
					    async downloadFile(path) {
 | 
				
			||||||
        try {
 | 
					        try {
 | 
				
			||||||
            const content = await this.webdavClient.get(path);
 | 
					            const content = await this.webdavClient.get(path);
 | 
				
			||||||
            const filename = path.split('/').pop();
 | 
					            const filename = PathUtils.getFileName(path);
 | 
				
			||||||
            this.triggerDownload(content, filename);
 | 
					            DownloadUtils.triggerDownload(content, filename);
 | 
				
			||||||
            showNotification('Downloaded', 'success');
 | 
					            showNotification('Downloaded', 'success');
 | 
				
			||||||
        } catch (error) {
 | 
					        } catch (error) {
 | 
				
			||||||
            console.error('Failed to download file:', error);
 | 
					            console.error('Failed to download file:', error);
 | 
				
			||||||
@@ -255,7 +820,7 @@ class FileTree {
 | 
				
			|||||||
            }
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            const zip = new JSZip();
 | 
					            const zip = new JSZip();
 | 
				
			||||||
            const folder = zip.folder(path.split('/').pop() || 'download');
 | 
					            const folder = zip.folder(PathUtils.getFileName(path) || 'download');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            // Add all files to zip
 | 
					            // Add all files to zip
 | 
				
			||||||
            for (const file of files) {
 | 
					            for (const file of files) {
 | 
				
			||||||
@@ -266,8 +831,8 @@ class FileTree {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
            // Generate zip
 | 
					            // Generate zip
 | 
				
			||||||
            const zipBlob = await zip.generateAsync({ type: 'blob' });
 | 
					            const zipBlob = await zip.generateAsync({ type: 'blob' });
 | 
				
			||||||
            const zipFilename = `${path.split('/').pop() || 'download'}.zip`;
 | 
					            const zipFilename = `${PathUtils.getFileName(path) || 'download'}.zip`;
 | 
				
			||||||
            this.triggerDownload(zipBlob, zipFilename);
 | 
					            DownloadUtils.triggerDownload(zipBlob, zipFilename);
 | 
				
			||||||
            showNotification('Downloaded', 'success');
 | 
					            showNotification('Downloaded', 'success');
 | 
				
			||||||
        } catch (error) {
 | 
					        } catch (error) {
 | 
				
			||||||
            console.error('Failed to download folder:', error);
 | 
					            console.error('Failed to download folder:', error);
 | 
				
			||||||
@@ -275,16 +840,29 @@ class FileTree {
 | 
				
			|||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    triggerDownload(content, filename) {
 | 
					    // triggerDownload method moved to DownloadUtils in utils.js
 | 
				
			||||||
        const blob = content instanceof Blob ? content : new Blob([content]);
 | 
					
 | 
				
			||||||
        const url = URL.createObjectURL(blob);
 | 
					    /**
 | 
				
			||||||
        const a = document.createElement('a');
 | 
					     * Get the first markdown file in the tree
 | 
				
			||||||
        a.href = url;
 | 
					     * Returns the path of the first .md file found, or null if none exist
 | 
				
			||||||
        a.download = filename;
 | 
					     */
 | 
				
			||||||
        document.body.appendChild(a);
 | 
					    getFirstMarkdownFile() {
 | 
				
			||||||
        a.click();
 | 
					        const findFirstFile = (nodes) => {
 | 
				
			||||||
        document.body.removeChild(a);
 | 
					            for (const node of nodes) {
 | 
				
			||||||
        URL.revokeObjectURL(url);
 | 
					                // If it's a file and ends with .md, return it
 | 
				
			||||||
 | 
					                if (!node.isDirectory && node.path.endsWith('.md')) {
 | 
				
			||||||
 | 
					                    return node.path;
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					                // If it's a directory with children, search recursively
 | 
				
			||||||
 | 
					                if (node.isDirectory && node.children && node.children.length > 0) {
 | 
				
			||||||
 | 
					                    const found = findFirstFile(node.children);
 | 
				
			||||||
 | 
					                    if (found) return found;
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					            return null;
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return findFirstFile(this.tree);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										37
									
								
								static/js/file-upload.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,37 @@
 | 
				
			|||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * File Upload Module
 | 
				
			||||||
 | 
					 * Handles file upload dialog for uploading files to the file tree
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Show file upload dialog
 | 
				
			||||||
 | 
					 * @param {string} targetPath - The target directory path
 | 
				
			||||||
 | 
					 * @param {Function} onUpload - Callback function to handle file upload
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					function showFileUploadDialog(targetPath, onUpload) {
 | 
				
			||||||
 | 
					    const input = document.createElement('input');
 | 
				
			||||||
 | 
					    input.type = 'file';
 | 
				
			||||||
 | 
					    input.multiple = true;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    input.addEventListener('change', async (e) => {
 | 
				
			||||||
 | 
					        const files = Array.from(e.target.files);
 | 
				
			||||||
 | 
					        if (files.length === 0) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        for (const file of files) {
 | 
				
			||||||
 | 
					            try {
 | 
				
			||||||
 | 
					                await onUpload(targetPath, file);
 | 
				
			||||||
 | 
					            } catch (error) {
 | 
				
			||||||
 | 
					                Logger.error('Upload failed:', error);
 | 
				
			||||||
 | 
					                if (window.showNotification) {
 | 
				
			||||||
 | 
					                    window.showNotification(`Failed to upload ${file.name}`, 'error');
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    input.click();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Make function globally available
 | 
				
			||||||
 | 
					window.showFileUploadDialog = showFileUploadDialog;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
							
								
								
									
										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;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
							
								
								
									
										174
									
								
								static/js/logger.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,174 @@
 | 
				
			|||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Logger Module
 | 
				
			||||||
 | 
					 * Provides structured logging with different levels
 | 
				
			||||||
 | 
					 * Can be configured to show/hide different log levels
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Logger {
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Log levels
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    static LEVELS = {
 | 
				
			||||||
 | 
					        DEBUG: 0,
 | 
				
			||||||
 | 
					        INFO: 1,
 | 
				
			||||||
 | 
					        WARN: 2,
 | 
				
			||||||
 | 
					        ERROR: 3,
 | 
				
			||||||
 | 
					        NONE: 4
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Current log level
 | 
				
			||||||
 | 
					     * Set to DEBUG by default, can be changed via setLevel()
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    static currentLevel = Logger.LEVELS.DEBUG;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Enable/disable logging
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    static enabled = true;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Set the minimum log level
 | 
				
			||||||
 | 
					     * @param {number} level - One of Logger.LEVELS
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    static setLevel(level) {
 | 
				
			||||||
 | 
					        if (typeof level === 'number' && level >= 0 && level <= 4) {
 | 
				
			||||||
 | 
					            Logger.currentLevel = level;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Enable or disable logging
 | 
				
			||||||
 | 
					     * @param {boolean} enabled - Whether to enable logging
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    static setEnabled(enabled) {
 | 
				
			||||||
 | 
					        Logger.enabled = enabled;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Log a debug message
 | 
				
			||||||
 | 
					     * @param {string} message - The message to log
 | 
				
			||||||
 | 
					     * @param {...any} args - Additional arguments to log
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    static debug(message, ...args) {
 | 
				
			||||||
 | 
					        if (!Logger.enabled || Logger.currentLevel > Logger.LEVELS.DEBUG) {
 | 
				
			||||||
 | 
					            return;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        console.log(`[DEBUG] ${message}`, ...args);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Log an info message
 | 
				
			||||||
 | 
					     * @param {string} message - The message to log
 | 
				
			||||||
 | 
					     * @param {...any} args - Additional arguments to log
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    static info(message, ...args) {
 | 
				
			||||||
 | 
					        if (!Logger.enabled || Logger.currentLevel > Logger.LEVELS.INFO) {
 | 
				
			||||||
 | 
					            return;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        console.info(`[INFO] ${message}`, ...args);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Log a warning message
 | 
				
			||||||
 | 
					     * @param {string} message - The message to log
 | 
				
			||||||
 | 
					     * @param {...any} args - Additional arguments to log
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    static warn(message, ...args) {
 | 
				
			||||||
 | 
					        if (!Logger.enabled || Logger.currentLevel > Logger.LEVELS.WARN) {
 | 
				
			||||||
 | 
					            return;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        console.warn(`[WARN] ${message}`, ...args);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Log an error message
 | 
				
			||||||
 | 
					     * @param {string} message - The message to log
 | 
				
			||||||
 | 
					     * @param {...any} args - Additional arguments to log
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    static error(message, ...args) {
 | 
				
			||||||
 | 
					        if (!Logger.enabled || Logger.currentLevel > Logger.LEVELS.ERROR) {
 | 
				
			||||||
 | 
					            return;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        console.error(`[ERROR] ${message}`, ...args);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Log a message with a custom prefix
 | 
				
			||||||
 | 
					     * @param {string} prefix - The prefix to use
 | 
				
			||||||
 | 
					     * @param {string} message - The message to log
 | 
				
			||||||
 | 
					     * @param {...any} args - Additional arguments to log
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    static log(prefix, message, ...args) {
 | 
				
			||||||
 | 
					        if (!Logger.enabled) {
 | 
				
			||||||
 | 
					            return;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        console.log(`[${prefix}] ${message}`, ...args);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Group related log messages
 | 
				
			||||||
 | 
					     * @param {string} label - The group label
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    static group(label) {
 | 
				
			||||||
 | 
					        if (!Logger.enabled) {
 | 
				
			||||||
 | 
					            return;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        console.group(label);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * End a log group
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    static groupEnd() {
 | 
				
			||||||
 | 
					        if (!Logger.enabled) {
 | 
				
			||||||
 | 
					            return;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        console.groupEnd();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Log a table (useful for arrays of objects)
 | 
				
			||||||
 | 
					     * @param {any} data - The data to display as a table
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    static table(data) {
 | 
				
			||||||
 | 
					        if (!Logger.enabled) {
 | 
				
			||||||
 | 
					            return;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        console.table(data);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Start a timer
 | 
				
			||||||
 | 
					     * @param {string} label - The timer label
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    static time(label) {
 | 
				
			||||||
 | 
					        if (!Logger.enabled) {
 | 
				
			||||||
 | 
					            return;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        console.time(label);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * End a timer and log the elapsed time
 | 
				
			||||||
 | 
					     * @param {string} label - The timer label
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    static timeEnd(label) {
 | 
				
			||||||
 | 
					        if (!Logger.enabled) {
 | 
				
			||||||
 | 
					            return;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        console.timeEnd(label);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Make Logger globally available
 | 
				
			||||||
 | 
					window.Logger = Logger;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Set default log level based on environment
 | 
				
			||||||
 | 
					// In production, you might want to set this to WARN or ERROR
 | 
				
			||||||
 | 
					if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
 | 
				
			||||||
 | 
					    Logger.setLevel(Logger.LEVELS.DEBUG);
 | 
				
			||||||
 | 
					} else {
 | 
				
			||||||
 | 
					    Logger.setLevel(Logger.LEVELS.INFO);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
							
								
								
									
										103
									
								
								static/js/macro-parser.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,103 @@
 | 
				
			|||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Macro Parser and Processor
 | 
				
			||||||
 | 
					 * Parses HeroScript-style macros from markdown content
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class MacroParser {
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Parse and extract all macros from content
 | 
				
			||||||
 | 
					     * Returns array of { fullMatch, actor, method, params }
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    static extractMacros(content) {
 | 
				
			||||||
 | 
					        const macroRegex = /!!([\w.]+)\s*([\s\S]*?)(?=\n!!|\n#|$)/g;
 | 
				
			||||||
 | 
					        const macros = [];
 | 
				
			||||||
 | 
					        let match;
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        while ((match = macroRegex.exec(content)) !== null) {
 | 
				
			||||||
 | 
					            const fullMatch = match[0];
 | 
				
			||||||
 | 
					            const actionPart = match[1]; // e.g., "include" or "core.include"
 | 
				
			||||||
 | 
					            const paramsPart = match[2];
 | 
				
			||||||
 | 
					            
 | 
				
			||||||
 | 
					            // Parse action: "method" or "actor.method"
 | 
				
			||||||
 | 
					            const [actor, method] = actionPart.includes('.')
 | 
				
			||||||
 | 
					                ? actionPart.split('.')
 | 
				
			||||||
 | 
					                : ['core', actionPart];
 | 
				
			||||||
 | 
					            
 | 
				
			||||||
 | 
					            // Parse parameters from HeroScript-like syntax
 | 
				
			||||||
 | 
					            const params = this.parseParams(paramsPart);
 | 
				
			||||||
 | 
					            
 | 
				
			||||||
 | 
					            macros.push({
 | 
				
			||||||
 | 
					                fullMatch: fullMatch.trim(),
 | 
				
			||||||
 | 
					                actor,
 | 
				
			||||||
 | 
					                method,
 | 
				
			||||||
 | 
					                params,
 | 
				
			||||||
 | 
					                start: match.index,
 | 
				
			||||||
 | 
					                end: match.index + fullMatch.length
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        return macros;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Parse HeroScript-style parameters
 | 
				
			||||||
 | 
					     * key: value
 | 
				
			||||||
 | 
					     * key: 'value with spaces'
 | 
				
			||||||
 | 
					     * key: |
 | 
				
			||||||
 | 
					     *   multiline
 | 
				
			||||||
 | 
					     *   value
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    static parseParams(paramsPart) {
 | 
				
			||||||
 | 
					        const params = {};
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        if (!paramsPart || !paramsPart.trim()) {
 | 
				
			||||||
 | 
					            return params;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        // Split by newlines but preserve multiline values
 | 
				
			||||||
 | 
					        const lines = paramsPart.split('\n');
 | 
				
			||||||
 | 
					        let currentKey = null;
 | 
				
			||||||
 | 
					        let currentValue = [];
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        for (const line of lines) {
 | 
				
			||||||
 | 
					            const trimmed = line.trim();
 | 
				
			||||||
 | 
					            
 | 
				
			||||||
 | 
					            if (!trimmed) continue;
 | 
				
			||||||
 | 
					            
 | 
				
			||||||
 | 
					            // Check if this is a key: value line
 | 
				
			||||||
 | 
					            if (trimmed.includes(':')) {
 | 
				
			||||||
 | 
					                // Save previous key-value
 | 
				
			||||||
 | 
					                if (currentKey) {
 | 
				
			||||||
 | 
					                    params[currentKey] = currentValue.join('\n').trim();
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					                
 | 
				
			||||||
 | 
					                const [key, ...valueParts] = trimmed.split(':');
 | 
				
			||||||
 | 
					                currentKey = key.trim();
 | 
				
			||||||
 | 
					                currentValue = [valueParts.join(':').trim()];
 | 
				
			||||||
 | 
					            } else if (currentKey) {
 | 
				
			||||||
 | 
					                // Continuation of multiline value
 | 
				
			||||||
 | 
					                currentValue.push(trimmed);
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        // Save last key-value
 | 
				
			||||||
 | 
					        if (currentKey) {
 | 
				
			||||||
 | 
					            params[currentKey] = currentValue.join('\n').trim();
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        return params;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Check if macro is valid
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    static validateMacro(macro) {
 | 
				
			||||||
 | 
					        if (!macro.actor || !macro.method) {
 | 
				
			||||||
 | 
					            return { valid: false, error: 'Invalid macro format' };
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        return { valid: true };
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					window.MacroParser = MacroParser;
 | 
				
			||||||
							
								
								
									
										157
									
								
								static/js/macro-processor.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,157 @@
 | 
				
			|||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Macro Processor
 | 
				
			||||||
 | 
					 * Handles macro execution and result rendering
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class MacroProcessor {
 | 
				
			||||||
 | 
					    constructor(webdavClient) {
 | 
				
			||||||
 | 
					        this.webdavClient = webdavClient;
 | 
				
			||||||
 | 
					        this.plugins = new Map();
 | 
				
			||||||
 | 
					        this.includeStack = []; // Track includes to detect cycles
 | 
				
			||||||
 | 
					        this.registerDefaultPlugins();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Register a macro plugin
 | 
				
			||||||
 | 
					     * Plugin must implement: { canHandle(actor, method), process(macro, webdavClient) }
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    registerPlugin(actor, method, plugin) {
 | 
				
			||||||
 | 
					        const key = `${actor}.${method}`;
 | 
				
			||||||
 | 
					        this.plugins.set(key, plugin);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Process all macros in content
 | 
				
			||||||
 | 
					     * Returns { success: boolean, content: string, errors: [] }
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    async processMacros(content) {
 | 
				
			||||||
 | 
					        const macros = MacroParser.extractMacros(content);
 | 
				
			||||||
 | 
					        const errors = [];
 | 
				
			||||||
 | 
					        let processedContent = content;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Process macros in reverse order to preserve positions
 | 
				
			||||||
 | 
					        for (let i = macros.length - 1; i >= 0; i--) {
 | 
				
			||||||
 | 
					            const macro = macros[i];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            try {
 | 
				
			||||||
 | 
					                const result = await this.processMacro(macro);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                if (result.success) {
 | 
				
			||||||
 | 
					                    // Replace macro with result
 | 
				
			||||||
 | 
					                    processedContent =
 | 
				
			||||||
 | 
					                        processedContent.substring(0, macro.start) +
 | 
				
			||||||
 | 
					                        result.content +
 | 
				
			||||||
 | 
					                        processedContent.substring(macro.end);
 | 
				
			||||||
 | 
					                } else {
 | 
				
			||||||
 | 
					                    errors.push({
 | 
				
			||||||
 | 
					                        macro: macro.fullMatch,
 | 
				
			||||||
 | 
					                        error: result.error
 | 
				
			||||||
 | 
					                    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    // Replace with error message
 | 
				
			||||||
 | 
					                    const errorMsg = `\n\n⚠️ **Macro Error**: ${result.error}\n\n`;
 | 
				
			||||||
 | 
					                    processedContent =
 | 
				
			||||||
 | 
					                        processedContent.substring(0, macro.start) +
 | 
				
			||||||
 | 
					                        errorMsg +
 | 
				
			||||||
 | 
					                        processedContent.substring(macro.end);
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            } catch (error) {
 | 
				
			||||||
 | 
					                errors.push({
 | 
				
			||||||
 | 
					                    macro: macro.fullMatch,
 | 
				
			||||||
 | 
					                    error: error.message
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                const errorMsg = `\n\n⚠️ **Macro Error**: ${error.message}\n\n`;
 | 
				
			||||||
 | 
					                processedContent =
 | 
				
			||||||
 | 
					                    processedContent.substring(0, macro.start) +
 | 
				
			||||||
 | 
					                    errorMsg +
 | 
				
			||||||
 | 
					                    processedContent.substring(macro.end);
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return {
 | 
				
			||||||
 | 
					            success: errors.length === 0,
 | 
				
			||||||
 | 
					            content: processedContent,
 | 
				
			||||||
 | 
					            errors
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Process single macro
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    async processMacro(macro) {
 | 
				
			||||||
 | 
					        const key = `${macro.actor}.${macro.method}`;
 | 
				
			||||||
 | 
					        const plugin = this.plugins.get(key);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Check for circular includes
 | 
				
			||||||
 | 
					        if (macro.method === 'include') {
 | 
				
			||||||
 | 
					            const path = macro.params.path || macro.params[''];
 | 
				
			||||||
 | 
					            if (this.includeStack.includes(path)) {
 | 
				
			||||||
 | 
					                return {
 | 
				
			||||||
 | 
					                    success: false,
 | 
				
			||||||
 | 
					                    error: `Circular include detected: ${this.includeStack.join(' → ')} → ${path}`
 | 
				
			||||||
 | 
					                };
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (!plugin) {
 | 
				
			||||||
 | 
					            return {
 | 
				
			||||||
 | 
					                success: false,
 | 
				
			||||||
 | 
					                error: `Unknown macro: !!${key}`
 | 
				
			||||||
 | 
					            };
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Validate macro
 | 
				
			||||||
 | 
					        const validation = MacroParser.validateMacro(macro);
 | 
				
			||||||
 | 
					        if (!validation.valid) {
 | 
				
			||||||
 | 
					            return { success: false, error: validation.error };
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Execute plugin
 | 
				
			||||||
 | 
					        try {
 | 
				
			||||||
 | 
					            return await plugin.process(macro, this.webdavClient);
 | 
				
			||||||
 | 
					        } catch (error) {
 | 
				
			||||||
 | 
					            return {
 | 
				
			||||||
 | 
					                success: false,
 | 
				
			||||||
 | 
					                error: `Plugin error: ${error.message}`
 | 
				
			||||||
 | 
					            };
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Register default plugins
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    registerDefaultPlugins() {
 | 
				
			||||||
 | 
					        // Include plugin
 | 
				
			||||||
 | 
					        this.registerPlugin('core', 'include', {
 | 
				
			||||||
 | 
					            process: async (macro, webdavClient) => {
 | 
				
			||||||
 | 
					                const path = macro.params.path || macro.params[''];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                if (!path) {
 | 
				
			||||||
 | 
					                    return {
 | 
				
			||||||
 | 
					                        success: false,
 | 
				
			||||||
 | 
					                        error: 'include macro requires "path" parameter'
 | 
				
			||||||
 | 
					                    };
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                try {
 | 
				
			||||||
 | 
					                    // Add to include stack
 | 
				
			||||||
 | 
					                    this.includeStack.push(path);
 | 
				
			||||||
 | 
					                    const content = await webdavClient.includeFile(path);
 | 
				
			||||||
 | 
					                    // Remove from include stack
 | 
				
			||||||
 | 
					                    this.includeStack.pop();
 | 
				
			||||||
 | 
					                    return { success: true, content };
 | 
				
			||||||
 | 
					                } catch (error) {
 | 
				
			||||||
 | 
					                    // Remove from include stack on error
 | 
				
			||||||
 | 
					                    this.includeStack.pop();
 | 
				
			||||||
 | 
					                    return {
 | 
				
			||||||
 | 
					                        success: false,
 | 
				
			||||||
 | 
					                        error: `Failed to include "${path}": ${error.message}`
 | 
				
			||||||
 | 
					                    };
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					window.MacroProcessor = MacroProcessor;
 | 
				
			||||||
							
								
								
									
										77
									
								
								static/js/notification-service.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,77 @@
 | 
				
			|||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Notification Service
 | 
				
			||||||
 | 
					 * Provides a standardized way to show toast notifications
 | 
				
			||||||
 | 
					 * Wraps the showNotification function from ui-utils.js
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class NotificationService {
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Show a success notification
 | 
				
			||||||
 | 
					     * @param {string} message - The message to display
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    static success(message) {
 | 
				
			||||||
 | 
					        if (window.showNotification) {
 | 
				
			||||||
 | 
					            window.showNotification(message, Config.NOTIFICATION_TYPES.SUCCESS);
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					            Logger.warn('showNotification not available, falling back to console');
 | 
				
			||||||
 | 
					            console.log(`✅ ${message}`);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Show an error notification
 | 
				
			||||||
 | 
					     * @param {string} message - The message to display
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    static error(message) {
 | 
				
			||||||
 | 
					        if (window.showNotification) {
 | 
				
			||||||
 | 
					            window.showNotification(message, Config.NOTIFICATION_TYPES.ERROR);
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					            Logger.warn('showNotification not available, falling back to console');
 | 
				
			||||||
 | 
					            console.error(`❌ ${message}`);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Show a warning notification
 | 
				
			||||||
 | 
					     * @param {string} message - The message to display
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    static warning(message) {
 | 
				
			||||||
 | 
					        if (window.showNotification) {
 | 
				
			||||||
 | 
					            window.showNotification(message, Config.NOTIFICATION_TYPES.WARNING);
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					            Logger.warn('showNotification not available, falling back to console');
 | 
				
			||||||
 | 
					            console.warn(`⚠️ ${message}`);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Show an info notification
 | 
				
			||||||
 | 
					     * @param {string} message - The message to display
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    static info(message) {
 | 
				
			||||||
 | 
					        if (window.showNotification) {
 | 
				
			||||||
 | 
					            window.showNotification(message, Config.NOTIFICATION_TYPES.INFO);
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					            Logger.warn('showNotification not available, falling back to console');
 | 
				
			||||||
 | 
					            console.info(`ℹ️ ${message}`);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Show a notification with a custom type
 | 
				
			||||||
 | 
					     * @param {string} message - The message to display
 | 
				
			||||||
 | 
					     * @param {string} type - The notification type (success, danger, warning, primary, etc.)
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    static show(message, type = 'primary') {
 | 
				
			||||||
 | 
					        if (window.showNotification) {
 | 
				
			||||||
 | 
					            window.showNotification(message, type);
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					            Logger.warn('showNotification not available, falling back to console');
 | 
				
			||||||
 | 
					            console.log(`[${type.toUpperCase()}] ${message}`);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Make NotificationService globally available
 | 
				
			||||||
 | 
					window.NotificationService = NotificationService;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
							
								
								
									
										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;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -1,10 +1,19 @@
 | 
				
			|||||||
/**
 | 
					/**
 | 
				
			||||||
 * UI Utilities Module
 | 
					 * UI Utilities Module
 | 
				
			||||||
 * Toast notifications, context menu, dark mode, file upload dialog
 | 
					 * Toast notifications (kept for backward compatibility)
 | 
				
			||||||
 | 
					 *
 | 
				
			||||||
 | 
					 * Other utilities have been moved to separate modules:
 | 
				
			||||||
 | 
					 * - Context menu: context-menu.js
 | 
				
			||||||
 | 
					 * - File upload: file-upload.js
 | 
				
			||||||
 | 
					 * - Dark mode: dark-mode.js
 | 
				
			||||||
 | 
					 * - Collection selector: collection-selector.js
 | 
				
			||||||
 | 
					 * - Editor drop handler: editor-drop-handler.js
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
 * Show toast notification
 | 
					 * Show toast notification
 | 
				
			||||||
 | 
					 * @param {string} message - The message to display
 | 
				
			||||||
 | 
					 * @param {string} type - The notification type (info, success, error, warning, danger, primary)
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
function showNotification(message, type = 'info') {
 | 
					function showNotification(message, type = 'info') {
 | 
				
			||||||
    const container = document.getElementById('toastContainer') || createToastContainer();
 | 
					    const container = document.getElementById('toastContainer') || createToastContainer();
 | 
				
			||||||
@@ -23,7 +32,7 @@ function showNotification(message, type = 'info') {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    container.appendChild(toast);
 | 
					    container.appendChild(toast);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const bsToast = new bootstrap.Toast(toast, { delay: 3000 });
 | 
					    const bsToast = new bootstrap.Toast(toast, { delay: Config.TOAST_DURATION });
 | 
				
			||||||
    bsToast.show();
 | 
					    bsToast.show();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    toast.addEventListener('hidden.bs.toast', () => {
 | 
					    toast.addEventListener('hidden.bs.toast', () => {
 | 
				
			||||||
@@ -31,226 +40,21 @@ function showNotification(message, type = 'info') {
 | 
				
			|||||||
    });
 | 
					    });
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Create the toast container if it doesn't exist
 | 
				
			||||||
 | 
					 * @returns {HTMLElement} The toast container element
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
function createToastContainer() {
 | 
					function createToastContainer() {
 | 
				
			||||||
    const container = document.createElement('div');
 | 
					    const container = document.createElement('div');
 | 
				
			||||||
    container.id = 'toastContainer';
 | 
					    container.id = 'toastContainer';
 | 
				
			||||||
    container.className = 'toast-container position-fixed top-0 end-0 p-3';
 | 
					    container.className = 'toast-container position-fixed top-0 end-0 p-3';
 | 
				
			||||||
    container.style.zIndex = '9999';
 | 
					    container.style.zIndex = Config.TOAST_Z_INDEX;
 | 
				
			||||||
    document.body.appendChild(container);
 | 
					    document.body.appendChild(container);
 | 
				
			||||||
    return container;
 | 
					    return container;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/**
 | 
					// All other UI utilities have been moved to separate modules
 | 
				
			||||||
 * Enhanced Context Menu
 | 
					// See the module list at the top of this file
 | 
				
			||||||
 */
 | 
					 | 
				
			||||||
function showContextMenu(x, y, target) {
 | 
					 | 
				
			||||||
    const menu = document.getElementById('contextMenu');
 | 
					 | 
				
			||||||
    if (!menu) return;
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    // Store target
 | 
					 | 
				
			||||||
    menu.dataset.targetPath = target.path;
 | 
					 | 
				
			||||||
    menu.dataset.targetIsDir = target.isDir;
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    // Show/hide menu items based on target type
 | 
					 | 
				
			||||||
    const newFileItem = menu.querySelector('[data-action="new-file"]');
 | 
					 | 
				
			||||||
    const newFolderItem = menu.querySelector('[data-action="new-folder"]');
 | 
					 | 
				
			||||||
    const uploadItem = menu.querySelector('[data-action="upload"]');
 | 
					 | 
				
			||||||
    const downloadItem = menu.querySelector('[data-action="download"]');
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    if (target.isDir) {
 | 
					 | 
				
			||||||
        // Folder context menu
 | 
					 | 
				
			||||||
        if (newFileItem) newFileItem.style.display = 'block';
 | 
					 | 
				
			||||||
        if (newFolderItem) newFolderItem.style.display = 'block';
 | 
					 | 
				
			||||||
        if (uploadItem) uploadItem.style.display = 'block';
 | 
					 | 
				
			||||||
        if (downloadItem) downloadItem.style.display = 'block';
 | 
					 | 
				
			||||||
    } else {
 | 
					 | 
				
			||||||
        // File context menu
 | 
					 | 
				
			||||||
        if (newFileItem) newFileItem.style.display = 'none';
 | 
					 | 
				
			||||||
        if (newFolderItem) newFolderItem.style.display = 'none';
 | 
					 | 
				
			||||||
        if (uploadItem) uploadItem.style.display = 'none';
 | 
					 | 
				
			||||||
        if (downloadItem) downloadItem.style.display = 'block';
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    // Position menu
 | 
					 | 
				
			||||||
    menu.style.display = 'block';
 | 
					 | 
				
			||||||
    menu.style.left = x + 'px';
 | 
					 | 
				
			||||||
    menu.style.top = y + 'px';
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    // Adjust if off-screen
 | 
					 | 
				
			||||||
    const rect = menu.getBoundingClientRect();
 | 
					 | 
				
			||||||
    if (rect.right > window.innerWidth) {
 | 
					 | 
				
			||||||
        menu.style.left = (window.innerWidth - rect.width - 10) + 'px';
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    if (rect.bottom > window.innerHeight) {
 | 
					 | 
				
			||||||
        menu.style.top = (window.innerHeight - rect.height - 10) + 'px';
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
function hideContextMenu() {
 | 
					 | 
				
			||||||
    const menu = document.getElementById('contextMenu');
 | 
					 | 
				
			||||||
    if (menu) {
 | 
					 | 
				
			||||||
        menu.style.display = 'none';
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// Hide context menu on click outside
 | 
					 | 
				
			||||||
document.addEventListener('click', (e) => {
 | 
					 | 
				
			||||||
    if (!e.target.closest('#contextMenu')) {
 | 
					 | 
				
			||||||
        hideContextMenu();
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/**
 | 
					 | 
				
			||||||
 * File Upload Dialog
 | 
					 | 
				
			||||||
 */
 | 
					 | 
				
			||||||
function showFileUploadDialog(targetPath, onUpload) {
 | 
					 | 
				
			||||||
    const input = document.createElement('input');
 | 
					 | 
				
			||||||
    input.type = 'file';
 | 
					 | 
				
			||||||
    input.multiple = true;
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    input.addEventListener('change', async (e) => {
 | 
					 | 
				
			||||||
        const files = Array.from(e.target.files);
 | 
					 | 
				
			||||||
        if (files.length === 0) return;
 | 
					 | 
				
			||||||
        
 | 
					 | 
				
			||||||
        for (const file of files) {
 | 
					 | 
				
			||||||
            try {
 | 
					 | 
				
			||||||
                await onUpload(targetPath, file);
 | 
					 | 
				
			||||||
            } catch (error) {
 | 
					 | 
				
			||||||
                console.error('Upload failed:', error);
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    input.click();
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/**
 | 
					 | 
				
			||||||
 * Dark Mode Manager
 | 
					 | 
				
			||||||
 */
 | 
					 | 
				
			||||||
class DarkMode {
 | 
					 | 
				
			||||||
    constructor() {
 | 
					 | 
				
			||||||
        this.isDark = localStorage.getItem('darkMode') === 'true';
 | 
					 | 
				
			||||||
        this.apply();
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    toggle() {
 | 
					 | 
				
			||||||
        this.isDark = !this.isDark;
 | 
					 | 
				
			||||||
        localStorage.setItem('darkMode', this.isDark);
 | 
					 | 
				
			||||||
        this.apply();
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    apply() {
 | 
					 | 
				
			||||||
        if (this.isDark) {
 | 
					 | 
				
			||||||
            document.body.classList.add('dark-mode');
 | 
					 | 
				
			||||||
            const btn = document.getElementById('darkModeBtn');
 | 
					 | 
				
			||||||
            if (btn) btn.textContent = '☀️';
 | 
					 | 
				
			||||||
            
 | 
					 | 
				
			||||||
            // Update mermaid theme
 | 
					 | 
				
			||||||
            if (window.mermaid) {
 | 
					 | 
				
			||||||
                mermaid.initialize({ theme: 'dark' });
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
        } else {
 | 
					 | 
				
			||||||
            document.body.classList.remove('dark-mode');
 | 
					 | 
				
			||||||
            const btn = document.getElementById('darkModeBtn');
 | 
					 | 
				
			||||||
            if (btn) btn.textContent = '🌙';
 | 
					 | 
				
			||||||
            
 | 
					 | 
				
			||||||
            // Update mermaid theme
 | 
					 | 
				
			||||||
            if (window.mermaid) {
 | 
					 | 
				
			||||||
                mermaid.initialize({ theme: 'default' });
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/**
 | 
					 | 
				
			||||||
 * Collection Selector
 | 
					 | 
				
			||||||
 */
 | 
					 | 
				
			||||||
class CollectionSelector {
 | 
					 | 
				
			||||||
    constructor(selectId, webdavClient) {
 | 
					 | 
				
			||||||
        this.select = document.getElementById(selectId);
 | 
					 | 
				
			||||||
        this.webdavClient = webdavClient;
 | 
					 | 
				
			||||||
        this.onChange = null;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    async load() {
 | 
					 | 
				
			||||||
        try {
 | 
					 | 
				
			||||||
            const collections = await this.webdavClient.getCollections();
 | 
					 | 
				
			||||||
            this.select.innerHTML = '';
 | 
					 | 
				
			||||||
            
 | 
					 | 
				
			||||||
            collections.forEach(collection => {
 | 
					 | 
				
			||||||
                const option = document.createElement('option');
 | 
					 | 
				
			||||||
                option.value = collection;
 | 
					 | 
				
			||||||
                option.textContent = collection;
 | 
					 | 
				
			||||||
                this.select.appendChild(option);
 | 
					 | 
				
			||||||
            });
 | 
					 | 
				
			||||||
            
 | 
					 | 
				
			||||||
            // Select first collection
 | 
					 | 
				
			||||||
            if (collections.length > 0) {
 | 
					 | 
				
			||||||
                this.select.value = collections[0];
 | 
					 | 
				
			||||||
                this.webdavClient.setCollection(collections[0]);
 | 
					 | 
				
			||||||
                if (this.onChange) {
 | 
					 | 
				
			||||||
                    this.onChange(collections[0]);
 | 
					 | 
				
			||||||
                }
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
            
 | 
					 | 
				
			||||||
            // Add change listener
 | 
					 | 
				
			||||||
            this.select.addEventListener('change', () => {
 | 
					 | 
				
			||||||
                const collection = this.select.value;
 | 
					 | 
				
			||||||
                this.webdavClient.setCollection(collection);
 | 
					 | 
				
			||||||
                if (this.onChange) {
 | 
					 | 
				
			||||||
                    this.onChange(collection);
 | 
					 | 
				
			||||||
                }
 | 
					 | 
				
			||||||
            });
 | 
					 | 
				
			||||||
        } catch (error) {
 | 
					 | 
				
			||||||
            console.error('Failed to load collections:', error);
 | 
					 | 
				
			||||||
            showNotification('Failed to load collections', 'error');
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/**
 | 
					 | 
				
			||||||
 * Editor Drop Handler
 | 
					 | 
				
			||||||
 * Handles file drops into the editor
 | 
					 | 
				
			||||||
 */
 | 
					 | 
				
			||||||
class EditorDropHandler {
 | 
					 | 
				
			||||||
    constructor(editorElement, onFileDrop) {
 | 
					 | 
				
			||||||
        this.editorElement = editorElement;
 | 
					 | 
				
			||||||
        this.onFileDrop = onFileDrop;
 | 
					 | 
				
			||||||
        this.setupHandlers();
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    setupHandlers() {
 | 
					 | 
				
			||||||
        this.editorElement.addEventListener('dragover', (e) => {
 | 
					 | 
				
			||||||
            e.preventDefault();
 | 
					 | 
				
			||||||
            e.stopPropagation();
 | 
					 | 
				
			||||||
            this.editorElement.classList.add('drag-over');
 | 
					 | 
				
			||||||
        });
 | 
					 | 
				
			||||||
        
 | 
					 | 
				
			||||||
        this.editorElement.addEventListener('dragleave', (e) => {
 | 
					 | 
				
			||||||
            e.preventDefault();
 | 
					 | 
				
			||||||
            e.stopPropagation();
 | 
					 | 
				
			||||||
            this.editorElement.classList.remove('drag-over');
 | 
					 | 
				
			||||||
        });
 | 
					 | 
				
			||||||
        
 | 
					 | 
				
			||||||
        this.editorElement.addEventListener('drop', async (e) => {
 | 
					 | 
				
			||||||
            e.preventDefault();
 | 
					 | 
				
			||||||
            e.stopPropagation();
 | 
					 | 
				
			||||||
            this.editorElement.classList.remove('drag-over');
 | 
					 | 
				
			||||||
            
 | 
					 | 
				
			||||||
            const files = Array.from(e.dataTransfer.files);
 | 
					 | 
				
			||||||
            if (files.length === 0) return;
 | 
					 | 
				
			||||||
            
 | 
					 | 
				
			||||||
            for (const file of files) {
 | 
					 | 
				
			||||||
                try {
 | 
					 | 
				
			||||||
                    if (this.onFileDrop) {
 | 
					 | 
				
			||||||
                        await this.onFileDrop(file);
 | 
					 | 
				
			||||||
                    }
 | 
					 | 
				
			||||||
                } catch (error) {
 | 
					 | 
				
			||||||
                    console.error('Drop failed:', error);
 | 
					 | 
				
			||||||
                    showNotification(`Failed to upload ${file.name}`, 'error');
 | 
					 | 
				
			||||||
                }
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
        });
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Make showNotification globally available
 | 
				
			||||||
 | 
					window.showNotification = showNotification;
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										429
									
								
								static/js/utils.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,429 @@
 | 
				
			|||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Utilities Module
 | 
				
			||||||
 | 
					 * Common utility functions used throughout the application
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Path Utilities
 | 
				
			||||||
 | 
					 * Helper functions for path manipulation
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					const PathUtils = {
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Get the filename from a path
 | 
				
			||||||
 | 
					     * @param {string} path - The file path
 | 
				
			||||||
 | 
					     * @returns {string} The filename
 | 
				
			||||||
 | 
					     * @example PathUtils.getFileName('folder/subfolder/file.md') // 'file.md'
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    getFileName(path) {
 | 
				
			||||||
 | 
					        if (!path) return '';
 | 
				
			||||||
 | 
					        return path.split('/').pop();
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Get the parent directory path
 | 
				
			||||||
 | 
					     * @param {string} path - The file path
 | 
				
			||||||
 | 
					     * @returns {string} The parent directory path
 | 
				
			||||||
 | 
					     * @example PathUtils.getParentPath('folder/subfolder/file.md') // 'folder/subfolder'
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    getParentPath(path) {
 | 
				
			||||||
 | 
					        if (!path) return '';
 | 
				
			||||||
 | 
					        const lastSlash = path.lastIndexOf('/');
 | 
				
			||||||
 | 
					        return lastSlash === -1 ? '' : path.substring(0, lastSlash);
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Normalize a path by removing duplicate slashes
 | 
				
			||||||
 | 
					     * @param {string} path - The path to normalize
 | 
				
			||||||
 | 
					     * @returns {string} The normalized path
 | 
				
			||||||
 | 
					     * @example PathUtils.normalizePath('folder//subfolder///file.md') // 'folder/subfolder/file.md'
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    normalizePath(path) {
 | 
				
			||||||
 | 
					        if (!path) return '';
 | 
				
			||||||
 | 
					        return path.replace(/\/+/g, '/');
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Join multiple path segments
 | 
				
			||||||
 | 
					     * @param {...string} paths - Path segments to join
 | 
				
			||||||
 | 
					     * @returns {string} The joined path
 | 
				
			||||||
 | 
					     * @example PathUtils.joinPaths('folder', 'subfolder', 'file.md') // 'folder/subfolder/file.md'
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    joinPaths(...paths) {
 | 
				
			||||||
 | 
					        return PathUtils.normalizePath(paths.filter(p => p).join('/'));
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Get the file extension
 | 
				
			||||||
 | 
					     * @param {string} path - The file path
 | 
				
			||||||
 | 
					     * @returns {string} The file extension (without dot)
 | 
				
			||||||
 | 
					     * @example PathUtils.getExtension('file.md') // 'md'
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    getExtension(path) {
 | 
				
			||||||
 | 
					        if (!path) return '';
 | 
				
			||||||
 | 
					        const fileName = PathUtils.getFileName(path);
 | 
				
			||||||
 | 
					        const lastDot = fileName.lastIndexOf('.');
 | 
				
			||||||
 | 
					        return lastDot === -1 ? '' : fileName.substring(lastDot + 1);
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Check if a path is a descendant of another path
 | 
				
			||||||
 | 
					     * @param {string} path - The path to check
 | 
				
			||||||
 | 
					     * @param {string} ancestorPath - The potential ancestor path
 | 
				
			||||||
 | 
					     * @returns {boolean} True if path is a descendant of ancestorPath
 | 
				
			||||||
 | 
					     * @example PathUtils.isDescendant('folder/subfolder/file.md', 'folder') // true
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    isDescendant(path, ancestorPath) {
 | 
				
			||||||
 | 
					        if (!path || !ancestorPath) return false;
 | 
				
			||||||
 | 
					        return path.startsWith(ancestorPath + '/');
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Check if a file is a binary/non-editable file based on extension
 | 
				
			||||||
 | 
					     * @param {string} path - The file path
 | 
				
			||||||
 | 
					     * @returns {boolean} True if the file is binary/non-editable
 | 
				
			||||||
 | 
					     * @example PathUtils.isBinaryFile('image.png') // true
 | 
				
			||||||
 | 
					     * @example PathUtils.isBinaryFile('document.md') // false
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    isBinaryFile(path) {
 | 
				
			||||||
 | 
					        const extension = PathUtils.getExtension(path).toLowerCase();
 | 
				
			||||||
 | 
					        const binaryExtensions = [
 | 
				
			||||||
 | 
					            // Images
 | 
				
			||||||
 | 
					            'png', 'jpg', 'jpeg', 'gif', 'bmp', 'ico', 'svg', 'webp', 'tiff', 'tif',
 | 
				
			||||||
 | 
					            // Documents
 | 
				
			||||||
 | 
					            'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx',
 | 
				
			||||||
 | 
					            // Archives
 | 
				
			||||||
 | 
					            'zip', 'rar', '7z', 'tar', 'gz', 'bz2',
 | 
				
			||||||
 | 
					            // Executables
 | 
				
			||||||
 | 
					            'exe', 'dll', 'so', 'dylib', 'app',
 | 
				
			||||||
 | 
					            // Media
 | 
				
			||||||
 | 
					            'mp3', 'mp4', 'avi', 'mov', 'wmv', 'flv', 'wav', 'ogg',
 | 
				
			||||||
 | 
					            // Other binary formats
 | 
				
			||||||
 | 
					            'bin', 'dat', 'db', 'sqlite'
 | 
				
			||||||
 | 
					        ];
 | 
				
			||||||
 | 
					        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
 | 
				
			||||||
 | 
					     * @returns {string} The file type description
 | 
				
			||||||
 | 
					     * @example PathUtils.getFileType('image.png') // 'Image'
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    getFileType(path) {
 | 
				
			||||||
 | 
					        const extension = PathUtils.getExtension(path).toLowerCase();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'ico', 'svg', 'webp', 'tiff', 'tif'];
 | 
				
			||||||
 | 
					        const documentExtensions = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'];
 | 
				
			||||||
 | 
					        const archiveExtensions = ['zip', 'rar', '7z', 'tar', 'gz', 'bz2'];
 | 
				
			||||||
 | 
					        const mediaExtensions = ['mp3', 'mp4', 'avi', 'mov', 'wmv', 'flv', 'wav', 'ogg'];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (imageExtensions.includes(extension)) return 'Image';
 | 
				
			||||||
 | 
					        if (documentExtensions.includes(extension)) return 'Document';
 | 
				
			||||||
 | 
					        if (archiveExtensions.includes(extension)) return 'Archive';
 | 
				
			||||||
 | 
					        if (mediaExtensions.includes(extension)) return 'Media';
 | 
				
			||||||
 | 
					        if (extension === 'pdf') return 'PDF';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return 'File';
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * DOM Utilities
 | 
				
			||||||
 | 
					 * Helper functions for DOM manipulation
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					const DOMUtils = {
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Create an element with optional class and attributes
 | 
				
			||||||
 | 
					     * @param {string} tag - The HTML tag name
 | 
				
			||||||
 | 
					     * @param {string} [className] - Optional class name(s)
 | 
				
			||||||
 | 
					     * @param {Object} [attributes] - Optional attributes object
 | 
				
			||||||
 | 
					     * @returns {HTMLElement} The created element
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    createElement(tag, className = '', attributes = {}) {
 | 
				
			||||||
 | 
					        const element = document.createElement(tag);
 | 
				
			||||||
 | 
					        if (className) {
 | 
				
			||||||
 | 
					            element.className = className;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        Object.entries(attributes).forEach(([key, value]) => {
 | 
				
			||||||
 | 
					            element.setAttribute(key, value);
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					        return element;
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Remove all children from an element
 | 
				
			||||||
 | 
					     * @param {HTMLElement} element - The element to clear
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    removeAllChildren(element) {
 | 
				
			||||||
 | 
					        while (element.firstChild) {
 | 
				
			||||||
 | 
					            element.removeChild(element.firstChild);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Toggle a class on an element
 | 
				
			||||||
 | 
					     * @param {HTMLElement} element - The element
 | 
				
			||||||
 | 
					     * @param {string} className - The class name
 | 
				
			||||||
 | 
					     * @param {boolean} [force] - Optional force add/remove
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    toggleClass(element, className, force) {
 | 
				
			||||||
 | 
					        if (force !== undefined) {
 | 
				
			||||||
 | 
					            element.classList.toggle(className, force);
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					            element.classList.toggle(className);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Query selector with error handling
 | 
				
			||||||
 | 
					     * @param {string} selector - The CSS selector
 | 
				
			||||||
 | 
					     * @param {HTMLElement} [parent] - Optional parent element
 | 
				
			||||||
 | 
					     * @returns {HTMLElement|null} The found element or null
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    querySelector(selector, parent = document) {
 | 
				
			||||||
 | 
					        try {
 | 
				
			||||||
 | 
					            return parent.querySelector(selector);
 | 
				
			||||||
 | 
					        } catch (error) {
 | 
				
			||||||
 | 
					            Logger.error(`Invalid selector: ${selector}`, error);
 | 
				
			||||||
 | 
					            return null;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Query selector all with error handling
 | 
				
			||||||
 | 
					     * @param {string} selector - The CSS selector
 | 
				
			||||||
 | 
					     * @param {HTMLElement} [parent] - Optional parent element
 | 
				
			||||||
 | 
					     * @returns {NodeList|Array} The found elements or empty array
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    querySelectorAll(selector, parent = document) {
 | 
				
			||||||
 | 
					        try {
 | 
				
			||||||
 | 
					            return parent.querySelectorAll(selector);
 | 
				
			||||||
 | 
					        } catch (error) {
 | 
				
			||||||
 | 
					            Logger.error(`Invalid selector: ${selector}`, error);
 | 
				
			||||||
 | 
					            return [];
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Timing Utilities
 | 
				
			||||||
 | 
					 * Helper functions for timing and throttling
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					const TimingUtils = {
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Debounce a function
 | 
				
			||||||
 | 
					     * @param {Function} func - The function to debounce
 | 
				
			||||||
 | 
					     * @param {number} wait - The wait time in milliseconds
 | 
				
			||||||
 | 
					     * @returns {Function} The debounced function
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    debounce(func, wait) {
 | 
				
			||||||
 | 
					        let timeout;
 | 
				
			||||||
 | 
					        return function executedFunction(...args) {
 | 
				
			||||||
 | 
					            const later = () => {
 | 
				
			||||||
 | 
					                clearTimeout(timeout);
 | 
				
			||||||
 | 
					                func(...args);
 | 
				
			||||||
 | 
					            };
 | 
				
			||||||
 | 
					            clearTimeout(timeout);
 | 
				
			||||||
 | 
					            timeout = setTimeout(later, wait);
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Throttle a function
 | 
				
			||||||
 | 
					     * @param {Function} func - The function to throttle
 | 
				
			||||||
 | 
					     * @param {number} wait - The wait time in milliseconds
 | 
				
			||||||
 | 
					     * @returns {Function} The throttled function
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    throttle(func, wait) {
 | 
				
			||||||
 | 
					        let inThrottle;
 | 
				
			||||||
 | 
					        return function executedFunction(...args) {
 | 
				
			||||||
 | 
					            if (!inThrottle) {
 | 
				
			||||||
 | 
					                func(...args);
 | 
				
			||||||
 | 
					                inThrottle = true;
 | 
				
			||||||
 | 
					                setTimeout(() => inThrottle = false, wait);
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Delay execution
 | 
				
			||||||
 | 
					     * @param {number} ms - Milliseconds to delay
 | 
				
			||||||
 | 
					     * @returns {Promise} Promise that resolves after delay
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    delay(ms) {
 | 
				
			||||||
 | 
					        return new Promise(resolve => setTimeout(resolve, ms));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Download Utilities
 | 
				
			||||||
 | 
					 * Helper functions for file downloads
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					const DownloadUtils = {
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Trigger a download in the browser
 | 
				
			||||||
 | 
					     * @param {string|Blob} content - The content to download
 | 
				
			||||||
 | 
					     * @param {string} filename - The filename for the download
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    triggerDownload(content, filename) {
 | 
				
			||||||
 | 
					        const blob = content instanceof Blob ? content : new Blob([content]);
 | 
				
			||||||
 | 
					        const url = URL.createObjectURL(blob);
 | 
				
			||||||
 | 
					        const a = document.createElement('a');
 | 
				
			||||||
 | 
					        a.href = url;
 | 
				
			||||||
 | 
					        a.download = filename;
 | 
				
			||||||
 | 
					        document.body.appendChild(a);
 | 
				
			||||||
 | 
					        a.click();
 | 
				
			||||||
 | 
					        document.body.removeChild(a);
 | 
				
			||||||
 | 
					        URL.revokeObjectURL(url);
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Download content as a blob
 | 
				
			||||||
 | 
					     * @param {Blob} blob - The blob to download
 | 
				
			||||||
 | 
					     * @param {string} filename - The filename for the download
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    downloadAsBlob(blob, filename) {
 | 
				
			||||||
 | 
					        DownloadUtils.triggerDownload(blob, filename);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Validation Utilities
 | 
				
			||||||
 | 
					 * Helper functions for input validation
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					const ValidationUtils = {
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Validate and sanitize a filename
 | 
				
			||||||
 | 
					     * @param {string} name - The filename to validate
 | 
				
			||||||
 | 
					     * @param {boolean} [isFolder=false] - Whether this is a folder name
 | 
				
			||||||
 | 
					     * @returns {Object} Validation result with {valid, sanitized, message}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    validateFileName(name, isFolder = false) {
 | 
				
			||||||
 | 
					        const type = isFolder ? 'folder' : 'file';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (!name || name.trim().length === 0) {
 | 
				
			||||||
 | 
					            return { valid: false, sanitized: '', message: `${type} name cannot be empty` };
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Check for invalid characters using pattern from Config
 | 
				
			||||||
 | 
					        const validPattern = Config.FILENAME_PATTERN;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (!validPattern.test(name)) {
 | 
				
			||||||
 | 
					            const sanitized = ValidationUtils.sanitizeFileName(name);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            return {
 | 
				
			||||||
 | 
					                valid: false,
 | 
				
			||||||
 | 
					                sanitized,
 | 
				
			||||||
 | 
					                message: `Invalid characters in ${type} name. Only lowercase letters, numbers, and underscores allowed.\n\nSuggestion: "${sanitized}"`
 | 
				
			||||||
 | 
					            };
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return { valid: true, sanitized: name, message: '' };
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Sanitize a filename by removing/replacing invalid characters
 | 
				
			||||||
 | 
					     * @param {string} name - The filename to sanitize
 | 
				
			||||||
 | 
					     * @returns {string} The sanitized filename
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    sanitizeFileName(name) {
 | 
				
			||||||
 | 
					        return name
 | 
				
			||||||
 | 
					            .toLowerCase()
 | 
				
			||||||
 | 
					            .replace(Config.FILENAME_INVALID_CHARS, '_')
 | 
				
			||||||
 | 
					            .replace(/_+/g, '_')
 | 
				
			||||||
 | 
					            .replace(/^_+|_+$/g, '');
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Check if a string is empty or whitespace
 | 
				
			||||||
 | 
					     * @param {string} str - The string to check
 | 
				
			||||||
 | 
					     * @returns {boolean} True if empty or whitespace
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    isEmpty(str) {
 | 
				
			||||||
 | 
					        return !str || str.trim().length === 0;
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Check if a value is a valid email
 | 
				
			||||||
 | 
					     * @param {string} email - The email to validate
 | 
				
			||||||
 | 
					     * @returns {boolean} True if valid email
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    isValidEmail(email) {
 | 
				
			||||||
 | 
					        const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
 | 
				
			||||||
 | 
					        return emailPattern.test(email);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * String Utilities
 | 
				
			||||||
 | 
					 * Helper functions for string manipulation
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					const StringUtils = {
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Truncate a string to a maximum length
 | 
				
			||||||
 | 
					     * @param {string} str - The string to truncate
 | 
				
			||||||
 | 
					     * @param {number} maxLength - Maximum length
 | 
				
			||||||
 | 
					     * @param {string} [suffix='...'] - Suffix to add if truncated
 | 
				
			||||||
 | 
					     * @returns {string} The truncated string
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    truncate(str, maxLength, suffix = '...') {
 | 
				
			||||||
 | 
					        if (!str || str.length <= maxLength) return str;
 | 
				
			||||||
 | 
					        return str.substring(0, maxLength - suffix.length) + suffix;
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Capitalize the first letter of a string
 | 
				
			||||||
 | 
					     * @param {string} str - The string to capitalize
 | 
				
			||||||
 | 
					     * @returns {string} The capitalized string
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    capitalize(str) {
 | 
				
			||||||
 | 
					        if (!str) return '';
 | 
				
			||||||
 | 
					        return str.charAt(0).toUpperCase() + str.slice(1);
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Convert a string to kebab-case
 | 
				
			||||||
 | 
					     * @param {string} str - The string to convert
 | 
				
			||||||
 | 
					     * @returns {string} The kebab-case string
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    toKebabCase(str) {
 | 
				
			||||||
 | 
					        return str
 | 
				
			||||||
 | 
					            .replace(/([a-z])([A-Z])/g, '$1-$2')
 | 
				
			||||||
 | 
					            .replace(/[\s_]+/g, '-')
 | 
				
			||||||
 | 
					            .toLowerCase();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Make utilities globally available
 | 
				
			||||||
 | 
					window.PathUtils = PathUtils;
 | 
				
			||||||
 | 
					window.DOMUtils = DOMUtils;
 | 
				
			||||||
 | 
					window.TimingUtils = TimingUtils;
 | 
				
			||||||
 | 
					window.DownloadUtils = DownloadUtils;
 | 
				
			||||||
 | 
					window.ValidationUtils = ValidationUtils;
 | 
				
			||||||
 | 
					window.StringUtils = StringUtils;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -29,6 +29,24 @@ class WebDAVClient {
 | 
				
			|||||||
        return await response.json();
 | 
					        return await response.json();
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    async createCollection(collectionName) {
 | 
				
			||||||
 | 
					        // Use POST API to create collection (not MKCOL, as collections are managed by the server)
 | 
				
			||||||
 | 
					        const response = await fetch(this.baseUrl, {
 | 
				
			||||||
 | 
					            method: 'POST',
 | 
				
			||||||
 | 
					            headers: {
 | 
				
			||||||
 | 
					                'Content-Type': 'application/json'
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            body: JSON.stringify({ name: collectionName })
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (!response.ok) {
 | 
				
			||||||
 | 
					            const errorData = await response.json().catch(() => ({ error: response.statusText }));
 | 
				
			||||||
 | 
					            throw new Error(errorData.error || `Failed to create collection: ${response.status} ${response.statusText}`);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return true;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    async propfind(path = '', depth = '1') {
 | 
					    async propfind(path = '', depth = '1') {
 | 
				
			||||||
        const url = this.getFullUrl(path);
 | 
					        const url = this.getFullUrl(path);
 | 
				
			||||||
        const response = await fetch(url, {
 | 
					        const response = await fetch(url, {
 | 
				
			||||||
@@ -47,6 +65,33 @@ class WebDAVClient {
 | 
				
			|||||||
        return this.parseMultiStatus(xml);
 | 
					        return this.parseMultiStatus(xml);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * List files and directories in a path
 | 
				
			||||||
 | 
					     * Returns only direct children (depth=1) to avoid infinite recursion
 | 
				
			||||||
 | 
					     * @param {string} path - Path to list
 | 
				
			||||||
 | 
					     * @param {boolean} recursive - If true, returns all nested items (depth=infinity)
 | 
				
			||||||
 | 
					     * @returns {Promise<Array>} Array of items
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    async list(path = '', recursive = false) {
 | 
				
			||||||
 | 
					        const depth = recursive ? 'infinity' : '1';
 | 
				
			||||||
 | 
					        const items = await this.propfind(path, depth);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // If not recursive, filter to only direct children
 | 
				
			||||||
 | 
					        if (!recursive && path) {
 | 
				
			||||||
 | 
					            // Normalize path (remove trailing slash)
 | 
				
			||||||
 | 
					            const normalizedPath = path.endsWith('/') ? path.slice(0, -1) : path;
 | 
				
			||||||
 | 
					            const pathDepth = normalizedPath.split('/').length;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Filter items to only include direct children
 | 
				
			||||||
 | 
					            return items.filter(item => {
 | 
				
			||||||
 | 
					                const itemDepth = item.path.split('/').length;
 | 
				
			||||||
 | 
					                return itemDepth === pathDepth + 1;
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return items;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    async get(path) {
 | 
					    async get(path) {
 | 
				
			||||||
        const url = this.getFullUrl(path);
 | 
					        const url = this.getFullUrl(path);
 | 
				
			||||||
        const response = await fetch(url);
 | 
					        const response = await fetch(url);
 | 
				
			||||||
@@ -162,6 +207,66 @@ class WebDAVClient {
 | 
				
			|||||||
        return true;
 | 
					        return true;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Alias for mkcol
 | 
				
			||||||
 | 
					    async createFolder(path) {
 | 
				
			||||||
 | 
					        return await this.mkcol(path);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Ensure all parent directories exist for a given path
 | 
				
			||||||
 | 
					     * Creates missing parent directories recursively
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    async ensureParentDirectories(filePath) {
 | 
				
			||||||
 | 
					        const parts = filePath.split('/');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Remove the filename (last part)
 | 
				
			||||||
 | 
					        parts.pop();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // If no parent directories, nothing to do
 | 
				
			||||||
 | 
					        if (parts.length === 0) {
 | 
				
			||||||
 | 
					            return;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Create each parent directory level
 | 
				
			||||||
 | 
					        let currentPath = '';
 | 
				
			||||||
 | 
					        for (const part of parts) {
 | 
				
			||||||
 | 
					            currentPath = currentPath ? `${currentPath}/${part}` : part;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            try {
 | 
				
			||||||
 | 
					                await this.mkcol(currentPath);
 | 
				
			||||||
 | 
					            } catch (error) {
 | 
				
			||||||
 | 
					                // Ignore errors - directory might already exist
 | 
				
			||||||
 | 
					                // Only log for debugging
 | 
				
			||||||
 | 
					                console.debug(`Directory ${currentPath} might already exist:`, error.message);
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    async includeFile(path) {
 | 
				
			||||||
 | 
					        try {
 | 
				
			||||||
 | 
					            // Parse path: "collection:path/to/file" or "path/to/file"
 | 
				
			||||||
 | 
					            let targetCollection = this.currentCollection;
 | 
				
			||||||
 | 
					            let targetPath = path;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            if (path.includes(':')) {
 | 
				
			||||||
 | 
					                [targetCollection, targetPath] = path.split(':');
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Temporarily switch collection
 | 
				
			||||||
 | 
					            const originalCollection = this.currentCollection;
 | 
				
			||||||
 | 
					            this.currentCollection = targetCollection;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            const content = await this.get(targetPath);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Restore collection
 | 
				
			||||||
 | 
					            this.currentCollection = originalCollection;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            return content;
 | 
				
			||||||
 | 
					        } catch (error) {
 | 
				
			||||||
 | 
					            throw new Error(`Cannot include file "${path}": ${error.message}`);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    parseMultiStatus(xml) {
 | 
					    parseMultiStatus(xml) {
 | 
				
			||||||
        const parser = new DOMParser();
 | 
					        const parser = new DOMParser();
 | 
				
			||||||
        const doc = parser.parseFromString(xml, 'text/xml');
 | 
					        const doc = parser.parseFromString(xml, 'text/xml');
 | 
				
			||||||
@@ -231,6 +336,7 @@ class WebDAVClient {
 | 
				
			|||||||
            } else {
 | 
					            } else {
 | 
				
			||||||
                root.push(node);
 | 
					                root.push(node);
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        return root;
 | 
					        return root;
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -33,7 +33,8 @@ body.dark-mode {
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/* Global styles */
 | 
					/* Global styles */
 | 
				
			||||||
html, body {
 | 
					html,
 | 
				
			||||||
 | 
					body {
 | 
				
			||||||
    height: 100%;
 | 
					    height: 100%;
 | 
				
			||||||
    margin: 0;
 | 
					    margin: 0;
 | 
				
			||||||
    padding: 0;
 | 
					    padding: 0;
 | 
				
			||||||
@@ -48,12 +49,6 @@ body {
 | 
				
			|||||||
    transition: background-color 0.3s ease, color 0.3s ease;
 | 
					    transition: background-color 0.3s ease, color 0.3s ease;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.container-fluid {
 | 
					 | 
				
			||||||
    flex: 1;
 | 
					 | 
				
			||||||
    padding: 0;
 | 
					 | 
				
			||||||
    overflow: hidden;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.row {
 | 
					.row {
 | 
				
			||||||
    margin: 0;
 | 
					    margin: 0;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -206,7 +201,12 @@ body.dark-mode .CodeMirror-linenumber {
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/* Markdown preview styles */
 | 
					/* Markdown preview styles */
 | 
				
			||||||
#preview h1, #preview h2, #preview h3, #preview h4, #preview h5, #preview h6 {
 | 
					#preview h1,
 | 
				
			||||||
 | 
					#preview h2,
 | 
				
			||||||
 | 
					#preview h3,
 | 
				
			||||||
 | 
					#preview h4,
 | 
				
			||||||
 | 
					#preview h5,
 | 
				
			||||||
 | 
					#preview h6 {
 | 
				
			||||||
    margin-top: 24px;
 | 
					    margin-top: 24px;
 | 
				
			||||||
    margin-bottom: 16px;
 | 
					    margin-bottom: 16px;
 | 
				
			||||||
    font-weight: 600;
 | 
					    font-weight: 600;
 | 
				
			||||||
@@ -286,7 +286,8 @@ body.dark-mode .CodeMirror-linenumber {
 | 
				
			|||||||
    margin-bottom: 16px;
 | 
					    margin-bottom: 16px;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#preview ul, #preview ol {
 | 
					#preview ul,
 | 
				
			||||||
 | 
					#preview ol {
 | 
				
			||||||
    margin-bottom: 16px;
 | 
					    margin-bottom: 16px;
 | 
				
			||||||
    padding-left: 2em;
 | 
					    padding-left: 2em;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -591,4 +592,3 @@ body.dark-mode .sidebar h6 {
 | 
				
			|||||||
body.dark-mode .tree-children {
 | 
					body.dark-mode .tree-children {
 | 
				
			||||||
    border-left-color: var(--border-color);
 | 
					    border-left-color: var(--border-color);
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					 | 
				
			||||||
 
 | 
				
			|||||||
@@ -23,26 +23,54 @@
 | 
				
			|||||||
    <link rel="stylesheet" href="/static/css/file-tree.css">
 | 
					    <link rel="stylesheet" href="/static/css/file-tree.css">
 | 
				
			||||||
    <link rel="stylesheet" href="/static/css/editor.css">
 | 
					    <link rel="stylesheet" href="/static/css/editor.css">
 | 
				
			||||||
    <link rel="stylesheet" href="/static/css/components.css">
 | 
					    <link rel="stylesheet" href="/static/css/components.css">
 | 
				
			||||||
 | 
					    <link rel="stylesheet" href="/static/css/modal.css">
 | 
				
			||||||
</head>
 | 
					</head>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<body>
 | 
					<body>
 | 
				
			||||||
    <!-- Navbar -->
 | 
					    <!-- Navbar -->
 | 
				
			||||||
    <nav class="navbar navbar-expand-lg">
 | 
					    <nav class="navbar navbar-expand-lg">
 | 
				
			||||||
        <div class="container-fluid">
 | 
					        <div class="container-fluid">
 | 
				
			||||||
            <span class="navbar-brand">
 | 
					            <!-- 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
 | 
					                    <i class="bi bi-markdown"></i> Markdown Editor
 | 
				
			||||||
            </span>
 | 
					                </a>
 | 
				
			||||||
            <div class="d-flex gap-2">
 | 
					            </div>
 | 
				
			||||||
                <button id="newBtn" class="btn btn-success btn-sm">
 | 
					
 | 
				
			||||||
 | 
					            <!-- Right: All Buttons -->
 | 
				
			||||||
 | 
					            <div class="ms-auto d-flex gap-2 align-items-center">
 | 
				
			||||||
 | 
					                <!-- View Mode Button -->
 | 
				
			||||||
 | 
					                <button id="editModeBtn" class="btn-flat btn-flat" style="display: none;">
 | 
				
			||||||
 | 
					                    <i class="bi bi-pencil-square"></i> Edit this file
 | 
				
			||||||
 | 
					                </button>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                <!-- Edit Mode Buttons -->
 | 
				
			||||||
 | 
					                <button id="newBtn" class="btn-flat btn-flat-success">
 | 
				
			||||||
                    <i class="bi bi-file-plus"></i> New
 | 
					                    <i class="bi bi-file-plus"></i> New
 | 
				
			||||||
                </button>
 | 
					                </button>
 | 
				
			||||||
                <button id="saveBtn" class="btn btn-primary btn-sm">
 | 
					                <button id="saveBtn" class="btn-flat btn-flat-primary">
 | 
				
			||||||
                    <i class="bi bi-save"></i> Save
 | 
					                    <i class="bi bi-save"></i> Save
 | 
				
			||||||
                </button>
 | 
					                </button>
 | 
				
			||||||
                <button id="deleteBtn" class="btn btn-danger btn-sm">
 | 
					                <button id="deleteBtn" class="btn-flat btn-flat-danger">
 | 
				
			||||||
                    <i class="bi bi-trash"></i> Delete
 | 
					                    <i class="bi bi-trash"></i> Delete
 | 
				
			||||||
                </button>
 | 
					                </button>
 | 
				
			||||||
                <button id="darkModeBtn" class="btn btn-secondary btn-sm">🌙</button>
 | 
					                <button id="exitEditModeBtn" class="btn-flat btn-flat-secondary">
 | 
				
			||||||
 | 
					                    <i class="bi bi-eye"></i> Exit Edit Mode
 | 
				
			||||||
 | 
					                </button>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                <!-- Divider -->
 | 
				
			||||||
 | 
					                <div class="vr" style="height: 40px;"></div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                <!-- Dark Mode Toggle -->
 | 
				
			||||||
 | 
					                <button id="darkModeBtn" class="btn-flat btn-flat-secondary">
 | 
				
			||||||
 | 
					                    <i class="bi bi-moon-fill"></i>
 | 
				
			||||||
 | 
					                </button>
 | 
				
			||||||
            </div>
 | 
					            </div>
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
    </nav>
 | 
					    </nav>
 | 
				
			||||||
@@ -51,19 +79,27 @@
 | 
				
			|||||||
    <div class="container-fluid">
 | 
					    <div class="container-fluid">
 | 
				
			||||||
        <div class="row h-100">
 | 
					        <div class="row h-100">
 | 
				
			||||||
            <!-- Sidebar -->
 | 
					            <!-- Sidebar -->
 | 
				
			||||||
            <div class="col-md-2 sidebar">
 | 
					            <div class="col-md-2 sidebar" id="sidebarPane">
 | 
				
			||||||
                <!-- Collection Selector -->
 | 
					                <!-- Collection Selector -->
 | 
				
			||||||
                <div class="collection-selector">
 | 
					                <div class="collection-selector">
 | 
				
			||||||
                    <label class="form-label small">Collection:</label>
 | 
					                    <label class="form-label small">Collection:</label>
 | 
				
			||||||
                    <select id="collectionSelect" class="form-select form-select-sm"></select>
 | 
					                    <div class="d-flex gap-1">
 | 
				
			||||||
 | 
					                        <select id="collectionSelect" class="form-select form-select-sm flex-grow-1"></select>
 | 
				
			||||||
 | 
					                        <button id="newCollectionBtn" class="btn btn-sm new-collection-btn"
 | 
				
			||||||
 | 
					                            title="Create New Collection">
 | 
				
			||||||
 | 
					                            <i class="bi bi-plus-lg"></i>
 | 
				
			||||||
 | 
					                        </button>
 | 
				
			||||||
 | 
					                    </div>
 | 
				
			||||||
                </div>
 | 
					                </div>
 | 
				
			||||||
 | 
					 | 
				
			||||||
                <!-- File Tree -->
 | 
					                <!-- File Tree -->
 | 
				
			||||||
                <div id="fileTree" class="file-tree"></div>
 | 
					                <div id="fileTree" class="file-tree"></div>
 | 
				
			||||||
            </div>
 | 
					            </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            <!-- Resizer between sidebar and editor -->
 | 
				
			||||||
 | 
					            <div class="column-resizer" id="resizer1"></div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            <!-- Editor Pane -->
 | 
					            <!-- Editor Pane -->
 | 
				
			||||||
            <div class="col-md-5 editor-pane">
 | 
					            <div class="col editor-pane" id="editorPane">
 | 
				
			||||||
                <div class="editor-header">
 | 
					                <div class="editor-header">
 | 
				
			||||||
                    <input type="text" id="filenameInput" placeholder="filename.md"
 | 
					                    <input type="text" id="filenameInput" placeholder="filename.md"
 | 
				
			||||||
                        class="form-control form-control-sm">
 | 
					                        class="form-control form-control-sm">
 | 
				
			||||||
@@ -73,9 +109,11 @@
 | 
				
			|||||||
                </div>
 | 
					                </div>
 | 
				
			||||||
            </div>
 | 
					            </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            <!-- Resizer between editor and preview -->
 | 
				
			||||||
 | 
					            <div class="column-resizer" id="resizer2"></div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            <!-- Preview Pane -->
 | 
					            <!-- Preview Pane -->
 | 
				
			||||||
            <div class="col-md-5 preview-pane">
 | 
					            <div class="col preview-pane" id="previewPane">
 | 
				
			||||||
                <h3>Preview</h3>
 | 
					 | 
				
			||||||
                <div id="preview">
 | 
					                <div id="preview">
 | 
				
			||||||
                    <p class="text-muted">Start typing in the editor to see the preview</p>
 | 
					                    <p class="text-muted">Start typing in the editor to see the preview</p>
 | 
				
			||||||
                </div>
 | 
					                </div>
 | 
				
			||||||
@@ -115,11 +153,43 @@
 | 
				
			|||||||
            <i class="bi bi-clipboard"></i> Paste
 | 
					            <i class="bi bi-clipboard"></i> Paste
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
        <div class="context-menu-divider"></div>
 | 
					        <div class="context-menu-divider"></div>
 | 
				
			||||||
 | 
					        <div class="context-menu-item" data-action="copy-to-collection">
 | 
				
			||||||
 | 
					            <i class="bi bi-box-arrow-right"></i> Copy to Collection...
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					        <div class="context-menu-item" data-action="move-to-collection">
 | 
				
			||||||
 | 
					            <i class="bi bi-arrow-right-square"></i> Move to Collection...
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					        <div class="context-menu-divider"></div>
 | 
				
			||||||
        <div class="context-menu-item text-danger" data-action="delete">
 | 
					        <div class="context-menu-item text-danger" data-action="delete">
 | 
				
			||||||
            <i class="bi bi-trash"></i> Delete
 | 
					            <i class="bi bi-trash"></i> Delete
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <!-- Confirmation Modal -->
 | 
				
			||||||
 | 
					    <div class="modal fade" id="confirmationModal" tabindex="-1" aria-labelledby="confirmationModalLabel"
 | 
				
			||||||
 | 
					        aria-hidden="true">
 | 
				
			||||||
 | 
					        <div class="modal-dialog">
 | 
				
			||||||
 | 
					            <div class="modal-content">
 | 
				
			||||||
 | 
					                <div class="modal-header">
 | 
				
			||||||
 | 
					                    <h5 class="modal-title" id="confirmationModalLabel">Confirmation</h5>
 | 
				
			||||||
 | 
					                    <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
 | 
					                <div class="modal-body">
 | 
				
			||||||
 | 
					                    <p id="confirmationMessage"></p>
 | 
				
			||||||
 | 
					                    <input type="text" id="confirmationInput" class="form-control" style="display: none;">
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
 | 
					                <div class="modal-footer">
 | 
				
			||||||
 | 
					                    <button type="button" class="btn-flat btn-flat-secondary" data-bs-dismiss="modal">
 | 
				
			||||||
 | 
					                        <i class="bi bi-x-circle"></i> Cancel
 | 
				
			||||||
 | 
					                    </button>
 | 
				
			||||||
 | 
					                    <button type="button" class="btn-flat btn-flat-primary" id="confirmButton">
 | 
				
			||||||
 | 
					                        <i class="bi bi-check-circle"></i> OK
 | 
				
			||||||
 | 
					                    </button>
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <!-- Bootstrap JS -->
 | 
					    <!-- Bootstrap JS -->
 | 
				
			||||||
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
 | 
					    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -153,12 +223,33 @@
 | 
				
			|||||||
    <!-- Mermaid for diagrams -->
 | 
					    <!-- Mermaid for diagrams -->
 | 
				
			||||||
    <script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
 | 
					    <script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <!-- Modular JavaScript -->
 | 
					    <!-- Application Configuration (must load first) -->
 | 
				
			||||||
    <script src="/static/js/webdav-client.js"></script>
 | 
					    <script src="/static/js/config.js"></script>
 | 
				
			||||||
    <script src="/static/js/file-tree.js"></script>
 | 
					    <script src="/static/js/logger.js"></script>
 | 
				
			||||||
    <script src="/static/js/editor.js"></script>
 | 
					    <script src="/static/js/event-bus.js"></script>
 | 
				
			||||||
    <script src="/static/js/ui-utils.js"></script>
 | 
					    <script src="/static/js/utils.js"></script>
 | 
				
			||||||
    <script src="/static/js/app.js"></script>
 | 
					    <script src="/static/js/notification-service.js"></script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <!-- UI Components -->
 | 
				
			||||||
 | 
					    <script src="/static/js/ui-utils.js" defer></script>
 | 
				
			||||||
 | 
					    <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>
 | 
				
			||||||
 | 
					    <script src="/static/js/loading-spinner.js" defer></script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <!-- Core Application Modules -->
 | 
				
			||||||
 | 
					    <script src="/static/js/webdav-client.js" defer></script>
 | 
				
			||||||
 | 
					    <script src="/static/js/file-tree.js" defer></script>
 | 
				
			||||||
 | 
					    <script src="/static/js/editor.js" defer></script>
 | 
				
			||||||
 | 
					    <script src="/static/js/confirmation.js" defer></script>
 | 
				
			||||||
 | 
					    <script src="/static/js/file-tree-actions.js" defer></script>
 | 
				
			||||||
 | 
					    <script src="/static/js/column-resizer.js" defer></script>
 | 
				
			||||||
 | 
					    <script src="/static/js/app.js" defer></script>
 | 
				
			||||||
 | 
					    <script src="/static/js/macro-parser.js" defer></script>
 | 
				
			||||||
 | 
					    <script src="/static/js/macro-processor.js" defer></script>
 | 
				
			||||||
</body>
 | 
					</body>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
</html>
 | 
					</html>
 | 
				
			||||||