diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..443f468
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+.venv
+server.log
diff --git a/collections/notes/images/logo-blue.png b/collections/notes/images/logo-blue.png
new file mode 100644
index 0000000..7790f52
Binary files /dev/null and b/collections/notes/images/logo-blue.png differ
diff --git a/collections/notes/new_folder/zeko.md b/collections/notes/new_folder/zeko.md
new file mode 100644
index 0000000..09f37b3
--- /dev/null
+++ b/collections/notes/new_folder/zeko.md
@@ -0,0 +1,2 @@
+# New File
+
diff --git a/collections/notes/test.md b/collections/notes/test.md
deleted file mode 100644
index 43df96f..0000000
--- a/collections/notes/test.md
+++ /dev/null
@@ -1,10 +0,0 @@
-
-# test
-
-- 1
-- 2
-
-[2025 SeaweedFS Intro Slides.pdf](/notes/2025 SeaweedFS Intro Slides.pdf)
-
-
-
diff --git a/collections/notes/ttt/test.md b/collections/notes/tests/test.md
similarity index 100%
rename from collections/notes/ttt/test.md
rename to collections/notes/tests/test.md
diff --git a/collections/notes/ttt/test2.md b/collections/notes/tests/test2.md
similarity index 100%
rename from collections/notes/ttt/test2.md
rename to collections/notes/tests/test2.md
diff --git a/collections/notes/tests/test3.md b/collections/notes/tests/test3.md
new file mode 100644
index 0000000..06bcd72
--- /dev/null
+++ b/collections/notes/tests/test3.md
@@ -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.
diff --git a/refactor-plan.md b/refactor-plan.md
new file mode 100644
index 0000000..06bcd72
--- /dev/null
+++ b/refactor-plan.md
@@ -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.
diff --git a/server_webdav.py b/server_webdav.py
index ac52ab1..070bce1 100755
--- a/server_webdav.py
+++ b/server_webdav.py
@@ -78,27 +78,36 @@ class MarkdownEditorApp:
# Root and index.html
if path == '/' or path == '/index.html':
return self.handle_index(environ, start_response)
-
+
# 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)
+ # 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)
- # Fallback for anything else (shouldn't happen with correct linking)
- start_response('404 Not Found', [('Content-Type', 'text/plain')])
- return [b'Not Found']
+ # 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):
"""Return list of available collections"""
diff --git a/static/app-tree.js b/static/app-tree.js
index 0fdea6d..e1fcb12 100644
--- a/static/app-tree.js
+++ b/static/app-tree.js
@@ -1,5 +1,5 @@
// Markdown Editor Application with File Tree
-(function() {
+(function () {
'use strict';
// State management
@@ -26,13 +26,13 @@
document.body.classList.add('dark-mode');
document.getElementById('darkModeIcon').textContent = 'āļø';
localStorage.setItem('darkMode', 'true');
-
- mermaid.initialize({
+
+ mermaid.initialize({
startOnLoad: false,
theme: 'dark',
securityLevel: 'loose'
});
-
+
if (editor && editor.getValue()) {
updatePreview();
}
@@ -43,13 +43,13 @@
document.body.classList.remove('dark-mode');
document.getElementById('darkModeIcon').textContent = 'š';
localStorage.setItem('darkMode', 'false');
-
- mermaid.initialize({
+
+ mermaid.initialize({
startOnLoad: false,
theme: 'default',
securityLevel: 'loose'
});
-
+
if (editor && editor.getValue()) {
updatePreview();
}
@@ -64,7 +64,7 @@
}
// Initialize Mermaid
- mermaid.initialize({
+ mermaid.initialize({
startOnLoad: false,
theme: 'default',
securityLevel: 'loose'
@@ -86,15 +86,15 @@
async function uploadImage(file) {
const formData = new FormData();
formData.append('file', file);
-
+
try {
const response = await fetch('/api/upload-image', {
method: 'POST',
body: formData
});
-
+
if (!response.ok) throw new Error('Upload failed');
-
+
const result = await response.json();
return result.url;
} catch (error) {
@@ -107,44 +107,44 @@
// Handle drag and drop for images
function setupDragAndDrop() {
const editorElement = document.querySelector('.CodeMirror');
-
+
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
editorElement.addEventListener(eventName, preventDefaults, false);
});
-
+
function preventDefaults(e) {
e.preventDefault();
e.stopPropagation();
}
-
+
['dragenter', 'dragover'].forEach(eventName => {
editorElement.addEventListener(eventName, () => {
editorElement.classList.add('drag-over');
}, false);
});
-
+
['dragleave', 'drop'].forEach(eventName => {
editorElement.addEventListener(eventName, () => {
editorElement.classList.remove('drag-over');
}, false);
});
-
+
editorElement.addEventListener('drop', async (e) => {
const files = e.dataTransfer.files;
-
+
if (files.length === 0) return;
-
- const imageFiles = Array.from(files).filter(file =>
+
+ const imageFiles = Array.from(files).filter(file =>
file.type.startsWith('image/')
);
-
+
if (imageFiles.length === 0) {
showNotification('Please drop image files only', 'warning');
return;
}
-
+
showNotification(`Uploading ${imageFiles.length} image(s)...`, 'info');
-
+
for (const file of imageFiles) {
const url = await uploadImage(file);
if (url) {
@@ -156,11 +156,11 @@
}
}
}, false);
-
+
editorElement.addEventListener('paste', async (e) => {
const items = e.clipboardData?.items;
if (!items) return;
-
+
for (const item of items) {
if (item.type.startsWith('image/')) {
e.preventDefault();
@@ -189,15 +189,15 @@
lineWrapping: true,
autofocus: true,
extraKeys: {
- 'Ctrl-S': function() { saveFile(); },
- 'Cmd-S': function() { saveFile(); }
+ 'Ctrl-S': function () { saveFile(); },
+ 'Cmd-S': function () { saveFile(); }
}
});
editor.on('change', debounce(updatePreview, 300));
-
+
setTimeout(setupDragAndDrop, 100);
-
+
setupScrollSync();
}
@@ -217,13 +217,13 @@
// Setup synchronized scrolling
function setupScrollSync() {
const previewDiv = document.getElementById('preview');
-
+
editor.on('scroll', () => {
if (!isScrollingSynced) return;
-
+
const scrollInfo = editor.getScrollInfo();
const scrollPercentage = scrollInfo.top / (scrollInfo.height - scrollInfo.clientHeight);
-
+
const previewScrollHeight = previewDiv.scrollHeight - previewDiv.clientHeight;
previewDiv.scrollTop = previewScrollHeight * scrollPercentage;
});
@@ -233,7 +233,7 @@
async function updatePreview() {
const markdown = editor.getValue();
const previewDiv = document.getElementById('preview');
-
+
if (!markdown.trim()) {
previewDiv.innerHTML = `
@@ -243,17 +243,17 @@
`;
return;
}
-
+
try {
let html = marked.parse(markdown);
-
+
html = html.replace(
/
([\s\S]*?)<\/code><\/pre>/g,
'$1
'
);
-
+
previewDiv.innerHTML = html;
-
+
const codeBlocks = previewDiv.querySelectorAll('pre code');
codeBlocks.forEach(block => {
const languageClass = Array.from(block.classList).find(cls => cls.startsWith('language-'));
@@ -261,7 +261,7 @@
Prism.highlightElement(block);
}
});
-
+
const mermaidElements = previewDiv.querySelectorAll('.mermaid');
if (mermaidElements.length > 0) {
try {
@@ -291,7 +291,7 @@
try {
const response = await fetch('/api/tree');
if (!response.ok) throw new Error('Failed to load file tree');
-
+
fileTree = await response.json();
renderFileTree();
} catch (error) {
@@ -303,12 +303,12 @@
function renderFileTree() {
const container = document.getElementById('fileTree');
container.innerHTML = '';
-
+
if (fileTree.length === 0) {
container.innerHTML = 'No files yet
';
return;
}
-
+
fileTree.forEach(node => {
container.appendChild(createTreeNode(node));
});
@@ -317,13 +317,13 @@
function createTreeNode(node, level = 0) {
const nodeDiv = document.createElement('div');
nodeDiv.className = 'tree-node-wrapper';
-
+
const nodeContent = document.createElement('div');
nodeContent.className = 'tree-node';
nodeContent.dataset.path = node.path;
nodeContent.dataset.type = node.type;
nodeContent.dataset.name = node.name;
-
+
// Make draggable
nodeContent.draggable = true;
nodeContent.addEventListener('dragstart', handleDragStart);
@@ -331,14 +331,13 @@
nodeContent.addEventListener('dragover', handleDragOver);
nodeContent.addEventListener('dragleave', handleDragLeave);
nodeContent.addEventListener('drop', handleDrop);
-
+
const contentWrapper = document.createElement('div');
contentWrapper.className = 'tree-node-content';
-
+
if (node.type === 'directory') {
const toggle = document.createElement('span');
toggle.className = 'tree-node-toggle';
- toggle.innerHTML = 'ā¶';
toggle.addEventListener('click', (e) => {
e.stopPropagation();
toggleNode(nodeDiv);
@@ -349,56 +348,56 @@
spacer.style.width = '16px';
contentWrapper.appendChild(spacer);
}
-
+
const icon = document.createElement('i');
icon.className = node.type === 'directory' ? 'bi bi-folder tree-node-icon' : 'bi bi-file-earmark-text tree-node-icon';
contentWrapper.appendChild(icon);
-
+
const name = document.createElement('span');
name.className = 'tree-node-name';
name.textContent = node.name;
contentWrapper.appendChild(name);
-
+
if (node.type === 'file' && node.size) {
const size = document.createElement('span');
size.className = 'file-size-badge';
size.textContent = formatFileSize(node.size);
contentWrapper.appendChild(size);
}
-
+
nodeContent.appendChild(contentWrapper);
-
+
nodeContent.addEventListener('click', (e) => {
if (node.type === 'file') {
loadFile(node.path);
}
});
-
+
nodeContent.addEventListener('contextmenu', (e) => {
e.preventDefault();
showContextMenu(e, node);
});
-
+
nodeDiv.appendChild(nodeContent);
-
+
if (node.children && node.children.length > 0) {
const childrenDiv = document.createElement('div');
childrenDiv.className = 'tree-children collapsed';
-
+
node.children.forEach(child => {
childrenDiv.appendChild(createTreeNode(child, level + 1));
});
-
+
nodeDiv.appendChild(childrenDiv);
}
-
+
return nodeDiv;
}
function toggleNode(nodeWrapper) {
const toggle = nodeWrapper.querySelector('.tree-node-toggle');
const children = nodeWrapper.querySelector('.tree-children');
-
+
if (children) {
children.classList.toggle('collapsed');
toggle.classList.toggle('expanded');
@@ -437,10 +436,10 @@
function handleDragOver(e) {
if (!draggedNode) return;
-
+
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
-
+
const targetType = e.currentTarget.dataset.type;
if (targetType === 'directory') {
e.currentTarget.classList.add('drag-over');
@@ -454,18 +453,18 @@
async function handleDrop(e) {
e.preventDefault();
e.currentTarget.classList.remove('drag-over');
-
+
if (!draggedNode) return;
-
+
const targetPath = e.currentTarget.dataset.path;
const targetType = e.currentTarget.dataset.type;
-
+
if (targetType !== 'directory') return;
if (draggedNode.path === targetPath) return;
-
+
const sourcePath = draggedNode.path;
const destPath = targetPath + '/' + draggedNode.name;
-
+
try {
const response = await fetch('/api/file/move', {
method: 'POST',
@@ -475,16 +474,16 @@
destination: destPath
})
});
-
+
if (!response.ok) throw new Error('Move failed');
-
+
showNotification(`Moved ${draggedNode.name}`, 'success');
loadFileTree();
} catch (error) {
console.error('Error moving file:', error);
showNotification('Error moving file', 'danger');
}
-
+
draggedNode = null;
}
@@ -496,18 +495,18 @@
contextMenuTarget = node;
const menu = document.getElementById('contextMenu');
const pasteItem = document.getElementById('pasteMenuItem');
-
+
// Show paste option only if clipboard has something and target is a directory
if (clipboard && node.type === 'directory') {
pasteItem.style.display = 'flex';
} else {
pasteItem.style.display = 'none';
}
-
+
menu.style.display = 'block';
menu.style.left = e.pageX + 'px';
menu.style.top = e.pageY + 'px';
-
+
document.addEventListener('click', hideContextMenu);
}
@@ -525,20 +524,20 @@
try {
const response = await fetch(`/api/file?path=${encodeURIComponent(path)}`);
if (!response.ok) throw new Error('Failed to load file');
-
+
const data = await response.json();
currentFile = data.filename;
currentFilePath = path;
-
+
document.getElementById('filenameInput').value = path;
editor.setValue(data.content);
updatePreview();
-
+
document.querySelectorAll('.tree-node').forEach(node => {
node.classList.remove('active');
});
document.querySelector(`[data-path="${path}"]`)?.classList.add('active');
-
+
showNotification(`Loaded ${data.filename}`, 'info');
} catch (error) {
console.error('Error loading file:', error);
@@ -548,27 +547,27 @@
async function saveFile() {
const path = document.getElementById('filenameInput').value.trim();
-
+
if (!path) {
showNotification('Please enter a filename', 'warning');
return;
}
-
+
const content = editor.getValue();
-
+
try {
const response = await fetch('/api/file', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path, content })
});
-
+
if (!response.ok) throw new Error('Failed to save file');
-
+
const result = await response.json();
currentFile = path.split('/').pop();
currentFilePath = result.path;
-
+
showNotification(`Saved ${currentFile}`, 'success');
loadFileTree();
} catch (error) {
@@ -582,26 +581,26 @@
showNotification('No file selected', 'warning');
return;
}
-
+
if (!confirm(`Are you sure you want to delete ${currentFile}?`)) {
return;
}
-
+
try {
const response = await fetch(`/api/file?path=${encodeURIComponent(currentFilePath)}`, {
method: 'DELETE'
});
-
+
if (!response.ok) throw new Error('Failed to delete file');
-
+
showNotification(`Deleted ${currentFile}`, 'success');
-
+
currentFile = null;
currentFilePath = null;
document.getElementById('filenameInput').value = '';
editor.setValue('');
updatePreview();
-
+
loadFileTree();
} catch (error) {
console.error('Error deleting file:', error);
@@ -617,27 +616,27 @@
document.getElementById('filenameInput').focus();
editor.setValue('');
updatePreview();
-
+
document.querySelectorAll('.tree-node').forEach(node => {
node.classList.remove('active');
});
-
+
showNotification('Enter filename and start typing', 'info');
}
async function createFolder() {
const folderName = prompt('Enter folder name:');
if (!folderName) return;
-
+
try {
const response = await fetch('/api/directory', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: folderName })
});
-
+
if (!response.ok) throw new Error('Failed to create folder');
-
+
showNotification(`Created folder ${folderName}`, 'success');
loadFileTree();
} catch (error) {
@@ -652,32 +651,32 @@
async function handleContextMenuAction(action) {
if (!contextMenuTarget) return;
-
+
switch (action) {
case 'open':
if (contextMenuTarget.type === 'file') {
loadFile(contextMenuTarget.path);
}
break;
-
+
case 'rename':
await renameItem();
break;
-
+
case 'copy':
clipboard = { ...contextMenuTarget, operation: 'copy' };
showNotification(`Copied ${contextMenuTarget.name}`, 'info');
break;
-
+
case 'move':
clipboard = { ...contextMenuTarget, operation: 'move' };
showNotification(`Cut ${contextMenuTarget.name}`, 'info');
break;
-
+
case 'paste':
await pasteItem();
break;
-
+
case 'delete':
await deleteItem();
break;
@@ -687,10 +686,10 @@
async function renameItem() {
const newName = prompt(`Rename ${contextMenuTarget.name}:`, contextMenuTarget.name);
if (!newName || newName === contextMenuTarget.name) return;
-
+
const oldPath = contextMenuTarget.path;
const newPath = oldPath.substring(0, oldPath.lastIndexOf('/') + 1) + newName;
-
+
try {
const endpoint = contextMenuTarget.type === 'directory' ? '/api/directory/rename' : '/api/file/rename';
const response = await fetch(endpoint, {
@@ -701,9 +700,9 @@
new_path: newPath
})
});
-
+
if (!response.ok) throw new Error('Rename failed');
-
+
showNotification(`Renamed to ${newName}`, 'success');
loadFileTree();
} catch (error) {
@@ -714,12 +713,12 @@
async function pasteItem() {
if (!clipboard) return;
-
+
const destDir = contextMenuTarget.path;
const sourcePath = clipboard.path;
const fileName = clipboard.name;
const destPath = destDir + '/' + fileName;
-
+
try {
if (clipboard.operation === 'copy') {
// Copy operation
@@ -731,7 +730,7 @@
destination: destPath
})
});
-
+
if (!response.ok) throw new Error('Copy failed');
showNotification(`Copied ${fileName} to ${contextMenuTarget.name}`, 'success');
} else if (clipboard.operation === 'move') {
@@ -744,12 +743,12 @@
destination: destPath
})
});
-
+
if (!response.ok) throw new Error('Move failed');
showNotification(`Moved ${fileName} to ${contextMenuTarget.name}`, 'success');
clipboard = null; // Clear clipboard after move
}
-
+
loadFileTree();
} catch (error) {
console.error('Error pasting:', error);
@@ -761,7 +760,7 @@
if (!confirm(`Are you sure you want to delete ${contextMenuTarget.name}?`)) {
return;
}
-
+
try {
let response;
if (contextMenuTarget.type === 'directory') {
@@ -773,9 +772,9 @@
method: 'DELETE'
});
}
-
+
if (!response.ok) throw new Error('Delete failed');
-
+
showNotification(`Deleted ${contextMenuTarget.name}`, 'success');
loadFileTree();
} catch (error) {
@@ -793,7 +792,7 @@
if (!toastContainer) {
toastContainer = createToastContainer();
}
-
+
const toast = document.createElement('div');
toast.className = `toast align-items-center text-white bg-${type} border-0`;
toast.setAttribute('role', 'alert');
@@ -803,12 +802,12 @@
`;
-
+
toastContainer.appendChild(toast);
-
+
const bsToast = new bootstrap.Toast(toast, { delay: 3000 });
bsToast.show();
-
+
toast.addEventListener('hidden.bs.toast', () => {
toast.remove();
});
@@ -831,13 +830,13 @@
initDarkMode();
initEditor();
loadFileTree();
-
+
document.getElementById('saveBtn').addEventListener('click', saveFile);
document.getElementById('deleteBtn').addEventListener('click', deleteFile);
document.getElementById('newFileBtn').addEventListener('click', newFile);
document.getElementById('newFolderBtn').addEventListener('click', createFolder);
document.getElementById('darkModeToggle').addEventListener('click', toggleDarkMode);
-
+
// Context menu actions
document.querySelectorAll('.context-menu-item').forEach(item => {
item.addEventListener('click', () => {
@@ -846,14 +845,14 @@
hideContextMenu();
});
});
-
+
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
saveFile();
}
});
-
+
console.log('Markdown Editor with File Tree initialized');
}
diff --git a/static/css/components.css b/static/css/components.css
index 1caaf93..0512ff5 100644
--- a/static/css/components.css
+++ b/static/css/components.css
@@ -2,10 +2,21 @@
.preview-pane {
font-size: 16px;
line-height: 1.6;
+ 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 {
+#preview {
+ 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-bottom: 16px;
font-weight: 600;
@@ -137,6 +148,7 @@ body.dark-mode .context-menu {
transform: translateX(400px);
opacity: 0;
}
+
to {
transform: translateX(0);
opacity: 1;
@@ -152,6 +164,7 @@ body.dark-mode .context-menu {
transform: translateX(0);
opacity: 1;
}
+
to {
transform: translateX(400px);
opacity: 0;
@@ -205,4 +218,62 @@ body.dark-mode .modal-footer {
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;
}
\ No newline at end of file
diff --git a/static/css/editor.css b/static/css/editor.css
index 6ee84a6..8ba1d70 100644
--- a/static/css/editor.css
+++ b/static/css/editor.css
@@ -6,6 +6,8 @@
display: flex;
gap: 10px;
align-items: center;
+ flex-shrink: 0;
+ /* Prevent header from shrinking */
}
.editor-header input {
@@ -19,18 +21,42 @@
.editor-container {
flex: 1;
+ /* Take remaining space */
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 {
- height: 100%;
+ height: 100% !important;
+ /* Force full height */
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 14px;
background-color: var(--bg-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 {
background-color: #1c2128;
color: #e6edf3;
@@ -71,5 +97,4 @@ body.dark-mode .CodeMirror-gutters {
color: var(--info-color);
pointer-events: none;
z-index: 1000;
-}
-
+}
\ No newline at end of file
diff --git a/static/css/file-tree.css b/static/css/file-tree.css
index 13cbf87..86369aa 100644
--- a/static/css/file-tree.css
+++ b/static/css/file-tree.css
@@ -20,8 +20,9 @@
color: var(--text-primary);
transition: all 0.15s ease;
white-space: nowrap;
- overflow: hidden;
+ overflow: visible;
text-overflow: ellipsis;
+ min-height: 28px;
}
.tree-node:hover {
@@ -29,14 +30,16 @@
}
.tree-node.active {
- background-color: var(--link-color);
- color: white;
+ color: var(--link-color);
font-weight: 500;
}
.tree-node.active:hover {
- background-color: var(--link-color);
- filter: brightness(1.1);
+ filter: brightness(1.2);
+}
+
+.tree-node.active .tree-node-icon {
+ color: var(--link-color);
}
/* Toggle arrow */
@@ -46,16 +49,25 @@
justify-content: center;
width: 16px;
height: 16px;
- font-size: 10px;
+ 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 {
width: 16px;
@@ -67,10 +79,6 @@
color: var(--text-secondary);
}
-.tree-node.active .tree-node-icon {
- color: white;
-}
-
/* Content wrapper */
.tree-node-content {
display: flex;
@@ -112,13 +120,54 @@
}
/* 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 {
- opacity: 0.5;
+ opacity: 0.4;
+ background-color: var(--bg-tertiary);
+ cursor: grabbing !important;
}
.tree-node.drag-over {
- background-color: rgba(13, 110, 253, 0.2);
- border: 1px dashed var(--link-color);
+ background-color: rgba(13, 110, 253, 0.15) !important;
+ border: 2px dashed var(--link-color) !important;
+ box-shadow: 0 0 8px rgba(13, 110, 253, 0.3);
+}
+
+/* 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 */
@@ -156,13 +205,34 @@ body.dark-mode .tree-node:hover {
}
body.dark-mode .tree-node.active {
- background-color: var(--link-color);
+ 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);
diff --git a/static/css/layout.css b/static/css/layout.css
index 08ba397..52c30b7 100644
--- a/static/css/layout.css
+++ b/static/css/layout.css
@@ -1,14 +1,22 @@
/* Base layout styles */
-html, body {
- height: 100%;
+html,
+body {
+ height: 100vh;
margin: 0;
padding: 0;
+ overflow: hidden;
+ /* Prevent page-level scrolling */
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif;
background-color: var(--bg-primary);
color: var(--text-primary);
transition: background-color 0.3s ease, color 0.3s ease;
}
+body {
+ display: flex;
+ flex-direction: column;
+}
+
/* Column Resizer */
.column-resizer {
width: 1px;
@@ -17,14 +25,21 @@ html, body {
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 */
+ padding: 0 3px;
+ /* Add invisible padding for easier grab */
+ margin: 0 -3px;
+ /* Compensate for padding */
+ height: 100%;
+ /* Take full height of parent */
+ 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 */
+ box-shadow: 0 0 6px rgba(13, 110, 253, 0.3);
+ /* Visual feedback instead of width change */
}
.column-resizer.dragging {
@@ -36,12 +51,59 @@ html, body {
background-color: var(--link-color);
}
-/* Adjust container for flex layout */
-.container-fluid {
+/* Navbar */
+.navbar {
+ background-color: var(--bg-secondary);
+ border-bottom: 1px solid var(--border-color);
+ 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;
- height: calc(100% - 56px);
+ align-items: center;
+ justify-content: space-between;
padding: 0;
+ overflow: visible;
+ /* Override the hidden overflow for navbar */
+}
+
+.navbar-brand {
+ color: var(--text-primary) !important;
+ 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 {
@@ -50,6 +112,8 @@ html, body {
flex-direction: row;
margin: 0;
height: 100%;
+ overflow: hidden;
+ /* Prevent row scrolling */
}
#sidebarPane {
@@ -57,6 +121,9 @@ html, body {
min-width: 150px;
max-width: 40%;
padding: 0;
+ height: 100%;
+ overflow: hidden;
+ /* Prevent pane scrolling */
}
#editorPane {
@@ -64,25 +131,23 @@ html, body {
min-width: 250px;
max-width: 70%;
padding: 0;
-}
-
-#previewPane {
- 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);
- overflow-y: auto;
- overflow-x: hidden;
height: 100%;
transition: background-color 0.3s ease;
display: flex;
flex-direction: column;
+ overflow: hidden;
+ /* Prevent sidebar container scrolling */
}
.sidebar h6 {
@@ -92,25 +157,27 @@ html, body {
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 0;
-}
-
-/* Navbar */
-.navbar {
- background-color: var(--bg-secondary);
- border-bottom: 1px solid var(--border-color);
- transition: background-color 0.3s ease;
-}
-
-.navbar-brand {
- color: var(--text-primary) !important;
- font-weight: 600;
+ padding: 4px 10px;
+ min-height: 0;
+ /* Important: allows flex child to shrink below content size */
}
/* Scrollbar styling */
@@ -135,28 +202,78 @@ html, body {
/* Preview Pane Styling */
#previewPane {
- flex: 1 1 40%;
min-width: 250px;
max-width: 70%;
padding: 0;
- overflow-y: auto;
- overflow-x: hidden;
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;
- min-height: 100%;
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 {
+#preview>p:first-child {
margin-top: 0;
}
-#preview > h1:first-child,
-#preview > h2:first-child {
+#preview>h1:first-child,
+#preview>h2:first-child {
margin-top: 0;
+}
+
+/* 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%;
}
\ No newline at end of file
diff --git a/static/js/app.js b/static/js/app.js
index c608778..a12f615 100644
--- a/static/js/app.js
+++ b/static/js/app.js
@@ -12,100 +12,430 @@ let collectionSelector;
let clipboard = null;
let currentFilePath = null;
-// Simple event bus
-const eventBus = {
- listeners: {},
- on(event, callback) {
- if (!this.listeners[event]) {
- this.listeners[event] = [];
+// Event bus is now loaded from event-bus.js module
+// No need to define it here - it's available as window.eventBus
+
+/**
+ * Auto-load page in view mode
+ * Tries to load the last viewed page, falls back to first file if none saved
+ */
+async function autoLoadPageInViewMode() {
+ if (!editor || !fileTree) return;
+
+ try {
+ // Try to get last viewed page
+ let pageToLoad = editor.getLastViewedPage();
+
+ // If no last viewed page, get the first markdown file
+ if (!pageToLoad) {
+ pageToLoad = fileTree.getFirstMarkdownFile();
}
- this.listeners[event].push(callback);
- },
- dispatch(event, data) {
- if (this.listeners[event]) {
- this.listeners[event].forEach(callback => callback(data));
+
+ // If we found a page to load, load it
+ if (pageToLoad) {
+ await editor.loadFile(pageToLoad);
+ // Highlight the file in the tree and expand parent directories
+ fileTree.selectAndExpandPath(pageToLoad);
+ } else {
+ // No files found, show empty state message
+ editor.previewElement.innerHTML = `
+
+ `;
}
+ } catch (error) {
+ console.error('Failed to auto-load page in view mode:', error);
+ editor.previewElement.innerHTML = `
+
+
Failed to load content
+
+ `;
}
-};
-window.eventBus = eventBus;
+}
+
+/**
+ * Show directory preview with list of files
+ * @param {string} dirPath - The directory path
+ */
+async function showDirectoryPreview(dirPath) {
+ if (!editor || !fileTree || !webdavClient) return;
+
+ try {
+ const dirName = dirPath.split('/').pop() || dirPath;
+ const files = fileTree.getDirectoryFiles(dirPath);
+
+ // Start building the preview HTML
+ let html = ``;
+ html += `
${dirName}
`;
+
+ if (files.length === 0) {
+ html += `
This directory is empty
`;
+ } else {
+ html += `
`;
+
+ // 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 += `
+
+
+ ${fileDescription ? `
${fileDescription}
` : ''}
+
+ `;
+ }
+
+ html += `
`;
+ }
+
+ html += `
`;
+
+ // 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 = `
+
+
Failed to load directory preview
+
+ `;
+ }
+}
+
+/**
+ * Parse URL to extract collection and file path
+ * URL format: // or ///
+ * @returns {Object} {collection, filePath} or {collection, null} if only collection
+ */
+function parseURLPath() {
+ const pathname = window.location.pathname;
+ const parts = pathname.split('/').filter(p => p); // Remove empty parts
+
+ if (parts.length === 0) {
+ return { collection: null, filePath: null };
+ }
+
+ const collection = parts[0];
+ const filePath = parts.length > 1 ? parts.slice(1).join('/') : null;
+
+ return { collection, filePath };
+}
+
+/**
+ * Update URL based on current collection and file
+ * @param {string} collection - The collection name
+ * @param {string} filePath - The file path (optional)
+ * @param {boolean} isEditMode - Whether in edit mode
+ */
+function updateURL(collection, filePath, isEditMode) {
+ let url = `/${collection}`;
+ if (filePath) {
+ url += `/${filePath}`;
+ }
+ if (isEditMode) {
+ url += '?edit=true';
+ }
+
+ // Use pushState to update URL without reloading
+ window.history.pushState({ collection, filePath }, '', url);
+}
+
+/**
+ * Load file from URL path
+ * Assumes the collection is already set and file tree is loaded
+ * @param {string} collection - The collection name (for validation)
+ * @param {string} filePath - The file path
+ */
+async function loadFileFromURL(collection, filePath) {
+ console.log('[loadFileFromURL] Called with:', { collection, filePath });
+
+ if (!fileTree || !editor || !collectionSelector) {
+ console.error('[loadFileFromURL] Missing dependencies:', { fileTree: !!fileTree, editor: !!editor, collectionSelector: !!collectionSelector });
+ return;
+ }
+
+ try {
+ // Verify we're on the right collection
+ const currentCollection = collectionSelector.getCurrentCollection();
+ if (currentCollection !== collection) {
+ console.error(`[loadFileFromURL] Collection mismatch: expected ${collection}, got ${currentCollection}`);
+ return;
+ }
+
+ // Load the file or directory
+ if (filePath) {
+ // Check if the path is a directory or a file
+ const node = fileTree.findNode(filePath);
+ console.log('[loadFileFromURL] Found node:', node);
+
+ if (node && node.isDirectory) {
+ // It's a directory, show directory preview
+ console.log('[loadFileFromURL] Loading directory preview');
+ await showDirectoryPreview(filePath);
+ fileTree.selectAndExpandPath(filePath);
+ } else if (node) {
+ // It's a file, load it
+ console.log('[loadFileFromURL] Loading file');
+ await editor.loadFile(filePath);
+ fileTree.selectAndExpandPath(filePath);
+ } else {
+ console.error(`[loadFileFromURL] Path not found in file tree: ${filePath}`);
+ }
+ }
+ } catch (error) {
+ console.error('[loadFileFromURL] Failed to load file from URL:', error);
+ }
+}
+
+/**
+ * Handle browser back/forward navigation
+ */
+function setupPopStateListener() {
+ window.addEventListener('popstate', async (event) => {
+ const { collection, filePath } = parseURLPath();
+ if (collection) {
+ // Ensure the collection is set
+ const currentCollection = collectionSelector.getCurrentCollection();
+ if (currentCollection !== collection) {
+ await collectionSelector.setCollection(collection);
+ await fileTree.load();
+ }
+
+ // Load the file/directory
+ await loadFileFromURL(collection, filePath);
+ }
+ });
+}
// Initialize application
document.addEventListener('DOMContentLoaded', async () => {
+ // Determine view mode from URL parameter
+ const urlParams = new URLSearchParams(window.location.search);
+ const isEditMode = urlParams.get('edit') === 'true';
+
+ // Set view mode class on body
+ if (isEditMode) {
+ document.body.classList.add('edit-mode');
+ document.body.classList.remove('view-mode');
+ } else {
+ document.body.classList.add('view-mode');
+ document.body.classList.remove('edit-mode');
+ }
+
// Initialize WebDAV client
webdavClient = new WebDAVClient('/fs/');
-
+
// Initialize dark mode
darkMode = new DarkMode();
document.getElementById('darkModeBtn').addEventListener('click', () => {
darkMode.toggle();
});
-
- // Initialize file tree
- fileTree = new FileTree('fileTree', webdavClient);
- fileTree.onFileSelect = async (item) => {
- await editor.loadFile(item.path);
- };
-
- // Initialize collection selector
+
+ // Initialize collection selector (always needed)
collectionSelector = new CollectionSelector('collectionSelect', webdavClient);
- collectionSelector.onChange = async (collection) => {
- await fileTree.load();
- };
await collectionSelector.load();
- await fileTree.load();
-
- // Initialize editor
- editor = new MarkdownEditor('editor', 'preview', 'filenameInput');
+
+ // Setup URL routing
+ setupPopStateListener();
+
+ // Initialize editor (always needed for preview)
+ // In view mode, editor is read-only
+ editor = new MarkdownEditor('editor', 'preview', 'filenameInput', !isEditMode);
editor.setWebDAVClient(webdavClient);
- // Add test content to verify preview works
- setTimeout(() => {
- if (!editor.editor.getValue()) {
- editor.editor.setValue('# Welcome to Markdown Editor\n\nStart typing to see preview...\n');
- editor.updatePreview();
+ // Initialize file tree (needed in both modes)
+ fileTree = new FileTree('fileTree', webdavClient);
+ fileTree.onFileSelect = async (item) => {
+ try {
+ await editor.loadFile(item.path);
+ // Highlight the file in the tree and expand parent directories
+ fileTree.selectAndExpandPath(item.path);
+ // Update URL to reflect current file
+ const currentCollection = collectionSelector.getCurrentCollection();
+ updateURL(currentCollection, item.path, isEditMode);
+ } catch (error) {
+ Logger.error('Failed to select file:', error);
+ if (window.showNotification) {
+ window.showNotification('Failed to load file', 'error');
+ }
}
- }, 200);
-
- // Setup editor drop handler
- const editorDropHandler = new EditorDropHandler(
- document.querySelector('.editor-container'),
- async (file) => {
- await handleEditorFileDrop(file);
- }
- );
-
- // Setup button handlers
- document.getElementById('newBtn').addEventListener('click', () => {
- editor.newFile();
- });
-
- document.getElementById('saveBtn').addEventListener('click', async () => {
- await editor.save();
- });
-
- document.getElementById('deleteBtn').addEventListener('click', async () => {
- await editor.deleteFile();
- });
-
- // Setup context menu handlers
- setupContextMenuHandlers();
-
- // Initialize mermaid
- mermaid.initialize({ startOnLoad: true, theme: darkMode.isDark ? 'dark' : 'default' });
+ };
- // Initialize file tree actions manager
- window.fileTreeActions = new FileTreeActions(webdavClient, fileTree, editor);
+ fileTree.onFolderSelect = async (item) => {
+ try {
+ // Show directory preview
+ await showDirectoryPreview(item.path);
+ // Highlight the directory in the tree and expand parent directories
+ fileTree.selectAndExpandPath(item.path);
+ // Update URL to reflect current directory
+ const currentCollection = collectionSelector.getCurrentCollection();
+ updateURL(currentCollection, item.path, isEditMode);
+ } catch (error) {
+ Logger.error('Failed to select folder:', error);
+ if (window.showNotification) {
+ window.showNotification('Failed to load folder', 'error');
+ }
+ }
+ };
+
+ collectionSelector.onChange = async (collection) => {
+ try {
+ await fileTree.load();
+ // In view mode, auto-load last viewed page when collection changes
+ if (!isEditMode) {
+ await autoLoadPageInViewMode();
+ }
+ } catch (error) {
+ Logger.error('Failed to change collection:', error);
+ if (window.showNotification) {
+ window.showNotification('Failed to change collection', 'error');
+ }
+ }
+ };
+ await fileTree.load();
+
+ // Parse URL to load file if specified
+ const { collection: urlCollection, filePath: urlFilePath } = parseURLPath();
+ console.log('[URL PARSE]', { urlCollection, urlFilePath });
+
+ if (urlCollection && urlFilePath) {
+ console.log('[URL LOAD] Loading from URL:', urlCollection, urlFilePath);
+
+ // First ensure the collection is set
+ const currentCollection = collectionSelector.getCurrentCollection();
+ if (currentCollection !== urlCollection) {
+ console.log('[URL LOAD] Switching collection from', currentCollection, 'to', urlCollection);
+ await collectionSelector.setCollection(urlCollection);
+ await fileTree.load();
+ }
+
+ // Now load the file from URL
+ console.log('[URL LOAD] Calling loadFileFromURL');
+ await loadFileFromURL(urlCollection, urlFilePath);
+ } else if (!isEditMode) {
+ // In view mode, auto-load last viewed page if no URL file specified
+ await autoLoadPageInViewMode();
+ }
+
+ // Initialize file tree and editor-specific features only in edit mode
+ if (isEditMode) {
+ // Add test content to verify preview works
+ setTimeout(() => {
+ if (!editor.editor.getValue()) {
+ editor.editor.setValue('# Welcome to Markdown Editor\n\nStart typing to see preview...\n');
+ editor.updatePreview();
+ }
+ }, 200);
+
+ // Setup editor drop handler
+ const editorDropHandler = new EditorDropHandler(
+ document.querySelector('.editor-container'),
+ async (file) => {
+ try {
+ await handleEditorFileDrop(file);
+ } catch (error) {
+ Logger.error('Failed to handle file drop:', error);
+ }
+ }
+ );
+
+ // Setup button handlers
+ document.getElementById('newBtn').addEventListener('click', () => {
+ editor.newFile();
+ });
+
+ document.getElementById('saveBtn').addEventListener('click', async () => {
+ try {
+ await editor.save();
+ } catch (error) {
+ Logger.error('Failed to save file:', error);
+ if (window.showNotification) {
+ window.showNotification('Failed to save file', 'error');
+ }
+ }
+ });
+
+ document.getElementById('deleteBtn').addEventListener('click', async () => {
+ try {
+ await editor.deleteFile();
+ } catch (error) {
+ Logger.error('Failed to delete file:', error);
+ if (window.showNotification) {
+ window.showNotification('Failed to delete file', 'error');
+ }
+ }
+ });
+
+ // Setup context menu handlers
+ setupContextMenuHandlers();
+
+ // Initialize file tree actions manager
+ window.fileTreeActions = new FileTreeActions(webdavClient, fileTree, editor);
+ } else {
+ // In view mode, hide editor buttons
+ document.getElementById('newBtn').style.display = 'none';
+ document.getElementById('saveBtn').style.display = 'none';
+ document.getElementById('deleteBtn').style.display = 'none';
+
+ // Auto-load last viewed page or first file
+ await autoLoadPageInViewMode();
+ }
+
+ // Initialize mermaid (always needed)
+ mermaid.initialize({ startOnLoad: true, theme: darkMode.isDark ? 'dark' : 'default' });
// Listen for file-saved event to reload file tree
window.eventBus.on('file-saved', async (path) => {
- if (fileTree) {
- await fileTree.load();
- fileTree.selectNode(path);
+ try {
+ if (fileTree) {
+ await fileTree.load();
+ fileTree.selectNode(path);
+ }
+ } catch (error) {
+ Logger.error('Failed to reload file tree after save:', error);
}
});
window.eventBus.on('file-deleted', async () => {
- if (fileTree) {
- await fileTree.load();
+ try {
+ if (fileTree) {
+ await fileTree.load();
+ }
+ } catch (error) {
+ Logger.error('Failed to reload file tree after delete:', error);
}
});
});
@@ -126,17 +456,17 @@ window.addEventListener('column-resize', () => {
*/
function setupContextMenuHandlers() {
const menu = document.getElementById('contextMenu');
-
+
menu.addEventListener('click', async (e) => {
const item = e.target.closest('.context-menu-item');
if (!item) return;
-
+
const action = item.dataset.action;
const targetPath = menu.dataset.targetPath;
const isDir = menu.dataset.targetIsDir === 'true';
-
+
hideContextMenu();
-
+
await window.fileTreeActions.execute(action, targetPath, isDir);
});
}
@@ -163,16 +493,16 @@ async function handleEditorFileDrop(file) {
parts.pop(); // Remove filename
targetDir = parts.join('/');
}
-
+
// Upload file
const uploadedPath = await fileTree.uploadFile(targetDir, file);
-
+
// Insert markdown link at cursor
const isImage = file.type.startsWith('image/');
- const link = isImage
+ const link = isImage
? ``
: `[${file.name}](/${webdavClient.currentCollection}/${uploadedPath})`;
-
+
editor.insertAtCursor(link);
showNotification(`Uploaded and inserted link`, 'success');
} catch (error) {
diff --git a/static/js/collection-selector.js b/static/js/collection-selector.js
new file mode 100644
index 0000000..b40ee5d
--- /dev/null
+++ b/static/js/collection-selector.js
@@ -0,0 +1,100 @@
+/**
+ * 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);
+ });
+
+ // Try to restore previously selected collection from localStorage
+ const savedCollection = localStorage.getItem(this.storageKey);
+ let collectionToSelect = collections[0]; // Default to first
+
+ if (savedCollection && collections.includes(savedCollection)) {
+ collectionToSelect = 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}`);
+
+ 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}`);
+
+ if (this.onChange) {
+ this.onChange(collection);
+ }
+ } else {
+ Logger.warn(`Collection "${collection}" not found in available collections`);
+ }
+ }
+}
+
+// Make CollectionSelector globally available
+window.CollectionSelector = CollectionSelector;
+
diff --git a/static/js/column-resizer.js b/static/js/column-resizer.js
index c00ef06..f571eec 100644
--- a/static/js/column-resizer.js
+++ b/static/js/column-resizer.js
@@ -10,68 +10,67 @@ class ColumnResizer {
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`;
- this.previewPane.style.flex = `1 1 auto`;
}
};
-
+
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,
@@ -80,16 +79,15 @@ class ColumnResizer {
};
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`;
- this.previewPane.style.flex = `1 1 auto`;
} catch (error) {
console.error('Failed to load column dimensions:', error);
}
diff --git a/static/js/config.js b/static/js/config.js
new file mode 100644
index 0000000..7a48845
--- /dev/null
+++ b/static/js/config.js
@@ -0,0 +1,202 @@
+/**
+ * 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'
+ },
+
+ // ===== 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;
+
diff --git a/static/js/confirmation.js b/static/js/confirmation.js
index 6582ac6..7c95a8a 100644
--- a/static/js/confirmation.js
+++ b/static/js/confirmation.js
@@ -1,68 +1,169 @@
/**
- * Confirmation Modal Manager
+ * 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 Confirmation {
+class ModalManager {
constructor(modalId) {
this.modalElement = document.getElementById(modalId);
- this.modal = new bootstrap.Modal(this.modalElement);
+ 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(message, title, showInput = false, defaultValue = '') {
+ /**
+ * 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} - 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';
- if (showInput) {
- this.inputElement.style.display = 'block';
- this.inputElement.value = defaultValue;
- this.inputElement.focus();
+ // Update button styling based on danger level
+ if (isDangerous) {
+ this.confirmButton.className = 'btn btn-danger';
+ this.confirmButton.textContent = 'Delete';
} else {
- this.inputElement.style.display = 'none';
+ this.confirmButton.className = 'btn btn-primary';
+ this.confirmButton.textContent = 'OK';
}
- this.confirmButton.onclick = () => this._handleConfirm(showInput);
- this.modalElement.addEventListener('hidden.bs.modal', () => this._handleCancel(), { once: true });
-
+ // 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 });
+
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} - 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 btn-primary';
+ this.confirmButton.textContent = '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 });
+
+ 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 : true;
- this.currentResolver(value);
+ const value = isPrompt ? this.inputElement.value.trim() : true;
+ const resolver = this.currentResolver;
this._cleanup();
+ resolver(value);
}
}
_handleCancel() {
if (this.currentResolver) {
- this.currentResolver(null); // Resolve with null for cancellation
+ const resolver = this.currentResolver;
this._cleanup();
+ resolver(null);
}
}
_cleanup() {
this.confirmButton.onclick = null;
- this.modal.hide();
+ this.inputElement.onkeydown = null;
this.currentResolver = null;
- }
-
- confirm(message, title = 'Confirmation') {
- return this._show(message, title, false);
- }
-
- prompt(message, defaultValue = '', title = 'Prompt') {
- return this._show(message, title, true, defaultValue);
+ this.isShowing = false;
+ this.modal.hide();
}
}
// Make it globally available
-window.ConfirmationManager = new Confirmation('confirmationModal');
+window.ConfirmationManager = new ModalManager('confirmationModal');
+window.ModalManager = window.ConfirmationManager; // Alias for clarity
diff --git a/static/js/context-menu.js b/static/js/context-menu.js
new file mode 100644
index 0000000..27b8722
--- /dev/null
+++ b/static/js/context-menu.js
@@ -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;
+
diff --git a/static/js/dark-mode.js b/static/js/dark-mode.js
new file mode 100644
index 0000000..7a1906e
--- /dev/null
+++ b/static/js/dark-mode.js
@@ -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.textContent = 'āļø';
+
+ // 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.textContent = 'š';
+
+ // 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;
+
diff --git a/static/js/editor-drop-handler.js b/static/js/editor-drop-handler.js
new file mode 100644
index 0000000..cb8312f
--- /dev/null
+++ b/static/js/editor-drop-handler.js
@@ -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;
+
diff --git a/static/js/editor.js b/static/js/editor.js
index c7042ca..c98169c 100644
--- a/static/js/editor.js
+++ b/static/js/editor.js
@@ -4,15 +4,21 @@
*/
class MarkdownEditor {
- constructor(editorId, previewId, filenameInputId) {
+ constructor(editorId, previewId, filenameInputId, readOnly = false) {
this.editorElement = document.getElementById(editorId);
this.previewElement = document.getElementById(previewId);
this.filenameInput = document.getElementById(filenameInputId);
this.currentFile = null;
this.webdavClient = null;
this.macroProcessor = new MacroProcessor(null); // Will be set later
-
- this.initCodeMirror();
+ 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
+
+ // Only initialize CodeMirror if not in read-only mode (view mode)
+ if (!readOnly) {
+ this.initCodeMirror();
+ }
this.initMarkdown();
this.initMermaid();
}
@@ -21,22 +27,27 @@ class MarkdownEditor {
* Initialize CodeMirror
*/
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, {
mode: 'markdown',
- theme: 'monokai',
+ theme: theme,
lineNumbers: true,
lineWrapping: true,
- autofocus: true,
- extraKeys: {
+ autofocus: !this.readOnly, // Don't autofocus in read-only mode
+ readOnly: this.readOnly, // Set read-only mode
+ extraKeys: this.readOnly ? {} : {
'Ctrl-S': () => this.save(),
'Cmd-S': () => this.save()
}
});
// Update preview on change with debouncing
- this.editor.on('change', this.debounce(() => {
+ this.editor.on('change', TimingUtils.debounce(() => {
this.updatePreview();
- }, 300));
+ }, Config.DEBOUNCE_DELAY));
// Initial preview render
setTimeout(() => {
@@ -47,6 +58,27 @@ class MarkdownEditor {
this.editor.on('scroll', () => {
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 });
}
/**
@@ -87,7 +119,7 @@ class MarkdownEditor {
*/
setWebDAVClient(client) {
this.webdavClient = client;
-
+
// Update macro processor with client
if (this.macroProcessor) {
this.macroProcessor.webdavClient = client;
@@ -101,13 +133,23 @@ class MarkdownEditor {
try {
const content = await this.webdavClient.get(path);
this.currentFile = path;
- this.filenameInput.value = path;
- this.editor.setValue(content);
- this.updatePreview();
-
- if (window.showNotification) {
- window.showNotification(`Loaded ${path}`, 'info');
+
+ // Update filename input if it exists
+ 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);
+ // No notification for successful file load - it's not critical
} catch (error) {
console.error('Failed to load file:', error);
if (window.showNotification) {
@@ -116,6 +158,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
*/
@@ -133,7 +201,7 @@ class MarkdownEditor {
try {
await this.webdavClient.put(path, content);
this.currentFile = path;
-
+
if (window.showNotification) {
window.showNotification('ā
Saved', 'success');
}
@@ -159,10 +227,7 @@ class MarkdownEditor {
this.filenameInput.focus();
this.editor.setValue('# New File\n\nStart typing...\n');
this.updatePreview();
-
- if (window.showNotification) {
- window.showNotification('Enter filename and start typing', 'info');
- }
+ // No notification needed - UI is self-explanatory
}
/**
@@ -174,7 +239,7 @@ class MarkdownEditor {
return;
}
- const confirmed = await window.ConfirmationManager.confirm(`Are you sure you want to delete ${this.currentFile}?`, 'Delete File');
+ const confirmed = await window.ConfirmationManager.confirm(`Are you sure you want to delete ${this.currentFile}?`, 'Delete File', true);
if (confirmed) {
try {
await this.webdavClient.delete(this.currentFile);
@@ -189,10 +254,12 @@ class MarkdownEditor {
}
/**
- * Update preview
+ * Render preview from markdown content
+ * Can be called with explicit content (for view mode) or from editor (for edit mode)
*/
- async updatePreview() {
- const markdown = this.editor.getValue();
+ 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()) {
@@ -207,24 +274,19 @@ class MarkdownEditor {
try {
// Step 1: Process macros
let processedContent = markdown;
-
+
if (this.macroProcessor) {
const processingResult = await this.macroProcessor.processMacros(markdown);
processedContent = processingResult.content;
-
- // Log errors if any
- if (processingResult.errors.length > 0) {
- console.warn('Macro processing errors:', processingResult.errors);
- }
}
-
+
// Step 2: Parse markdown to HTML
if (!this.marked) {
console.error("Markdown parser (marked) not initialized.");
previewDiv.innerHTML = `Preview engine not loaded.
`;
return;
}
-
+
let html = this.marked.parse(processedContent);
// Replace mermaid code blocks
@@ -270,13 +332,25 @@ class MarkdownEditor {
}
}
+ /**
+ * Update preview (backward compatibility wrapper)
+ * Calls renderPreview with content from editor
+ */
+ async updatePreview() {
+ if (this.editor) {
+ await this.renderPreview();
+ }
+ }
+
/**
* Sync scroll between editor and preview
*/
syncScroll() {
+ if (!this.editor) return; // Skip if no editor (view mode)
+
const scrollInfo = this.editor.getScrollInfo();
const scrollPercent = scrollInfo.top / (scrollInfo.height - scrollInfo.clientHeight);
-
+
const previewHeight = this.previewElement.scrollHeight - this.previewElement.clientHeight;
this.previewElement.scrollTop = previewHeight * scrollPercent;
}
@@ -289,10 +363,10 @@ class MarkdownEditor {
const filename = await this.webdavClient.uploadImage(file);
const imageUrl = `/fs/${this.webdavClient.currentCollection}/images/${filename}`;
const markdown = ``;
-
+
// Insert at cursor
this.editor.replaceSelection(markdown);
-
+
if (window.showNotification) {
window.showNotification('Image uploaded', 'success');
}
@@ -310,7 +384,7 @@ class MarkdownEditor {
getValue() {
return this.editor.getValue();
}
-
+
insertAtCursor(text) {
const doc = this.editor.getDoc();
const cursor = doc.getCursor();
@@ -324,20 +398,7 @@ class MarkdownEditor {
this.editor.setValue(content);
}
- /**
- * Debounce function
- */
- debounce(func, wait) {
- let timeout;
- return function executedFunction(...args) {
- const later = () => {
- clearTimeout(timeout);
- func(...args);
- };
- clearTimeout(timeout);
- timeout = setTimeout(later, wait);
- };
- }
+ // Debounce function moved to TimingUtils in utils.js
}
// Export for use in other modules
diff --git a/static/js/event-bus.js b/static/js/event-bus.js
new file mode 100644
index 0000000..5e986c8
--- /dev/null
+++ b/static/js/event-bus.js
@@ -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.}
+ */
+ 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;
+
diff --git a/static/js/file-tree-actions.js b/static/js/file-tree-actions.js
index 399a1c1..a391b61 100644
--- a/static/js/file-tree-actions.js
+++ b/static/js/file-tree-actions.js
@@ -14,32 +14,10 @@ class FileTreeActions {
/**
* Validate and sanitize filename/folder name
* Returns { valid: boolean, sanitized: string, message: string }
+ * Now uses ValidationUtils from utils.js
*/
validateFileName(name, isFolder = false) {
- const type = isFolder ? 'folder' : 'file';
-
- if (!name || name.trim().length === 0) {
- return { valid: false, message: `${type} name cannot be empty` };
- }
-
- // Check for invalid characters
- const validPattern = /^[a-z0-9_]+(\.[a-z0-9_]+)*$/;
-
- if (!validPattern.test(name)) {
- const sanitized = name
- .toLowerCase()
- .replace(/[^a-z0-9_.]/g, '_')
- .replace(/_+/g, '_')
- .replace(/^_+|_+$/g, '');
-
- 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: '' };
+ return ValidationUtils.validateFileName(name, isFolder);
}
async execute(action, targetPath, isDirectory) {
@@ -48,7 +26,7 @@ class FileTreeActions {
console.error(`Unknown action: ${action}`);
return;
}
-
+
try {
await handler.call(this, targetPath, isDirectory);
} catch (error) {
@@ -58,140 +36,198 @@ class FileTreeActions {
}
actions = {
- open: async function(path, isDir) {
+ open: async function (path, isDir) {
if (!isDir) {
await this.editor.loadFile(path);
}
},
- 'new-file': async function(path, isDir) {
+ 'new-file': async function (path, isDir) {
if (!isDir) return;
-
- await this.showInputDialog('Enter filename (lowercase, underscore only):', 'new_file.md', async (filename) => {
- if (!filename) return;
-
- const validation = this.validateFileName(filename, false);
-
- if (!validation.valid) {
- showNotification(validation.message, 'warning');
-
- // Ask if user wants to use sanitized version
- if (validation.sanitized) {
- if (await this.showConfirmDialog('Use sanitized name?', `${filename} ā ${validation.sanitized}`)) {
- filename = validation.sanitized;
- } else {
- 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}/${filename}`.replace(/\/+/g, '/');
- await this.webdavClient.put(fullPath, '# New File\n\n');
- await this.fileTree.load();
- showNotification(`Created ${filename}`, 'success');
- await this.editor.loadFile(fullPath);
- });
+ }
+
+ 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) {
+ 'new-folder': async function (path, isDir) {
if (!isDir) return;
-
- await this.showInputDialog('Enter folder name (lowercase, underscore only):', 'new_folder', async (foldername) => {
- if (!foldername) return;
-
- const validation = this.validateFileName(foldername, true);
-
- if (!validation.valid) {
- showNotification(validation.message, 'warning');
-
- if (validation.sanitized) {
- if (await this.showConfirmDialog('Use sanitized name?', `${foldername} ā ${validation.sanitized}`)) {
- foldername = validation.sanitized;
- } else {
- 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}/${foldername}`.replace(/\/+/g, '/');
- await this.webdavClient.mkcol(fullPath);
- await this.fileTree.load();
- showNotification(`Created folder ${foldername}`, 'success');
- });
+ }
+
+ 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) {
+ rename: async function (path, isDir) {
const oldName = path.split('/').pop();
- const newName = await this.showInputDialog('Rename to:', oldName);
+ 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) {
+ copy: async function (path, isDir) {
this.clipboard = { path, operation: 'copy', isDirectory: isDir };
- showNotification(`Copied: ${path.split('/').pop()}`, 'info');
+ // No notification for copy - it's a quick operation
this.updatePasteMenuItem();
},
- cut: async function(path, isDir) {
+ cut: async function (path, isDir) {
this.clipboard = { path, operation: 'cut', isDirectory: isDir };
- showNotification(`Cut: ${path.split('/').pop()}`, 'warning');
+ // No notification for cut - it's a quick operation
this.updatePasteMenuItem();
},
- paste: async function(targetPath, isDir) {
+ 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);
- showNotification('Copied', 'success');
+ // No notification for paste - file tree updates show the result
} else {
await this.webdavClient.move(this.clipboard.path, destPath);
this.clipboard = null;
this.updatePasteMenuItem();
- showNotification('Moved', 'success');
+ // No notification for move - file tree updates show the result
}
-
+
await this.fileTree.load();
},
- delete: async function(path, isDir) {
+ delete: async function (path, isDir) {
const name = path.split('/').pop();
const type = isDir ? 'folder' : 'file';
-
- if (!await this.showConfirmDialog(`Delete this ${type}?`, `${name}`)) {
- return;
- }
-
+
+ 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) {
- showNotification('Downloading...', 'info');
- // Implementation here
+ 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) {
+ 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) {
@@ -202,156 +238,12 @@ class FileTreeActions {
}
await this.fileTree.load();
};
-
+
input.click();
}
};
- // Modern dialog implementations
- async showInputDialog(title, placeholder = '', callback) {
- return new Promise((resolve) => {
- const dialog = this.createInputDialog(title, placeholder);
- const input = dialog.querySelector('input');
- const confirmBtn = dialog.querySelector('.btn-primary');
- const cancelBtn = dialog.querySelector('.btn-secondary');
-
- const cleanup = (value) => {
- const modalInstance = bootstrap.Modal.getInstance(dialog);
- if (modalInstance) {
- modalInstance.hide();
- }
- dialog.remove();
- const backdrop = document.querySelector('.modal-backdrop');
- if (backdrop) backdrop.remove();
- document.body.classList.remove('modal-open');
- resolve(value);
- if (callback) callback(value);
- };
-
- confirmBtn.onclick = () => {
- cleanup(input.value.trim());
- };
-
- cancelBtn.onclick = () => {
- cleanup(null);
- };
-
- dialog.addEventListener('hidden.bs.modal', () => {
- cleanup(null);
- });
-
- input.onkeypress = (e) => {
- if (e.key === 'Enter') confirmBtn.click();
- };
-
- document.body.appendChild(dialog);
- const modal = new bootstrap.Modal(dialog);
- modal.show();
- input.focus();
- input.select();
- });
- }
-
- async showConfirmDialog(title, message = '', callback) {
- return new Promise((resolve) => {
- const dialog = this.createConfirmDialog(title, message);
- const confirmBtn = dialog.querySelector('.btn-danger');
- const cancelBtn = dialog.querySelector('.btn-secondary');
-
- const cleanup = (value) => {
- const modalInstance = bootstrap.Modal.getInstance(dialog);
- if (modalInstance) {
- modalInstance.hide();
- }
- dialog.remove();
- const backdrop = document.querySelector('.modal-backdrop');
- if (backdrop) backdrop.remove();
- document.body.classList.remove('modal-open');
- resolve(value);
- if (callback) callback(value);
- };
-
- confirmBtn.onclick = () => {
- cleanup(true);
- };
-
- cancelBtn.onclick = () => {
- cleanup(false);
- };
-
- dialog.addEventListener('hidden.bs.modal', () => {
- cleanup(false);
- });
-
- document.body.appendChild(dialog);
- const modal = new bootstrap.Modal(dialog);
- modal.show();
- confirmBtn.focus();
- });
- }
-
- createInputDialog(title, placeholder) {
- const backdrop = document.createElement('div');
- backdrop.className = 'modal-backdrop fade show';
-
- const dialog = document.createElement('div');
- dialog.className = 'modal fade show d-block';
- dialog.setAttribute('tabindex', '-1');
- dialog.style.display = 'block';
-
- dialog.innerHTML = `
-
- `;
-
- document.body.appendChild(backdrop);
- return dialog;
- }
-
- createConfirmDialog(title, message) {
- const backdrop = document.createElement('div');
- backdrop.className = 'modal-backdrop fade show';
-
- const dialog = document.createElement('div');
- dialog.className = 'modal fade show d-block';
- dialog.setAttribute('tabindex', '-1');
- dialog.style.display = 'block';
-
- dialog.innerHTML = `
-
-
-
-
-
${message}
-
This action cannot be undone.
-
-
-
-
- `;
-
- document.body.appendChild(backdrop);
- return dialog;
- }
+ // Old deprecated modal methods removed - all modals now use window.ModalManager
updatePasteMenuItem() {
const pasteItem = document.getElementById('pasteMenuItem');
diff --git a/static/js/file-tree.js b/static/js/file-tree.js
index 29a3fd6..3b33394 100644
--- a/static/js/file-tree.js
+++ b/static/js/file-tree.js
@@ -11,23 +11,41 @@ class FileTree {
this.selectedPath = null;
this.onFileSelect = null;
this.onFolderSelect = null;
-
+
+ // 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.setupUndoListener();
}
-
+
setupEventListeners() {
// Click handler for tree nodes
this.container.addEventListener('click', (e) => {
- console.log('Container clicked', e.target);
const node = e.target.closest('.tree-node');
if (!node) return;
-
- console.log('Node found', node);
+
const path = node.dataset.path;
const isDir = node.dataset.isdir === 'true';
-
- // The toggle is handled inside renderNodes now
-
+
+ // Check if toggle was clicked (icon or toggle button)
+ const toggle = e.target.closest('.tree-node-toggle');
+ if (toggle) {
+ // Toggle is handled by its own click listener in renderNodes
+ return;
+ }
+
// Select node
if (isDir) {
this.selectFolder(path);
@@ -35,9 +53,19 @@ class FileTree {
this.selectFile(path);
}
});
-
- // Context menu
+
+ // Context menu (only in edit mode)
this.container.addEventListener('contextmenu', (e) => {
+ // Check if we're in edit mode
+ 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();
@@ -51,8 +79,335 @@ class FileTree {
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() {
try {
const items = await this.webdavClient.propfind('', 'infinity');
@@ -63,12 +418,12 @@ class FileTree {
showNotification('Failed to load files', 'error');
}
}
-
+
render() {
this.container.innerHTML = '';
this.renderNodes(this.tree, this.container, 0);
}
-
+
renderNodes(nodes, parentElement, level) {
nodes.forEach(node => {
const nodeWrapper = document.createElement('div');
@@ -78,40 +433,56 @@ class FileTree {
const nodeElement = this.createNodeElement(node, level);
nodeWrapper.appendChild(nodeElement);
- // Create children container ONLY if has children
- if (node.children && node.children.length > 0) {
+ // Create children container for directories
+ if (node.isDirectory) {
const childContainer = document.createElement('div');
childContainer.className = 'tree-children';
childContainer.style.display = 'none';
childContainer.dataset.parent = node.path;
childContainer.style.marginLeft = `${(level + 1) * 12}px`;
- // Recursively render children
- this.renderNodes(node.children, childContainer, level + 1);
+ // Only render children if they exist
+ if (node.children && node.children.length > 0) {
+ 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
+ // Make toggle functional for ALL directories (including empty ones)
const toggle = nodeElement.querySelector('.tree-node-toggle');
if (toggle) {
- toggle.addEventListener('click', (e) => {
- console.log('Toggle clicked', e.target);
+ const toggleHandler = (e) => {
e.stopPropagation();
const isHidden = childContainer.style.display === 'none';
- console.log('Is hidden?', isHidden);
childContainer.style.display = isHidden ? 'block' : 'none';
- toggle.innerHTML = isHidden ? 'ā¼' : 'ā¶';
+ 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);
});
}
-
-
+
+
// toggleFolder is no longer needed as the event listener is added in renderNodes.
-
+
selectFile(path) {
this.selectedPath = path;
this.updateSelection();
@@ -119,7 +490,7 @@ class FileTree {
this.onFileSelect({ path, isDirectory: false });
}
}
-
+
selectFolder(path) {
this.selectedPath = path;
this.updateSelection();
@@ -127,18 +498,111 @@ class FileTree {
this.onFolderSelect({ path, isDirectory: true });
}
}
-
+
+ /**
+ * 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() {
// Remove previous selection
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) {
+ // Add active class to the selected file/folder
const node = this.container.querySelector(`[data-path="${this.selectedPath}"]`);
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');
+ }
+ }
+ }
}
}
}
@@ -150,25 +614,111 @@ class FileTree {
nodeElement.dataset.isdir = node.isDirectory;
nodeElement.style.paddingLeft = `${level * 12}px`;
- const icon = document.createElement('span');
- icon.className = 'tree-node-icon';
+ // 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) {
- icon.innerHTML = 'ā¶'; // Collapsed by default
- icon.classList.add('tree-node-toggle');
+ // 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 {
- icon.innerHTML = 'ā'; // File icon
+ // 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(icon);
+ 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) {
if (bytes === 0) return '0 B';
const k = 1024;
@@ -176,7 +726,7 @@ class FileTree {
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 10) / 10 + ' ' + sizes[i];
}
-
+
newFile() {
this.selectedPath = null;
this.updateSelection();
@@ -200,7 +750,7 @@ class FileTree {
throw error;
}
}
-
+
async createFolder(parentPath, foldername) {
try {
const fullPath = parentPath ? `${parentPath}/${foldername}` : foldername;
@@ -214,7 +764,7 @@ class FileTree {
throw error;
}
}
-
+
async uploadFile(parentPath, file) {
try {
const fullPath = parentPath ? `${parentPath}/${file.name}` : file.name;
@@ -229,63 +779,76 @@ class FileTree {
throw error;
}
}
-
+
async downloadFile(path) {
try {
const content = await this.webdavClient.get(path);
- const filename = path.split('/').pop();
- this.triggerDownload(content, filename);
+ const filename = PathUtils.getFileName(path);
+ DownloadUtils.triggerDownload(content, filename);
showNotification('Downloaded', 'success');
} catch (error) {
console.error('Failed to download file:', error);
showNotification('Failed to download file', 'error');
}
}
-
+
async downloadFolder(path) {
try {
showNotification('Creating zip...', 'info');
// Get all files in folder
const items = await this.webdavClient.propfind(path, 'infinity');
const files = items.filter(item => !item.isDirectory);
-
+
// Use JSZip to create zip file
const JSZip = window.JSZip;
if (!JSZip) {
throw new Error('JSZip not loaded');
}
-
+
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
for (const file of files) {
const content = await this.webdavClient.get(file.path);
const relativePath = file.path.replace(path + '/', '');
folder.file(relativePath, content);
}
-
+
// Generate zip
const zipBlob = await zip.generateAsync({ type: 'blob' });
- const zipFilename = `${path.split('/').pop() || 'download'}.zip`;
- this.triggerDownload(zipBlob, zipFilename);
+ const zipFilename = `${PathUtils.getFileName(path) || 'download'}.zip`;
+ DownloadUtils.triggerDownload(zipBlob, zipFilename);
showNotification('Downloaded', 'success');
} catch (error) {
console.error('Failed to download folder:', error);
showNotification('Failed to download folder', 'error');
}
}
-
- 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);
+
+ // triggerDownload method moved to DownloadUtils in utils.js
+
+ /**
+ * Get the first markdown file in the tree
+ * Returns the path of the first .md file found, or null if none exist
+ */
+ getFirstMarkdownFile() {
+ const findFirstFile = (nodes) => {
+ for (const node of nodes) {
+ // 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);
}
}
diff --git a/static/js/file-upload.js b/static/js/file-upload.js
new file mode 100644
index 0000000..7c88021
--- /dev/null
+++ b/static/js/file-upload.js
@@ -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;
+
diff --git a/static/js/logger.js b/static/js/logger.js
new file mode 100644
index 0000000..a9f904b
--- /dev/null
+++ b/static/js/logger.js
@@ -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);
+}
+
diff --git a/static/js/macro-processor.js b/static/js/macro-processor.js
index 3a7174d..3d20662 100644
--- a/static/js/macro-processor.js
+++ b/static/js/macro-processor.js
@@ -10,7 +10,7 @@ class MacroProcessor {
this.includeStack = []; // Track includes to detect cycles
this.registerDefaultPlugins();
}
-
+
/**
* Register a macro plugin
* Plugin must implement: { canHandle(actor, method), process(macro, webdavClient) }
@@ -19,27 +19,23 @@ class MacroProcessor {
const key = `${actor}.${method}`;
this.plugins.set(key, plugin);
}
-
+
/**
* Process all macros in content
* Returns { success: boolean, content: string, errors: [] }
*/
async processMacros(content) {
- console.log('MacroProcessor: Starting macro processing for content:', content);
const macros = MacroParser.extractMacros(content);
- console.log('MacroProcessor: Extracted macros:', macros);
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];
- console.log('MacroProcessor: Processing macro:', macro);
-
+
try {
const result = await this.processMacro(macro);
- console.log('MacroProcessor: Macro processing result:', result);
-
+
if (result.success) {
// Replace macro with result
processedContent =
@@ -51,7 +47,7 @@ class MacroProcessor {
macro: macro.fullMatch,
error: result.error
});
-
+
// Replace with error message
const errorMsg = `\n\nā ļø **Macro Error**: ${result.error}\n\n`;
processedContent =
@@ -64,7 +60,7 @@ class MacroProcessor {
macro: macro.fullMatch,
error: error.message
});
-
+
const errorMsg = `\n\nā ļø **Macro Error**: ${error.message}\n\n`;
processedContent =
processedContent.substring(0, macro.start) +
@@ -72,15 +68,14 @@ class MacroProcessor {
processedContent.substring(macro.end);
}
}
-
- console.log('MacroProcessor: Final processed content:', processedContent);
+
return {
success: errors.length === 0,
content: processedContent,
errors
};
}
-
+
/**
* Process single macro
*/
@@ -98,20 +93,20 @@ class MacroProcessor {
};
}
}
-
+
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);
@@ -122,7 +117,7 @@ class MacroProcessor {
};
}
}
-
+
/**
* Register default plugins
*/
@@ -131,14 +126,14 @@ class MacroProcessor {
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);
diff --git a/static/js/notification-service.js b/static/js/notification-service.js
new file mode 100644
index 0000000..bcbd820
--- /dev/null
+++ b/static/js/notification-service.js
@@ -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;
+
diff --git a/static/js/ui-utils.js b/static/js/ui-utils.js
index afc5057..2ba0838 100644
--- a/static/js/ui-utils.js
+++ b/static/js/ui-utils.js
@@ -1,270 +1,60 @@
/**
* 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
+ * @param {string} message - The message to display
+ * @param {string} type - The notification type (info, success, error, warning, danger, primary)
*/
function showNotification(message, type = 'info') {
const container = document.getElementById('toastContainer') || createToastContainer();
-
+
const toast = document.createElement('div');
const bgClass = type === 'error' ? 'danger' : type === 'success' ? 'success' : type === 'warning' ? 'warning' : 'primary';
toast.className = `toast align-items-center text-white bg-${bgClass} border-0`;
toast.setAttribute('role', 'alert');
-
+
toast.innerHTML = `
`;
-
+
container.appendChild(toast);
-
- const bsToast = new bootstrap.Toast(toast, { delay: 3000 });
+
+ const bsToast = new bootstrap.Toast(toast, { delay: Config.TOAST_DURATION });
bsToast.show();
-
+
toast.addEventListener('hidden.bs.toast', () => {
toast.remove();
});
}
+/**
+ * Create the toast container if it doesn't exist
+ * @returns {HTMLElement} The toast container element
+ */
function createToastContainer() {
const container = document.createElement('div');
container.id = 'toastContainer';
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);
return container;
}
-/**
- * Enhanced Context Menu
- */
-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);
-}
-
-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();
- }
-});
-
-/**
- * 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');
- }
- }
- });
- }
-}
+// All other UI utilities have been moved to separate modules
+// See the module list at the top of this file
+// Make showNotification globally available
+window.showNotification = showNotification;
diff --git a/static/js/utils.js b/static/js/utils.js
new file mode 100644
index 0000000..8bee004
--- /dev/null
+++ b/static/js/utils.js
@@ -0,0 +1,355 @@
+/**
+ * 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 + '/');
+ }
+};
+
+/**
+ * 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;
+
diff --git a/static/style.css b/static/style.css
index ed66b87..42a5e4d 100644
--- a/static/style.css
+++ b/static/style.css
@@ -33,7 +33,8 @@ body.dark-mode {
}
/* Global styles */
-html, body {
+html,
+body {
height: 100%;
margin: 0;
padding: 0;
@@ -48,12 +49,6 @@ body {
transition: background-color 0.3s ease, color 0.3s ease;
}
-.container-fluid {
- flex: 1;
- padding: 0;
- overflow: hidden;
-}
-
.row {
margin: 0;
}
@@ -206,7 +201,12 @@ body.dark-mode .CodeMirror-linenumber {
}
/* 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-bottom: 16px;
font-weight: 600;
@@ -286,7 +286,8 @@ body.dark-mode .CodeMirror-linenumber {
margin-bottom: 16px;
}
-#preview ul, #preview ol {
+#preview ul,
+#preview ol {
margin-bottom: 16px;
padding-left: 2em;
}
@@ -378,7 +379,7 @@ body.dark-mode .mermaid svg {
.sidebar {
display: none;
}
-
+
.editor-pane,
.preview-pane {
height: 50vh;
@@ -590,5 +591,4 @@ body.dark-mode .sidebar h6 {
body.dark-mode .tree-children {
border-left-color: var(--border-color);
-}
-
+}
\ No newline at end of file
diff --git a/templates/index.html b/templates/index.html
index b59e6f1..e5865d9 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -30,10 +30,13 @@