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 = ` +
+

No content available

+
+ `; } + } 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 += ` +
+
+ + ${fileName} +
+ ${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})` : `[${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 = `![${file.name}](${imageUrl})`; - + // 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 = ` - - `; - - 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 = `
${message}
`; - + 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 @@ @@ -126,7 +135,8 @@ -