Compare commits
	
		
			6 Commits
		
	
	
		
			23a24d42e2
			...
			16aef59298
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 16aef59298 | |||
|  | 3961628b3d | ||
|  | afcd074913 | ||
|  | 7a9efd3542 | ||
|  | f319f29d4c | ||
|  | 0ed6bcf1f2 | 
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | |||||||
|  | .venv | ||||||
							
								
								
									
										22
									
								
								collections/7madah/tests/sub_tests/file1.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,22 @@ | |||||||
|  | # Start to end file | ||||||
|  |  | ||||||
|  | ### Graph | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | This is just for testing | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | **See what i did?** | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | ```mermaid | ||||||
|  | graph TD | ||||||
|  |     A[Start] --> B{Process}; | ||||||
|  |     B --> C{Decision}; | ||||||
|  |     C -- Yes --> D[End Yes]; | ||||||
|  |     C -- No --> E[End No]; | ||||||
|  | ``` | ||||||
|  |  | ||||||
							
								
								
									
										426
									
								
								collections/7madah/tests/test3.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,426 @@ | |||||||
|  | # UI Code Refactoring Plan | ||||||
|  |  | ||||||
|  | **Project:** Markdown Editor   | ||||||
|  | **Date:** 2025-10-26   | ||||||
|  | **Status:** In Progress | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | ## Executive Summary | ||||||
|  |  | ||||||
|  | This document outlines a comprehensive refactoring plan for the UI codebase to improve maintainability, remove dead code, extract utilities, and standardize patterns. The refactoring is organized into 6 phases with 14 tasks, prioritized by risk and impact. | ||||||
|  |  | ||||||
|  | **Key Metrics:** | ||||||
|  |  | ||||||
|  | - Total Lines of Code: ~3,587 | ||||||
|  | - Dead Code to Remove: 213 lines (6%) | ||||||
|  | - Estimated Effort: 5-8 days | ||||||
|  | - Risk Level: Mostly LOW to MEDIUM | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | ## Phase 1: Analysis Summary | ||||||
|  |  | ||||||
|  | ### Files Reviewed | ||||||
|  |  | ||||||
|  | **JavaScript Files (10):** | ||||||
|  |  | ||||||
|  | - `/static/js/app.js` (484 lines) | ||||||
|  | - `/static/js/column-resizer.js` (100 lines) | ||||||
|  | - `/static/js/confirmation.js` (170 lines) | ||||||
|  | - `/static/js/editor.js` (420 lines) | ||||||
|  | - `/static/js/file-tree-actions.js` (482 lines) | ||||||
|  | - `/static/js/file-tree.js` (865 lines) | ||||||
|  | - `/static/js/macro-parser.js` (103 lines) | ||||||
|  | - `/static/js/macro-processor.js` (157 lines) | ||||||
|  | - `/static/js/ui-utils.js` (305 lines) | ||||||
|  | - `/static/js/webdav-client.js` (266 lines) | ||||||
|  |  | ||||||
|  | **CSS Files (6):** | ||||||
|  |  | ||||||
|  | - `/static/css/variables.css` (32 lines) | ||||||
|  | - `/static/css/layout.css` | ||||||
|  | - `/static/css/file-tree.css` | ||||||
|  | - `/static/css/editor.css` | ||||||
|  | - `/static/css/components.css` | ||||||
|  | - `/static/css/modal.css` | ||||||
|  |  | ||||||
|  | **HTML Templates (1):** | ||||||
|  |  | ||||||
|  | - `/templates/index.html` (203 lines) | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | ## Issues Found | ||||||
|  |  | ||||||
|  | ### 🔴 HIGH PRIORITY | ||||||
|  |  | ||||||
|  | 1. **Deprecated Modal Code (Dead Code)** | ||||||
|  |    - Location: `/static/js/file-tree-actions.js` lines 262-474 | ||||||
|  |    - Impact: 213 lines of unused code (44% of file) | ||||||
|  |    - Risk: LOW to remove | ||||||
|  |  | ||||||
|  | 2. **Duplicated Event Bus Implementation** | ||||||
|  |    - Location: `/static/js/app.js` lines 16-30 | ||||||
|  |    - Should be extracted to reusable module | ||||||
|  |  | ||||||
|  | 3. **Duplicated Debounce Function** | ||||||
|  |    - Location: `/static/js/editor.js` lines 404-414 | ||||||
|  |    - Should be shared utility | ||||||
|  |  | ||||||
|  | 4. **Inconsistent Notification Usage** | ||||||
|  |    - Mixed usage of `window.showNotification` vs `showNotification` | ||||||
|  |  | ||||||
|  | 5. **Duplicated File Download Logic** | ||||||
|  |    - Location: `/static/js/file-tree.js` lines 829-839 | ||||||
|  |    - Should be shared utility | ||||||
|  |  | ||||||
|  | 6. **Hard-coded Values** | ||||||
|  |    - Long-press threshold: 400ms | ||||||
|  |    - Debounce delay: 300ms | ||||||
|  |    - Drag preview width: 200px | ||||||
|  |    - Toast delay: 3000ms | ||||||
|  |  | ||||||
|  | ### 🟡 MEDIUM PRIORITY | ||||||
|  |  | ||||||
|  | 7. **Global State Management** | ||||||
|  |    - Location: `/static/js/app.js` lines 6-13 | ||||||
|  |    - Makes testing difficult | ||||||
|  |  | ||||||
|  | 8. **Duplicated Path Manipulation** | ||||||
|  |    - `path.split('/').pop()` appears 10+ times | ||||||
|  |    - `path.substring(0, path.lastIndexOf('/'))` appears 5+ times | ||||||
|  |  | ||||||
|  | 9. **Mixed Responsibility in ui-utils.js** | ||||||
|  |    - Contains 6 different classes/utilities | ||||||
|  |    - Should be split into separate modules | ||||||
|  |  | ||||||
|  | 10. **Deprecated Event Handler** | ||||||
|  |     - Location: `/static/js/file-tree-actions.js` line 329 | ||||||
|  |     - Uses deprecated `onkeypress` | ||||||
|  |  | ||||||
|  | ### 🟢 LOW PRIORITY | ||||||
|  |  | ||||||
|  | 11. **Unused Function Parameters** | ||||||
|  | 12. **Magic Numbers in Styling** | ||||||
|  | 13. **Inconsistent Comment Styles** | ||||||
|  | 14. **Console.log Statements** | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | ## Phase 2: Proposed Reusable Components | ||||||
|  |  | ||||||
|  | ### 1. Config Module (`/static/js/config.js`) | ||||||
|  |  | ||||||
|  | Centralize all configuration values: | ||||||
|  |  | ||||||
|  | ```javascript | ||||||
|  | export const Config = { | ||||||
|  |     // Timing | ||||||
|  |     LONG_PRESS_THRESHOLD: 400, | ||||||
|  |     DEBOUNCE_DELAY: 300, | ||||||
|  |     TOAST_DURATION: 3000, | ||||||
|  |      | ||||||
|  |     // UI | ||||||
|  |     DRAG_PREVIEW_WIDTH: 200, | ||||||
|  |     TREE_INDENT_PX: 12, | ||||||
|  |     MOUSE_MOVE_THRESHOLD: 5, | ||||||
|  |      | ||||||
|  |     // Validation | ||||||
|  |     FILENAME_PATTERN: /^[a-z0-9_]+(\.[a-z0-9_]+)*$/, | ||||||
|  |      | ||||||
|  |     // Storage Keys | ||||||
|  |     STORAGE_KEYS: { | ||||||
|  |         DARK_MODE: 'darkMode', | ||||||
|  |         SELECTED_COLLECTION: 'selectedCollection', | ||||||
|  |         LAST_VIEWED_PAGE: 'lastViewedPage', | ||||||
|  |         COLUMN_DIMENSIONS: 'columnDimensions' | ||||||
|  |     } | ||||||
|  | }; | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ### 2. Logger Module (`/static/js/logger.js`) | ||||||
|  |  | ||||||
|  | Structured logging with levels: | ||||||
|  |  | ||||||
|  | ```javascript | ||||||
|  | export class Logger { | ||||||
|  |     static debug(message, ...args) | ||||||
|  |     static info(message, ...args) | ||||||
|  |     static warn(message, ...args) | ||||||
|  |     static error(message, ...args) | ||||||
|  |     static setLevel(level) | ||||||
|  | } | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ### 3. Event Bus Module (`/static/js/event-bus.js`) | ||||||
|  |  | ||||||
|  | Centralized event system: | ||||||
|  |  | ||||||
|  | ```javascript | ||||||
|  | export class EventBus { | ||||||
|  |     on(event, callback) | ||||||
|  |     off(event, callback) | ||||||
|  |     once(event, callback) | ||||||
|  |     dispatch(event, data) | ||||||
|  |     clear(event) | ||||||
|  | } | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ### 4. Utilities Module (`/static/js/utils.js`) | ||||||
|  |  | ||||||
|  | Common utility functions: | ||||||
|  |  | ||||||
|  | ```javascript | ||||||
|  | export const PathUtils = { | ||||||
|  |     getFileName(path), | ||||||
|  |     getParentPath(path), | ||||||
|  |     normalizePath(path), | ||||||
|  |     joinPaths(...paths), | ||||||
|  |     getExtension(path) | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export const TimingUtils = { | ||||||
|  |     debounce(func, wait), | ||||||
|  |     throttle(func, wait) | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export const DownloadUtils = { | ||||||
|  |     triggerDownload(content, filename), | ||||||
|  |     downloadAsBlob(blob, filename) | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export const ValidationUtils = { | ||||||
|  |     validateFileName(name, isFolder), | ||||||
|  |     sanitizeFileName(name) | ||||||
|  | }; | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ### 5. Notification Service (`/static/js/notification-service.js`) | ||||||
|  |  | ||||||
|  | Standardized notifications: | ||||||
|  |  | ||||||
|  | ```javascript | ||||||
|  | export class NotificationService { | ||||||
|  |     static success(message) | ||||||
|  |     static error(message) | ||||||
|  |     static warning(message) | ||||||
|  |     static info(message) | ||||||
|  | } | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | ## Phase 3: Refactoring Tasks | ||||||
|  |  | ||||||
|  | ### 🔴 HIGH PRIORITY | ||||||
|  |  | ||||||
|  | **Task 1: Remove Dead Code** | ||||||
|  |  | ||||||
|  | - Files: `/static/js/file-tree-actions.js` | ||||||
|  | - Lines: 262-474 (213 lines) | ||||||
|  | - Risk: LOW | ||||||
|  | - Dependencies: None | ||||||
|  |  | ||||||
|  | **Task 2: Extract Event Bus** | ||||||
|  |  | ||||||
|  | - Files: NEW `/static/js/event-bus.js`, MODIFY `app.js`, `editor.js` | ||||||
|  | - Risk: MEDIUM | ||||||
|  | - Dependencies: None | ||||||
|  |  | ||||||
|  | **Task 3: Create Utilities Module** | ||||||
|  |  | ||||||
|  | - Files: NEW `/static/js/utils.js`, MODIFY multiple files | ||||||
|  | - Risk: MEDIUM | ||||||
|  | - Dependencies: None | ||||||
|  |  | ||||||
|  | **Task 4: Create Config Module** | ||||||
|  |  | ||||||
|  | - Files: NEW `/static/js/config.js`, MODIFY multiple files | ||||||
|  | - Risk: LOW | ||||||
|  | - Dependencies: None | ||||||
|  |  | ||||||
|  | **Task 5: Standardize Notification Usage** | ||||||
|  |  | ||||||
|  | - Files: NEW `/static/js/notification-service.js`, MODIFY multiple files | ||||||
|  | - Risk: LOW | ||||||
|  | - Dependencies: None | ||||||
|  |  | ||||||
|  | ### 🟡 MEDIUM PRIORITY | ||||||
|  |  | ||||||
|  | **Task 6: Fix Deprecated Event Handler** | ||||||
|  |  | ||||||
|  | - Files: `/static/js/file-tree-actions.js` line 329 | ||||||
|  | - Risk: LOW | ||||||
|  | - Dependencies: None | ||||||
|  |  | ||||||
|  | **Task 7: Refactor ui-utils.js** | ||||||
|  |  | ||||||
|  | - Files: DELETE `ui-utils.js`, CREATE 5 new modules | ||||||
|  | - Risk: HIGH | ||||||
|  | - Dependencies: Task 5 | ||||||
|  |  | ||||||
|  | **Task 8: Standardize Class Export Pattern** | ||||||
|  |  | ||||||
|  | - Files: All class files | ||||||
|  | - Risk: MEDIUM | ||||||
|  | - Dependencies: None | ||||||
|  |  | ||||||
|  | **Task 9: Create Logger Module** | ||||||
|  |  | ||||||
|  | - Files: NEW `/static/js/logger.js`, MODIFY multiple files | ||||||
|  | - Risk: LOW | ||||||
|  | - Dependencies: None | ||||||
|  |  | ||||||
|  | **Task 10: Implement Download Action** | ||||||
|  |  | ||||||
|  | - Files: `/static/js/file-tree-actions.js` | ||||||
|  | - Risk: LOW | ||||||
|  | - Dependencies: Task 3 | ||||||
|  |  | ||||||
|  | ### 🟢 LOW PRIORITY | ||||||
|  |  | ||||||
|  | **Task 11: Standardize JSDoc Comments** | ||||||
|  | **Task 12: Extract Magic Numbers to CSS** | ||||||
|  | **Task 13: Add Error Boundaries** | ||||||
|  | **Task 14: Cache DOM Elements** | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | ## Phase 4: Implementation Order | ||||||
|  |  | ||||||
|  | ### Step 1: Foundation (Do First) | ||||||
|  |  | ||||||
|  | 1. Create Config Module (Task 4) | ||||||
|  | 2. Create Logger Module (Task 9) | ||||||
|  | 3. Create Event Bus Module (Task 2) | ||||||
|  |  | ||||||
|  | ### Step 2: Utilities (Do Second) | ||||||
|  |  | ||||||
|  | 4. Create Utilities Module (Task 3) | ||||||
|  | 5. Create Notification Service (Task 5) | ||||||
|  |  | ||||||
|  | ### Step 3: Cleanup (Do Third) | ||||||
|  |  | ||||||
|  | 6. Remove Dead Code (Task 1) | ||||||
|  | 7. Fix Deprecated Event Handler (Task 6) | ||||||
|  |  | ||||||
|  | ### Step 4: Restructuring (Do Fourth) | ||||||
|  |  | ||||||
|  | 8. Refactor ui-utils.js (Task 7) | ||||||
|  | 9. Standardize Class Export Pattern (Task 8) | ||||||
|  |  | ||||||
|  | ### Step 5: Enhancements (Do Fifth) | ||||||
|  |  | ||||||
|  | 10. Implement Download Action (Task 10) | ||||||
|  | 11. Add Error Boundaries (Task 13) | ||||||
|  |  | ||||||
|  | ### Step 6: Polish (Do Last) | ||||||
|  |  | ||||||
|  | 12. Standardize JSDoc Comments (Task 11) | ||||||
|  | 13. Extract Magic Numbers to CSS (Task 12) | ||||||
|  | 14. Cache DOM Elements (Task 14) | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | ## Phase 5: Testing Checklist | ||||||
|  |  | ||||||
|  | ### Core Functionality | ||||||
|  |  | ||||||
|  | - [ ] File tree loads and displays correctly | ||||||
|  | - [ ] Files can be selected and opened | ||||||
|  | - [ ] Folders can be expanded/collapsed | ||||||
|  | - [ ] Editor loads file content | ||||||
|  | - [ ] Preview renders markdown correctly | ||||||
|  | - [ ] Save button saves files | ||||||
|  | - [ ] Delete button deletes files | ||||||
|  | - [ ] New button creates new files | ||||||
|  |  | ||||||
|  | ### Context Menu Actions | ||||||
|  |  | ||||||
|  | - [ ] Right-click shows context menu | ||||||
|  | - [ ] New file action works | ||||||
|  | - [ ] New folder action works | ||||||
|  | - [ ] Rename action works | ||||||
|  | - [ ] Delete action works | ||||||
|  | - [ ] Copy/Cut/Paste actions work | ||||||
|  | - [ ] Upload action works | ||||||
|  |  | ||||||
|  | ### Drag and Drop | ||||||
|  |  | ||||||
|  | - [ ] Long-press detection works | ||||||
|  | - [ ] Drag preview appears correctly | ||||||
|  | - [ ] Drop targets highlight properly | ||||||
|  | - [ ] Files can be moved | ||||||
|  | - [ ] Undo (Ctrl+Z) works | ||||||
|  |  | ||||||
|  | ### Modals | ||||||
|  |  | ||||||
|  | - [ ] Confirmation modals appear | ||||||
|  | - [ ] Prompt modals appear | ||||||
|  | - [ ] Modals don't double-open | ||||||
|  | - [ ] Enter/Escape keys work | ||||||
|  |  | ||||||
|  | ### UI Features | ||||||
|  |  | ||||||
|  | - [ ] Dark mode toggle works | ||||||
|  | - [ ] Collection selector works | ||||||
|  | - [ ] Column resizers work | ||||||
|  | - [ ] Notifications appear | ||||||
|  | - [ ] URL routing works | ||||||
|  | - [ ] View/Edit modes work | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | ## Recommendations | ||||||
|  |  | ||||||
|  | ### Immediate Actions (Before Production) | ||||||
|  |  | ||||||
|  | 1. Remove dead code (Task 1) | ||||||
|  | 2. Fix deprecated event handler (Task 6) | ||||||
|  | 3. Create config module (Task 4) | ||||||
|  |  | ||||||
|  | ### Short-term Actions (Next Sprint) | ||||||
|  |  | ||||||
|  | 4. Extract utilities (Task 3) | ||||||
|  | 5. Standardize notifications (Task 5) | ||||||
|  | 6. Create event bus (Task 2) | ||||||
|  |  | ||||||
|  | ### Medium-term Actions (Future Sprints) | ||||||
|  |  | ||||||
|  | 7. Refactor ui-utils.js (Task 7) | ||||||
|  | 8. Add logger (Task 9) | ||||||
|  | 9. Standardize exports (Task 8) | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | ## Success Metrics | ||||||
|  |  | ||||||
|  | **Before Refactoring:** | ||||||
|  |  | ||||||
|  | - Total Lines: ~3,587 | ||||||
|  | - Dead Code: 213 lines (6%) | ||||||
|  | - Duplicated Code: ~50 lines | ||||||
|  | - Hard-coded Values: 15+ | ||||||
|  |  | ||||||
|  | **After Refactoring:** | ||||||
|  |  | ||||||
|  | - Total Lines: ~3,400 (-5%) | ||||||
|  | - Dead Code: 0 lines | ||||||
|  | - Duplicated Code: 0 lines | ||||||
|  | - Hard-coded Values: 0 | ||||||
|  |  | ||||||
|  | **Estimated Effort:** 5-8 days | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | ## Conclusion | ||||||
|  |  | ||||||
|  | The UI codebase is generally well-structured. Main improvements needed: | ||||||
|  |  | ||||||
|  | 1. Remove dead code | ||||||
|  | 2. Extract duplicated utilities | ||||||
|  | 3. Centralize configuration | ||||||
|  | 4. Standardize patterns | ||||||
|  |  | ||||||
|  | Start with high-impact, low-risk changes first to ensure production readiness. | ||||||
							
								
								
									
										44
									
								
								collections/documents/docusaurus.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,44 @@ | |||||||
|  | ## Using Docusaurus | ||||||
|  |  | ||||||
|  | Once you've set up Hero, you can use it to develop, manage and publish Docusaurus websites. | ||||||
|  |  | ||||||
|  | ## Launch the Hero Website | ||||||
|  |  | ||||||
|  | To start a Hero Docusaurus website in development mode: | ||||||
|  |  | ||||||
|  | - Build the book then close the prompt with `Ctrl+C` | ||||||
|  |  | ||||||
|  |   ```bash | ||||||
|  |   hero docs -d | ||||||
|  |   ``` | ||||||
|  |  | ||||||
|  | - See the book on the local browser | ||||||
|  |  | ||||||
|  |   ``` | ||||||
|  |   bash /root/hero/var/docusaurus/develop.sh | ||||||
|  |   ``` | ||||||
|  |  | ||||||
|  | You can then view the website in your browser at `https://localhost:3100`. | ||||||
|  |  | ||||||
|  | ## Publish a Website | ||||||
|  |  | ||||||
|  | - To build and publish a Hero website: | ||||||
|  |   - Development | ||||||
|  |  | ||||||
|  |     ```bash | ||||||
|  |     hero docs -bpd | ||||||
|  |     ``` | ||||||
|  |  | ||||||
|  |   - Production | ||||||
|  |  | ||||||
|  |     ```bash | ||||||
|  |     hero docs -bp | ||||||
|  |     ``` | ||||||
|  |  | ||||||
|  | If you want to specify a different SSH key, use `-dk`: | ||||||
|  |  | ||||||
|  | ```bash | ||||||
|  | hero docs -bpd -dk ~/.ssh/id_ed25519 | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | > Note: The container handles the SSH agent and key management automatically on startup, so in most cases, you won't need to manually specify keys. | ||||||
							
								
								
									
										67
									
								
								collections/documents/getting_started/hero_docker.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,67 @@ | |||||||
|  | You can build Hero as a Docker container. | ||||||
|  |  | ||||||
|  | The code is availabe at this [open-source repository](https://github.com/mik-tf/hero-container). | ||||||
|  |  | ||||||
|  | ## Prerequisites | ||||||
|  |  | ||||||
|  | - Docker installed on your system (More info [here](https://manual.grid.tf/documentation/system_administrators/computer_it_basics/docker_basics.html#install-docker-desktop-and-docker-engine)) | ||||||
|  | - SSH keys for deploying Hero websites (if publishing) | ||||||
|  |  | ||||||
|  | ## Build the Image | ||||||
|  |  | ||||||
|  | - Clone the repository | ||||||
|  |  | ||||||
|  |     ``` | ||||||
|  |     git clone https://github.com/mik-tf/hero-container | ||||||
|  |     cd hero-container | ||||||
|  |     ``` | ||||||
|  |  | ||||||
|  | - Build the Docker image: | ||||||
|  |  | ||||||
|  |     ```bash | ||||||
|  |     docker build -t heroc . | ||||||
|  |     ``` | ||||||
|  |  | ||||||
|  | ## Pull the Image from Docker Hub | ||||||
|  |  | ||||||
|  | If you don't want to build the image, you can pull it from Docker Hub. | ||||||
|  |  | ||||||
|  | ``` | ||||||
|  | docker pull logismosis/heroc | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | In this case, use `logismosi/heroc` instead of `heroc` to use the container. | ||||||
|  |  | ||||||
|  | ## Run the Hero Container | ||||||
|  |  | ||||||
|  | You can run the container with an interactive shell: | ||||||
|  |  | ||||||
|  | ```bash | ||||||
|  | docker run -it heroc | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | You can run the container with an interactive shell, while setting the host as your local network, mounting your current directory as the workspace and adding your SSH keys: | ||||||
|  |  | ||||||
|  | ```bash | ||||||
|  | docker run --network=host \ | ||||||
|  |   -v $(pwd):/workspace \ | ||||||
|  |   -v ~/.ssh:/root/ssh \ | ||||||
|  |   -it heroc | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | By default, the container will: | ||||||
|  |  | ||||||
|  | - Start Redis server in the background | ||||||
|  | - Copy your SSH keys to the proper location | ||||||
|  | - Initialize the SSH agent | ||||||
|  | - Add your default SSH key (`id_ed25519`) | ||||||
|  |  | ||||||
|  | To use a different SSH key, specify it with the KEY environment variable (e.g. `KEY=id_ed25519`): | ||||||
|  |  | ||||||
|  | ```bash | ||||||
|  | docker run --network=host \ | ||||||
|  |   -v $(pwd):/workspace \ | ||||||
|  |   -v ~/.ssh:/root/ssh \ | ||||||
|  |   -e KEY=your_custom_key_name \ | ||||||
|  |   -it heroc | ||||||
|  | ``` | ||||||
							
								
								
									
										22
									
								
								collections/documents/getting_started/hero_native.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,22 @@ | |||||||
|  | ## Basic Hero | ||||||
|  |  | ||||||
|  | You can build Hero natively with the following lines: | ||||||
|  |  | ||||||
|  | ``` | ||||||
|  | curl https://raw.githubusercontent.com/freeflowuniverse/herolib/refs/heads/development/install_hero.sh > /tmp/install_hero.sh | ||||||
|  | bash /tmp/install_hero.sh | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ## Hero for Developers | ||||||
|  |  | ||||||
|  | For developers, use the following commands: | ||||||
|  |  | ||||||
|  | ``` | ||||||
|  | curl 'https://raw.githubusercontent.com/freeflowuniverse/herolib/refs/heads/development/install_v.sh' > /tmp/install_v.sh | ||||||
|  | bash /tmp/install_v.sh --analyzer --herolib  | ||||||
|  | #DONT FORGET TO START A NEW SHELL (otherwise the paths will not be set) | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ## Hero with Docker | ||||||
|  |  | ||||||
|  | If you have issues running Hero natively, you can use the [Docker version of Hero](hero_docker.md). | ||||||
							
								
								
									
										5
									
								
								collections/documents/intro.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,5 @@ | |||||||
|  | This ebook contains the basic information to get you started with the Hero tool. | ||||||
|  |  | ||||||
|  | ## What is Hero? | ||||||
|  |  | ||||||
|  | Hero is an open-source toolset to work with Git, AI, mdBook, Docusaurus, Starlight and more. | ||||||
							
								
								
									
										1
									
								
								collections/documents/support.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | |||||||
|  | If you need help with Hero, reach out to the ThreeFold Support team [here](https://threefoldfaq.crisp.help/en/). | ||||||
							
								
								
									
										
											BIN
										
									
								
								collections/notes/images/logo-blue.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 98 KiB | 
							
								
								
									
										18
									
								
								collections/notes/introduction.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,18 @@ | |||||||
|  | # Introduction | ||||||
|  |  | ||||||
|  | ### This is an introduction | ||||||
|  |  | ||||||
|  |  | ||||||
|  | * **This is an internal image** | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | * **This is an external image** | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
|  | --- | ||||||
							
								
								
									
										2
									
								
								collections/notes/new_folder/zeko.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,2 @@ | |||||||
|  | # New File | ||||||
|  |  | ||||||
							
								
								
									
										40
									
								
								collections/notes/presentation.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,40 @@ | |||||||
|  | ## Mycelium Product Presentation | ||||||
|  |  | ||||||
|  | This document provides an overview of the Mycelium technology stack (as commercially sold my our company GeoMind). | ||||||
|  |  | ||||||
|  | <div style={{ | ||||||
|  |   position: 'relative', | ||||||
|  |   width: '100%', | ||||||
|  |   height: 0, | ||||||
|  |   paddingTop: '56.25%', | ||||||
|  |   marginTop: '1.6em', | ||||||
|  |   marginBottom: '0.9em', | ||||||
|  |   overflow: 'hidden', | ||||||
|  |   borderRadius: '8px', | ||||||
|  |   willChange: 'transform' | ||||||
|  | }}> | ||||||
|  |   <iframe | ||||||
|  |     src="https://www.canva.com/design/DAG0UtzICsk/rqXpn5f3ibo2OpX-yDWmPQ/view?embed" | ||||||
|  |     style={{ | ||||||
|  |       position: 'absolute', | ||||||
|  |       width: '100%', | ||||||
|  |       height: '100%', | ||||||
|  |       top: 0, | ||||||
|  |       left: 0, | ||||||
|  |       border: 'none', | ||||||
|  |       padding: 0, | ||||||
|  |       margin: 0 | ||||||
|  |     }} | ||||||
|  |     allowFullScreen={true} | ||||||
|  |     allow="fullscreen"> | ||||||
|  |   </iframe> | ||||||
|  | </div> | ||||||
|  |  | ||||||
|  | <div style={{ marginTop: '10px' }}> | ||||||
|  |   <a href="https://www.canva.com/design/DAG0UtzICsk/rqXpn5f3ibo2OpX-yDWmPQ/view" | ||||||
|  |      target="_blank" | ||||||
|  |      rel="noopener" | ||||||
|  |      style={{ textDecoration: 'none' }}> | ||||||
|  |     Geomind Product Intro 2025 (based on mycelium technology) | ||||||
|  |   </a> | ||||||
|  | </div> | ||||||
| @@ -1,10 +0,0 @@ | |||||||
|  |  | ||||||
| # test |  | ||||||
|  |  | ||||||
| - 1 |  | ||||||
| - 2 |  | ||||||
|  |  | ||||||
| [2025 SeaweedFS Intro Slides.pdf](/notes/2025 SeaweedFS Intro Slides.pdf) |  | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
							
								
								
									
										9
									
								
								collections/notes/tests/test.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,9 @@ | |||||||
|  |  | ||||||
|  | # test | ||||||
|  |  | ||||||
|  | - 1 | ||||||
|  | - 2 | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
|  | !!include path:test2.md | ||||||
							
								
								
									
										12
									
								
								collections/notes/tests/test2.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,12 @@ | |||||||
|  |  | ||||||
|  | ## test2 | ||||||
|  |  | ||||||
|  | - something | ||||||
|  | - another thing | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
							
								
								
									
										426
									
								
								collections/notes/tests/test3.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,426 @@ | |||||||
|  | # UI Code Refactoring Plan | ||||||
|  |  | ||||||
|  | **Project:** Markdown Editor   | ||||||
|  | **Date:** 2025-10-26   | ||||||
|  | **Status:** In Progress | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | ## Executive Summary | ||||||
|  |  | ||||||
|  | This document outlines a comprehensive refactoring plan for the UI codebase to improve maintainability, remove dead code, extract utilities, and standardize patterns. The refactoring is organized into 6 phases with 14 tasks, prioritized by risk and impact. | ||||||
|  |  | ||||||
|  | **Key Metrics:** | ||||||
|  |  | ||||||
|  | - Total Lines of Code: ~3,587 | ||||||
|  | - Dead Code to Remove: 213 lines (6%) | ||||||
|  | - Estimated Effort: 5-8 days | ||||||
|  | - Risk Level: Mostly LOW to MEDIUM | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | ## Phase 1: Analysis Summary | ||||||
|  |  | ||||||
|  | ### Files Reviewed | ||||||
|  |  | ||||||
|  | **JavaScript Files (10):** | ||||||
|  |  | ||||||
|  | - `/static/js/app.js` (484 lines) | ||||||
|  | - `/static/js/column-resizer.js` (100 lines) | ||||||
|  | - `/static/js/confirmation.js` (170 lines) | ||||||
|  | - `/static/js/editor.js` (420 lines) | ||||||
|  | - `/static/js/file-tree-actions.js` (482 lines) | ||||||
|  | - `/static/js/file-tree.js` (865 lines) | ||||||
|  | - `/static/js/macro-parser.js` (103 lines) | ||||||
|  | - `/static/js/macro-processor.js` (157 lines) | ||||||
|  | - `/static/js/ui-utils.js` (305 lines) | ||||||
|  | - `/static/js/webdav-client.js` (266 lines) | ||||||
|  |  | ||||||
|  | **CSS Files (6):** | ||||||
|  |  | ||||||
|  | - `/static/css/variables.css` (32 lines) | ||||||
|  | - `/static/css/layout.css` | ||||||
|  | - `/static/css/file-tree.css` | ||||||
|  | - `/static/css/editor.css` | ||||||
|  | - `/static/css/components.css` | ||||||
|  | - `/static/css/modal.css` | ||||||
|  |  | ||||||
|  | **HTML Templates (1):** | ||||||
|  |  | ||||||
|  | - `/templates/index.html` (203 lines) | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | ## Issues Found | ||||||
|  |  | ||||||
|  | ### 🔴 HIGH PRIORITY | ||||||
|  |  | ||||||
|  | 1. **Deprecated Modal Code (Dead Code)** | ||||||
|  |    - Location: `/static/js/file-tree-actions.js` lines 262-474 | ||||||
|  |    - Impact: 213 lines of unused code (44% of file) | ||||||
|  |    - Risk: LOW to remove | ||||||
|  |  | ||||||
|  | 2. **Duplicated Event Bus Implementation** | ||||||
|  |    - Location: `/static/js/app.js` lines 16-30 | ||||||
|  |    - Should be extracted to reusable module | ||||||
|  |  | ||||||
|  | 3. **Duplicated Debounce Function** | ||||||
|  |    - Location: `/static/js/editor.js` lines 404-414 | ||||||
|  |    - Should be shared utility | ||||||
|  |  | ||||||
|  | 4. **Inconsistent Notification Usage** | ||||||
|  |    - Mixed usage of `window.showNotification` vs `showNotification` | ||||||
|  |  | ||||||
|  | 5. **Duplicated File Download Logic** | ||||||
|  |    - Location: `/static/js/file-tree.js` lines 829-839 | ||||||
|  |    - Should be shared utility | ||||||
|  |  | ||||||
|  | 6. **Hard-coded Values** | ||||||
|  |    - Long-press threshold: 400ms | ||||||
|  |    - Debounce delay: 300ms | ||||||
|  |    - Drag preview width: 200px | ||||||
|  |    - Toast delay: 3000ms | ||||||
|  |  | ||||||
|  | ### 🟡 MEDIUM PRIORITY | ||||||
|  |  | ||||||
|  | 7. **Global State Management** | ||||||
|  |    - Location: `/static/js/app.js` lines 6-13 | ||||||
|  |    - Makes testing difficult | ||||||
|  |  | ||||||
|  | 8. **Duplicated Path Manipulation** | ||||||
|  |    - `path.split('/').pop()` appears 10+ times | ||||||
|  |    - `path.substring(0, path.lastIndexOf('/'))` appears 5+ times | ||||||
|  |  | ||||||
|  | 9. **Mixed Responsibility in ui-utils.js** | ||||||
|  |    - Contains 6 different classes/utilities | ||||||
|  |    - Should be split into separate modules | ||||||
|  |  | ||||||
|  | 10. **Deprecated Event Handler** | ||||||
|  |     - Location: `/static/js/file-tree-actions.js` line 329 | ||||||
|  |     - Uses deprecated `onkeypress` | ||||||
|  |  | ||||||
|  | ### 🟢 LOW PRIORITY | ||||||
|  |  | ||||||
|  | 11. **Unused Function Parameters** | ||||||
|  | 12. **Magic Numbers in Styling** | ||||||
|  | 13. **Inconsistent Comment Styles** | ||||||
|  | 14. **Console.log Statements** | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | ## Phase 2: Proposed Reusable Components | ||||||
|  |  | ||||||
|  | ### 1. Config Module (`/static/js/config.js`) | ||||||
|  |  | ||||||
|  | Centralize all configuration values: | ||||||
|  |  | ||||||
|  | ```javascript | ||||||
|  | export const Config = { | ||||||
|  |     // Timing | ||||||
|  |     LONG_PRESS_THRESHOLD: 400, | ||||||
|  |     DEBOUNCE_DELAY: 300, | ||||||
|  |     TOAST_DURATION: 3000, | ||||||
|  |      | ||||||
|  |     // UI | ||||||
|  |     DRAG_PREVIEW_WIDTH: 200, | ||||||
|  |     TREE_INDENT_PX: 12, | ||||||
|  |     MOUSE_MOVE_THRESHOLD: 5, | ||||||
|  |      | ||||||
|  |     // Validation | ||||||
|  |     FILENAME_PATTERN: /^[a-z0-9_]+(\.[a-z0-9_]+)*$/, | ||||||
|  |      | ||||||
|  |     // Storage Keys | ||||||
|  |     STORAGE_KEYS: { | ||||||
|  |         DARK_MODE: 'darkMode', | ||||||
|  |         SELECTED_COLLECTION: 'selectedCollection', | ||||||
|  |         LAST_VIEWED_PAGE: 'lastViewedPage', | ||||||
|  |         COLUMN_DIMENSIONS: 'columnDimensions' | ||||||
|  |     } | ||||||
|  | }; | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ### 2. Logger Module (`/static/js/logger.js`) | ||||||
|  |  | ||||||
|  | Structured logging with levels: | ||||||
|  |  | ||||||
|  | ```javascript | ||||||
|  | export class Logger { | ||||||
|  |     static debug(message, ...args) | ||||||
|  |     static info(message, ...args) | ||||||
|  |     static warn(message, ...args) | ||||||
|  |     static error(message, ...args) | ||||||
|  |     static setLevel(level) | ||||||
|  | } | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ### 3. Event Bus Module (`/static/js/event-bus.js`) | ||||||
|  |  | ||||||
|  | Centralized event system: | ||||||
|  |  | ||||||
|  | ```javascript | ||||||
|  | export class EventBus { | ||||||
|  |     on(event, callback) | ||||||
|  |     off(event, callback) | ||||||
|  |     once(event, callback) | ||||||
|  |     dispatch(event, data) | ||||||
|  |     clear(event) | ||||||
|  | } | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ### 4. Utilities Module (`/static/js/utils.js`) | ||||||
|  |  | ||||||
|  | Common utility functions: | ||||||
|  |  | ||||||
|  | ```javascript | ||||||
|  | export const PathUtils = { | ||||||
|  |     getFileName(path), | ||||||
|  |     getParentPath(path), | ||||||
|  |     normalizePath(path), | ||||||
|  |     joinPaths(...paths), | ||||||
|  |     getExtension(path) | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export const TimingUtils = { | ||||||
|  |     debounce(func, wait), | ||||||
|  |     throttle(func, wait) | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export const DownloadUtils = { | ||||||
|  |     triggerDownload(content, filename), | ||||||
|  |     downloadAsBlob(blob, filename) | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export const ValidationUtils = { | ||||||
|  |     validateFileName(name, isFolder), | ||||||
|  |     sanitizeFileName(name) | ||||||
|  | }; | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ### 5. Notification Service (`/static/js/notification-service.js`) | ||||||
|  |  | ||||||
|  | Standardized notifications: | ||||||
|  |  | ||||||
|  | ```javascript | ||||||
|  | export class NotificationService { | ||||||
|  |     static success(message) | ||||||
|  |     static error(message) | ||||||
|  |     static warning(message) | ||||||
|  |     static info(message) | ||||||
|  | } | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | ## Phase 3: Refactoring Tasks | ||||||
|  |  | ||||||
|  | ### 🔴 HIGH PRIORITY | ||||||
|  |  | ||||||
|  | **Task 1: Remove Dead Code** | ||||||
|  |  | ||||||
|  | - Files: `/static/js/file-tree-actions.js` | ||||||
|  | - Lines: 262-474 (213 lines) | ||||||
|  | - Risk: LOW | ||||||
|  | - Dependencies: None | ||||||
|  |  | ||||||
|  | **Task 2: Extract Event Bus** | ||||||
|  |  | ||||||
|  | - Files: NEW `/static/js/event-bus.js`, MODIFY `app.js`, `editor.js` | ||||||
|  | - Risk: MEDIUM | ||||||
|  | - Dependencies: None | ||||||
|  |  | ||||||
|  | **Task 3: Create Utilities Module** | ||||||
|  |  | ||||||
|  | - Files: NEW `/static/js/utils.js`, MODIFY multiple files | ||||||
|  | - Risk: MEDIUM | ||||||
|  | - Dependencies: None | ||||||
|  |  | ||||||
|  | **Task 4: Create Config Module** | ||||||
|  |  | ||||||
|  | - Files: NEW `/static/js/config.js`, MODIFY multiple files | ||||||
|  | - Risk: LOW | ||||||
|  | - Dependencies: None | ||||||
|  |  | ||||||
|  | **Task 5: Standardize Notification Usage** | ||||||
|  |  | ||||||
|  | - Files: NEW `/static/js/notification-service.js`, MODIFY multiple files | ||||||
|  | - Risk: LOW | ||||||
|  | - Dependencies: None | ||||||
|  |  | ||||||
|  | ### 🟡 MEDIUM PRIORITY | ||||||
|  |  | ||||||
|  | **Task 6: Fix Deprecated Event Handler** | ||||||
|  |  | ||||||
|  | - Files: `/static/js/file-tree-actions.js` line 329 | ||||||
|  | - Risk: LOW | ||||||
|  | - Dependencies: None | ||||||
|  |  | ||||||
|  | **Task 7: Refactor ui-utils.js** | ||||||
|  |  | ||||||
|  | - Files: DELETE `ui-utils.js`, CREATE 5 new modules | ||||||
|  | - Risk: HIGH | ||||||
|  | - Dependencies: Task 5 | ||||||
|  |  | ||||||
|  | **Task 8: Standardize Class Export Pattern** | ||||||
|  |  | ||||||
|  | - Files: All class files | ||||||
|  | - Risk: MEDIUM | ||||||
|  | - Dependencies: None | ||||||
|  |  | ||||||
|  | **Task 9: Create Logger Module** | ||||||
|  |  | ||||||
|  | - Files: NEW `/static/js/logger.js`, MODIFY multiple files | ||||||
|  | - Risk: LOW | ||||||
|  | - Dependencies: None | ||||||
|  |  | ||||||
|  | **Task 10: Implement Download Action** | ||||||
|  |  | ||||||
|  | - Files: `/static/js/file-tree-actions.js` | ||||||
|  | - Risk: LOW | ||||||
|  | - Dependencies: Task 3 | ||||||
|  |  | ||||||
|  | ### 🟢 LOW PRIORITY | ||||||
|  |  | ||||||
|  | **Task 11: Standardize JSDoc Comments** | ||||||
|  | **Task 12: Extract Magic Numbers to CSS** | ||||||
|  | **Task 13: Add Error Boundaries** | ||||||
|  | **Task 14: Cache DOM Elements** | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | ## Phase 4: Implementation Order | ||||||
|  |  | ||||||
|  | ### Step 1: Foundation (Do First) | ||||||
|  |  | ||||||
|  | 1. Create Config Module (Task 4) | ||||||
|  | 2. Create Logger Module (Task 9) | ||||||
|  | 3. Create Event Bus Module (Task 2) | ||||||
|  |  | ||||||
|  | ### Step 2: Utilities (Do Second) | ||||||
|  |  | ||||||
|  | 4. Create Utilities Module (Task 3) | ||||||
|  | 5. Create Notification Service (Task 5) | ||||||
|  |  | ||||||
|  | ### Step 3: Cleanup (Do Third) | ||||||
|  |  | ||||||
|  | 6. Remove Dead Code (Task 1) | ||||||
|  | 7. Fix Deprecated Event Handler (Task 6) | ||||||
|  |  | ||||||
|  | ### Step 4: Restructuring (Do Fourth) | ||||||
|  |  | ||||||
|  | 8. Refactor ui-utils.js (Task 7) | ||||||
|  | 9. Standardize Class Export Pattern (Task 8) | ||||||
|  |  | ||||||
|  | ### Step 5: Enhancements (Do Fifth) | ||||||
|  |  | ||||||
|  | 10. Implement Download Action (Task 10) | ||||||
|  | 11. Add Error Boundaries (Task 13) | ||||||
|  |  | ||||||
|  | ### Step 6: Polish (Do Last) | ||||||
|  |  | ||||||
|  | 12. Standardize JSDoc Comments (Task 11) | ||||||
|  | 13. Extract Magic Numbers to CSS (Task 12) | ||||||
|  | 14. Cache DOM Elements (Task 14) | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | ## Phase 5: Testing Checklist | ||||||
|  |  | ||||||
|  | ### Core Functionality | ||||||
|  |  | ||||||
|  | - [ ] File tree loads and displays correctly | ||||||
|  | - [ ] Files can be selected and opened | ||||||
|  | - [ ] Folders can be expanded/collapsed | ||||||
|  | - [ ] Editor loads file content | ||||||
|  | - [ ] Preview renders markdown correctly | ||||||
|  | - [ ] Save button saves files | ||||||
|  | - [ ] Delete button deletes files | ||||||
|  | - [ ] New button creates new files | ||||||
|  |  | ||||||
|  | ### Context Menu Actions | ||||||
|  |  | ||||||
|  | - [ ] Right-click shows context menu | ||||||
|  | - [ ] New file action works | ||||||
|  | - [ ] New folder action works | ||||||
|  | - [ ] Rename action works | ||||||
|  | - [ ] Delete action works | ||||||
|  | - [ ] Copy/Cut/Paste actions work | ||||||
|  | - [ ] Upload action works | ||||||
|  |  | ||||||
|  | ### Drag and Drop | ||||||
|  |  | ||||||
|  | - [ ] Long-press detection works | ||||||
|  | - [ ] Drag preview appears correctly | ||||||
|  | - [ ] Drop targets highlight properly | ||||||
|  | - [ ] Files can be moved | ||||||
|  | - [ ] Undo (Ctrl+Z) works | ||||||
|  |  | ||||||
|  | ### Modals | ||||||
|  |  | ||||||
|  | - [ ] Confirmation modals appear | ||||||
|  | - [ ] Prompt modals appear | ||||||
|  | - [ ] Modals don't double-open | ||||||
|  | - [ ] Enter/Escape keys work | ||||||
|  |  | ||||||
|  | ### UI Features | ||||||
|  |  | ||||||
|  | - [ ] Dark mode toggle works | ||||||
|  | - [ ] Collection selector works | ||||||
|  | - [ ] Column resizers work | ||||||
|  | - [ ] Notifications appear | ||||||
|  | - [ ] URL routing works | ||||||
|  | - [ ] View/Edit modes work | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | ## Recommendations | ||||||
|  |  | ||||||
|  | ### Immediate Actions (Before Production) | ||||||
|  |  | ||||||
|  | 1. Remove dead code (Task 1) | ||||||
|  | 2. Fix deprecated event handler (Task 6) | ||||||
|  | 3. Create config module (Task 4) | ||||||
|  |  | ||||||
|  | ### Short-term Actions (Next Sprint) | ||||||
|  |  | ||||||
|  | 4. Extract utilities (Task 3) | ||||||
|  | 5. Standardize notifications (Task 5) | ||||||
|  | 6. Create event bus (Task 2) | ||||||
|  |  | ||||||
|  | ### Medium-term Actions (Future Sprints) | ||||||
|  |  | ||||||
|  | 7. Refactor ui-utils.js (Task 7) | ||||||
|  | 8. Add logger (Task 9) | ||||||
|  | 9. Standardize exports (Task 8) | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | ## Success Metrics | ||||||
|  |  | ||||||
|  | **Before Refactoring:** | ||||||
|  |  | ||||||
|  | - Total Lines: ~3,587 | ||||||
|  | - Dead Code: 213 lines (6%) | ||||||
|  | - Duplicated Code: ~50 lines | ||||||
|  | - Hard-coded Values: 15+ | ||||||
|  |  | ||||||
|  | **After Refactoring:** | ||||||
|  |  | ||||||
|  | - Total Lines: ~3,400 (-5%) | ||||||
|  | - Dead Code: 0 lines | ||||||
|  | - Duplicated Code: 0 lines | ||||||
|  | - Hard-coded Values: 0 | ||||||
|  |  | ||||||
|  | **Estimated Effort:** 5-8 days | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | ## Conclusion | ||||||
|  |  | ||||||
|  | The UI codebase is generally well-structured. Main improvements needed: | ||||||
|  |  | ||||||
|  | 1. Remove dead code | ||||||
|  | 2. Extract duplicated utilities | ||||||
|  | 3. Centralize configuration | ||||||
|  | 4. Standardize patterns | ||||||
|  |  | ||||||
|  | Start with high-impact, low-risk changes first to ensure production readiness. | ||||||
							
								
								
									
										78
									
								
								collections/notes/why.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,78 @@ | |||||||
|  | **Decentralized Infrastructure Technology for Everyone, Everywhere** | ||||||
|  |  | ||||||
|  | Mycelium is a comprehensive DePIN (Decentralized Physical Infrastructure) system designed to scale to planetary level, capable of providing resilient services with end-to-end encryption, and enabling any machine and human to communicate efficiently over optimal paths. | ||||||
|  |  | ||||||
|  | Mycelium is Compatible with Kubernetes, Docker, VMs, Web2, Web3 – and building towards Web4. | ||||||
|  |  | ||||||
|  | ## Terminology Clarification | ||||||
|  |  | ||||||
|  | - **Mycelium Tech**: The core technology stack (ZOS, QSS, Mycelium Network) | ||||||
|  | - **ThreeFold Grid**: The decentralized infrastructure offering built on Mycelium Tech | ||||||
|  | - **GeoMind**: The commercial tech company operating tier-S/H datacenters with Mycelium | ||||||
|  |  | ||||||
|  | ## Why Decentralized Infrastructure Matters | ||||||
|  |  | ||||||
|  | Traditional internet infrastructure is burdened with inefficiencies, risks, and growing dependency on centralization. | ||||||
|  |  | ||||||
|  | ### **The Challenges We Face**   | ||||||
|  |  | ||||||
|  | - **Centralization Risks**: Digital infrastructure is controlled by a few corporations, compromising autonomy and creating single points of failure.   | ||||||
|  | - **Economic Inefficiency**: Current infrastructure models extract value from local economies, funneling resources to centralized providers. | ||||||
|  | - **Outdated Protocols**: TCP/IP, the internet's core protocol, was never designed for modern needs like dynamic networks, security, and session management. | ||||||
|  | - **Geographic Inefficiency**: Over 70% of the world relies on distant infrastructure, making access expensive, unreliable, and dependent on fragile global systems. | ||||||
|  | - **Limited Access**: Over 50% of the world lacks decent internet access, widening opportunity gaps. | ||||||
|  |  | ||||||
|  | Mycelium addresses these challenges through a complete, integrated technology stack designed from first principles. | ||||||
|  |  | ||||||
|  | ## What Mycelium Provides | ||||||
|  |  | ||||||
|  | Mycelium is unique in its ability to deliver an integrated platform covering all three fundamental layers of internet infrastructure: | ||||||
|  |  | ||||||
|  | ### **Compute Layer** - ZOS | ||||||
|  | - Autonomous, stateless operating system | ||||||
|  | - MyImage architecture (up to 100x faster deployment) | ||||||
|  | - Deterministic, cryptographically verified deployment | ||||||
|  | - Supports Kubernetes, containers, VMs, and Linux workloads | ||||||
|  | - Self-healing with no manual maintenance required | ||||||
|  |  | ||||||
|  | ### **Storage Layer** - Quantum Safe Storage (QSS) | ||||||
|  | - Mathematical encoding with forward error correction | ||||||
|  | - 20% overhead vs 400% for traditional replication | ||||||
|  | - Zero-knowledge design: storage nodes can't access data | ||||||
|  | - Petabyte-to-zetabyte scalability | ||||||
|  | - Self-healing bitrot protection | ||||||
|  |  | ||||||
|  | ### **Network Layer** - Mycelium Network | ||||||
|  | - End-to-end encrypted IPv6 overlay | ||||||
|  | - Shortest-path optimization | ||||||
|  | - Multi-protocol support (TCP, QUIC, UDP, satellite, wireless) | ||||||
|  | - Peer-to-peer architecture with no central points of failure | ||||||
|  | - Distributed secure name services | ||||||
|  |  | ||||||
|  | ## Key Differentiators | ||||||
|  |  | ||||||
|  | | Feature                  | Mycelium                                     | Traditional Cloud                          | | ||||||
|  | | ------------------------ | -------------------------------------------- | ------------------------------------------ | | ||||||
|  | | **Architecture**         | Distributed peer-to-peer, no central control | Centralized control planes                 | | ||||||
|  | | **Deployment**           | Stateless network boot, zero-install         | Local image installation                   | | ||||||
|  | | **Storage Efficiency**   | 20% overhead                                 | 300-400% overhead                          | | ||||||
|  | | **Security**             | End-to-end encrypted, zero-knowledge design  | Perimeter-based, trust intermediaries      | | ||||||
|  | | **Energy**               | Up to 10x more efficient                     | Higher consumption                         | | ||||||
|  | | **Autonomy**             | Self-healing, autonomous agents              | Requires active management                 | | ||||||
|  | | **Geographic Awareness** | Shortest path routing, location-aware        | Static routing, no geographic optimization | | ||||||
|  |  | ||||||
|  | ## Current Status | ||||||
|  |  | ||||||
|  | - **Deployed**: 20+ countries, 30,000+ vCPU | ||||||
|  | - **Proof of Concept**: Technology validated in production | ||||||
|  | - **Commercialization**: Beginning phase with enterprise roadmap | ||||||
|  |  | ||||||
|  | ## Technology Maturity | ||||||
|  |  | ||||||
|  | - **All our core cloud technology**: Production | ||||||
|  | - **Quantum Safe Storage**: Production (6+ years) | ||||||
|  | - **Mycelium Network**: Beta  | ||||||
|  | - **Deterministic Deployment**: OEM only | ||||||
|  | - **FungiStor**: H1 2026 | ||||||
|  |  | ||||||
|  | Mycelium represents not just an upgrade to existing infrastructure, but a fundamental rethinking of how internet infrastructure should be built—distributed, autonomous, secure, and efficient.%    | ||||||
							
								
								
									
										
											BIN
										
									
								
								collections/tech/images/arch.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 1.8 MiB | 
							
								
								
									
										
											BIN
										
									
								
								collections/tech/images/dashboard.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 403 KiB | 
							
								
								
									
										
											BIN
										
									
								
								collections/tech/images/letsfix.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 409 KiB | 
							
								
								
									
										
											BIN
										
									
								
								collections/tech/images/opportunity.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 254 KiB | 
							
								
								
									
										
											BIN
										
									
								
								collections/tech/images/status.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 388 KiB | 
							
								
								
									
										
											BIN
										
									
								
								collections/tech/images/unique.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 1.4 MiB | 
							
								
								
									
										
											BIN
										
									
								
								collections/tech/images/usable_by_all.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 248 KiB | 
							
								
								
									
										
											BIN
										
									
								
								collections/tech/images/web4.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 61 KiB | 
							
								
								
									
										79
									
								
								collections/tech/introduction.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,79 @@ | |||||||
|  |  | ||||||
|  | **Decentralized Infrastructure Technology for Everyone, Everywhere** | ||||||
|  |  | ||||||
|  | Mycelium is a comprehensive DePIN (Decentralized Physical Infrastructure) system designed to scale to planetary level, capable of providing resilient services with end-to-end encryption, and enabling any machine and human to communicate efficiently over optimal paths. | ||||||
|  |  | ||||||
|  | Mycelium is Compatible with Kubernetes, Docker, VMs, Web2, Web3 – and building towards Web4. | ||||||
|  |  | ||||||
|  | ## Terminology Clarification | ||||||
|  |  | ||||||
|  | - **Mycelium Tech**: The core technology stack (ZOS, QSS, Mycelium Network) | ||||||
|  | - **ThreeFold Grid**: The decentralized infrastructure offering built on Mycelium Tech | ||||||
|  | - **GeoMind**: The commercial tech company operating tier-S/H datacenters with Mycelium | ||||||
|  |  | ||||||
|  | ## Why Decentralized Infrastructure Matters | ||||||
|  |  | ||||||
|  | Traditional internet infrastructure is burdened with inefficiencies, risks, and growing dependency on centralization. | ||||||
|  |  | ||||||
|  | ### **The Challenges We Face**   | ||||||
|  |  | ||||||
|  | - **Centralization Risks**: Digital infrastructure is controlled by a few corporations, compromising autonomy and creating single points of failure.   | ||||||
|  | - **Economic Inefficiency**: Current infrastructure models extract value from local economies, funneling resources to centralized providers. | ||||||
|  | - **Outdated Protocols**: TCP/IP, the internet's core protocol, was never designed for modern needs like dynamic networks, security, and session management. | ||||||
|  | - **Geographic Inefficiency**: Over 70% of the world relies on distant infrastructure, making access expensive, unreliable, and dependent on fragile global systems. | ||||||
|  | - **Limited Access**: Over 50% of the world lacks decent internet access, widening opportunity gaps. | ||||||
|  |  | ||||||
|  | Mycelium addresses these challenges through a complete, integrated technology stack designed from first principles. | ||||||
|  |  | ||||||
|  | ## What Mycelium Provides | ||||||
|  |  | ||||||
|  | Mycelium is unique in its ability to deliver an integrated platform covering all three fundamental layers of internet infrastructure: | ||||||
|  |  | ||||||
|  | ### **Compute Layer** - ZOS | ||||||
|  | - Autonomous, stateless operating system | ||||||
|  | - MyImage architecture (up to 100x faster deployment) | ||||||
|  | - Deterministic, cryptographically verified deployment | ||||||
|  | - Supports Kubernetes, containers, VMs, and Linux workloads | ||||||
|  | - Self-healing with no manual maintenance required | ||||||
|  |  | ||||||
|  | ### **Storage Layer** - Quantum Safe Storage (QSS) | ||||||
|  | - Mathematical encoding with forward error correction | ||||||
|  | - 20% overhead vs 400% for traditional replication | ||||||
|  | - Zero-knowledge design: storage nodes can't access data | ||||||
|  | - Petabyte-to-zetabyte scalability | ||||||
|  | - Self-healing bitrot protection | ||||||
|  |  | ||||||
|  | ### **Network Layer** - Mycelium Network | ||||||
|  | - End-to-end encrypted IPv6 overlay | ||||||
|  | - Shortest-path optimization | ||||||
|  | - Multi-protocol support (TCP, QUIC, UDP, satellite, wireless) | ||||||
|  | - Peer-to-peer architecture with no central points of failure | ||||||
|  | - Distributed secure name services | ||||||
|  |  | ||||||
|  | ## Key Differentiators | ||||||
|  |  | ||||||
|  | | Feature                  | Mycelium                                     | Traditional Cloud                          | | ||||||
|  | | ------------------------ | -------------------------------------------- | ------------------------------------------ | | ||||||
|  | | **Architecture**         | Distributed peer-to-peer, no central control | Centralized control planes                 | | ||||||
|  | | **Deployment**           | Stateless network boot, zero-install         | Local image installation                   | | ||||||
|  | | **Storage Efficiency**   | 20% overhead                                 | 300-400% overhead                          | | ||||||
|  | | **Security**             | End-to-end encrypted, zero-knowledge design  | Perimeter-based, trust intermediaries      | | ||||||
|  | | **Energy**               | Up to 10x more efficient                     | Higher consumption                         | | ||||||
|  | | **Autonomy**             | Self-healing, autonomous agents              | Requires active management                 | | ||||||
|  | | **Geographic Awareness** | Shortest path routing, location-aware        | Static routing, no geographic optimization | | ||||||
|  |  | ||||||
|  | ## Current Status | ||||||
|  |  | ||||||
|  | - **Deployed**: 20+ countries, 30,000+ vCPU | ||||||
|  | - **Proof of Concept**: Technology validated in production | ||||||
|  | - **Commercialization**: Beginning phase with enterprise roadmap | ||||||
|  |  | ||||||
|  | ## Technology Maturity | ||||||
|  |  | ||||||
|  | - **All our core cloud technology**: Production | ||||||
|  | - **Quantum Safe Storage**: Production (6+ years) | ||||||
|  | - **Mycelium Network**: Beta  | ||||||
|  | - **Deterministic Deployment**: OEM only | ||||||
|  | - **FungiStor**: H1 2026 | ||||||
|  |  | ||||||
|  | Mycelium represents not just an upgrade to existing infrastructure, but a fundamental rethinking of how internet infrastructure should be built—distributed, autonomous, secure, and efficient. | ||||||
							
								
								
									
										42
									
								
								collections/tech/presentation.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,42 @@ | |||||||
|  | ## Mycelium Product Presentation | ||||||
|  |  | ||||||
|  | This document provides an overview of the Mycelium technology stack (as commercially sold my our company GeoMind). | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
|  | <div style={{ | ||||||
|  |   position: 'relative', | ||||||
|  |   width: '100%', | ||||||
|  |   height: 0, | ||||||
|  |   paddingTop: '56.25%', | ||||||
|  |   marginTop: '1.6em', | ||||||
|  |   marginBottom: '0.9em', | ||||||
|  |   overflow: 'hidden', | ||||||
|  |   borderRadius: '8px', | ||||||
|  |   willChange: 'transform' | ||||||
|  | }}> | ||||||
|  |   <iframe | ||||||
|  |     src="https://www.canva.com/design/DAG0UtzICsk/rqXpn5f3ibo2OpX-yDWmPQ/view?embed" | ||||||
|  |     style={{ | ||||||
|  |       position: 'absolute', | ||||||
|  |       width: '100%', | ||||||
|  |       height: '100%', | ||||||
|  |       top: 0, | ||||||
|  |       left: 0, | ||||||
|  |       border: 'none', | ||||||
|  |       padding: 0, | ||||||
|  |       margin: 0 | ||||||
|  |     }} | ||||||
|  |     allowFullScreen={true} | ||||||
|  |     allow="fullscreen"> | ||||||
|  |   </iframe> | ||||||
|  | </div> | ||||||
|  |  | ||||||
|  | <div style={{ marginTop: '10px' }}> | ||||||
|  |   <a href="https://www.canva.com/design/DAG0UtzICsk/rqXpn5f3ibo2OpX-yDWmPQ/view" | ||||||
|  |      target="_blank" | ||||||
|  |      rel="noopener" | ||||||
|  |      style={{ textDecoration: 'none' }}> | ||||||
|  |     Geomind Product Intro 2025 (based on mycelium technology) | ||||||
|  |   </a> | ||||||
|  | </div> | ||||||
							
								
								
									
										50
									
								
								collections/tech/roadmap/enterprise_roadmap.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,50 @@ | |||||||
|  |  | ||||||
|  | # Government, Commercial Hosters, Telco and Enterprise Roadmap | ||||||
|  |  | ||||||
|  | We are working on the government, commercial hosters, telco and enterprise releases of our technology. | ||||||
|  |  | ||||||
|  | > 90% of the work has been done as part of our base offering but we need additional features for enterprises. | ||||||
|  |  | ||||||
|  | ## Enterprise User Interface | ||||||
|  |  | ||||||
|  | The current user interface is designed for an open-source tech audience. For enterprise use, we need a different approach to meet the unique needs of enterprise environments: | ||||||
|  |  | ||||||
|  | - **Private or Hybrid Context**: All operations should be conducted within a private or hybrid cloud context to ensure security and compliance. | ||||||
|  | - **Enhanced Monitoring**: We need more comprehensive monitoring dashboard screens to provide real-time insights and analytics. | ||||||
|  | - **Identity Management Integration**: Integration with enterprise-grade Identity Management solutions, such as LDAP, Active Directory, and SSO (Single Sign-On), is essential. | ||||||
|  | - **Enterprise-Friendly UI**: The user interface needs to be redesigned to be more intuitive and tailored to enterprise users, focusing on usability and efficiency. | ||||||
|  | - **Token Irrelevance**: Tokens are not a priority in this context and should be de-emphasized in the solution. | ||||||
|  |  | ||||||
|  | ## Windows Support | ||||||
|  |  | ||||||
|  | The virtual Machine technology we use does support Windows, but we need to do some further integration. | ||||||
|  |  | ||||||
|  | ## High Performance Network Integration | ||||||
|  |  | ||||||
|  | - **Local Network Integration**: ZOS is designed to support a wide range of technologies, though additional integration work is required to optimize performance. | ||||||
|  | - **High-Speed Backbones**: We aim to support high-speed Ethernet and RDMA (Infiniband) based backbones. | ||||||
|  | - **Instrumentation Enhancements**: Additional instrumentation needs to be incorporated into ZOS to achieve optimal performance. | ||||||
|  | - **Target Performance**: Our goal is to achieve network speeds exceeding 100 Gbps. | ||||||
|  | - **Custom Integration**: We offer integration with selected network equipment from our customers, accommodating custom integration requirements. | ||||||
|  |  | ||||||
|  | ## High Performance Storage Block Device Integration | ||||||
|  |  | ||||||
|  | Next to the existing already integrated storage backends we want to support a high performance redundant storage block device. | ||||||
|  |  | ||||||
|  | - High performance redundant storage network | ||||||
|  | - Supports high-speed backbones as defined above | ||||||
|  | - Scalable to thousands of machines per cluster. | ||||||
|  | - Replication capability between zones. | ||||||
|  | - Custom Integration | ||||||
|  |   - We offer integration with selected storage equipment from our customers, accommodating custom integration requirements. | ||||||
|  |  | ||||||
|  | ## Service Level Management | ||||||
|  |  | ||||||
|  | - The system will have hooks and visualization for achievement of Service levels. | ||||||
|  | - This will allow a commercial service provider to get to higher revenue and better uptime management. | ||||||
|  |  | ||||||
|  | ## Support for Liquid Cooling Tanks | ||||||
|  |  | ||||||
|  | - Do a test setup in liquid cooling rack or node. | ||||||
|  |   - We can use our self-healing capabilities to manage in a better way. | ||||||
|  | - This is an integration effort, and not much code changes are needed. | ||||||
							
								
								
									
										24
									
								
								collections/tech/roadmap/hero_roadmap.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,24 @@ | |||||||
|  |  | ||||||
|  | ## AI Agent High Level Roadmap | ||||||
|  |  | ||||||
|  | MyAgent is our private AI agent. | ||||||
|  |  | ||||||
|  | The first version of our MyAgent enables the management of core services such as an innovative database backend, a autonomous decentralized git system, and the automatic integration and deployment of our workloads. | ||||||
|  |  | ||||||
|  | This stack allows everyone to deploy scalable Web 2,3 and 4 apps on top of the TFGrid in a fully automated way. | ||||||
|  |  | ||||||
|  | |                                     | Roadmap                                                                                                      | Timing | | ||||||
|  | | ----------------------------------- | ------------------------------------------------------------------------------------------------------------ | ------ | | ||||||
|  | | MyAgent Publisher                   | Publish websites, e-books, and more on top of the ThreeFold Grid                                             | H2 25  | | ||||||
|  | | MyAgent CI = Continuous Integration | Easier to use Continuous Integration/Development, very powerfull, with multinode support                     | H2 25  | | ||||||
|  | | MyAgent Play                        | Integrate declarative automation and configuration management as part of wiki approach in MyAgent Publisher  | H2 25  | | ||||||
|  | | MyAgent Git                         | Alternative to centralized Github (based on Gitea), fully integrated on top of TFGrid                        | H2 25  | | ||||||
|  | | MyAgent DB                          | Flexible ultra redundant database stor with indexing, queries, stored procedures, super scalable replication | H2 25  | | ||||||
|  | | MyAgent OSIS                        | Object Storage and Index system                                                                              | H2 25  | | ||||||
|  | | MyAgent WEB                         | Web framework, deployable globally on TFGrid, integrated with Mycelium Net and Names                         | H2 25  | | ||||||
|  | | MyAgent Monitor                     | Monitor all your different components on redundant monitoring stack                                          | H2 25  | | ||||||
|  | | MyAgent Happs                       | MyAgent natively supports Holochain HAPPS                                                                    | Q4 25  | | ||||||
|  | | MyAgent Actors                      | MyAgent can serve actors which respond and act on OpenRPC calls ideal as backend for web or other apps       | Q4 25  | | ||||||
|  | | MyAgent Web 3 Gateway               | MyAgent aims to have native support for chosen Web3 partner solutions (Bitcoin, Ethereum, and more)          | Q4 25  | | ||||||
|  |  | ||||||
|  | All of the specs above are fully integrated with the Mycelium Network and the ThreeFold Grid. | ||||||
							
								
								
									
										40
									
								
								collections/tech/roadmap/high_level.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,40 @@ | |||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
|  | # Roadmap in Phases | ||||||
|  |  | ||||||
|  | ## Phase 1: Wave 1 of Companies, Leading to Our expertise (DONE) | ||||||
|  |  | ||||||
|  | - Technology creation | ||||||
|  |   - This was result of 20 years of evolution | ||||||
|  | - 7 startups acquired as part of this process | ||||||
|  | - Technology used globally by big vendors | ||||||
|  | - +600m USD in exits | ||||||
|  |  | ||||||
|  | ## Phase 2: Proof of Tech (DONE) | ||||||
|  |  | ||||||
|  | - Open source technology launched globally | ||||||
|  | - +60,000,000 active vCPU | ||||||
|  | - Large scale proof of core technology | ||||||
|  | - Focus on early adoptors in tech space (Cloud, Web2, Web3, etc.) | ||||||
|  | - 50m USD funded by founders, community and hosters (people providing capacity) | ||||||
|  |  | ||||||
|  | ## Phase 3: Commercialization & Global Expansion (START) | ||||||
|  |  | ||||||
|  | ### Phase 3.1: Commercial Partners | ||||||
|  |  | ||||||
|  | - Mycelium Launches with commercial strategic partners | ||||||
|  |   - Telco Operatators | ||||||
|  |   - IT Integrators | ||||||
|  | - Enterprise roadmap delivered within 6 months | ||||||
|  |   - This is mainly about integration, documentation and UI work | ||||||
|  | - Together with partners we deliver on the many projects which are in our funnel today, e.g., East Africa, Brazil | ||||||
|  |  | ||||||
|  | ### Phase 3.2: Large Scale Financancing for Infrastructure | ||||||
|  |  | ||||||
|  | **Large Scaling Financing Round** | ||||||
|  |  | ||||||
|  | - Financing for infrastructure projects (trillions available right now for infrastructures in emerging countries) | ||||||
|  | - Public STO (security token offering) | ||||||
|  |   - This lets people around the world to co-own the infrastructure for their internet | ||||||
|  | - Large partnerships drive alternative to Tier 3 and 4 datacenters | ||||||
							
								
								
									
										
											BIN
										
									
								
								collections/tech/roadmap/img/roadmap.jpg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 97 KiB | 
							
								
								
									
										50
									
								
								collections/tech/roadmap/tfgrid_roadmap.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,50 @@ | |||||||
|  |  | ||||||
|  | ## High Level Roadmap | ||||||
|  |  | ||||||
|  | ### Status Today | ||||||
|  |  | ||||||
|  | The core offering is functioning effectively, maintained through a community-driven, best-effort approach. Currently, | ||||||
|  | there are no Service Level Agreements (SLAs) in place, and there should be increased visibility for users regarding their expectations for uptime, performance, and other service related requirements. | ||||||
|  |  | ||||||
|  | The uptime and stability of ZOS are very good. | ||||||
|  |  | ||||||
|  | Additionally, hardware compatibility is excellent, with most machines now supported out of the box. | ||||||
|  |  | ||||||
|  | |                        | Status today                                                                                                            | SDK/API | Web UI | | ||||||
|  | | ---------------------- | ----------------------------------------------------------------------------------------------------------------------- | ------- | ------ | | ||||||
|  | | ZOS                    | Used for management of +30,000 logical CPU cores                                                                        | yes     | yes    | | ||||||
|  | | MyImage (flists)       | Basis for ZOS modules as well as replaces images for VM's ...                                                           | yes     | yes    | | ||||||
|  | | MyImage from Docker    | convert docker through our Hub                                                                                          | yes     | yes    | | ||||||
|  | | MyImage Hub            | Mycelium is hosting some as well as everyone can install their own Hub                                                  | yes     | yes    | | ||||||
|  | | Mycelium Core          | Integrated in ZOS for VM's as well s ZDB and monitoring                                                                 | yes     | yes    | | ||||||
|  | | Mycelium Message Bus   | Can be used by any developer for their own usecases                                                                     | NA      | NA     | | ||||||
|  | | Quantum Safe Storage   | Usable for experts only, is reliably working for +6 years, +100 MB/sec per stream                                       | yes     | no     | | ||||||
|  | | Unbreakable Filesystem | Quantum Safe FS= usable for experts, is a fuse based filesystem on top of the QSS Core                                  | yes     | no     | | ||||||
|  | | ZOS Kubernetes         | Working very well, Integrated in ZOS, uses our overlay networks based on Wireguard, can use Quantum Safe FS underneith. | yes     | yes    | | ||||||
|  | | ZOS VM's               | The base of our service portfolio, missing is better service level management                                           | yes     | yes    | | ||||||
|  | | ZOS Monitoring         | Working well                                                                                                            | yes     | yes    | | ||||||
|  | | ZOS VM Monitoring      | Working well, can be retrieved through SDK                                                                              | yes     | yes    | | ||||||
|  | | ZOS Web Gateway        | Working well, but documentation not good enough, and not enough of them deployed                                        | yes     | yes    | | ||||||
|  | | Zero-Boot              | There are multiple ways active on how to deploy ZOS all are stateless and capable for full secure boot                  | yes     | yes    | | ||||||
|  |  | ||||||
|  | ### Planned new features | ||||||
|  |  | ||||||
|  | Considerable effort is being made to enable our partners to go into production; | ||||||
|  | however, for this initiative to truly succeed on planetary level, we need many more nodes deployed in the field. | ||||||
|  |  | ||||||
|  | Below you can find some of the planned features of Mycelium Network 4.0 mainly to achieve ability to scale to hundred of thousand of nodes. | ||||||
|  |  | ||||||
|  | |                                 | Roadmap                                                             | Timing  | | ||||||
|  | | ------------------------------- | ------------------------------------------------------------------- | ------- | | ||||||
|  | | ZOS v4 (our next major release) | V4, without Mycelium Chain, mutual credit, marketplace              | Q2/3 25 | | ||||||
|  | | MyImage from Docker             | CI/CD integration (See MyAgent CI/CD)                               | Q1 25   | | ||||||
|  | | MyImage Hub Integration         | CI/CD integration (See MyAgent CI/CD) no more need for separate Hub | Q1 25   | | ||||||
|  | | Mycelium Core                   | Just more hardening and testing                                     | Q1 25   | | ||||||
|  | | Mycelium Message Bus            | Replace our current RMB, all our own RPC over Mycelium              | Q1 25   | | ||||||
|  | | ZOS VM's Cloud Slices           | Integration MyAgent CI, use cloud slices to manage                  | Q2 25   | | ||||||
|  | | ZOS Monitoring Docu             | More docu and easier API                                            | Q2 25   | | ||||||
|  | | ZOS Web Gateway Expansion       | Need more deployed, better integration with new Mycelium            | Q2 25   | | ||||||
|  | | Mycelium Names                  | In V4, name services                                                | Q2 25   | | ||||||
|  | | ZOS Cloud,Storage,AI Slices     | As part of marketplace for V4, flexible billing mutual credit       | Q3 25   | | ||||||
|  | | FungiStor                       | A revolutionary different way how to deliver content                | Q3 25   | | ||||||
|  | | MyImage on FungiStor            | Can be stored on FungiStor                                          | Q3 25   | | ||||||
							
								
								
									
										21
									
								
								collections/tech/status.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,21 @@ | |||||||
|  | ## Technology Status | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
|  | The Mycelium technology stack is proven and operational in production environments globally. | ||||||
|  |  | ||||||
|  | Ongoing deployment and enhancement activities continue across the platform, with expanding adoption and application scope. | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
|  | ## Usable for Any Infrastructure Use Case | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
|  | Mycelium is designed to support any infrastructure workload - from traditional cloud applications to edge computing, AI services, and decentralized applications. | ||||||
|  |  | ||||||
|  | ## Differentiated Architecture | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
|  | Mycelium's unique value lies in its integrated approach: autonomous infrastructure, deterministic deployment, zero-knowledge storage, and optimized networking - delivered as a cohesive platform rather than point solutions. | ||||||
							
								
								
									
										21
									
								
								collections/tech/vision.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,21 @@ | |||||||
|  |  | ||||||
|  |  | ||||||
|  | ## Vision | ||||||
|  |  | ||||||
|  | Building the foundational internet infrastructure layer that is more reliable, safe, private, scalable, and sustainable. | ||||||
|  |  | ||||||
|  | Our technology enables anyone to become an infrastructure provider while maintaining autonomous, self-healing services covering all three fundamental layers of internet architecture. | ||||||
|  |  | ||||||
|  | Our system is unique in its ability to deliver integrated services across compute (ZOS), storage (Quantum Safe Storage), and networking (Mycelium Network) within a single, coherent platform. | ||||||
|  |  | ||||||
|  | ## Lets Fix Our Internet | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
|  | **We are a grounded project:** | ||||||
|  |  | ||||||
|  | - Already deployed in 30+ countries with 30,000+ vCPUs live | ||||||
|  | - Proven technology in production for multiple years | ||||||
|  | - Complete stack: OS, storage, networking, AI agents | ||||||
|  | - Focused on building and proving technology | ||||||
|  | - Commercial phase launching with enterprise roadmap | ||||||
							
								
								
									
										43
									
								
								collections/tech/what.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,43 @@ | |||||||
|  |  | ||||||
|  | ## What do we do? | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
|  | A truly reliable Internet requires fundamentally better systems for networking (communication), storage (data), and compute.  | ||||||
|  |  | ||||||
|  | Mycelium has built these core technologies from the ground up, enabling anyone to become an infrastructure provider while maintaining autonomous, self-healing services covering all three fundamental layers of internet architecture. | ||||||
|  |  | ||||||
|  | ### Authentic, Secure & Globally Scalable Network Technology | ||||||
|  |  | ||||||
|  | - Our Mycelium Network technology enables seamless, private communication between people and machines, anywhere in the world, using the most efficient path available. | ||||||
|  | - It integrates with a global edge network of ultra-connected, low-latency supernodes to deliver superior performance and resilience. | ||||||
|  | - Mycelium is designed to overcome the limitations of the traditional Internet, such as unreliability, poor performance, and security risks. | ||||||
|  | - It provides core services including Naming, Shortest Path Routing, End-to-End Encryption, Authentication, a Secure Message Bus, and Content Delivery. | ||||||
|  |  | ||||||
|  | ### Data Storage & Distribution | ||||||
|  |  | ||||||
|  | - Our Quantum-Safe Storage system enables users to store unlimited amounts of data with full ownership and control. | ||||||
|  | - As soon as data leaves the application or compute layer, it is encoded in a way that is resistant even to quantum-level attacks. | ||||||
|  | - Users have full control over data availability, redundancy, and geographic placement. | ||||||
|  | - The system supports multiple interfaces, including IPFS, S3, WebDAV, HTTP, and standard file system access. | ||||||
|  | - Data can never be corrupted, and the storage system is self-healing by design. | ||||||
|  |  | ||||||
|  | ### Secure Compute | ||||||
|  |  | ||||||
|  | - Self-Managing & Stateless: Requires no manual interactions, enabling fully autonomous operation across global infrastructure. | ||||||
|  | - Secure & Deterministic Deployments: Every workload is cryptographically verified and deployed with guaranteed consistency—no room for tampering or drift. | ||||||
|  | - Efficient Deployment Storage System (Zero-Image): Achieves up to 100x reduction in image size and transfer using a unique metadata-driven architecture. | ||||||
|  | - Compatible: Runs Docker containers, virtual machines, and Linux workloads seamlessly. | ||||||
|  | - Smart Contract-Based Deployment: Workloads are governed by cryptographically signed contracts, ensuring transparent, tamper-proof deployment and execution. | ||||||
|  |  | ||||||
|  | ## Compare | ||||||
|  |  | ||||||
|  | | Feature                                                                                                                                                       | Others | Mycelium Tech | | ||||||
|  | | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ------------- | | ||||||
|  | | Deterministic Deployments Possible, no one (hacker) can alter state.                                                                                          | NO     | YES           | | ||||||
|  | | Autonomous/Self Healing Infrastructure which can scale to the planet.                                                                                         | NO     | YES           | | ||||||
|  | | Usable for any web2, web3 workload, compatible with now & future.                                                                                             | NO     | YES           | | ||||||
|  | | Data is geo-aware, war & disaster proof.                                                                                                                      | NO     | YES           | | ||||||
|  | | Can work in hyperscale datacenters as well as at edge.                                                                                                        | NO     | YES           | | ||||||
|  | | Cost effective, can be 3x less                                                                                                                                | NO     | YES           | | ||||||
|  | | Networks can always find the shortest path and work over multiple media e.g. satellite, std internet, meshed wireless, lorawan, etc all end to end encrypted. | NO     | YES           | | ||||||
							
								
								
									
										29
									
								
								config.yaml
									
									
									
									
									
								
							
							
						
						| @@ -1,25 +1,22 @@ | |||||||
| # WsgiDAV Configuration |  | ||||||
| # Collections define WebDAV-accessible directories |  | ||||||
|  |  | ||||||
| collections: | collections: | ||||||
|   documents: |   documents: | ||||||
|     path: "./collections/documents" |     path: ./collections/documents | ||||||
|     description: "General documents and notes" |     description: General documents and notes | ||||||
|  |  | ||||||
|   notes: |   notes: | ||||||
|     path: "./collections/notes" |     path: ./collections/notes | ||||||
|     description: "Personal notes and drafts" |     description: Personal notes and drafts | ||||||
|  |  | ||||||
|   projects: |   projects: | ||||||
|     path: "./collections/projects" |     path: ./collections/projects | ||||||
|     description: "Project documentation" |     description: Project documentation | ||||||
|  |   7madah: | ||||||
| # Server settings |     path: collections/7madah | ||||||
|  |     description: 'User-created collection: 7madah' | ||||||
|  |   tech: | ||||||
|  |     path: collections/tech | ||||||
|  |     description: 'User-created collection: tech' | ||||||
| server: | server: | ||||||
|   host: "localhost" |   host: localhost | ||||||
|   port: 8004 |   port: 8004 | ||||||
|  |  | ||||||
| # WebDAV settings |  | ||||||
| webdav: | webdav: | ||||||
|   verbose: 1 |   verbose: 1 | ||||||
|   enable_loggers: [] |   enable_loggers: [] | ||||||
|   | |||||||
							
								
								
									
										426
									
								
								refactor-plan.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,426 @@ | |||||||
|  | # UI Code Refactoring Plan | ||||||
|  |  | ||||||
|  | **Project:** Markdown Editor   | ||||||
|  | **Date:** 2025-10-26   | ||||||
|  | **Status:** In Progress | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | ## Executive Summary | ||||||
|  |  | ||||||
|  | This document outlines a comprehensive refactoring plan for the UI codebase to improve maintainability, remove dead code, extract utilities, and standardize patterns. The refactoring is organized into 6 phases with 14 tasks, prioritized by risk and impact. | ||||||
|  |  | ||||||
|  | **Key Metrics:** | ||||||
|  |  | ||||||
|  | - Total Lines of Code: ~3,587 | ||||||
|  | - Dead Code to Remove: 213 lines (6%) | ||||||
|  | - Estimated Effort: 5-8 days | ||||||
|  | - Risk Level: Mostly LOW to MEDIUM | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | ## Phase 1: Analysis Summary | ||||||
|  |  | ||||||
|  | ### Files Reviewed | ||||||
|  |  | ||||||
|  | **JavaScript Files (10):** | ||||||
|  |  | ||||||
|  | - `/static/js/app.js` (484 lines) | ||||||
|  | - `/static/js/column-resizer.js` (100 lines) | ||||||
|  | - `/static/js/confirmation.js` (170 lines) | ||||||
|  | - `/static/js/editor.js` (420 lines) | ||||||
|  | - `/static/js/file-tree-actions.js` (482 lines) | ||||||
|  | - `/static/js/file-tree.js` (865 lines) | ||||||
|  | - `/static/js/macro-parser.js` (103 lines) | ||||||
|  | - `/static/js/macro-processor.js` (157 lines) | ||||||
|  | - `/static/js/ui-utils.js` (305 lines) | ||||||
|  | - `/static/js/webdav-client.js` (266 lines) | ||||||
|  |  | ||||||
|  | **CSS Files (6):** | ||||||
|  |  | ||||||
|  | - `/static/css/variables.css` (32 lines) | ||||||
|  | - `/static/css/layout.css` | ||||||
|  | - `/static/css/file-tree.css` | ||||||
|  | - `/static/css/editor.css` | ||||||
|  | - `/static/css/components.css` | ||||||
|  | - `/static/css/modal.css` | ||||||
|  |  | ||||||
|  | **HTML Templates (1):** | ||||||
|  |  | ||||||
|  | - `/templates/index.html` (203 lines) | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | ## Issues Found | ||||||
|  |  | ||||||
|  | ### 🔴 HIGH PRIORITY | ||||||
|  |  | ||||||
|  | 1. **Deprecated Modal Code (Dead Code)** | ||||||
|  |    - Location: `/static/js/file-tree-actions.js` lines 262-474 | ||||||
|  |    - Impact: 213 lines of unused code (44% of file) | ||||||
|  |    - Risk: LOW to remove | ||||||
|  |  | ||||||
|  | 2. **Duplicated Event Bus Implementation** | ||||||
|  |    - Location: `/static/js/app.js` lines 16-30 | ||||||
|  |    - Should be extracted to reusable module | ||||||
|  |  | ||||||
|  | 3. **Duplicated Debounce Function** | ||||||
|  |    - Location: `/static/js/editor.js` lines 404-414 | ||||||
|  |    - Should be shared utility | ||||||
|  |  | ||||||
|  | 4. **Inconsistent Notification Usage** | ||||||
|  |    - Mixed usage of `window.showNotification` vs `showNotification` | ||||||
|  |  | ||||||
|  | 5. **Duplicated File Download Logic** | ||||||
|  |    - Location: `/static/js/file-tree.js` lines 829-839 | ||||||
|  |    - Should be shared utility | ||||||
|  |  | ||||||
|  | 6. **Hard-coded Values** | ||||||
|  |    - Long-press threshold: 400ms | ||||||
|  |    - Debounce delay: 300ms | ||||||
|  |    - Drag preview width: 200px | ||||||
|  |    - Toast delay: 3000ms | ||||||
|  |  | ||||||
|  | ### 🟡 MEDIUM PRIORITY | ||||||
|  |  | ||||||
|  | 7. **Global State Management** | ||||||
|  |    - Location: `/static/js/app.js` lines 6-13 | ||||||
|  |    - Makes testing difficult | ||||||
|  |  | ||||||
|  | 8. **Duplicated Path Manipulation** | ||||||
|  |    - `path.split('/').pop()` appears 10+ times | ||||||
|  |    - `path.substring(0, path.lastIndexOf('/'))` appears 5+ times | ||||||
|  |  | ||||||
|  | 9. **Mixed Responsibility in ui-utils.js** | ||||||
|  |    - Contains 6 different classes/utilities | ||||||
|  |    - Should be split into separate modules | ||||||
|  |  | ||||||
|  | 10. **Deprecated Event Handler** | ||||||
|  |     - Location: `/static/js/file-tree-actions.js` line 329 | ||||||
|  |     - Uses deprecated `onkeypress` | ||||||
|  |  | ||||||
|  | ### 🟢 LOW PRIORITY | ||||||
|  |  | ||||||
|  | 11. **Unused Function Parameters** | ||||||
|  | 12. **Magic Numbers in Styling** | ||||||
|  | 13. **Inconsistent Comment Styles** | ||||||
|  | 14. **Console.log Statements** | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | ## Phase 2: Proposed Reusable Components | ||||||
|  |  | ||||||
|  | ### 1. Config Module (`/static/js/config.js`) | ||||||
|  |  | ||||||
|  | Centralize all configuration values: | ||||||
|  |  | ||||||
|  | ```javascript | ||||||
|  | export const Config = { | ||||||
|  |     // Timing | ||||||
|  |     LONG_PRESS_THRESHOLD: 400, | ||||||
|  |     DEBOUNCE_DELAY: 300, | ||||||
|  |     TOAST_DURATION: 3000, | ||||||
|  |      | ||||||
|  |     // UI | ||||||
|  |     DRAG_PREVIEW_WIDTH: 200, | ||||||
|  |     TREE_INDENT_PX: 12, | ||||||
|  |     MOUSE_MOVE_THRESHOLD: 5, | ||||||
|  |      | ||||||
|  |     // Validation | ||||||
|  |     FILENAME_PATTERN: /^[a-z0-9_]+(\.[a-z0-9_]+)*$/, | ||||||
|  |      | ||||||
|  |     // Storage Keys | ||||||
|  |     STORAGE_KEYS: { | ||||||
|  |         DARK_MODE: 'darkMode', | ||||||
|  |         SELECTED_COLLECTION: 'selectedCollection', | ||||||
|  |         LAST_VIEWED_PAGE: 'lastViewedPage', | ||||||
|  |         COLUMN_DIMENSIONS: 'columnDimensions' | ||||||
|  |     } | ||||||
|  | }; | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ### 2. Logger Module (`/static/js/logger.js`) | ||||||
|  |  | ||||||
|  | Structured logging with levels: | ||||||
|  |  | ||||||
|  | ```javascript | ||||||
|  | export class Logger { | ||||||
|  |     static debug(message, ...args) | ||||||
|  |     static info(message, ...args) | ||||||
|  |     static warn(message, ...args) | ||||||
|  |     static error(message, ...args) | ||||||
|  |     static setLevel(level) | ||||||
|  | } | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ### 3. Event Bus Module (`/static/js/event-bus.js`) | ||||||
|  |  | ||||||
|  | Centralized event system: | ||||||
|  |  | ||||||
|  | ```javascript | ||||||
|  | export class EventBus { | ||||||
|  |     on(event, callback) | ||||||
|  |     off(event, callback) | ||||||
|  |     once(event, callback) | ||||||
|  |     dispatch(event, data) | ||||||
|  |     clear(event) | ||||||
|  | } | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ### 4. Utilities Module (`/static/js/utils.js`) | ||||||
|  |  | ||||||
|  | Common utility functions: | ||||||
|  |  | ||||||
|  | ```javascript | ||||||
|  | export const PathUtils = { | ||||||
|  |     getFileName(path), | ||||||
|  |     getParentPath(path), | ||||||
|  |     normalizePath(path), | ||||||
|  |     joinPaths(...paths), | ||||||
|  |     getExtension(path) | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export const TimingUtils = { | ||||||
|  |     debounce(func, wait), | ||||||
|  |     throttle(func, wait) | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export const DownloadUtils = { | ||||||
|  |     triggerDownload(content, filename), | ||||||
|  |     downloadAsBlob(blob, filename) | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export const ValidationUtils = { | ||||||
|  |     validateFileName(name, isFolder), | ||||||
|  |     sanitizeFileName(name) | ||||||
|  | }; | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ### 5. Notification Service (`/static/js/notification-service.js`) | ||||||
|  |  | ||||||
|  | Standardized notifications: | ||||||
|  |  | ||||||
|  | ```javascript | ||||||
|  | export class NotificationService { | ||||||
|  |     static success(message) | ||||||
|  |     static error(message) | ||||||
|  |     static warning(message) | ||||||
|  |     static info(message) | ||||||
|  | } | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | ## Phase 3: Refactoring Tasks | ||||||
|  |  | ||||||
|  | ### 🔴 HIGH PRIORITY | ||||||
|  |  | ||||||
|  | **Task 1: Remove Dead Code** | ||||||
|  |  | ||||||
|  | - Files: `/static/js/file-tree-actions.js` | ||||||
|  | - Lines: 262-474 (213 lines) | ||||||
|  | - Risk: LOW | ||||||
|  | - Dependencies: None | ||||||
|  |  | ||||||
|  | **Task 2: Extract Event Bus** | ||||||
|  |  | ||||||
|  | - Files: NEW `/static/js/event-bus.js`, MODIFY `app.js`, `editor.js` | ||||||
|  | - Risk: MEDIUM | ||||||
|  | - Dependencies: None | ||||||
|  |  | ||||||
|  | **Task 3: Create Utilities Module** | ||||||
|  |  | ||||||
|  | - Files: NEW `/static/js/utils.js`, MODIFY multiple files | ||||||
|  | - Risk: MEDIUM | ||||||
|  | - Dependencies: None | ||||||
|  |  | ||||||
|  | **Task 4: Create Config Module** | ||||||
|  |  | ||||||
|  | - Files: NEW `/static/js/config.js`, MODIFY multiple files | ||||||
|  | - Risk: LOW | ||||||
|  | - Dependencies: None | ||||||
|  |  | ||||||
|  | **Task 5: Standardize Notification Usage** | ||||||
|  |  | ||||||
|  | - Files: NEW `/static/js/notification-service.js`, MODIFY multiple files | ||||||
|  | - Risk: LOW | ||||||
|  | - Dependencies: None | ||||||
|  |  | ||||||
|  | ### 🟡 MEDIUM PRIORITY | ||||||
|  |  | ||||||
|  | **Task 6: Fix Deprecated Event Handler** | ||||||
|  |  | ||||||
|  | - Files: `/static/js/file-tree-actions.js` line 329 | ||||||
|  | - Risk: LOW | ||||||
|  | - Dependencies: None | ||||||
|  |  | ||||||
|  | **Task 7: Refactor ui-utils.js** | ||||||
|  |  | ||||||
|  | - Files: DELETE `ui-utils.js`, CREATE 5 new modules | ||||||
|  | - Risk: HIGH | ||||||
|  | - Dependencies: Task 5 | ||||||
|  |  | ||||||
|  | **Task 8: Standardize Class Export Pattern** | ||||||
|  |  | ||||||
|  | - Files: All class files | ||||||
|  | - Risk: MEDIUM | ||||||
|  | - Dependencies: None | ||||||
|  |  | ||||||
|  | **Task 9: Create Logger Module** | ||||||
|  |  | ||||||
|  | - Files: NEW `/static/js/logger.js`, MODIFY multiple files | ||||||
|  | - Risk: LOW | ||||||
|  | - Dependencies: None | ||||||
|  |  | ||||||
|  | **Task 10: Implement Download Action** | ||||||
|  |  | ||||||
|  | - Files: `/static/js/file-tree-actions.js` | ||||||
|  | - Risk: LOW | ||||||
|  | - Dependencies: Task 3 | ||||||
|  |  | ||||||
|  | ### 🟢 LOW PRIORITY | ||||||
|  |  | ||||||
|  | **Task 11: Standardize JSDoc Comments** | ||||||
|  | **Task 12: Extract Magic Numbers to CSS** | ||||||
|  | **Task 13: Add Error Boundaries** | ||||||
|  | **Task 14: Cache DOM Elements** | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | ## Phase 4: Implementation Order | ||||||
|  |  | ||||||
|  | ### Step 1: Foundation (Do First) | ||||||
|  |  | ||||||
|  | 1. Create Config Module (Task 4) | ||||||
|  | 2. Create Logger Module (Task 9) | ||||||
|  | 3. Create Event Bus Module (Task 2) | ||||||
|  |  | ||||||
|  | ### Step 2: Utilities (Do Second) | ||||||
|  |  | ||||||
|  | 4. Create Utilities Module (Task 3) | ||||||
|  | 5. Create Notification Service (Task 5) | ||||||
|  |  | ||||||
|  | ### Step 3: Cleanup (Do Third) | ||||||
|  |  | ||||||
|  | 6. Remove Dead Code (Task 1) | ||||||
|  | 7. Fix Deprecated Event Handler (Task 6) | ||||||
|  |  | ||||||
|  | ### Step 4: Restructuring (Do Fourth) | ||||||
|  |  | ||||||
|  | 8. Refactor ui-utils.js (Task 7) | ||||||
|  | 9. Standardize Class Export Pattern (Task 8) | ||||||
|  |  | ||||||
|  | ### Step 5: Enhancements (Do Fifth) | ||||||
|  |  | ||||||
|  | 10. Implement Download Action (Task 10) | ||||||
|  | 11. Add Error Boundaries (Task 13) | ||||||
|  |  | ||||||
|  | ### Step 6: Polish (Do Last) | ||||||
|  |  | ||||||
|  | 12. Standardize JSDoc Comments (Task 11) | ||||||
|  | 13. Extract Magic Numbers to CSS (Task 12) | ||||||
|  | 14. Cache DOM Elements (Task 14) | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | ## Phase 5: Testing Checklist | ||||||
|  |  | ||||||
|  | ### Core Functionality | ||||||
|  |  | ||||||
|  | - [ ] File tree loads and displays correctly | ||||||
|  | - [ ] Files can be selected and opened | ||||||
|  | - [ ] Folders can be expanded/collapsed | ||||||
|  | - [ ] Editor loads file content | ||||||
|  | - [ ] Preview renders markdown correctly | ||||||
|  | - [ ] Save button saves files | ||||||
|  | - [ ] Delete button deletes files | ||||||
|  | - [ ] New button creates new files | ||||||
|  |  | ||||||
|  | ### Context Menu Actions | ||||||
|  |  | ||||||
|  | - [ ] Right-click shows context menu | ||||||
|  | - [ ] New file action works | ||||||
|  | - [ ] New folder action works | ||||||
|  | - [ ] Rename action works | ||||||
|  | - [ ] Delete action works | ||||||
|  | - [ ] Copy/Cut/Paste actions work | ||||||
|  | - [ ] Upload action works | ||||||
|  |  | ||||||
|  | ### Drag and Drop | ||||||
|  |  | ||||||
|  | - [ ] Long-press detection works | ||||||
|  | - [ ] Drag preview appears correctly | ||||||
|  | - [ ] Drop targets highlight properly | ||||||
|  | - [ ] Files can be moved | ||||||
|  | - [ ] Undo (Ctrl+Z) works | ||||||
|  |  | ||||||
|  | ### Modals | ||||||
|  |  | ||||||
|  | - [ ] Confirmation modals appear | ||||||
|  | - [ ] Prompt modals appear | ||||||
|  | - [ ] Modals don't double-open | ||||||
|  | - [ ] Enter/Escape keys work | ||||||
|  |  | ||||||
|  | ### UI Features | ||||||
|  |  | ||||||
|  | - [ ] Dark mode toggle works | ||||||
|  | - [ ] Collection selector works | ||||||
|  | - [ ] Column resizers work | ||||||
|  | - [ ] Notifications appear | ||||||
|  | - [ ] URL routing works | ||||||
|  | - [ ] View/Edit modes work | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | ## Recommendations | ||||||
|  |  | ||||||
|  | ### Immediate Actions (Before Production) | ||||||
|  |  | ||||||
|  | 1. Remove dead code (Task 1) | ||||||
|  | 2. Fix deprecated event handler (Task 6) | ||||||
|  | 3. Create config module (Task 4) | ||||||
|  |  | ||||||
|  | ### Short-term Actions (Next Sprint) | ||||||
|  |  | ||||||
|  | 4. Extract utilities (Task 3) | ||||||
|  | 5. Standardize notifications (Task 5) | ||||||
|  | 6. Create event bus (Task 2) | ||||||
|  |  | ||||||
|  | ### Medium-term Actions (Future Sprints) | ||||||
|  |  | ||||||
|  | 7. Refactor ui-utils.js (Task 7) | ||||||
|  | 8. Add logger (Task 9) | ||||||
|  | 9. Standardize exports (Task 8) | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | ## Success Metrics | ||||||
|  |  | ||||||
|  | **Before Refactoring:** | ||||||
|  |  | ||||||
|  | - Total Lines: ~3,587 | ||||||
|  | - Dead Code: 213 lines (6%) | ||||||
|  | - Duplicated Code: ~50 lines | ||||||
|  | - Hard-coded Values: 15+ | ||||||
|  |  | ||||||
|  | **After Refactoring:** | ||||||
|  |  | ||||||
|  | - Total Lines: ~3,400 (-5%) | ||||||
|  | - Dead Code: 0 lines | ||||||
|  | - Duplicated Code: 0 lines | ||||||
|  | - Hard-coded Values: 0 | ||||||
|  |  | ||||||
|  | **Estimated Effort:** 5-8 days | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | ## Conclusion | ||||||
|  |  | ||||||
|  | The UI codebase is generally well-structured. Main improvements needed: | ||||||
|  |  | ||||||
|  | 1. Remove dead code | ||||||
|  | 2. Extract duplicated utilities | ||||||
|  | 3. Centralize configuration | ||||||
|  | 4. Standardize patterns | ||||||
|  |  | ||||||
|  | Start with high-impact, low-risk changes first to ensure production readiness. | ||||||
							
								
								
									
										8
									
								
								server_debug.log
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,8 @@ | |||||||
|  | ============================================== | ||||||
|  | Markdown Editor v3.0 - WebDAV Server | ||||||
|  | ============================================== | ||||||
|  | Activating virtual environment... | ||||||
|  | Installing dependencies... | ||||||
|  | Audited 3 packages in 29ms | ||||||
|  | Checking for process on port 8004... | ||||||
|  | Starting WebDAV server... | ||||||
							
								
								
									
										164
									
								
								server_webdav.py
									
									
									
									
									
								
							
							
						
						| @@ -28,9 +28,17 @@ class MarkdownEditorApp: | |||||||
|      |      | ||||||
|     def load_config(self, config_path): |     def load_config(self, config_path): | ||||||
|         """Load configuration from YAML file""" |         """Load configuration from YAML file""" | ||||||
|  |         self.config_path = config_path | ||||||
|         with open(config_path, 'r') as f: |         with open(config_path, 'r') as f: | ||||||
|             return yaml.safe_load(f) |             return yaml.safe_load(f) | ||||||
|  |  | ||||||
|  |     def save_config(self): | ||||||
|  |         """Save configuration to YAML file""" | ||||||
|  |         # Update config with current collections | ||||||
|  |         self.config['collections'] = self.collections | ||||||
|  |         with open(self.config_path, 'w') as f: | ||||||
|  |             yaml.dump(self.config, f, default_flow_style=False, sort_keys=False) | ||||||
|  |      | ||||||
|     def setup_collections(self): |     def setup_collections(self): | ||||||
|         """Create collection directories if they don't exist""" |         """Create collection directories if they don't exist""" | ||||||
|         for name, config in self.collections.items(): |         for name, config in self.collections.items(): | ||||||
| @@ -92,13 +100,30 @@ class MarkdownEditorApp: | |||||||
|         if path == '/fs/' and method == 'GET': |         if path == '/fs/' and method == 'GET': | ||||||
|             return self.handle_collections_list(environ, start_response) |             return self.handle_collections_list(environ, start_response) | ||||||
|  |  | ||||||
|  |         # API to create new collection | ||||||
|  |         if path == '/fs/' and method == 'POST': | ||||||
|  |             return self.handle_create_collection(environ, start_response) | ||||||
|  |  | ||||||
|  |         # API to delete a collection | ||||||
|  |         if path.startswith('/api/collections/') and method == 'DELETE': | ||||||
|  |             return self.handle_delete_collection(environ, start_response) | ||||||
|  |  | ||||||
|  |         # Check if path starts with a collection name (for SPA routing) | ||||||
|  |         # This handles URLs like /notes/ttt or /documents/file.md | ||||||
|  |         # MUST be checked BEFORE WebDAV routing to prevent WebDAV from intercepting SPA routes | ||||||
|  |         path_parts = path.strip('/').split('/') | ||||||
|  |         if path_parts and path_parts[0] in self.collections: | ||||||
|  |             # This is a SPA route for a collection, serve index.html | ||||||
|  |             # The client-side router will handle the path | ||||||
|  |             return self.handle_index(environ, start_response) | ||||||
|  |  | ||||||
|         # All other /fs/ requests go to WebDAV |         # All other /fs/ requests go to WebDAV | ||||||
|         if path.startswith('/fs/'): |         if path.startswith('/fs/'): | ||||||
|             return self.webdav_app(environ, start_response) |             return self.webdav_app(environ, start_response) | ||||||
|  |  | ||||||
|         # Fallback for anything else (shouldn't happen with correct linking) |         # Fallback: Serve index.html for all other routes (SPA routing) | ||||||
|         start_response('404 Not Found', [('Content-Type', 'text/plain')]) |         # This allows client-side routing to handle any other paths | ||||||
|         return [b'Not Found'] |         return self.handle_index(environ, start_response) | ||||||
|      |      | ||||||
|     def handle_collections_list(self, environ, start_response): |     def handle_collections_list(self, environ, start_response): | ||||||
|         """Return list of available collections""" |         """Return list of available collections""" | ||||||
| @@ -113,6 +138,139 @@ class MarkdownEditorApp: | |||||||
|  |  | ||||||
|         return [response_body] |         return [response_body] | ||||||
|  |  | ||||||
|  |     def handle_create_collection(self, environ, start_response): | ||||||
|  |         """Create a new collection""" | ||||||
|  |         try: | ||||||
|  |             # Read request body | ||||||
|  |             content_length = int(environ.get('CONTENT_LENGTH', 0)) | ||||||
|  |             request_body = environ['wsgi.input'].read(content_length) | ||||||
|  |             data = json.loads(request_body.decode('utf-8')) | ||||||
|  |  | ||||||
|  |             collection_name = data.get('name') | ||||||
|  |             if not collection_name: | ||||||
|  |                 start_response('400 Bad Request', [('Content-Type', 'application/json')]) | ||||||
|  |                 return [json.dumps({'error': 'Collection name is required'}).encode('utf-8')] | ||||||
|  |  | ||||||
|  |             # Check if collection already exists | ||||||
|  |             if collection_name in self.collections: | ||||||
|  |                 start_response('409 Conflict', [('Content-Type', 'application/json')]) | ||||||
|  |                 return [json.dumps({'error': f'Collection "{collection_name}" already exists'}).encode('utf-8')] | ||||||
|  |  | ||||||
|  |             # Create collection directory | ||||||
|  |             collection_path = Path(f'./collections/{collection_name}') | ||||||
|  |             collection_path.mkdir(parents=True, exist_ok=True) | ||||||
|  |  | ||||||
|  |             # Create images subdirectory | ||||||
|  |             images_path = collection_path / 'images' | ||||||
|  |             images_path.mkdir(exist_ok=True) | ||||||
|  |  | ||||||
|  |             # Add to collections dict | ||||||
|  |             self.collections[collection_name] = { | ||||||
|  |                 'path': str(collection_path), | ||||||
|  |                 'description': f'User-created collection: {collection_name}' | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             # Update config file | ||||||
|  |             self.save_config() | ||||||
|  |  | ||||||
|  |             # Add to WebDAV provider mapping | ||||||
|  |             from wsgidav.fs_dav_provider import FilesystemProvider | ||||||
|  |             provider_path = os.path.abspath(str(collection_path)) | ||||||
|  |             provider_key = f'/fs/{collection_name}' | ||||||
|  |  | ||||||
|  |             # Use the add_provider method if available, otherwise add directly to provider_map | ||||||
|  |             provider = FilesystemProvider(provider_path) | ||||||
|  |             if hasattr(self.webdav_app, 'add_provider'): | ||||||
|  |                 self.webdav_app.add_provider(provider_key, provider) | ||||||
|  |                 print(f"Added provider using add_provider(): {provider_key}") | ||||||
|  |             else: | ||||||
|  |                 self.webdav_app.provider_map[provider_key] = provider | ||||||
|  |                 print(f"Added provider to provider_map: {provider_key}") | ||||||
|  |  | ||||||
|  |             # Also update sorted_share_list if it exists | ||||||
|  |             if hasattr(self.webdav_app, 'sorted_share_list'): | ||||||
|  |                 if provider_key not in self.webdav_app.sorted_share_list: | ||||||
|  |                     self.webdav_app.sorted_share_list.append(provider_key) | ||||||
|  |                     self.webdav_app.sorted_share_list.sort(reverse=True) | ||||||
|  |                     print(f"Updated sorted_share_list") | ||||||
|  |  | ||||||
|  |             print(f"Created collection '{collection_name}' at {provider_path}") | ||||||
|  |  | ||||||
|  |             response_body = json.dumps({'success': True, 'name': collection_name}).encode('utf-8') | ||||||
|  |             start_response('201 Created', [ | ||||||
|  |                 ('Content-Type', 'application/json'), | ||||||
|  |                 ('Content-Length', str(len(response_body))), | ||||||
|  |                 ('Access-Control-Allow-Origin', '*') | ||||||
|  |             ]) | ||||||
|  |  | ||||||
|  |             return [response_body] | ||||||
|  |  | ||||||
|  |         except Exception as e: | ||||||
|  |             print(f"Error creating collection: {e}") | ||||||
|  |             start_response('500 Internal Server Error', [('Content-Type', 'application/json')]) | ||||||
|  |             return [json.dumps({'error': str(e)}).encode('utf-8')] | ||||||
|  |  | ||||||
|  |     def handle_delete_collection(self, environ, start_response): | ||||||
|  |         """Delete a collection""" | ||||||
|  |         try: | ||||||
|  |             # Extract collection name from path: /api/collections/{name} | ||||||
|  |             path = environ.get('PATH_INFO', '') | ||||||
|  |             collection_name = path.split('/')[-1] | ||||||
|  |  | ||||||
|  |             if not collection_name: | ||||||
|  |                 start_response('400 Bad Request', [('Content-Type', 'application/json')]) | ||||||
|  |                 return [json.dumps({'error': 'Collection name is required'}).encode('utf-8')] | ||||||
|  |  | ||||||
|  |             # Check if collection exists | ||||||
|  |             if collection_name not in self.collections: | ||||||
|  |                 start_response('404 Not Found', [('Content-Type', 'application/json')]) | ||||||
|  |                 return [json.dumps({'error': f'Collection "{collection_name}" not found'}).encode('utf-8')] | ||||||
|  |  | ||||||
|  |             # Get collection path | ||||||
|  |             collection_config = self.collections[collection_name] | ||||||
|  |             collection_path = Path(collection_config['path']) | ||||||
|  |  | ||||||
|  |             # Delete the collection directory and all its contents | ||||||
|  |             import shutil | ||||||
|  |             if collection_path.exists(): | ||||||
|  |                 shutil.rmtree(collection_path) | ||||||
|  |                 print(f"Deleted collection directory: {collection_path}") | ||||||
|  |  | ||||||
|  |             # Remove from collections dict | ||||||
|  |             del self.collections[collection_name] | ||||||
|  |  | ||||||
|  |             # Update config file | ||||||
|  |             self.save_config() | ||||||
|  |  | ||||||
|  |             # Remove from WebDAV provider mapping | ||||||
|  |             provider_key = f'/fs/{collection_name}' | ||||||
|  |             if hasattr(self.webdav_app, 'provider_map') and provider_key in self.webdav_app.provider_map: | ||||||
|  |                 del self.webdav_app.provider_map[provider_key] | ||||||
|  |                 print(f"Removed provider from provider_map: {provider_key}") | ||||||
|  |  | ||||||
|  |             # Remove from sorted_share_list if it exists | ||||||
|  |             if hasattr(self.webdav_app, 'sorted_share_list') and provider_key in self.webdav_app.sorted_share_list: | ||||||
|  |                 self.webdav_app.sorted_share_list.remove(provider_key) | ||||||
|  |                 print(f"Removed from sorted_share_list: {provider_key}") | ||||||
|  |  | ||||||
|  |             print(f"Deleted collection '{collection_name}'") | ||||||
|  |  | ||||||
|  |             response_body = json.dumps({'success': True, 'name': collection_name}).encode('utf-8') | ||||||
|  |             start_response('200 OK', [ | ||||||
|  |                 ('Content-Type', 'application/json'), | ||||||
|  |                 ('Content-Length', str(len(response_body))), | ||||||
|  |                 ('Access-Control-Allow-Origin', '*') | ||||||
|  |             ]) | ||||||
|  |  | ||||||
|  |             return [response_body] | ||||||
|  |  | ||||||
|  |         except Exception as e: | ||||||
|  |             print(f"Error deleting collection: {e}") | ||||||
|  |             import traceback | ||||||
|  |             traceback.print_exc() | ||||||
|  |             start_response('500 Internal Server Error', [('Content-Type', 'application/json')]) | ||||||
|  |             return [json.dumps({'error': str(e)}).encode('utf-8')] | ||||||
|  |  | ||||||
|     def handle_static(self, environ, start_response): |     def handle_static(self, environ, start_response): | ||||||
|         """Serve static files""" |         """Serve static files""" | ||||||
|         path = environ.get('PATH_INFO', '')[1:]  # Remove leading / |         path = environ.get('PATH_INFO', '')[1:]  # Remove leading / | ||||||
|   | |||||||
| @@ -1,5 +1,5 @@ | |||||||
| // Markdown Editor Application with File Tree | // Markdown Editor Application with File Tree | ||||||
| (function() { | (function () { | ||||||
|     'use strict'; |     'use strict'; | ||||||
|  |  | ||||||
|     // State management |     // State management | ||||||
| @@ -24,7 +24,7 @@ | |||||||
|     function enableDarkMode() { |     function enableDarkMode() { | ||||||
|         isDarkMode = true; |         isDarkMode = true; | ||||||
|         document.body.classList.add('dark-mode'); |         document.body.classList.add('dark-mode'); | ||||||
|         document.getElementById('darkModeIcon').textContent = '☀️'; |         document.getElementById('darkModeIcon').innerHTML = '<i class="bi bi-sun-fill"></i>'; | ||||||
|         localStorage.setItem('darkMode', 'true'); |         localStorage.setItem('darkMode', 'true'); | ||||||
|  |  | ||||||
|         mermaid.initialize({ |         mermaid.initialize({ | ||||||
| @@ -41,7 +41,7 @@ | |||||||
|     function disableDarkMode() { |     function disableDarkMode() { | ||||||
|         isDarkMode = false; |         isDarkMode = false; | ||||||
|         document.body.classList.remove('dark-mode'); |         document.body.classList.remove('dark-mode'); | ||||||
|         document.getElementById('darkModeIcon').textContent = '🌙'; |         // document.getElementById('darkModeIcon').textContent = '🌙'; | ||||||
|         localStorage.setItem('darkMode', 'false'); |         localStorage.setItem('darkMode', 'false'); | ||||||
|  |  | ||||||
|         mermaid.initialize({ |         mermaid.initialize({ | ||||||
| @@ -189,8 +189,8 @@ | |||||||
|             lineWrapping: true, |             lineWrapping: true, | ||||||
|             autofocus: true, |             autofocus: true, | ||||||
|             extraKeys: { |             extraKeys: { | ||||||
|                 'Ctrl-S': function() { saveFile(); }, |                 'Ctrl-S': function () { saveFile(); }, | ||||||
|                 'Cmd-S': function() { saveFile(); } |                 'Cmd-S': function () { saveFile(); } | ||||||
|             } |             } | ||||||
|         }); |         }); | ||||||
|  |  | ||||||
| @@ -338,7 +338,6 @@ | |||||||
|         if (node.type === 'directory') { |         if (node.type === 'directory') { | ||||||
|             const toggle = document.createElement('span'); |             const toggle = document.createElement('span'); | ||||||
|             toggle.className = 'tree-node-toggle'; |             toggle.className = 'tree-node-toggle'; | ||||||
|             toggle.innerHTML = '▶'; |  | ||||||
|             toggle.addEventListener('click', (e) => { |             toggle.addEventListener('click', (e) => { | ||||||
|                 e.stopPropagation(); |                 e.stopPropagation(); | ||||||
|                 toggleNode(nodeDiv); |                 toggleNode(nodeDiv); | ||||||
|   | |||||||
| @@ -1,5 +1,5 @@ | |||||||
| // Markdown Editor Application | // Markdown Editor Application | ||||||
| (function() { | (function () { | ||||||
|     'use strict'; |     'use strict'; | ||||||
|  |  | ||||||
|     // State management |     // State management | ||||||
| @@ -21,7 +21,7 @@ | |||||||
|     function enableDarkMode() { |     function enableDarkMode() { | ||||||
|         isDarkMode = true; |         isDarkMode = true; | ||||||
|         document.body.classList.add('dark-mode'); |         document.body.classList.add('dark-mode'); | ||||||
|         document.getElementById('darkModeIcon').textContent = '☀️'; |         document.getElementById('darkModeIcon').innerHTML = '<i class="bi bi-sun-fill"></i>'; | ||||||
|         localStorage.setItem('darkMode', 'true'); |         localStorage.setItem('darkMode', 'true'); | ||||||
|  |  | ||||||
|         // Update mermaid theme |         // Update mermaid theme | ||||||
| @@ -40,7 +40,7 @@ | |||||||
|     function disableDarkMode() { |     function disableDarkMode() { | ||||||
|         isDarkMode = false; |         isDarkMode = false; | ||||||
|         document.body.classList.remove('dark-mode'); |         document.body.classList.remove('dark-mode'); | ||||||
|         document.getElementById('darkModeIcon').textContent = '🌙'; |         // document.getElementById('darkModeIcon').textContent = '🌙'; | ||||||
|         localStorage.setItem('darkMode', 'false'); |         localStorage.setItem('darkMode', 'false'); | ||||||
|  |  | ||||||
|         // Update mermaid theme |         // Update mermaid theme | ||||||
| @@ -198,8 +198,8 @@ | |||||||
|             lineWrapping: true, |             lineWrapping: true, | ||||||
|             autofocus: true, |             autofocus: true, | ||||||
|             extraKeys: { |             extraKeys: { | ||||||
|                 'Ctrl-S': function() { saveFile(); }, |                 'Ctrl-S': function () { saveFile(); }, | ||||||
|                 'Cmd-S': function() { saveFile(); } |                 'Cmd-S': function () { saveFile(); } | ||||||
|             } |             } | ||||||
|         }); |         }); | ||||||
|  |  | ||||||
|   | |||||||
| @@ -2,10 +2,21 @@ | |||||||
| .preview-pane { | .preview-pane { | ||||||
|     font-size: 16px; |     font-size: 16px; | ||||||
|     line-height: 1.6; |     line-height: 1.6; | ||||||
|  |     color: var(--text-primary); | ||||||
|  |     background-color: var(--bg-primary); | ||||||
| } | } | ||||||
|  |  | ||||||
| .preview-pane h1, .preview-pane h2, .preview-pane h3, | #preview { | ||||||
| .preview-pane h4, .preview-pane h5, .preview-pane h6 { |     color: var(--text-primary); | ||||||
|  |     background-color: var(--bg-primary); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .preview-pane h1, | ||||||
|  | .preview-pane h2, | ||||||
|  | .preview-pane h3, | ||||||
|  | .preview-pane h4, | ||||||
|  | .preview-pane h5, | ||||||
|  | .preview-pane h6 { | ||||||
|     margin-top: 24px; |     margin-top: 24px; | ||||||
|     margin-bottom: 16px; |     margin-bottom: 16px; | ||||||
|     font-weight: 600; |     font-weight: 600; | ||||||
| @@ -132,11 +143,21 @@ body.dark-mode .context-menu { | |||||||
|     animation: slideIn 0.3s ease; |     animation: slideIn 0.3s ease; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | /* Override Bootstrap warning background to be darker for better text contrast */ | ||||||
|  | .toast.bg-warning { | ||||||
|  |     background-color: #cc9a06 !important; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | body.dark-mode .toast.bg-warning { | ||||||
|  |     background-color: #b8860b !important; | ||||||
|  | } | ||||||
|  |  | ||||||
| @keyframes slideIn { | @keyframes slideIn { | ||||||
|     from { |     from { | ||||||
|         transform: translateX(400px); |         transform: translateX(400px); | ||||||
|         opacity: 0; |         opacity: 0; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     to { |     to { | ||||||
|         transform: translateX(0); |         transform: translateX(0); | ||||||
|         opacity: 1; |         opacity: 1; | ||||||
| @@ -152,6 +173,7 @@ body.dark-mode .context-menu { | |||||||
|         transform: translateX(0); |         transform: translateX(0); | ||||||
|         opacity: 1; |         opacity: 1; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     to { |     to { | ||||||
|         transform: translateX(400px); |         transform: translateX(400px); | ||||||
|         opacity: 0; |         opacity: 0; | ||||||
| @@ -206,3 +228,226 @@ body.dark-mode .modal-footer { | |||||||
|     border-color: var(--link-color); |     border-color: var(--link-color); | ||||||
|     box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25); |     box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25); | ||||||
| } | } | ||||||
|  |  | ||||||
|  | /* Directory Preview Styles */ | ||||||
|  | .directory-preview { | ||||||
|  |     padding: 20px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .directory-preview h2 { | ||||||
|  |     margin-bottom: 20px; | ||||||
|  |     /* color: var(--text-primary); */ | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .directory-files { | ||||||
|  |     display: grid; | ||||||
|  |     grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); | ||||||
|  |     gap: 16px; | ||||||
|  |     margin-top: 20px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .file-card { | ||||||
|  |     background-color: var(--bg-tertiary); | ||||||
|  |     border: 1px solid var(--border-color); | ||||||
|  |     border-radius: 8px; | ||||||
|  |     padding: 16px; | ||||||
|  |     cursor: pointer; | ||||||
|  |     transition: all 0.2s ease; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .file-card:hover { | ||||||
|  |     background-color: var(--bg-secondary); | ||||||
|  |     border-color: var(--link-color); | ||||||
|  |     transform: translateY(-2px); | ||||||
|  |     box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .file-card-header { | ||||||
|  |     display: flex; | ||||||
|  |     align-items: center; | ||||||
|  |     gap: 8px; | ||||||
|  |     margin-bottom: 8px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .file-card-header i { | ||||||
|  |     color: var(--link-color); | ||||||
|  |     font-size: 18px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .file-card-name { | ||||||
|  |     font-weight: 500; | ||||||
|  |     color: var(--text-primary); | ||||||
|  |     word-break: break-word; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .file-card-description { | ||||||
|  |     font-size: 13px; | ||||||
|  |     color: var(--text-secondary); | ||||||
|  |     line-height: 1.4; | ||||||
|  |     margin-top: 8px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* Flat Button Styles */ | ||||||
|  | .btn-flat { | ||||||
|  |     border: none; | ||||||
|  |     border-radius: 0; | ||||||
|  |     padding: 6px 12px; | ||||||
|  |     font-size: 14px; | ||||||
|  |     font-weight: 500; | ||||||
|  |     transition: all 0.2s ease; | ||||||
|  |     background-color: transparent; | ||||||
|  |     color: var(--text-primary); | ||||||
|  |     position: relative; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .btn-flat:hover { | ||||||
|  |     background-color: var(--bg-tertiary); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .btn-flat:active { | ||||||
|  |     transform: scale(0.95); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* Flat button variants */ | ||||||
|  | .btn-flat-primary { | ||||||
|  |     color: #0d6efd; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .btn-flat-primary:hover { | ||||||
|  |     background-color: rgba(13, 110, 253, 0.1); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .btn-flat-success { | ||||||
|  |     color: #198754; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .btn-flat-success:hover { | ||||||
|  |     background-color: rgba(25, 135, 84, 0.1); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .btn-flat-danger { | ||||||
|  |     color: #dc3545; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .btn-flat-danger:hover { | ||||||
|  |     background-color: rgba(220, 53, 69, 0.1); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .btn-flat-warning { | ||||||
|  |     color: #ffc107; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .btn-flat-warning:hover { | ||||||
|  |     background-color: rgba(255, 193, 7, 0.1); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .btn-flat-secondary { | ||||||
|  |     color: var(--text-secondary); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .btn-flat-secondary:hover { | ||||||
|  |     background-color: var(--bg-tertiary); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* Dark mode adjustments */ | ||||||
|  | body.dark-mode .btn-flat-primary { | ||||||
|  |     color: #6ea8fe; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | body.dark-mode .btn-flat-success { | ||||||
|  |     color: #75b798; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | body.dark-mode .btn-flat-danger { | ||||||
|  |     color: #ea868f; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | body.dark-mode .btn-flat-warning { | ||||||
|  |     color: #ffda6a; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* Dark Mode Button Icon Styles */ | ||||||
|  | #darkModeBtn i { | ||||||
|  |     font-size: 16px; | ||||||
|  |     color: inherit; | ||||||
|  |     /* Inherit color from parent button */ | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* Light mode: moon icon */ | ||||||
|  | body:not(.dark-mode) #darkModeBtn i { | ||||||
|  |     color: var(--text-secondary); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* Dark mode: sun icon */ | ||||||
|  | body.dark-mode #darkModeBtn i { | ||||||
|  |     color: #ffc107; | ||||||
|  |     /* Warm sun color */ | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* Hover effects */ | ||||||
|  | #darkModeBtn:hover i { | ||||||
|  |     color: inherit; | ||||||
|  |     /* Inherit hover color from parent */ | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* =================================== | ||||||
|  |    Loading Spinner Component | ||||||
|  |    =================================== */ | ||||||
|  |  | ||||||
|  | /* Loading overlay - covers the target container */ | ||||||
|  | .loading-overlay { | ||||||
|  |     position: absolute; | ||||||
|  |     top: 0; | ||||||
|  |     left: 0; | ||||||
|  |     right: 0; | ||||||
|  |     bottom: 0; | ||||||
|  |     background: var(--bg-primary); | ||||||
|  |     opacity: 0.95; | ||||||
|  |     display: flex; | ||||||
|  |     align-items: center; | ||||||
|  |     justify-content: center; | ||||||
|  |     z-index: 1000; | ||||||
|  |     transition: opacity 0.2s ease; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* Loading spinner */ | ||||||
|  | .loading-spinner { | ||||||
|  |     width: 48px; | ||||||
|  |     height: 48px; | ||||||
|  |     border: 4px solid var(--border-color); | ||||||
|  |     border-top-color: var(--primary-color); | ||||||
|  |     border-radius: 50%; | ||||||
|  |     animation: spin 0.8s linear infinite; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* Spinner animation */ | ||||||
|  | @keyframes spin { | ||||||
|  |     to { | ||||||
|  |         transform: rotate(360deg); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* Loading text */ | ||||||
|  | .loading-text { | ||||||
|  |     margin-top: 16px; | ||||||
|  |     color: var(--text-secondary); | ||||||
|  |     font-size: 14px; | ||||||
|  |     text-align: center; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* Loading container with spinner and text */ | ||||||
|  | .loading-content { | ||||||
|  |     display: flex; | ||||||
|  |     flex-direction: column; | ||||||
|  |     align-items: center; | ||||||
|  |     gap: 12px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* Hide loading overlay by default */ | ||||||
|  | .loading-overlay.hidden { | ||||||
|  |     display: none; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .language-bash { | ||||||
|  |     color: var(--text-primary) !important; | ||||||
|  | } | ||||||
| @@ -6,6 +6,8 @@ | |||||||
|     display: flex; |     display: flex; | ||||||
|     gap: 10px; |     gap: 10px; | ||||||
|     align-items: center; |     align-items: center; | ||||||
|  |     flex-shrink: 0; | ||||||
|  |     /* Prevent header from shrinking */ | ||||||
| } | } | ||||||
|  |  | ||||||
| .editor-header input { | .editor-header input { | ||||||
| @@ -19,18 +21,42 @@ | |||||||
|  |  | ||||||
| .editor-container { | .editor-container { | ||||||
|     flex: 1; |     flex: 1; | ||||||
|  |     /* Take remaining space */ | ||||||
|     overflow: hidden; |     overflow: hidden; | ||||||
|  |     /* Prevent container overflow, CodeMirror handles its own scrolling */ | ||||||
|  |     display: flex; | ||||||
|  |     flex-direction: column; | ||||||
|  |     min-height: 0; | ||||||
|  |     /* Important: allows flex child to shrink below content size */ | ||||||
|  |     position: relative; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #editor { | ||||||
|  |     flex: 1; | ||||||
|  |     /* Take all available space */ | ||||||
|  |     min-height: 0; | ||||||
|  |     /* Allow shrinking */ | ||||||
|  |     overflow: hidden; | ||||||
|  |     /* CodeMirror will handle scrolling */ | ||||||
| } | } | ||||||
|  |  | ||||||
| /* CodeMirror customization */ | /* CodeMirror customization */ | ||||||
| .CodeMirror { | .CodeMirror { | ||||||
|     height: 100%; |     height: 100% !important; | ||||||
|  |     /* Force full height */ | ||||||
|     font-family: 'Consolas', 'Monaco', 'Courier New', monospace; |     font-family: 'Consolas', 'Monaco', 'Courier New', monospace; | ||||||
|     font-size: 14px; |     font-size: 14px; | ||||||
|     background-color: var(--bg-primary); |     background-color: var(--bg-primary); | ||||||
|     color: var(--text-primary); |     color: var(--text-primary); | ||||||
| } | } | ||||||
|  |  | ||||||
|  | .CodeMirror-scroll { | ||||||
|  |     overflow-y: auto !important; | ||||||
|  |     /* Ensure vertical scrolling is enabled */ | ||||||
|  |     overflow-x: auto !important; | ||||||
|  |     /* Ensure horizontal scrolling is enabled */ | ||||||
|  | } | ||||||
|  |  | ||||||
| body.dark-mode .CodeMirror { | body.dark-mode .CodeMirror { | ||||||
|     background-color: #1c2128; |     background-color: #1c2128; | ||||||
|     color: #e6edf3; |     color: #e6edf3; | ||||||
| @@ -72,4 +98,3 @@ body.dark-mode .CodeMirror-gutters { | |||||||
|     pointer-events: none; |     pointer-events: none; | ||||||
|     z-index: 1000; |     z-index: 1000; | ||||||
| } | } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -20,8 +20,9 @@ | |||||||
|     color: var(--text-primary); |     color: var(--text-primary); | ||||||
|     transition: all 0.15s ease; |     transition: all 0.15s ease; | ||||||
|     white-space: nowrap; |     white-space: nowrap; | ||||||
|     overflow: hidden; |     overflow: visible; | ||||||
|     text-overflow: ellipsis; |     text-overflow: ellipsis; | ||||||
|  |     min-height: 28px; | ||||||
| } | } | ||||||
|  |  | ||||||
| .tree-node:hover { | .tree-node:hover { | ||||||
| @@ -29,14 +30,16 @@ | |||||||
| } | } | ||||||
|  |  | ||||||
| .tree-node.active { | .tree-node.active { | ||||||
|     background-color: var(--link-color); |     color: var(--link-color); | ||||||
|     color: white; |  | ||||||
|     font-weight: 500; |     font-weight: 500; | ||||||
| } | } | ||||||
|  |  | ||||||
| .tree-node.active:hover { | .tree-node.active:hover { | ||||||
|     background-color: var(--link-color); |     filter: brightness(1.2); | ||||||
|     filter: brightness(1.1); | } | ||||||
|  |  | ||||||
|  | .tree-node.active .tree-node-icon { | ||||||
|  |     color: var(--link-color); | ||||||
| } | } | ||||||
|  |  | ||||||
| /* Toggle arrow */ | /* Toggle arrow */ | ||||||
| @@ -46,16 +49,25 @@ | |||||||
|     justify-content: center; |     justify-content: center; | ||||||
|     width: 16px; |     width: 16px; | ||||||
|     height: 16px; |     height: 16px; | ||||||
|     font-size: 10px; |     min-width: 16px; | ||||||
|  |     min-height: 16px; | ||||||
|     color: var(--text-secondary); |     color: var(--text-secondary); | ||||||
|     flex-shrink: 0; |     flex-shrink: 0; | ||||||
|     transition: transform 0.2s ease; |     transition: transform 0.2s ease; | ||||||
|  |     position: relative; | ||||||
|  |     z-index: 1; | ||||||
|  |     overflow: visible; | ||||||
|  |     cursor: pointer; | ||||||
| } | } | ||||||
|  |  | ||||||
| .tree-node-toggle.expanded { | .tree-node-toggle.expanded { | ||||||
|     transform: rotate(90deg); |     transform: rotate(90deg); | ||||||
| } | } | ||||||
|  |  | ||||||
|  | .tree-node-toggle:hover { | ||||||
|  |     color: var(--link-color); | ||||||
|  | } | ||||||
|  |  | ||||||
| /* Icon styling */ | /* Icon styling */ | ||||||
| .tree-node-icon { | .tree-node-icon { | ||||||
|     width: 16px; |     width: 16px; | ||||||
| @@ -67,10 +79,6 @@ | |||||||
|     color: var(--text-secondary); |     color: var(--text-secondary); | ||||||
| } | } | ||||||
|  |  | ||||||
| .tree-node.active .tree-node-icon { |  | ||||||
|     color: white; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /* Content wrapper */ | /* Content wrapper */ | ||||||
| .tree-node-content { | .tree-node-content { | ||||||
|     display: flex; |     display: flex; | ||||||
| @@ -112,13 +120,54 @@ | |||||||
| } | } | ||||||
|  |  | ||||||
| /* Drag and drop */ | /* Drag and drop */ | ||||||
|  | /* Default cursor is pointer, not grab (only show grab after long-press) */ | ||||||
|  | .tree-node { | ||||||
|  |     cursor: pointer; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* Show grab cursor only when drag is ready (after long-press) */ | ||||||
|  | .tree-node.drag-ready { | ||||||
|  |     cursor: grab !important; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .tree-node.drag-ready:active { | ||||||
|  |     cursor: grabbing !important; | ||||||
|  | } | ||||||
|  |  | ||||||
| .tree-node.dragging { | .tree-node.dragging { | ||||||
|     opacity: 0.5; |     opacity: 0.4; | ||||||
|  |     background-color: var(--bg-tertiary); | ||||||
|  |     cursor: grabbing !important; | ||||||
| } | } | ||||||
|  |  | ||||||
| .tree-node.drag-over { | .tree-node.drag-over { | ||||||
|     background-color: rgba(13, 110, 253, 0.2); |     background-color: rgba(13, 110, 253, 0.15) !important; | ||||||
|     border: 1px dashed var(--link-color); |     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 */ | /* Collection selector - Bootstrap styled */ | ||||||
| @@ -156,13 +205,34 @@ body.dark-mode .tree-node:hover { | |||||||
| } | } | ||||||
|  |  | ||||||
| body.dark-mode .tree-node.active { | 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 { | body.dark-mode .tree-children { | ||||||
|     border-left-color: var(--border-color); |     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 */ | /* Scrollbar in sidebar */ | ||||||
| .sidebar::-webkit-scrollbar-thumb { | .sidebar::-webkit-scrollbar-thumb { | ||||||
|     background-color: var(--border-color); |     background-color: var(--border-color); | ||||||
| @@ -171,3 +241,13 @@ body.dark-mode .tree-children { | |||||||
| .sidebar::-webkit-scrollbar-thumb:hover { | .sidebar::-webkit-scrollbar-thumb:hover { | ||||||
|     background-color: var(--text-secondary); |     background-color: var(--text-secondary); | ||||||
| } | } | ||||||
|  |  | ||||||
|  | .new-collection-btn { | ||||||
|  |     padding: 0.375rem 0.75rem; | ||||||
|  |     font-size: 1rem; | ||||||
|  |     border-radius: 0.25rem; | ||||||
|  |     transition: all 0.2s ease; | ||||||
|  |     color: var(--text-primary); | ||||||
|  |     border: 1px solid var(--border-color); | ||||||
|  |     background-color: transparent; | ||||||
|  | } | ||||||
| @@ -1,14 +1,22 @@ | |||||||
| /* Base layout styles */ | /* Base layout styles */ | ||||||
| html, body { | html, | ||||||
|     height: 100%; | body { | ||||||
|  |     height: 100vh; | ||||||
|     margin: 0; |     margin: 0; | ||||||
|     padding: 0; |     padding: 0; | ||||||
|  |     overflow: hidden; | ||||||
|  |     /* Prevent page-level scrolling */ | ||||||
|     font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif; |     font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif; | ||||||
|     background-color: var(--bg-primary); |     background-color: var(--bg-primary); | ||||||
|     color: var(--text-primary); |     color: var(--text-primary); | ||||||
|     transition: background-color 0.3s ease, color 0.3s ease; |     transition: background-color 0.3s ease, color 0.3s ease; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | body { | ||||||
|  |     display: flex; | ||||||
|  |     flex-direction: column; | ||||||
|  | } | ||||||
|  |  | ||||||
| /* Column Resizer */ | /* Column Resizer */ | ||||||
| .column-resizer { | .column-resizer { | ||||||
|     width: 1px; |     width: 1px; | ||||||
| @@ -17,14 +25,21 @@ html, body { | |||||||
|     transition: background-color 0.2s ease, width 0.2s ease, box-shadow 0.2s ease; |     transition: background-color 0.2s ease, width 0.2s ease, box-shadow 0.2s ease; | ||||||
|     user-select: none; |     user-select: none; | ||||||
|     flex-shrink: 0; |     flex-shrink: 0; | ||||||
|     padding: 0 3px;  /* Add invisible padding for easier grab */ |     padding: 0 3px; | ||||||
|     margin: 0 -3px;  /* Compensate for padding */ |     /* 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 { | .column-resizer:hover { | ||||||
|     background-color: var(--link-color); |     background-color: var(--link-color); | ||||||
|     width: 1px; |     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 { | .column-resizer.dragging { | ||||||
| @@ -36,12 +51,59 @@ html, body { | |||||||
|     background-color: var(--link-color); |     background-color: var(--link-color); | ||||||
| } | } | ||||||
|  |  | ||||||
| /* Adjust container for flex layout */ | /* Navbar */ | ||||||
| .container-fluid { | .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; |     display: flex; | ||||||
|     flex-direction: row; |     flex-direction: row; | ||||||
|     height: calc(100% - 56px); |     align-items: center; | ||||||
|  |     justify-content: space-between; | ||||||
|     padding: 0; |     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 { | .row { | ||||||
| @@ -50,13 +112,75 @@ html, body { | |||||||
|     flex-direction: row; |     flex-direction: row; | ||||||
|     margin: 0; |     margin: 0; | ||||||
|     height: 100%; |     height: 100%; | ||||||
|  |     overflow: hidden; | ||||||
|  |     /* Prevent row scrolling */ | ||||||
| } | } | ||||||
|  |  | ||||||
| #sidebarPane { | #sidebarPane { | ||||||
|     flex: 0 0 20%; |     flex: 0 0 20%; | ||||||
|     min-width: 150px; |     min-width: 150px; | ||||||
|     max-width: 40%; |     max-width: 20%; | ||||||
|     padding: 0; |     padding: 0; | ||||||
|  |     height: 100%; | ||||||
|  |     overflow: hidden; | ||||||
|  |     /* Prevent pane scrolling */ | ||||||
|  |     transition: flex 0.3s ease, min-width 0.3s ease, max-width 0.3s ease; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* Collapsed sidebar state - mini sidebar */ | ||||||
|  | #sidebarPane.collapsed { | ||||||
|  |     flex: 0 0 50px; | ||||||
|  |     min-width: 50px; | ||||||
|  |     max-width: 50px; | ||||||
|  |     border-right: 1px solid var(--border-color); | ||||||
|  |     position: relative; | ||||||
|  |     cursor: pointer; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* Hide file tree content when collapsed */ | ||||||
|  | #sidebarPane.collapsed #fileTree { | ||||||
|  |     display: none; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* Hide collection selector when collapsed */ | ||||||
|  | #sidebarPane.collapsed .collection-selector { | ||||||
|  |     display: none; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* Visual indicator in the mini sidebar */ | ||||||
|  | #sidebarPane.collapsed::before { | ||||||
|  |     content: ''; | ||||||
|  |     display: block; | ||||||
|  |     width: 100%; | ||||||
|  |     height: 100%; | ||||||
|  |     background: var(--bg-secondary); | ||||||
|  |     transition: background 0.2s ease; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* Hover effect on mini sidebar */ | ||||||
|  | #sidebarPane.collapsed:hover::before { | ||||||
|  |     background: var(--hover-bg); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* Right arrow icon in the center of mini sidebar */ | ||||||
|  | #sidebarPane.collapsed::after { | ||||||
|  |     content: '\F285'; | ||||||
|  |     /* Bootstrap icon chevron-right */ | ||||||
|  |     font-family: 'bootstrap-icons'; | ||||||
|  |     position: absolute; | ||||||
|  |     top: 50%; | ||||||
|  |     left: 50%; | ||||||
|  |     transform: translate(-50%, -50%); | ||||||
|  |     font-size: 20px; | ||||||
|  |     color: var(--text-secondary); | ||||||
|  |     pointer-events: none; | ||||||
|  |     opacity: 0.5; | ||||||
|  |     transition: opacity 0.2s ease; | ||||||
|  |     cursor: pointer; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #sidebarPane.collapsed:hover::after { | ||||||
|  |     opacity: 1; | ||||||
| } | } | ||||||
|  |  | ||||||
| #editorPane { | #editorPane { | ||||||
| @@ -64,25 +188,23 @@ html, body { | |||||||
|     min-width: 250px; |     min-width: 250px; | ||||||
|     max-width: 70%; |     max-width: 70%; | ||||||
|     padding: 0; |     padding: 0; | ||||||
| } |     display: flex; | ||||||
|  |     flex-direction: column; | ||||||
| #previewPane { |     height: 100%; | ||||||
|     flex: 1 1 40%; |     overflow: hidden; | ||||||
|     min-width: 250px; |     /* Prevent pane scrolling */ | ||||||
|     max-width: 70%; |  | ||||||
|     padding: 0; |  | ||||||
| } | } | ||||||
|  |  | ||||||
| /* Sidebar - improved */ | /* Sidebar - improved */ | ||||||
| .sidebar { | .sidebar { | ||||||
|     background-color: var(--bg-secondary); |     background-color: var(--bg-secondary); | ||||||
|     border-right: 1px solid var(--border-color); |     border-right: 1px solid var(--border-color); | ||||||
|     overflow-y: auto; |  | ||||||
|     overflow-x: hidden; |  | ||||||
|     height: 100%; |     height: 100%; | ||||||
|     transition: background-color 0.3s ease; |     transition: background-color 0.3s ease; | ||||||
|     display: flex; |     display: flex; | ||||||
|     flex-direction: column; |     flex-direction: column; | ||||||
|  |     overflow: hidden; | ||||||
|  |     /* Prevent sidebar container scrolling */ | ||||||
| } | } | ||||||
|  |  | ||||||
| .sidebar h6 { | .sidebar h6 { | ||||||
| @@ -92,25 +214,27 @@ html, body { | |||||||
|     color: var(--text-secondary); |     color: var(--text-secondary); | ||||||
|     text-transform: uppercase; |     text-transform: uppercase; | ||||||
|     letter-spacing: 0.5px; |     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 { | #fileTree { | ||||||
|     flex: 1; |     flex: 1; | ||||||
|  |     /* Take remaining space */ | ||||||
|     overflow-y: auto; |     overflow-y: auto; | ||||||
|  |     /* Enable vertical scrolling */ | ||||||
|     overflow-x: hidden; |     overflow-x: hidden; | ||||||
|     padding: 4px 0; |     padding: 4px 10px; | ||||||
| } |     min-height: 0; | ||||||
|  |     /* Important: allows flex child to shrink below content size */ | ||||||
| /* 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; |  | ||||||
| } | } | ||||||
|  |  | ||||||
| /* Scrollbar styling */ | /* Scrollbar styling */ | ||||||
| @@ -135,28 +259,86 @@ html, body { | |||||||
|  |  | ||||||
| /* Preview Pane Styling */ | /* Preview Pane Styling */ | ||||||
| #previewPane { | #previewPane { | ||||||
|     flex: 1 1 40%; |  | ||||||
|     min-width: 250px; |     min-width: 250px; | ||||||
|     max-width: 70%; |     max-width: 70%; | ||||||
|     padding: 0; |     padding: 0; | ||||||
|     overflow-y: auto; |  | ||||||
|     overflow-x: hidden; |  | ||||||
|     background-color: var(--bg-primary); |     background-color: var(--bg-primary); | ||||||
|     border-left: 1px solid var(--border-color); |     border-left: 1px solid var(--border-color); | ||||||
|  |     flex: 1; | ||||||
|  |     height: 100%; | ||||||
|  |     overflow-y: auto; | ||||||
|  |     /* Enable vertical scrolling for preview pane */ | ||||||
|  |     overflow-x: hidden; | ||||||
| } | } | ||||||
|  |  | ||||||
| #preview { | #preview { | ||||||
|     padding: 20px; |     padding: 20px; | ||||||
|     min-height: 100%; |  | ||||||
|     overflow-wrap: break-word; |     overflow-wrap: break-word; | ||||||
|     word-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; |     margin-top: 0; | ||||||
| } | } | ||||||
|  |  | ||||||
| #preview > h1:first-child, | #preview>h1:first-child, | ||||||
| #preview > h2:first-child { | #preview>h2:first-child { | ||||||
|     margin-top: 0; |     margin-top: 0; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | /* Iframe styles in preview - minimal defaults that can be overridden */ | ||||||
|  | #preview iframe { | ||||||
|  |     border: none; | ||||||
|  |     /* Default to no border, can be overridden by inline styles */ | ||||||
|  |     display: block; | ||||||
|  |     /* Prevent inline spacing issues */ | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* View Mode Styles */ | ||||||
|  | body.view-mode #editorPane { | ||||||
|  |     display: none; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | body.view-mode #resizer1 { | ||||||
|  |     display: none; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | body.view-mode #resizer2 { | ||||||
|  |     display: none; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | body.view-mode #previewPane { | ||||||
|  |     max-width: 100%; | ||||||
|  |     min-width: auto; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | body.view-mode #sidebarPane { | ||||||
|  |     display: flex; | ||||||
|  |     flex: 0 0 20%; | ||||||
|  |     height: 100%; | ||||||
|  |     /* Keep sidebar at 20% width in view mode */ | ||||||
|  | } | ||||||
|  |  | ||||||
|  | body.edit-mode #editorPane { | ||||||
|  |     display: flex; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | body.edit-mode #resizer1 { | ||||||
|  |     display: block; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | body.edit-mode #resizer2 { | ||||||
|  |     display: block; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | body.edit-mode #previewPane { | ||||||
|  |     max-width: 70%; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | body.edit-mode #sidebarPane { | ||||||
|  |     display: flex; | ||||||
|  |     height: 100%; | ||||||
|  | } | ||||||
							
								
								
									
										589
									
								
								static/js/app.js
									
									
									
									
									
								
							
							
						
						| @@ -12,25 +12,262 @@ let collectionSelector; | |||||||
| let clipboard = null; | let clipboard = null; | ||||||
| let currentFilePath = null; | let currentFilePath = null; | ||||||
|  |  | ||||||
| // Simple event bus | // Event bus is now loaded from event-bus.js module | ||||||
| const eventBus = { | // No need to define it here - it's available as window.eventBus | ||||||
|     listeners: {}, |  | ||||||
|     on(event, callback) { | /** | ||||||
|         if (!this.listeners[event]) { |  * Auto-load page in view mode | ||||||
|             this.listeners[event] = []; |  * 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); |  | ||||||
|     }, |         // If we found a page to load, load it | ||||||
|     dispatch(event, data) { |         if (pageToLoad) { | ||||||
|         if (this.listeners[event]) { |             // Use fileTree.onFileSelect to handle both text and binary files | ||||||
|             this.listeners[event].forEach(callback => callback(data)); |             if (fileTree.onFileSelect) { | ||||||
|  |                 fileTree.onFileSelect({ path: pageToLoad, isDirectory: false }); | ||||||
|  |             } else { | ||||||
|  |                 // Fallback to direct loading (for text files only) | ||||||
|  |                 await editor.loadFile(pageToLoad); | ||||||
|  |                 fileTree.selectAndExpandPath(pageToLoad); | ||||||
|  |             } | ||||||
|  |         } else { | ||||||
|  |             // No files found, show empty state message | ||||||
|  |             editor.previewElement.innerHTML = ` | ||||||
|  |                 <div class="text-muted text-center mt-5"> | ||||||
|  |                     <p>No content available</p> | ||||||
|  |                 </div> | ||||||
|  |             `; | ||||||
|  |         } | ||||||
|  |     } catch (error) { | ||||||
|  |         console.error('Failed to auto-load page in view mode:', error); | ||||||
|  |         editor.previewElement.innerHTML = ` | ||||||
|  |             <div class="alert alert-danger"> | ||||||
|  |                 <p>Failed to load content</p> | ||||||
|  |             </div> | ||||||
|  |         `; | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Show directory preview with list of files | ||||||
|  |  * @param {string} dirPath - The directory path | ||||||
|  |  */ | ||||||
|  | async function showDirectoryPreview(dirPath) { | ||||||
|  |     if (!editor || !fileTree || !webdavClient) return; | ||||||
|  |  | ||||||
|  |     try { | ||||||
|  |         const dirName = dirPath.split('/').pop() || dirPath; | ||||||
|  |         const files = fileTree.getDirectoryFiles(dirPath); | ||||||
|  |  | ||||||
|  |         // Start building the preview HTML | ||||||
|  |         let html = `<div class="directory-preview">`; | ||||||
|  |         html += `<h2>${dirName}</h2>`; | ||||||
|  |  | ||||||
|  |         if (files.length === 0) { | ||||||
|  |             html += `<p>This directory is empty</p>`; | ||||||
|  |         } else { | ||||||
|  |             html += `<div class="directory-files">`; | ||||||
|  |  | ||||||
|  |             // Create cards for each file | ||||||
|  |             for (const file of files) { | ||||||
|  |                 const fileName = file.name; | ||||||
|  |                 let fileDescription = ''; | ||||||
|  |  | ||||||
|  |                 // Try to get file description from markdown files | ||||||
|  |                 if (file.name.endsWith('.md')) { | ||||||
|  |                     try { | ||||||
|  |                         const content = await webdavClient.get(file.path); | ||||||
|  |                         // Extract first heading or first line as description | ||||||
|  |                         const lines = content.split('\n'); | ||||||
|  |                         for (const line of lines) { | ||||||
|  |                             if (line.trim().startsWith('#')) { | ||||||
|  |                                 fileDescription = line.replace(/^#+\s*/, '').trim(); | ||||||
|  |                                 break; | ||||||
|  |                             } else if (line.trim() && !line.startsWith('---')) { | ||||||
|  |                                 fileDescription = line.trim().substring(0, 100); | ||||||
|  |                                 break; | ||||||
|                             } |                             } | ||||||
|                         } |                         } | ||||||
| }; |                     } catch (error) { | ||||||
| window.eventBus = eventBus; |                         console.error('Failed to read file description:', error); | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 html += ` | ||||||
|  |                     <div class="file-card" data-path="${file.path}"> | ||||||
|  |                         <div class="file-card-header"> | ||||||
|  |                             <i class="bi bi-file-earmark-text"></i> | ||||||
|  |                             <span class="file-card-name">${fileName}</span> | ||||||
|  |                         </div> | ||||||
|  |                         ${fileDescription ? `<div class="file-card-description">${fileDescription}</div>` : ''} | ||||||
|  |                     </div> | ||||||
|  |                 `; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             html += `</div>`; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         html += `</div>`; | ||||||
|  |  | ||||||
|  |         // Set the preview content | ||||||
|  |         editor.previewElement.innerHTML = html; | ||||||
|  |  | ||||||
|  |         // Add click handlers to file cards | ||||||
|  |         editor.previewElement.querySelectorAll('.file-card').forEach(card => { | ||||||
|  |             card.addEventListener('click', async () => { | ||||||
|  |                 const filePath = card.dataset.path; | ||||||
|  |                 await editor.loadFile(filePath); | ||||||
|  |                 fileTree.selectAndExpandPath(filePath); | ||||||
|  |             }); | ||||||
|  |         }); | ||||||
|  |     } catch (error) { | ||||||
|  |         console.error('Failed to show directory preview:', error); | ||||||
|  |         editor.previewElement.innerHTML = ` | ||||||
|  |             <div class="alert alert-danger"> | ||||||
|  |                 <p>Failed to load directory preview</p> | ||||||
|  |             </div> | ||||||
|  |         `; | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Parse URL to extract collection and file path | ||||||
|  |  * URL format: /<collection>/<file_path> or /<collection>/<dir>/<file> | ||||||
|  |  * @returns {Object} {collection, filePath} or {collection, null} if only collection | ||||||
|  |  */ | ||||||
|  | function parseURLPath() { | ||||||
|  |     const pathname = window.location.pathname; | ||||||
|  |     const parts = pathname.split('/').filter(p => p); // Remove empty parts | ||||||
|  |  | ||||||
|  |     if (parts.length === 0) { | ||||||
|  |         return { collection: null, filePath: null }; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const collection = parts[0]; | ||||||
|  |     const filePath = parts.length > 1 ? parts.slice(1).join('/') : null; | ||||||
|  |  | ||||||
|  |     return { collection, filePath }; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Update URL based on current collection and file | ||||||
|  |  * @param {string} collection - The collection name | ||||||
|  |  * @param {string} filePath - The file path (optional) | ||||||
|  |  * @param {boolean} isEditMode - Whether in edit mode | ||||||
|  |  */ | ||||||
|  | function updateURL(collection, filePath, isEditMode) { | ||||||
|  |     let url = `/${collection}`; | ||||||
|  |     if (filePath) { | ||||||
|  |         url += `/${filePath}`; | ||||||
|  |     } | ||||||
|  |     if (isEditMode) { | ||||||
|  |         url += '?edit=true'; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Use pushState to update URL without reloading | ||||||
|  |     window.history.pushState({ collection, filePath }, '', url); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Load file from URL path | ||||||
|  |  * Assumes the collection is already set and file tree is loaded | ||||||
|  |  * @param {string} collection - The collection name (for validation) | ||||||
|  |  * @param {string} filePath - The file path | ||||||
|  |  */ | ||||||
|  | async function loadFileFromURL(collection, filePath) { | ||||||
|  |     console.log('[loadFileFromURL] Called with:', { collection, filePath }); | ||||||
|  |  | ||||||
|  |     if (!fileTree || !editor || !collectionSelector) { | ||||||
|  |         console.error('[loadFileFromURL] Missing dependencies:', { fileTree: !!fileTree, editor: !!editor, collectionSelector: !!collectionSelector }); | ||||||
|  |         return; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     try { | ||||||
|  |         // Verify we're on the right collection | ||||||
|  |         const currentCollection = collectionSelector.getCurrentCollection(); | ||||||
|  |         if (currentCollection !== collection) { | ||||||
|  |             console.error(`[loadFileFromURL] Collection mismatch: expected ${collection}, got ${currentCollection}`); | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // Load the file or directory | ||||||
|  |         if (filePath) { | ||||||
|  |             // Check if the path is a directory or a file | ||||||
|  |             const node = fileTree.findNode(filePath); | ||||||
|  |             console.log('[loadFileFromURL] Found node:', node); | ||||||
|  |  | ||||||
|  |             if (node && node.isDirectory) { | ||||||
|  |                 // It's a directory, show directory preview | ||||||
|  |                 console.log('[loadFileFromURL] Loading directory preview'); | ||||||
|  |                 await showDirectoryPreview(filePath); | ||||||
|  |                 fileTree.selectAndExpandPath(filePath); | ||||||
|  |             } else if (node) { | ||||||
|  |                 // It's a file, check if it's binary | ||||||
|  |                 console.log('[loadFileFromURL] Loading file'); | ||||||
|  |  | ||||||
|  |                 // Use the fileTree.onFileSelect callback to handle both text and binary files | ||||||
|  |                 if (fileTree.onFileSelect) { | ||||||
|  |                     fileTree.onFileSelect({ path: filePath, isDirectory: false }); | ||||||
|  |                 } else { | ||||||
|  |                     // Fallback to direct loading | ||||||
|  |                     await editor.loadFile(filePath); | ||||||
|  |                     fileTree.selectAndExpandPath(filePath); | ||||||
|  |                 } | ||||||
|  |             } else { | ||||||
|  |                 console.error(`[loadFileFromURL] Path not found in file tree: ${filePath}`); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } catch (error) { | ||||||
|  |         console.error('[loadFileFromURL] Failed to load file from URL:', error); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Handle browser back/forward navigation | ||||||
|  |  */ | ||||||
|  | function setupPopStateListener() { | ||||||
|  |     window.addEventListener('popstate', async (event) => { | ||||||
|  |         const { collection, filePath } = parseURLPath(); | ||||||
|  |         if (collection) { | ||||||
|  |             // Ensure the collection is set | ||||||
|  |             const currentCollection = collectionSelector.getCurrentCollection(); | ||||||
|  |             if (currentCollection !== collection) { | ||||||
|  |                 await collectionSelector.setCollection(collection); | ||||||
|  |                 await fileTree.load(); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             // Load the file/directory | ||||||
|  |             await loadFileFromURL(collection, filePath); | ||||||
|  |         } | ||||||
|  |     }); | ||||||
|  | } | ||||||
|  |  | ||||||
| // Initialize application | // Initialize application | ||||||
| document.addEventListener('DOMContentLoaded', async () => { | document.addEventListener('DOMContentLoaded', async () => { | ||||||
|  |     // Determine view mode from URL parameter | ||||||
|  |     const urlParams = new URLSearchParams(window.location.search); | ||||||
|  |     const isEditMode = urlParams.get('edit') === 'true'; | ||||||
|  |  | ||||||
|  |     // Set view mode class on body | ||||||
|  |     if (isEditMode) { | ||||||
|  |         document.body.classList.add('edit-mode'); | ||||||
|  |         document.body.classList.remove('view-mode'); | ||||||
|  |     } else { | ||||||
|  |         document.body.classList.add('view-mode'); | ||||||
|  |         document.body.classList.remove('edit-mode'); | ||||||
|  |     } | ||||||
|  |  | ||||||
|     // Initialize WebDAV client |     // Initialize WebDAV client | ||||||
|     webdavClient = new WebDAVClient('/fs/'); |     webdavClient = new WebDAVClient('/fs/'); | ||||||
|  |  | ||||||
| @@ -40,24 +277,241 @@ document.addEventListener('DOMContentLoaded', async () => { | |||||||
|         darkMode.toggle(); |         darkMode.toggle(); | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
|     // Initialize file tree |     // Initialize sidebar toggle | ||||||
|     fileTree = new FileTree('fileTree', webdavClient); |     const sidebarToggle = new SidebarToggle('sidebarPane', 'sidebarToggleBtn'); | ||||||
|     fileTree.onFileSelect = async (item) => { |  | ||||||
|         await editor.loadFile(item.path); |  | ||||||
|     }; |  | ||||||
|  |  | ||||||
|     // Initialize collection selector |     // Initialize collection selector (always needed) | ||||||
|     collectionSelector = new CollectionSelector('collectionSelect', webdavClient); |     collectionSelector = new CollectionSelector('collectionSelect', webdavClient); | ||||||
|     collectionSelector.onChange = async (collection) => { |  | ||||||
|         await fileTree.load(); |  | ||||||
|     }; |  | ||||||
|     await collectionSelector.load(); |     await collectionSelector.load(); | ||||||
|     await fileTree.load(); |  | ||||||
|  |  | ||||||
|     // Initialize editor |     // Setup New Collection button | ||||||
|     editor = new MarkdownEditor('editor', 'preview', 'filenameInput'); |     document.getElementById('newCollectionBtn').addEventListener('click', async () => { | ||||||
|  |         try { | ||||||
|  |             const collectionName = await window.ModalManager.prompt( | ||||||
|  |                 'Enter new collection name (lowercase, underscore only):', | ||||||
|  |                 'new_collection' | ||||||
|  |             ); | ||||||
|  |  | ||||||
|  |             if (!collectionName) return; | ||||||
|  |  | ||||||
|  |             // Validate collection name | ||||||
|  |             const validation = ValidationUtils.validateFileName(collectionName, true); | ||||||
|  |             if (!validation.valid) { | ||||||
|  |                 window.showNotification(validation.message, 'warning'); | ||||||
|  |                 return; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             // Create the collection | ||||||
|  |             await webdavClient.createCollection(validation.sanitized); | ||||||
|  |  | ||||||
|  |             // Reload collections and switch to the new one | ||||||
|  |             await collectionSelector.load(); | ||||||
|  |             await collectionSelector.setCollection(validation.sanitized); | ||||||
|  |  | ||||||
|  |             window.showNotification(`Collection "${validation.sanitized}" created`, 'success'); | ||||||
|  |         } catch (error) { | ||||||
|  |             Logger.error('Failed to create collection:', error); | ||||||
|  |             window.showNotification('Failed to create collection', 'error'); | ||||||
|  |         } | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     // Setup URL routing | ||||||
|  |     setupPopStateListener(); | ||||||
|  |  | ||||||
|  |     // Initialize editor (always needed for preview) | ||||||
|  |     // In view mode, editor is read-only | ||||||
|  |     editor = new MarkdownEditor('editor', 'preview', 'filenameInput', !isEditMode); | ||||||
|     editor.setWebDAVClient(webdavClient); |     editor.setWebDAVClient(webdavClient); | ||||||
|  |  | ||||||
|  |     // Initialize file tree (needed in both modes) | ||||||
|  |     // Pass isEditMode to control image filtering (hide images only in view mode) | ||||||
|  |     fileTree = new FileTree('fileTree', webdavClient, isEditMode); | ||||||
|  |     fileTree.onFileSelect = async (item) => { | ||||||
|  |         try { | ||||||
|  |             const currentCollection = collectionSelector.getCurrentCollection(); | ||||||
|  |  | ||||||
|  |             // Check if the file is a binary/non-editable file | ||||||
|  |             if (PathUtils.isBinaryFile(item.path)) { | ||||||
|  |                 const fileType = PathUtils.getFileType(item.path); | ||||||
|  |                 const fileName = PathUtils.getFileName(item.path); | ||||||
|  |  | ||||||
|  |                 Logger.info(`Previewing binary file: ${item.path}`); | ||||||
|  |  | ||||||
|  |                 // Initialize and show loading spinner for binary file preview | ||||||
|  |                 editor.initLoadingSpinners(); | ||||||
|  |                 if (editor.previewSpinner) { | ||||||
|  |                     editor.previewSpinner.show(`Loading ${fileType.toLowerCase()}...`); | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 // Set flag to prevent auto-update of preview | ||||||
|  |                 editor.isShowingCustomPreview = true; | ||||||
|  |  | ||||||
|  |                 // In edit mode, show a warning notification | ||||||
|  |                 if (isEditMode) { | ||||||
|  |                     if (window.showNotification) { | ||||||
|  |                         window.showNotification( | ||||||
|  |                             `"${fileName}" is read-only. Showing preview only.`, | ||||||
|  |                             'warning' | ||||||
|  |                         ); | ||||||
|  |                     } | ||||||
|  |  | ||||||
|  |                     // Hide the editor pane temporarily | ||||||
|  |                     const editorPane = document.getElementById('editorPane'); | ||||||
|  |                     const resizer1 = document.getElementById('resizer1'); | ||||||
|  |                     if (editorPane) editorPane.style.display = 'none'; | ||||||
|  |                     if (resizer1) resizer1.style.display = 'none'; | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 // Clear the editor (but don't trigger preview update due to flag) | ||||||
|  |                 if (editor.editor) { | ||||||
|  |                     editor.editor.setValue(''); | ||||||
|  |                 } | ||||||
|  |                 editor.filenameInput.value = item.path; | ||||||
|  |                 editor.currentFile = item.path; | ||||||
|  |  | ||||||
|  |                 // Build the file URL using the WebDAV client's method | ||||||
|  |                 const fileUrl = webdavClient.getFullUrl(item.path); | ||||||
|  |                 Logger.debug(`Binary file URL: ${fileUrl}`); | ||||||
|  |  | ||||||
|  |                 // Generate preview HTML based on file type | ||||||
|  |                 let previewHtml = ''; | ||||||
|  |  | ||||||
|  |                 if (fileType === 'Image') { | ||||||
|  |                     // Preview images | ||||||
|  |                     previewHtml = ` | ||||||
|  |                         <div style="padding: 20px; text-align: center;"> | ||||||
|  |                             <h3>${fileName}</h3> | ||||||
|  |                             <p style="color: var(--text-secondary); margin-bottom: 20px;">Image Preview (Read-only)</p> | ||||||
|  |                             <img src="${fileUrl}" alt="${fileName}" style="max-width: 100%; height: auto; border: 1px solid var(--border-color); border-radius: 4px;"> | ||||||
|  |                         </div> | ||||||
|  |                     `; | ||||||
|  |                 } else if (fileType === 'PDF') { | ||||||
|  |                     // Preview PDFs | ||||||
|  |                     previewHtml = ` | ||||||
|  |                         <div style="padding: 20px;"> | ||||||
|  |                             <h3>${fileName}</h3> | ||||||
|  |                             <p style="color: var(--text-secondary); margin-bottom: 20px;">PDF Preview (Read-only)</p> | ||||||
|  |                             <iframe src="${fileUrl}" style="width: 100%; height: 80vh; border: 1px solid var(--border-color); border-radius: 4px;"></iframe> | ||||||
|  |                         </div> | ||||||
|  |                     `; | ||||||
|  |                 } else { | ||||||
|  |                     // For other binary files, show download link | ||||||
|  |                     previewHtml = ` | ||||||
|  |                         <div style="padding: 20px;"> | ||||||
|  |                             <h3>${fileName}</h3> | ||||||
|  |                             <p style="color: var(--text-secondary); margin-bottom: 20px;">${fileType} File (Read-only)</p> | ||||||
|  |                             <p>This file cannot be previewed in the browser.</p> | ||||||
|  |                             <a href="${fileUrl}" download="${fileName}" class="btn btn-primary">Download ${fileName}</a> | ||||||
|  |                         </div> | ||||||
|  |                     `; | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 // Display in preview pane | ||||||
|  |                 editor.previewElement.innerHTML = previewHtml; | ||||||
|  |  | ||||||
|  |                 // Hide loading spinner after content is set | ||||||
|  |                 // Add small delay for images to start loading | ||||||
|  |                 setTimeout(() => { | ||||||
|  |                     if (editor.previewSpinner) { | ||||||
|  |                         editor.previewSpinner.hide(); | ||||||
|  |                     } | ||||||
|  |                 }, fileType === 'Image' ? 300 : 100); | ||||||
|  |  | ||||||
|  |                 // Highlight the file in the tree | ||||||
|  |                 fileTree.selectAndExpandPath(item.path); | ||||||
|  |  | ||||||
|  |                 // Save as last viewed page (for binary files too) | ||||||
|  |                 editor.saveLastViewedPage(item.path); | ||||||
|  |  | ||||||
|  |                 // Update URL to reflect current file | ||||||
|  |                 updateURL(currentCollection, item.path, isEditMode); | ||||||
|  |  | ||||||
|  |                 return; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             // For text files, restore the editor pane if it was hidden | ||||||
|  |             if (isEditMode) { | ||||||
|  |                 const editorPane = document.getElementById('editorPane'); | ||||||
|  |                 const resizer1 = document.getElementById('resizer1'); | ||||||
|  |                 if (editorPane) editorPane.style.display = ''; | ||||||
|  |                 if (resizer1) resizer1.style.display = ''; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             await editor.loadFile(item.path); | ||||||
|  |             // Highlight the file in the tree and expand parent directories | ||||||
|  |             fileTree.selectAndExpandPath(item.path); | ||||||
|  |             // Update URL to reflect current file | ||||||
|  |             updateURL(currentCollection, item.path, isEditMode); | ||||||
|  |         } catch (error) { | ||||||
|  |             Logger.error('Failed to select file:', error); | ||||||
|  |             if (window.showNotification) { | ||||||
|  |                 window.showNotification('Failed to load file', 'error'); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     fileTree.onFolderSelect = async (item) => { | ||||||
|  |         try { | ||||||
|  |             // Show directory preview | ||||||
|  |             await showDirectoryPreview(item.path); | ||||||
|  |             // Highlight the directory in the tree and expand parent directories | ||||||
|  |             fileTree.selectAndExpandPath(item.path); | ||||||
|  |             // Update URL to reflect current directory | ||||||
|  |             const currentCollection = collectionSelector.getCurrentCollection(); | ||||||
|  |             updateURL(currentCollection, item.path, isEditMode); | ||||||
|  |         } catch (error) { | ||||||
|  |             Logger.error('Failed to select folder:', error); | ||||||
|  |             if (window.showNotification) { | ||||||
|  |                 window.showNotification('Failed to load folder', 'error'); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     collectionSelector.onChange = async (collection) => { | ||||||
|  |         try { | ||||||
|  |             await fileTree.load(); | ||||||
|  |             // In view mode, auto-load last viewed page when collection changes | ||||||
|  |             if (!isEditMode) { | ||||||
|  |                 await autoLoadPageInViewMode(); | ||||||
|  |             } | ||||||
|  |         } catch (error) { | ||||||
|  |             Logger.error('Failed to change collection:', error); | ||||||
|  |             if (window.showNotification) { | ||||||
|  |                 window.showNotification('Failed to change collection', 'error'); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     }; | ||||||
|  |     await fileTree.load(); | ||||||
|  |  | ||||||
|  |     // Parse URL to load file if specified | ||||||
|  |     const { collection: urlCollection, filePath: urlFilePath } = parseURLPath(); | ||||||
|  |     console.log('[URL PARSE]', { urlCollection, urlFilePath }); | ||||||
|  |  | ||||||
|  |     if (urlCollection) { | ||||||
|  |         // First ensure the collection is set | ||||||
|  |         const currentCollection = collectionSelector.getCurrentCollection(); | ||||||
|  |         if (currentCollection !== urlCollection) { | ||||||
|  |             console.log('[URL LOAD] Switching collection from', currentCollection, 'to', urlCollection); | ||||||
|  |             await collectionSelector.setCollection(urlCollection); | ||||||
|  |             await fileTree.load(); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // If there's a file path in the URL, load it | ||||||
|  |         if (urlFilePath) { | ||||||
|  |             console.log('[URL LOAD] Loading file from URL:', urlCollection, urlFilePath); | ||||||
|  |             await loadFileFromURL(urlCollection, urlFilePath); | ||||||
|  |         } else if (!isEditMode) { | ||||||
|  |             // Collection-only URL in view mode: auto-load last viewed page | ||||||
|  |             console.log('[URL LOAD] Collection-only URL, auto-loading page'); | ||||||
|  |             await autoLoadPageInViewMode(); | ||||||
|  |         } | ||||||
|  |     } else if (!isEditMode) { | ||||||
|  |         // No URL collection specified, in view mode: auto-load last viewed page | ||||||
|  |         await autoLoadPageInViewMode(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Initialize file tree and editor-specific features only in edit mode | ||||||
|  |     if (isEditMode) { | ||||||
|         // Add test content to verify preview works |         // Add test content to verify preview works | ||||||
|         setTimeout(() => { |         setTimeout(() => { | ||||||
|             if (!editor.editor.getValue()) { |             if (!editor.editor.getValue()) { | ||||||
| @@ -70,7 +524,11 @@ document.addEventListener('DOMContentLoaded', async () => { | |||||||
|         const editorDropHandler = new EditorDropHandler( |         const editorDropHandler = new EditorDropHandler( | ||||||
|             document.querySelector('.editor-container'), |             document.querySelector('.editor-container'), | ||||||
|             async (file) => { |             async (file) => { | ||||||
|  |                 try { | ||||||
|                     await handleEditorFileDrop(file); |                     await handleEditorFileDrop(file); | ||||||
|  |                 } catch (error) { | ||||||
|  |                     Logger.error('Failed to handle file drop:', error); | ||||||
|  |                 } | ||||||
|             } |             } | ||||||
|         ); |         ); | ||||||
|  |  | ||||||
| @@ -80,33 +538,103 @@ document.addEventListener('DOMContentLoaded', async () => { | |||||||
|         }); |         }); | ||||||
|  |  | ||||||
|         document.getElementById('saveBtn').addEventListener('click', async () => { |         document.getElementById('saveBtn').addEventListener('click', async () => { | ||||||
|  |             try { | ||||||
|                 await editor.save(); |                 await editor.save(); | ||||||
|  |             } catch (error) { | ||||||
|  |                 Logger.error('Failed to save file:', error); | ||||||
|  |                 if (window.showNotification) { | ||||||
|  |                     window.showNotification('Failed to save file', 'error'); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|         }); |         }); | ||||||
|  |  | ||||||
|         document.getElementById('deleteBtn').addEventListener('click', async () => { |         document.getElementById('deleteBtn').addEventListener('click', async () => { | ||||||
|  |             try { | ||||||
|                 await editor.deleteFile(); |                 await editor.deleteFile(); | ||||||
|  |             } catch (error) { | ||||||
|  |                 Logger.error('Failed to delete file:', error); | ||||||
|  |                 if (window.showNotification) { | ||||||
|  |                     window.showNotification('Failed to delete file', 'error'); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|         }); |         }); | ||||||
|  |  | ||||||
|         // Setup context menu handlers |         // Setup context menu handlers | ||||||
|         setupContextMenuHandlers(); |         setupContextMenuHandlers(); | ||||||
|  |  | ||||||
|     // Initialize mermaid |  | ||||||
|     mermaid.initialize({ startOnLoad: true, theme: darkMode.isDark ? 'dark' : 'default' }); |  | ||||||
|  |  | ||||||
|         // Initialize file tree actions manager |         // Initialize file tree actions manager | ||||||
|         window.fileTreeActions = new FileTreeActions(webdavClient, fileTree, editor); |         window.fileTreeActions = new FileTreeActions(webdavClient, fileTree, editor); | ||||||
|  |  | ||||||
|  |         // Setup Exit Edit Mode button | ||||||
|  |         document.getElementById('exitEditModeBtn').addEventListener('click', () => { | ||||||
|  |             // Switch to view mode by removing edit=true from URL | ||||||
|  |             const url = new URL(window.location.href); | ||||||
|  |             url.searchParams.delete('edit'); | ||||||
|  |             window.location.href = url.toString(); | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         // Hide Edit Mode button in edit mode | ||||||
|  |         document.getElementById('editModeBtn').style.display = 'none'; | ||||||
|  |     } else { | ||||||
|  |         // In view mode, hide editor buttons | ||||||
|  |         document.getElementById('newBtn').style.display = 'none'; | ||||||
|  |         document.getElementById('saveBtn').style.display = 'none'; | ||||||
|  |         document.getElementById('deleteBtn').style.display = 'none'; | ||||||
|  |         document.getElementById('exitEditModeBtn').style.display = 'none'; | ||||||
|  |  | ||||||
|  |         // Show Edit Mode button in view mode | ||||||
|  |         document.getElementById('editModeBtn').style.display = 'block'; | ||||||
|  |  | ||||||
|  |         // Setup Edit Mode button | ||||||
|  |         document.getElementById('editModeBtn').addEventListener('click', () => { | ||||||
|  |             // Switch to edit mode by adding edit=true to URL | ||||||
|  |             const url = new URL(window.location.href); | ||||||
|  |             url.searchParams.set('edit', 'true'); | ||||||
|  |             window.location.href = url.toString(); | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         // Auto-load last viewed page or first file | ||||||
|  |         await autoLoadPageInViewMode(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Setup clickable navbar brand (logo/title) | ||||||
|  |     const navbarBrand = document.getElementById('navbarBrand'); | ||||||
|  |     if (navbarBrand) { | ||||||
|  |         navbarBrand.addEventListener('click', (e) => { | ||||||
|  |             e.preventDefault(); | ||||||
|  |             const currentCollection = collectionSelector ? collectionSelector.getCurrentCollection() : null; | ||||||
|  |             if (currentCollection) { | ||||||
|  |                 // Navigate to collection root | ||||||
|  |                 window.location.href = `/${currentCollection}/`; | ||||||
|  |             } else { | ||||||
|  |                 // Navigate to home page | ||||||
|  |                 window.location.href = '/'; | ||||||
|  |             } | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Initialize mermaid (always needed) | ||||||
|  |     mermaid.initialize({ startOnLoad: true, theme: darkMode.isDark ? 'dark' : 'default' }); | ||||||
|     // Listen for file-saved event to reload file tree |     // Listen for file-saved event to reload file tree | ||||||
|     window.eventBus.on('file-saved', async (path) => { |     window.eventBus.on('file-saved', async (path) => { | ||||||
|  |         try { | ||||||
|             if (fileTree) { |             if (fileTree) { | ||||||
|                 await fileTree.load(); |                 await fileTree.load(); | ||||||
|                 fileTree.selectNode(path); |                 fileTree.selectNode(path); | ||||||
|             } |             } | ||||||
|  |         } catch (error) { | ||||||
|  |             Logger.error('Failed to reload file tree after save:', error); | ||||||
|  |         } | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
|     window.eventBus.on('file-deleted', async () => { |     window.eventBus.on('file-deleted', async () => { | ||||||
|  |         try { | ||||||
|             if (fileTree) { |             if (fileTree) { | ||||||
|                 await fileTree.load(); |                 await fileTree.load(); | ||||||
|             } |             } | ||||||
|  |         } catch (error) { | ||||||
|  |             Logger.error('Failed to reload file tree after delete:', error); | ||||||
|  |         } | ||||||
|     }); |     }); | ||||||
| }); | }); | ||||||
|  |  | ||||||
| @@ -168,10 +696,11 @@ async function handleEditorFileDrop(file) { | |||||||
|         const uploadedPath = await fileTree.uploadFile(targetDir, file); |         const uploadedPath = await fileTree.uploadFile(targetDir, file); | ||||||
|  |  | ||||||
|         // Insert markdown link at cursor |         // Insert markdown link at cursor | ||||||
|  |         // Use relative path (without collection name) so the image renderer can resolve it correctly | ||||||
|         const isImage = file.type.startsWith('image/'); |         const isImage = file.type.startsWith('image/'); | ||||||
|         const link = isImage |         const link = isImage | ||||||
|             ? `` |             ? `` | ||||||
|             : `[${file.name}](/${webdavClient.currentCollection}/${uploadedPath})`; |             : `[${file.name}](${uploadedPath})`; | ||||||
|  |  | ||||||
|         editor.insertAtCursor(link); |         editor.insertAtCursor(link); | ||||||
|         showNotification(`Uploaded and inserted link`, 'success'); |         showNotification(`Uploaded and inserted link`, 'success'); | ||||||
|   | |||||||
							
								
								
									
										152
									
								
								static/js/collection-selector.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,152 @@ | |||||||
|  | /** | ||||||
|  |  * Collection Selector Module | ||||||
|  |  * Manages the collection dropdown selector and persistence | ||||||
|  |  */ | ||||||
|  |  | ||||||
|  | class CollectionSelector { | ||||||
|  |     constructor(selectId, webdavClient) { | ||||||
|  |         this.select = document.getElementById(selectId); | ||||||
|  |         this.webdavClient = webdavClient; | ||||||
|  |         this.onChange = null; | ||||||
|  |         this.storageKey = Config.STORAGE_KEYS.SELECTED_COLLECTION; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Load collections from WebDAV and populate the selector | ||||||
|  |      */ | ||||||
|  |     async load() { | ||||||
|  |         try { | ||||||
|  |             const collections = await this.webdavClient.getCollections(); | ||||||
|  |             this.select.innerHTML = ''; | ||||||
|  |  | ||||||
|  |             collections.forEach(collection => { | ||||||
|  |                 const option = document.createElement('option'); | ||||||
|  |                 option.value = collection; | ||||||
|  |                 option.textContent = collection; | ||||||
|  |                 this.select.appendChild(option); | ||||||
|  |             }); | ||||||
|  |  | ||||||
|  |             // Determine which collection to select (priority: URL > localStorage > first) | ||||||
|  |             let collectionToSelect = collections[0]; // Default to first | ||||||
|  |  | ||||||
|  |             // Check URL first (highest priority) | ||||||
|  |             const urlCollection = this.getCollectionFromURL(); | ||||||
|  |             if (urlCollection && collections.includes(urlCollection)) { | ||||||
|  |                 collectionToSelect = urlCollection; | ||||||
|  |                 Logger.info(`Using collection from URL: ${urlCollection}`); | ||||||
|  |             } else { | ||||||
|  |                 // Fall back to localStorage | ||||||
|  |                 const savedCollection = localStorage.getItem(this.storageKey); | ||||||
|  |                 if (savedCollection && collections.includes(savedCollection)) { | ||||||
|  |                     collectionToSelect = savedCollection; | ||||||
|  |                     Logger.info(`Using collection from localStorage: ${savedCollection}`); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (collections.length > 0) { | ||||||
|  |                 this.select.value = collectionToSelect; | ||||||
|  |                 this.webdavClient.setCollection(collectionToSelect); | ||||||
|  |                 if (this.onChange) { | ||||||
|  |                     this.onChange(collectionToSelect); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             // Add change listener | ||||||
|  |             this.select.addEventListener('change', () => { | ||||||
|  |                 const collection = this.select.value; | ||||||
|  |                 // Save to localStorage | ||||||
|  |                 localStorage.setItem(this.storageKey, collection); | ||||||
|  |                 this.webdavClient.setCollection(collection); | ||||||
|  |  | ||||||
|  |                 Logger.info(`Collection changed to: ${collection}`); | ||||||
|  |  | ||||||
|  |                 // Update URL to reflect collection change | ||||||
|  |                 this.updateURLForCollection(collection); | ||||||
|  |  | ||||||
|  |                 if (this.onChange) { | ||||||
|  |                     this.onChange(collection); | ||||||
|  |                 } | ||||||
|  |             }); | ||||||
|  |  | ||||||
|  |             Logger.debug(`Loaded ${collections.length} collections`); | ||||||
|  |         } catch (error) { | ||||||
|  |             Logger.error('Failed to load collections:', error); | ||||||
|  |             if (window.showNotification) { | ||||||
|  |                 window.showNotification('Failed to load collections', 'error'); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Get the currently selected collection | ||||||
|  |      * @returns {string} The collection name | ||||||
|  |      */ | ||||||
|  |     getCurrentCollection() { | ||||||
|  |         return this.select.value; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Set the collection to a specific value | ||||||
|  |      * @param {string} collection - The collection name to set | ||||||
|  |      */ | ||||||
|  |     async setCollection(collection) { | ||||||
|  |         const collections = Array.from(this.select.options).map(opt => opt.value); | ||||||
|  |         if (collections.includes(collection)) { | ||||||
|  |             this.select.value = collection; | ||||||
|  |             localStorage.setItem(this.storageKey, collection); | ||||||
|  |             this.webdavClient.setCollection(collection); | ||||||
|  |  | ||||||
|  |             Logger.info(`Collection set to: ${collection}`); | ||||||
|  |  | ||||||
|  |             // Update URL to reflect collection change | ||||||
|  |             this.updateURLForCollection(collection); | ||||||
|  |  | ||||||
|  |             if (this.onChange) { | ||||||
|  |                 this.onChange(collection); | ||||||
|  |             } | ||||||
|  |         } else { | ||||||
|  |             Logger.warn(`Collection "${collection}" not found in available collections`); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Update the browser URL to reflect the current collection | ||||||
|  |      * @param {string} collection - The collection name | ||||||
|  |      */ | ||||||
|  |     updateURLForCollection(collection) { | ||||||
|  |         // Get current URL parameters | ||||||
|  |         const urlParams = new URLSearchParams(window.location.search); | ||||||
|  |         const isEditMode = urlParams.get('edit') === 'true'; | ||||||
|  |  | ||||||
|  |         // Build new URL with collection | ||||||
|  |         let url = `/${collection}/`; | ||||||
|  |         if (isEditMode) { | ||||||
|  |             url += '?edit=true'; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // Use pushState to update URL without reloading | ||||||
|  |         window.history.pushState({ collection, filePath: null }, '', url); | ||||||
|  |         Logger.debug(`Updated URL to: ${url}`); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Extract collection name from current URL | ||||||
|  |      * URL format: /<collection>/ or /<collection>/<file_path> | ||||||
|  |      * @returns {string|null} The collection name or null if not found | ||||||
|  |      */ | ||||||
|  |     getCollectionFromURL() { | ||||||
|  |         const pathname = window.location.pathname; | ||||||
|  |         const parts = pathname.split('/').filter(p => p); // Remove empty parts | ||||||
|  |  | ||||||
|  |         if (parts.length === 0) { | ||||||
|  |             return null; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // First part is the collection | ||||||
|  |         return parts[0]; | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Make CollectionSelector globally available | ||||||
|  | window.CollectionSelector = CollectionSelector; | ||||||
|  |  | ||||||
| @@ -50,7 +50,6 @@ class ColumnResizer { | |||||||
|                 const containerFlex = this.sidebarPane.offsetWidth; |                 const containerFlex = this.sidebarPane.offsetWidth; | ||||||
|  |  | ||||||
|                 this.editorPane.style.flex = `0 0 ${newWidth2}px`; |                 this.editorPane.style.flex = `0 0 ${newWidth2}px`; | ||||||
|                 this.previewPane.style.flex = `1 1 auto`; |  | ||||||
|             } |             } | ||||||
|         }; |         }; | ||||||
|  |  | ||||||
| @@ -89,7 +88,6 @@ class ColumnResizer { | |||||||
|             const { sidebar, editor, preview } = JSON.parse(saved); |             const { sidebar, editor, preview } = JSON.parse(saved); | ||||||
|             this.sidebarPane.style.flex = `0 0 ${sidebar}px`; |             this.sidebarPane.style.flex = `0 0 ${sidebar}px`; | ||||||
|             this.editorPane.style.flex = `0 0 ${editor}px`; |             this.editorPane.style.flex = `0 0 ${editor}px`; | ||||||
|             this.previewPane.style.flex = `1 1 auto`; |  | ||||||
|         } catch (error) { |         } catch (error) { | ||||||
|             console.error('Failed to load column dimensions:', error); |             console.error('Failed to load column dimensions:', error); | ||||||
|         } |         } | ||||||
|   | |||||||
							
								
								
									
										207
									
								
								static/js/config.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,207 @@ | |||||||
|  | /** | ||||||
|  |  * Application Configuration | ||||||
|  |  * Centralized configuration values for the markdown editor | ||||||
|  |  */ | ||||||
|  |  | ||||||
|  | const Config = { | ||||||
|  |     // ===== TIMING CONFIGURATION ===== | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Long-press threshold in milliseconds | ||||||
|  |      * Used for drag-and-drop detection in file tree | ||||||
|  |      */ | ||||||
|  |     LONG_PRESS_THRESHOLD: 400, | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Debounce delay in milliseconds | ||||||
|  |      * Used for editor preview updates | ||||||
|  |      */ | ||||||
|  |     DEBOUNCE_DELAY: 300, | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Toast notification duration in milliseconds | ||||||
|  |      */ | ||||||
|  |     TOAST_DURATION: 3000, | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Mouse move threshold in pixels | ||||||
|  |      * Used to detect if user is dragging vs clicking | ||||||
|  |      */ | ||||||
|  |     MOUSE_MOVE_THRESHOLD: 5, | ||||||
|  |  | ||||||
|  |     // ===== UI CONFIGURATION ===== | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Drag preview width in pixels | ||||||
|  |      * Width of the drag ghost image during drag-and-drop | ||||||
|  |      */ | ||||||
|  |     DRAG_PREVIEW_WIDTH: 200, | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Tree indentation in pixels | ||||||
|  |      * Indentation per level in the file tree | ||||||
|  |      */ | ||||||
|  |     TREE_INDENT_PX: 12, | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Toast container z-index | ||||||
|  |      * Ensures toasts appear above other elements | ||||||
|  |      */ | ||||||
|  |     TOAST_Z_INDEX: 9999, | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Minimum sidebar width in pixels | ||||||
|  |      */ | ||||||
|  |     MIN_SIDEBAR_WIDTH: 150, | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Maximum sidebar width as percentage of container | ||||||
|  |      */ | ||||||
|  |     MAX_SIDEBAR_WIDTH_PERCENT: 40, | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Minimum editor width in pixels | ||||||
|  |      */ | ||||||
|  |     MIN_EDITOR_WIDTH: 250, | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Maximum editor width as percentage of container | ||||||
|  |      */ | ||||||
|  |     MAX_EDITOR_WIDTH_PERCENT: 70, | ||||||
|  |  | ||||||
|  |     // ===== VALIDATION CONFIGURATION ===== | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Valid filename pattern | ||||||
|  |      * Only lowercase letters, numbers, underscores, and dots allowed | ||||||
|  |      */ | ||||||
|  |     FILENAME_PATTERN: /^[a-z0-9_]+(\.[a-z0-9_]+)*$/, | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Characters to replace in filenames | ||||||
|  |      * All invalid characters will be replaced with underscore | ||||||
|  |      */ | ||||||
|  |     FILENAME_INVALID_CHARS: /[^a-z0-9_.]/g, | ||||||
|  |  | ||||||
|  |     // ===== STORAGE KEYS ===== | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * LocalStorage keys used throughout the application | ||||||
|  |      */ | ||||||
|  |     STORAGE_KEYS: { | ||||||
|  |         /** | ||||||
|  |          * Dark mode preference | ||||||
|  |          */ | ||||||
|  |         DARK_MODE: 'darkMode', | ||||||
|  |  | ||||||
|  |         /** | ||||||
|  |          * Currently selected collection | ||||||
|  |          */ | ||||||
|  |         SELECTED_COLLECTION: 'selectedCollection', | ||||||
|  |  | ||||||
|  |         /** | ||||||
|  |          * Last viewed page (per collection) | ||||||
|  |          * Actual key will be: lastViewedPage:{collection} | ||||||
|  |          */ | ||||||
|  |         LAST_VIEWED_PAGE: 'lastViewedPage', | ||||||
|  |  | ||||||
|  |         /** | ||||||
|  |          * Column dimensions (sidebar, editor, preview widths) | ||||||
|  |          */ | ||||||
|  |         COLUMN_DIMENSIONS: 'columnDimensions', | ||||||
|  |  | ||||||
|  |         /** | ||||||
|  |          * Sidebar collapsed state | ||||||
|  |          */ | ||||||
|  |         SIDEBAR_COLLAPSED: 'sidebarCollapsed' | ||||||
|  |     }, | ||||||
|  |  | ||||||
|  |     // ===== EDITOR CONFIGURATION ===== | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * CodeMirror theme for light mode | ||||||
|  |      */ | ||||||
|  |     EDITOR_THEME_LIGHT: 'default', | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * CodeMirror theme for dark mode | ||||||
|  |      */ | ||||||
|  |     EDITOR_THEME_DARK: 'monokai', | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Mermaid theme for light mode | ||||||
|  |      */ | ||||||
|  |     MERMAID_THEME_LIGHT: 'default', | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Mermaid theme for dark mode | ||||||
|  |      */ | ||||||
|  |     MERMAID_THEME_DARK: 'dark', | ||||||
|  |  | ||||||
|  |     // ===== FILE TREE CONFIGURATION ===== | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Default content for new files | ||||||
|  |      */ | ||||||
|  |     DEFAULT_FILE_CONTENT: '# New File\n\n', | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Default filename for new files | ||||||
|  |      */ | ||||||
|  |     DEFAULT_NEW_FILENAME: 'new_file.md', | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Default folder name for new folders | ||||||
|  |      */ | ||||||
|  |     DEFAULT_NEW_FOLDERNAME: 'new_folder', | ||||||
|  |  | ||||||
|  |     // ===== WEBDAV CONFIGURATION ===== | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * WebDAV base URL | ||||||
|  |      */ | ||||||
|  |     WEBDAV_BASE_URL: '/fs/', | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * PROPFIND depth for file tree loading | ||||||
|  |      */ | ||||||
|  |     PROPFIND_DEPTH: 'infinity', | ||||||
|  |  | ||||||
|  |     // ===== DRAG AND DROP CONFIGURATION ===== | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Drag preview opacity | ||||||
|  |      */ | ||||||
|  |     DRAG_PREVIEW_OPACITY: 0.8, | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Dragging item opacity | ||||||
|  |      */ | ||||||
|  |     DRAGGING_OPACITY: 0.4, | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Drag preview offset X in pixels | ||||||
|  |      */ | ||||||
|  |     DRAG_PREVIEW_OFFSET_X: 10, | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Drag preview offset Y in pixels | ||||||
|  |      */ | ||||||
|  |     DRAG_PREVIEW_OFFSET_Y: 10, | ||||||
|  |  | ||||||
|  |     // ===== NOTIFICATION TYPES ===== | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Bootstrap notification type mappings | ||||||
|  |      */ | ||||||
|  |     NOTIFICATION_TYPES: { | ||||||
|  |         SUCCESS: 'success', | ||||||
|  |         ERROR: 'danger', | ||||||
|  |         WARNING: 'warning', | ||||||
|  |         INFO: 'primary' | ||||||
|  |     } | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | // Make Config globally available | ||||||
|  | window.Config = Config; | ||||||
|  |  | ||||||
| @@ -1,68 +1,180 @@ | |||||||
| /** | /** | ||||||
|  * Confirmation Modal Manager |  * Unified Modal Manager | ||||||
|  * Handles showing and hiding a Bootstrap modal for confirmations and prompts. |  * 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) { |     constructor(modalId) { | ||||||
|         this.modalElement = document.getElementById(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.messageElement = this.modalElement.querySelector('#confirmationMessage'); | ||||||
|         this.inputElement = this.modalElement.querySelector('#confirmationInput'); |         this.inputElement = this.modalElement.querySelector('#confirmationInput'); | ||||||
|         this.confirmButton = this.modalElement.querySelector('#confirmButton'); |         this.confirmButton = this.modalElement.querySelector('#confirmButton'); | ||||||
|  |         this.cancelButton = this.modalElement.querySelector('[data-bs-dismiss="modal"]'); | ||||||
|         this.titleElement = this.modalElement.querySelector('.modal-title'); |         this.titleElement = this.modalElement.querySelector('.modal-title'); | ||||||
|         this.currentResolver = null; |         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<boolean>} - Resolves to true if confirmed, false/null if cancelled | ||||||
|  |      */ | ||||||
|  |     confirm(message, title = 'Confirmation', isDangerous = false) { | ||||||
|         return new Promise((resolve) => { |         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.currentResolver = resolve; | ||||||
|             this.titleElement.textContent = title; |             this.titleElement.textContent = title; | ||||||
|             this.messageElement.textContent = message; |             this.messageElement.textContent = message; | ||||||
|  |  | ||||||
|             if (showInput) { |  | ||||||
|                 this.inputElement.style.display = 'block'; |  | ||||||
|                 this.inputElement.value = defaultValue; |  | ||||||
|                 this.inputElement.focus(); |  | ||||||
|             } else { |  | ||||||
|             this.inputElement.style.display = 'none'; |             this.inputElement.style.display = 'none'; | ||||||
|  |  | ||||||
|  |             // Update button styling based on danger level | ||||||
|  |             if (isDangerous) { | ||||||
|  |                 this.confirmButton.className = 'btn-flat btn-flat-danger'; | ||||||
|  |                 this.confirmButton.innerHTML = '<i class="bi bi-trash"></i> Delete'; | ||||||
|  |             } else { | ||||||
|  |                 this.confirmButton.className = 'btn-flat btn-flat-primary'; | ||||||
|  |                 this.confirmButton.innerHTML = '<i class="bi bi-check-circle"></i> OK'; | ||||||
|             } |             } | ||||||
|  |  | ||||||
|             this.confirmButton.onclick = () => this._handleConfirm(showInput); |             // Set up event handlers | ||||||
|             this.modalElement.addEventListener('hidden.bs.modal', () => this._handleCancel(), { once: true }); |             this.confirmButton.onclick = (e) => { | ||||||
|  |                 e.preventDefault(); | ||||||
|  |                 e.stopPropagation(); | ||||||
|  |                 this._handleConfirm(false); | ||||||
|  |             }; | ||||||
|  |  | ||||||
|  |             // Handle modal hidden event for cleanup | ||||||
|  |             this.modalElement.addEventListener('hidden.bs.modal', () => { | ||||||
|  |                 if (this.currentResolver) { | ||||||
|  |                     this._handleCancel(); | ||||||
|  |                 } | ||||||
|  |             }, { once: true }); | ||||||
|  |  | ||||||
|  |             // Remove aria-hidden before showing to prevent accessibility warning | ||||||
|  |             this.modalElement.removeAttribute('aria-hidden'); | ||||||
|  |  | ||||||
|             this.modal.show(); |             this.modal.show(); | ||||||
|  |  | ||||||
|  |             // Focus confirm button after modal is shown | ||||||
|  |             this.modalElement.addEventListener('shown.bs.modal', () => { | ||||||
|  |                 this.confirmButton.focus(); | ||||||
|  |             }, { once: true }); | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Show a prompt dialog (input dialog) | ||||||
|  |      * @param {string} message - The message/label to display | ||||||
|  |      * @param {string} defaultValue - The default input value | ||||||
|  |      * @param {string} title - The dialog title | ||||||
|  |      * @returns {Promise<string|null>} - Resolves to input value if confirmed, null if cancelled | ||||||
|  |      */ | ||||||
|  |     prompt(message, defaultValue = '', title = 'Input') { | ||||||
|  |         return new Promise((resolve) => { | ||||||
|  |             // Prevent double-opening | ||||||
|  |             if (this.isShowing) { | ||||||
|  |                 console.warn('Modal is already showing, ignoring duplicate request'); | ||||||
|  |                 resolve(null); | ||||||
|  |                 return; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             this.isShowing = true; | ||||||
|  |             this.currentResolver = resolve; | ||||||
|  |             this.titleElement.textContent = title; | ||||||
|  |             this.messageElement.textContent = message; | ||||||
|  |             this.inputElement.style.display = 'block'; | ||||||
|  |             this.inputElement.value = defaultValue; | ||||||
|  |  | ||||||
|  |             // Reset button to primary style for prompts | ||||||
|  |             this.confirmButton.className = 'btn-flat btn-flat-primary'; | ||||||
|  |             this.confirmButton.innerHTML = '<i class="bi bi-check-circle"></i> OK'; | ||||||
|  |  | ||||||
|  |             // Set up event handlers | ||||||
|  |             this.confirmButton.onclick = (e) => { | ||||||
|  |                 e.preventDefault(); | ||||||
|  |                 e.stopPropagation(); | ||||||
|  |                 this._handleConfirm(true); | ||||||
|  |             }; | ||||||
|  |  | ||||||
|  |             // Handle Enter key in input | ||||||
|  |             this.inputElement.onkeydown = (e) => { | ||||||
|  |                 if (e.key === 'Enter') { | ||||||
|  |                     e.preventDefault(); | ||||||
|  |                     this._handleConfirm(true); | ||||||
|  |                 } | ||||||
|  |             }; | ||||||
|  |  | ||||||
|  |             // Handle modal hidden event for cleanup | ||||||
|  |             this.modalElement.addEventListener('hidden.bs.modal', () => { | ||||||
|  |                 if (this.currentResolver) { | ||||||
|  |                     this._handleCancel(); | ||||||
|  |                 } | ||||||
|  |             }, { once: true }); | ||||||
|  |  | ||||||
|  |             // Remove aria-hidden before showing to prevent accessibility warning | ||||||
|  |             this.modalElement.removeAttribute('aria-hidden'); | ||||||
|  |  | ||||||
|  |             this.modal.show(); | ||||||
|  |  | ||||||
|  |             // Focus and select input after modal is shown | ||||||
|  |             this.modalElement.addEventListener('shown.bs.modal', () => { | ||||||
|  |                 this.inputElement.focus(); | ||||||
|  |                 this.inputElement.select(); | ||||||
|  |             }, { once: true }); | ||||||
|         }); |         }); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     _handleConfirm(isPrompt) { |     _handleConfirm(isPrompt) { | ||||||
|         if (this.currentResolver) { |         if (this.currentResolver) { | ||||||
|             const value = isPrompt ? this.inputElement.value : true; |             const value = isPrompt ? this.inputElement.value.trim() : true; | ||||||
|             this.currentResolver(value); |             const resolver = this.currentResolver; | ||||||
|             this._cleanup(); |             this._cleanup(); | ||||||
|  |             resolver(value); | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     _handleCancel() { |     _handleCancel() { | ||||||
|         if (this.currentResolver) { |         if (this.currentResolver) { | ||||||
|             this.currentResolver(null); // Resolve with null for cancellation |             const resolver = this.currentResolver; | ||||||
|             this._cleanup(); |             this._cleanup(); | ||||||
|  |             resolver(null); | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     _cleanup() { |     _cleanup() { | ||||||
|         this.confirmButton.onclick = null; |         this.confirmButton.onclick = null; | ||||||
|         this.modal.hide(); |         this.inputElement.onkeydown = null; | ||||||
|         this.currentResolver = null; |         this.currentResolver = null; | ||||||
|     } |         this.isShowing = false; | ||||||
|  |         this.modal.hide(); | ||||||
|  |  | ||||||
|     confirm(message, title = 'Confirmation') { |         // Restore aria-hidden after modal is hidden | ||||||
|         return this._show(message, title, false); |         this.modalElement.addEventListener('hidden.bs.modal', () => { | ||||||
|     } |             this.modalElement.setAttribute('aria-hidden', 'true'); | ||||||
|  |         }, { once: true }); | ||||||
|     prompt(message, defaultValue = '', title = 'Prompt') { |  | ||||||
|         return this._show(message, title, true, defaultValue); |  | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
| // Make it globally available | // Make it globally available | ||||||
| window.ConfirmationManager = new Confirmation('confirmationModal'); | window.ConfirmationManager = new ModalManager('confirmationModal'); | ||||||
|  | window.ModalManager = window.ConfirmationManager; // Alias for clarity | ||||||
|   | |||||||
							
								
								
									
										89
									
								
								static/js/context-menu.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,89 @@ | |||||||
|  | /** | ||||||
|  |  * Context Menu Module | ||||||
|  |  * Handles the right-click context menu for file tree items | ||||||
|  |  */ | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Show context menu at specified position | ||||||
|  |  * @param {number} x - X coordinate | ||||||
|  |  * @param {number} y - Y coordinate | ||||||
|  |  * @param {Object} target - Target object with path and isDir properties | ||||||
|  |  */ | ||||||
|  | function showContextMenu(x, y, target) { | ||||||
|  |     const menu = document.getElementById('contextMenu'); | ||||||
|  |     if (!menu) return; | ||||||
|  |  | ||||||
|  |     // Store target data | ||||||
|  |     menu.dataset.targetPath = target.path; | ||||||
|  |     menu.dataset.targetIsDir = target.isDir; | ||||||
|  |  | ||||||
|  |     // Show/hide menu items based on target type | ||||||
|  |     const items = { | ||||||
|  |         'new-file': target.isDir, | ||||||
|  |         'new-folder': target.isDir, | ||||||
|  |         'upload': target.isDir, | ||||||
|  |         'download': true, | ||||||
|  |         'paste': target.isDir && window.fileTreeActions?.clipboard, | ||||||
|  |         'open': !target.isDir | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     Object.entries(items).forEach(([action, show]) => { | ||||||
|  |         const item = menu.querySelector(`[data-action="${action}"]`); | ||||||
|  |         if (item) { | ||||||
|  |             item.style.display = show ? 'flex' : 'none'; | ||||||
|  |         } | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     // Position menu | ||||||
|  |     menu.style.display = 'block'; | ||||||
|  |     menu.style.left = x + 'px'; | ||||||
|  |     menu.style.top = y + 'px'; | ||||||
|  |  | ||||||
|  |     // Adjust if off-screen | ||||||
|  |     setTimeout(() => { | ||||||
|  |         const rect = menu.getBoundingClientRect(); | ||||||
|  |         if (rect.right > window.innerWidth) { | ||||||
|  |             menu.style.left = (window.innerWidth - rect.width - 10) + 'px'; | ||||||
|  |         } | ||||||
|  |         if (rect.bottom > window.innerHeight) { | ||||||
|  |             menu.style.top = (window.innerHeight - rect.height - 10) + 'px'; | ||||||
|  |         } | ||||||
|  |     }, 0); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Hide the context menu | ||||||
|  |  */ | ||||||
|  | function hideContextMenu() { | ||||||
|  |     const menu = document.getElementById('contextMenu'); | ||||||
|  |     if (menu) { | ||||||
|  |         menu.style.display = 'none'; | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Combined click handler for context menu and outside clicks | ||||||
|  | document.addEventListener('click', async (e) => { | ||||||
|  |     const menuItem = e.target.closest('.context-menu-item'); | ||||||
|  |  | ||||||
|  |     if (menuItem) { | ||||||
|  |         // Handle context menu item click | ||||||
|  |         const action = menuItem.dataset.action; | ||||||
|  |         const menu = document.getElementById('contextMenu'); | ||||||
|  |         const targetPath = menu.dataset.targetPath; | ||||||
|  |         const isDir = menu.dataset.targetIsDir === 'true'; | ||||||
|  |  | ||||||
|  |         hideContextMenu(); | ||||||
|  |  | ||||||
|  |         if (window.fileTreeActions) { | ||||||
|  |             await window.fileTreeActions.execute(action, targetPath, isDir); | ||||||
|  |         } | ||||||
|  |     } else if (!e.target.closest('#contextMenu') && !e.target.closest('.tree-node')) { | ||||||
|  |         // Hide on outside click | ||||||
|  |         hideContextMenu(); | ||||||
|  |     } | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // Make functions globally available | ||||||
|  | window.showContextMenu = showContextMenu; | ||||||
|  | window.hideContextMenu = hideContextMenu; | ||||||
|  |  | ||||||
							
								
								
									
										77
									
								
								static/js/dark-mode.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,77 @@ | |||||||
|  | /** | ||||||
|  |  * Dark Mode Module | ||||||
|  |  * Manages dark mode theme switching and persistence | ||||||
|  |  */ | ||||||
|  |  | ||||||
|  | class DarkMode { | ||||||
|  |     constructor() { | ||||||
|  |         this.isDark = localStorage.getItem(Config.STORAGE_KEYS.DARK_MODE) === 'true'; | ||||||
|  |         this.apply(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Toggle dark mode on/off | ||||||
|  |      */ | ||||||
|  |     toggle() { | ||||||
|  |         this.isDark = !this.isDark; | ||||||
|  |         localStorage.setItem(Config.STORAGE_KEYS.DARK_MODE, this.isDark); | ||||||
|  |         this.apply(); | ||||||
|  |  | ||||||
|  |         Logger.debug(`Dark mode ${this.isDark ? 'enabled' : 'disabled'}`); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Apply the current dark mode state | ||||||
|  |      */ | ||||||
|  |     apply() { | ||||||
|  |         if (this.isDark) { | ||||||
|  |             document.body.classList.add('dark-mode'); | ||||||
|  |             const btn = document.getElementById('darkModeBtn'); | ||||||
|  |             if (btn) btn.innerHTML = '<i class="bi bi-sun-fill"></i>'; | ||||||
|  |  | ||||||
|  |             // Update mermaid theme | ||||||
|  |             if (window.mermaid) { | ||||||
|  |                 mermaid.initialize({ theme: Config.MERMAID_THEME_DARK }); | ||||||
|  |             } | ||||||
|  |         } else { | ||||||
|  |             document.body.classList.remove('dark-mode'); | ||||||
|  |             const btn = document.getElementById('darkModeBtn'); | ||||||
|  |             if (btn) btn.innerHTML = '<i class="bi bi-moon-fill"></i>'; | ||||||
|  |  | ||||||
|  |             // Update mermaid theme | ||||||
|  |             if (window.mermaid) { | ||||||
|  |                 mermaid.initialize({ theme: Config.MERMAID_THEME_LIGHT }); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Check if dark mode is currently enabled | ||||||
|  |      * @returns {boolean} True if dark mode is enabled | ||||||
|  |      */ | ||||||
|  |     isEnabled() { | ||||||
|  |         return this.isDark; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Enable dark mode | ||||||
|  |      */ | ||||||
|  |     enable() { | ||||||
|  |         if (!this.isDark) { | ||||||
|  |             this.toggle(); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Disable dark mode | ||||||
|  |      */ | ||||||
|  |     disable() { | ||||||
|  |         if (this.isDark) { | ||||||
|  |             this.toggle(); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Make DarkMode globally available | ||||||
|  | window.DarkMode = DarkMode; | ||||||
|  |  | ||||||
							
								
								
									
										67
									
								
								static/js/editor-drop-handler.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,67 @@ | |||||||
|  | /** | ||||||
|  |  * Editor Drop Handler Module | ||||||
|  |  * Handles file drops into the editor for uploading | ||||||
|  |  */ | ||||||
|  |  | ||||||
|  | class EditorDropHandler { | ||||||
|  |     constructor(editorElement, onFileDrop) { | ||||||
|  |         this.editorElement = editorElement; | ||||||
|  |         this.onFileDrop = onFileDrop; | ||||||
|  |         this.setupHandlers(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Setup drag and drop event handlers | ||||||
|  |      */ | ||||||
|  |     setupHandlers() { | ||||||
|  |         this.editorElement.addEventListener('dragover', (e) => { | ||||||
|  |             e.preventDefault(); | ||||||
|  |             e.stopPropagation(); | ||||||
|  |             this.editorElement.classList.add('drag-over'); | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         this.editorElement.addEventListener('dragleave', (e) => { | ||||||
|  |             e.preventDefault(); | ||||||
|  |             e.stopPropagation(); | ||||||
|  |             this.editorElement.classList.remove('drag-over'); | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         this.editorElement.addEventListener('drop', async (e) => { | ||||||
|  |             e.preventDefault(); | ||||||
|  |             e.stopPropagation(); | ||||||
|  |             this.editorElement.classList.remove('drag-over'); | ||||||
|  |  | ||||||
|  |             const files = Array.from(e.dataTransfer.files); | ||||||
|  |             if (files.length === 0) return; | ||||||
|  |  | ||||||
|  |             Logger.debug(`Dropped ${files.length} file(s) into editor`); | ||||||
|  |  | ||||||
|  |             for (const file of files) { | ||||||
|  |                 try { | ||||||
|  |                     if (this.onFileDrop) { | ||||||
|  |                         await this.onFileDrop(file); | ||||||
|  |                     } | ||||||
|  |                 } catch (error) { | ||||||
|  |                     Logger.error('Drop failed:', error); | ||||||
|  |                     if (window.showNotification) { | ||||||
|  |                         window.showNotification(`Failed to upload ${file.name}`, 'error'); | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Remove event handlers | ||||||
|  |      */ | ||||||
|  |     destroy() { | ||||||
|  |         // Note: We can't easily remove the event listeners without keeping references | ||||||
|  |         // This is a limitation of the current implementation | ||||||
|  |         // In a future refactor, we could store the bound handlers | ||||||
|  |         Logger.debug('EditorDropHandler destroyed'); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Make EditorDropHandler globally available | ||||||
|  | window.EditorDropHandler = EditorDropHandler; | ||||||
|  |  | ||||||
| @@ -4,15 +4,26 @@ | |||||||
|  */ |  */ | ||||||
|  |  | ||||||
| class MarkdownEditor { | class MarkdownEditor { | ||||||
|     constructor(editorId, previewId, filenameInputId) { |     constructor(editorId, previewId, filenameInputId, readOnly = false) { | ||||||
|         this.editorElement = document.getElementById(editorId); |         this.editorElement = document.getElementById(editorId); | ||||||
|         this.previewElement = document.getElementById(previewId); |         this.previewElement = document.getElementById(previewId); | ||||||
|         this.filenameInput = document.getElementById(filenameInputId); |         this.filenameInput = document.getElementById(filenameInputId); | ||||||
|         this.currentFile = null; |         this.currentFile = null; | ||||||
|         this.webdavClient = null; |         this.webdavClient = null; | ||||||
|         this.macroProcessor = new MacroProcessor(null); // Will be set later |         this.macroProcessor = new MacroProcessor(null); // Will be set later | ||||||
|  |         this.lastViewedStorageKey = 'lastViewedPage'; // localStorage key for tracking last viewed page | ||||||
|  |         this.readOnly = readOnly; // Whether editor is in read-only mode | ||||||
|  |         this.editor = null; // Will be initialized later | ||||||
|  |         this.isShowingCustomPreview = false; // Flag to prevent auto-update when showing binary files | ||||||
|  |  | ||||||
|  |         // Initialize loading spinners (will be created lazily when needed) | ||||||
|  |         this.editorSpinner = null; | ||||||
|  |         this.previewSpinner = null; | ||||||
|  |  | ||||||
|  |         // Only initialize CodeMirror if not in read-only mode (view mode) | ||||||
|  |         if (!readOnly) { | ||||||
|             this.initCodeMirror(); |             this.initCodeMirror(); | ||||||
|  |         } | ||||||
|         this.initMarkdown(); |         this.initMarkdown(); | ||||||
|         this.initMermaid(); |         this.initMermaid(); | ||||||
|     } |     } | ||||||
| @@ -21,22 +32,27 @@ class MarkdownEditor { | |||||||
|      * Initialize CodeMirror |      * Initialize CodeMirror | ||||||
|      */ |      */ | ||||||
|     initCodeMirror() { |     initCodeMirror() { | ||||||
|  |         // Determine theme based on dark mode | ||||||
|  |         const isDarkMode = document.body.classList.contains('dark-mode'); | ||||||
|  |         const theme = isDarkMode ? 'monokai' : 'default'; | ||||||
|  |  | ||||||
|         this.editor = CodeMirror(this.editorElement, { |         this.editor = CodeMirror(this.editorElement, { | ||||||
|             mode: 'markdown', |             mode: 'markdown', | ||||||
|             theme: 'monokai', |             theme: theme, | ||||||
|             lineNumbers: true, |             lineNumbers: true, | ||||||
|             lineWrapping: true, |             lineWrapping: true, | ||||||
|             autofocus: true, |             autofocus: !this.readOnly, // Don't autofocus in read-only mode | ||||||
|             extraKeys: { |             readOnly: this.readOnly, // Set read-only mode | ||||||
|  |             extraKeys: this.readOnly ? {} : { | ||||||
|                 'Ctrl-S': () => this.save(), |                 'Ctrl-S': () => this.save(), | ||||||
|                 'Cmd-S': () => this.save() |                 'Cmd-S': () => this.save() | ||||||
|             } |             } | ||||||
|         }); |         }); | ||||||
|  |  | ||||||
|         // Update preview on change with debouncing |         // Update preview on change with debouncing | ||||||
|         this.editor.on('change', this.debounce(() => { |         this.editor.on('change', TimingUtils.debounce(() => { | ||||||
|             this.updatePreview(); |             this.updatePreview(); | ||||||
|         }, 300)); |         }, Config.DEBOUNCE_DELAY)); | ||||||
|  |  | ||||||
|         // Initial preview render |         // Initial preview render | ||||||
|         setTimeout(() => { |         setTimeout(() => { | ||||||
| @@ -47,6 +63,27 @@ class MarkdownEditor { | |||||||
|         this.editor.on('scroll', () => { |         this.editor.on('scroll', () => { | ||||||
|             this.syncScroll(); |             this.syncScroll(); | ||||||
|         }); |         }); | ||||||
|  |  | ||||||
|  |         // Listen for dark mode changes | ||||||
|  |         this.setupThemeListener(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Setup listener for dark mode changes | ||||||
|  |      */ | ||||||
|  |     setupThemeListener() { | ||||||
|  |         // Watch for dark mode class changes | ||||||
|  |         const observer = new MutationObserver((mutations) => { | ||||||
|  |             mutations.forEach((mutation) => { | ||||||
|  |                 if (mutation.attributeName === 'class') { | ||||||
|  |                     const isDarkMode = document.body.classList.contains('dark-mode'); | ||||||
|  |                     const newTheme = isDarkMode ? 'monokai' : 'default'; | ||||||
|  |                     this.editor.setOption('theme', newTheme); | ||||||
|  |                 } | ||||||
|  |             }); | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         observer.observe(document.body, { attributes: true }); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     /** |     /** | ||||||
| @@ -55,9 +92,88 @@ class MarkdownEditor { | |||||||
|     initMarkdown() { |     initMarkdown() { | ||||||
|         if (window.marked) { |         if (window.marked) { | ||||||
|             this.marked = window.marked; |             this.marked = window.marked; | ||||||
|  |  | ||||||
|  |             // Create custom renderer for images | ||||||
|  |             const renderer = new marked.Renderer(); | ||||||
|  |  | ||||||
|  |             renderer.image = (token) => { | ||||||
|  |                 // Handle both old API (string params) and new API (token object) | ||||||
|  |                 let href, title, text; | ||||||
|  |  | ||||||
|  |                 if (typeof token === 'object' && token !== null) { | ||||||
|  |                     // New API: token is an object | ||||||
|  |                     href = token.href || ''; | ||||||
|  |                     title = token.title || ''; | ||||||
|  |                     text = token.text || ''; | ||||||
|  |                 } else { | ||||||
|  |                     // Old API: separate parameters (href, title, text) | ||||||
|  |                     href = arguments[0] || ''; | ||||||
|  |                     title = arguments[1] || ''; | ||||||
|  |                     text = arguments[2] || ''; | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 // Ensure all are strings | ||||||
|  |                 href = String(href || ''); | ||||||
|  |                 title = String(title || ''); | ||||||
|  |                 text = String(text || ''); | ||||||
|  |  | ||||||
|  |                 Logger.debug(`Image renderer called with href="${href}", title="${title}", text="${text}"`); | ||||||
|  |  | ||||||
|  |                 // Check if href contains binary data (starts with non-printable characters) | ||||||
|  |                 if (href && href.length > 100 && /^[\x00-\x1F\x7F-\xFF]/.test(href)) { | ||||||
|  |                     Logger.error('Image href contains binary data - this should not happen!'); | ||||||
|  |                     Logger.error('First 50 chars:', href.substring(0, 50)); | ||||||
|  |                     // Return a placeholder image | ||||||
|  |                     return `<div class="alert alert-warning">⚠️ Invalid image data detected. Please re-upload the image.</div>`; | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 // Fix relative image paths to use WebDAV base URL | ||||||
|  |                 if (href && !href.startsWith('http://') && !href.startsWith('https://') && !href.startsWith('data:')) { | ||||||
|  |                     // Get the directory of the current file | ||||||
|  |                     const currentDir = this.currentFile ? PathUtils.getParentPath(this.currentFile) : ''; | ||||||
|  |  | ||||||
|  |                     // Resolve relative path | ||||||
|  |                     let imagePath = href; | ||||||
|  |                     if (href.startsWith('./')) { | ||||||
|  |                         // Relative to current directory | ||||||
|  |                         imagePath = PathUtils.joinPaths(currentDir, href.substring(2)); | ||||||
|  |                     } else if (href.startsWith('../')) { | ||||||
|  |                         // Relative to parent directory | ||||||
|  |                         imagePath = PathUtils.joinPaths(currentDir, href); | ||||||
|  |                     } else if (!href.startsWith('/')) { | ||||||
|  |                         // Relative to current directory (no ./) | ||||||
|  |                         imagePath = PathUtils.joinPaths(currentDir, href); | ||||||
|  |                     } else { | ||||||
|  |                         // Absolute path from collection root | ||||||
|  |                         imagePath = href.substring(1); // Remove leading / | ||||||
|  |                     } | ||||||
|  |  | ||||||
|  |                     // Build WebDAV URL - ensure no double slashes | ||||||
|  |                     if (this.webdavClient && this.webdavClient.currentCollection) { | ||||||
|  |                         // Remove trailing slash from baseUrl if present | ||||||
|  |                         const baseUrl = this.webdavClient.baseUrl.endsWith('/') | ||||||
|  |                             ? this.webdavClient.baseUrl.slice(0, -1) | ||||||
|  |                             : this.webdavClient.baseUrl; | ||||||
|  |  | ||||||
|  |                         // Ensure imagePath doesn't start with / | ||||||
|  |                         const cleanImagePath = imagePath.startsWith('/') ? imagePath.substring(1) : imagePath; | ||||||
|  |  | ||||||
|  |                         href = `${baseUrl}/${this.webdavClient.currentCollection}/${cleanImagePath}`; | ||||||
|  |  | ||||||
|  |                         Logger.debug(`Resolved image URL: ${href}`); | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 // Generate HTML directly | ||||||
|  |                 const titleAttr = title ? ` title="${title}"` : ''; | ||||||
|  |                 const altAttr = text ? ` alt="${text}"` : ''; | ||||||
|  |                 return `<img src="${href}"${altAttr}${titleAttr}>`; | ||||||
|  |             }; | ||||||
|  |  | ||||||
|             this.marked.setOptions({ |             this.marked.setOptions({ | ||||||
|                 breaks: true, |                 breaks: true, | ||||||
|                 gfm: true, |                 gfm: true, | ||||||
|  |                 renderer: renderer, | ||||||
|                 highlight: (code, lang) => { |                 highlight: (code, lang) => { | ||||||
|                     if (lang && window.Prism.languages[lang]) { |                     if (lang && window.Prism.languages[lang]) { | ||||||
|                         return window.Prism.highlight(code, window.Prism.languages[lang], lang); |                         return window.Prism.highlight(code, window.Prism.languages[lang], lang); | ||||||
| @@ -94,21 +210,74 @@ class MarkdownEditor { | |||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Initialize loading spinners (lazy initialization) | ||||||
|  |      */ | ||||||
|  |     initLoadingSpinners() { | ||||||
|  |         if (!this.editorSpinner && !this.readOnly && this.editorElement) { | ||||||
|  |             this.editorSpinner = new LoadingSpinner(this.editorElement, 'Loading file...'); | ||||||
|  |         } | ||||||
|  |         if (!this.previewSpinner && this.previewElement) { | ||||||
|  |             this.previewSpinner = new LoadingSpinner(this.previewElement, 'Rendering preview...'); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|     /** |     /** | ||||||
|      * Load file |      * Load file | ||||||
|      */ |      */ | ||||||
|     async loadFile(path) { |     async loadFile(path) { | ||||||
|         try { |         try { | ||||||
|  |             // Initialize loading spinners if not already done | ||||||
|  |             this.initLoadingSpinners(); | ||||||
|  |  | ||||||
|  |             // Show loading spinners | ||||||
|  |             if (this.editorSpinner) { | ||||||
|  |                 this.editorSpinner.show('Loading file...'); | ||||||
|  |             } | ||||||
|  |             if (this.previewSpinner) { | ||||||
|  |                 this.previewSpinner.show('Loading preview...'); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             // Reset custom preview flag when loading text files | ||||||
|  |             this.isShowingCustomPreview = false; | ||||||
|  |  | ||||||
|             const content = await this.webdavClient.get(path); |             const content = await this.webdavClient.get(path); | ||||||
|             this.currentFile = path; |             this.currentFile = path; | ||||||
|             this.filenameInput.value = path; |  | ||||||
|             this.editor.setValue(content); |  | ||||||
|             this.updatePreview(); |  | ||||||
|  |  | ||||||
|             if (window.showNotification) { |             // Update filename input if it exists | ||||||
|                 window.showNotification(`Loaded ${path}`, 'info'); |             if (this.filenameInput) { | ||||||
|  |                 this.filenameInput.value = path; | ||||||
|             } |             } | ||||||
|  |  | ||||||
|  |             // Update editor if it exists (edit mode) | ||||||
|  |             if (this.editor) { | ||||||
|  |                 this.editor.setValue(content); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             // Update preview with the loaded content | ||||||
|  |             await this.renderPreview(content); | ||||||
|  |  | ||||||
|  |             // Save as last viewed page | ||||||
|  |             this.saveLastViewedPage(path); | ||||||
|  |  | ||||||
|  |             // Hide loading spinners | ||||||
|  |             if (this.editorSpinner) { | ||||||
|  |                 this.editorSpinner.hide(); | ||||||
|  |             } | ||||||
|  |             if (this.previewSpinner) { | ||||||
|  |                 this.previewSpinner.hide(); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             // No notification for successful file load - it's not critical | ||||||
|         } catch (error) { |         } catch (error) { | ||||||
|  |             // Hide loading spinners on error | ||||||
|  |             if (this.editorSpinner) { | ||||||
|  |                 this.editorSpinner.hide(); | ||||||
|  |             } | ||||||
|  |             if (this.previewSpinner) { | ||||||
|  |                 this.previewSpinner.hide(); | ||||||
|  |             } | ||||||
|  |  | ||||||
|             console.error('Failed to load file:', error); |             console.error('Failed to load file:', error); | ||||||
|             if (window.showNotification) { |             if (window.showNotification) { | ||||||
|                 window.showNotification('Failed to load file', 'danger'); |                 window.showNotification('Failed to load file', 'danger'); | ||||||
| @@ -116,6 +285,32 @@ class MarkdownEditor { | |||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Save the last viewed page to localStorage | ||||||
|  |      * Stores per collection so different collections can have different last viewed pages | ||||||
|  |      */ | ||||||
|  |     saveLastViewedPage(path) { | ||||||
|  |         if (!this.webdavClient || !this.webdavClient.currentCollection) { | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |         const collection = this.webdavClient.currentCollection; | ||||||
|  |         const storageKey = `${this.lastViewedStorageKey}:${collection}`; | ||||||
|  |         localStorage.setItem(storageKey, path); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Get the last viewed page from localStorage | ||||||
|  |      * Returns null if no page was previously viewed | ||||||
|  |      */ | ||||||
|  |     getLastViewedPage() { | ||||||
|  |         if (!this.webdavClient || !this.webdavClient.currentCollection) { | ||||||
|  |             return null; | ||||||
|  |         } | ||||||
|  |         const collection = this.webdavClient.currentCollection; | ||||||
|  |         const storageKey = `${this.lastViewedStorageKey}:${collection}`; | ||||||
|  |         return localStorage.getItem(storageKey); | ||||||
|  |     } | ||||||
|  |  | ||||||
|     /** |     /** | ||||||
|      * Save file |      * Save file | ||||||
|      */ |      */ | ||||||
| @@ -159,10 +354,7 @@ class MarkdownEditor { | |||||||
|         this.filenameInput.focus(); |         this.filenameInput.focus(); | ||||||
|         this.editor.setValue('# New File\n\nStart typing...\n'); |         this.editor.setValue('# New File\n\nStart typing...\n'); | ||||||
|         this.updatePreview(); |         this.updatePreview(); | ||||||
|  |         // No notification needed - UI is self-explanatory | ||||||
|         if (window.showNotification) { |  | ||||||
|             window.showNotification('Enter filename and start typing', 'info'); |  | ||||||
|         } |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     /** |     /** | ||||||
| @@ -174,7 +366,7 @@ class MarkdownEditor { | |||||||
|             return; |             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) { |         if (confirmed) { | ||||||
|             try { |             try { | ||||||
|                 await this.webdavClient.delete(this.currentFile); |                 await this.webdavClient.delete(this.currentFile); | ||||||
| @@ -189,10 +381,66 @@ class MarkdownEditor { | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     /** |     /** | ||||||
|      * Update preview |      * Convert JSX-style attributes to HTML attributes | ||||||
|  |      * Handles style={{...}} and boolean attributes like allowFullScreen={true} | ||||||
|      */ |      */ | ||||||
|     async updatePreview() { |     convertJSXToHTML(content) { | ||||||
|         const markdown = this.editor.getValue(); |         Logger.debug('Converting JSX to HTML...'); | ||||||
|  |  | ||||||
|  |         // Convert style={{...}} to style="..." | ||||||
|  |         // This regex finds style={{...}} and converts the object notation to CSS string | ||||||
|  |         content = content.replace(/style=\{\{([^}]+)\}\}/g, (match, styleContent) => { | ||||||
|  |             Logger.debug(`Found JSX style: ${match}`); | ||||||
|  |  | ||||||
|  |             // Parse the object-like syntax and convert to CSS | ||||||
|  |             const cssRules = styleContent | ||||||
|  |                 .split(',') | ||||||
|  |                 .map(rule => { | ||||||
|  |                     const colonIndex = rule.indexOf(':'); | ||||||
|  |                     if (colonIndex === -1) return ''; | ||||||
|  |  | ||||||
|  |                     const key = rule.substring(0, colonIndex).trim(); | ||||||
|  |                     const value = rule.substring(colonIndex + 1).trim(); | ||||||
|  |  | ||||||
|  |                     if (!key || !value) return ''; | ||||||
|  |  | ||||||
|  |                     // Convert camelCase to kebab-case (e.g., paddingTop -> padding-top) | ||||||
|  |                     const cssKey = key.replace(/([A-Z])/g, '-$1').toLowerCase(); | ||||||
|  |  | ||||||
|  |                     // Remove quotes from value | ||||||
|  |                     let cssValue = value.replace(/^['"]|['"]$/g, ''); | ||||||
|  |  | ||||||
|  |                     return `${cssKey}: ${cssValue}`; | ||||||
|  |                 }) | ||||||
|  |                 .filter(rule => rule) | ||||||
|  |                 .join('; '); | ||||||
|  |  | ||||||
|  |             Logger.debug(`Converted to CSS: style="${cssRules}"`); | ||||||
|  |             return `style="${cssRules}"`; | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         // Convert boolean attributes like allowFullScreen={true} to allowfullscreen | ||||||
|  |         content = content.replace(/(\w+)=\{true\}/g, (match, attrName) => { | ||||||
|  |             Logger.debug(`Found boolean attribute: ${match}`); | ||||||
|  |             // Convert camelCase to lowercase for HTML attributes | ||||||
|  |             const htmlAttr = attrName.toLowerCase(); | ||||||
|  |             Logger.debug(`Converted to: ${htmlAttr}`); | ||||||
|  |             return htmlAttr; | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         // Remove attributes set to {false} | ||||||
|  |         content = content.replace(/\s+\w+=\{false\}/g, ''); | ||||||
|  |  | ||||||
|  |         return content; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Render preview from markdown content | ||||||
|  |      * Can be called with explicit content (for view mode) or from editor (for edit mode) | ||||||
|  |      */ | ||||||
|  |     async renderPreview(markdownContent = null) { | ||||||
|  |         // Get markdown content from editor if not provided | ||||||
|  |         const markdown = markdownContent !== null ? markdownContent : (this.editor ? this.editor.getValue() : ''); | ||||||
|         const previewDiv = this.previewElement; |         const previewDiv = this.previewElement; | ||||||
|  |  | ||||||
|         if (!markdown || !markdown.trim()) { |         if (!markdown || !markdown.trim()) { | ||||||
| @@ -205,23 +453,30 @@ class MarkdownEditor { | |||||||
|         } |         } | ||||||
|  |  | ||||||
|         try { |         try { | ||||||
|             // Step 1: Process macros |             // Initialize loading spinners if not already done | ||||||
|             let processedContent = markdown; |             this.initLoadingSpinners(); | ||||||
|  |  | ||||||
|             if (this.macroProcessor) { |             // Show preview loading spinner (only if not already shown by loadFile) | ||||||
|                 const processingResult = await this.macroProcessor.processMacros(markdown); |             if (this.previewSpinner && !this.previewSpinner.isVisible()) { | ||||||
|                 processedContent = processingResult.content; |                 this.previewSpinner.show('Rendering preview...'); | ||||||
|                  |  | ||||||
|                 // Log errors if any |  | ||||||
|                 if (processingResult.errors.length > 0) { |  | ||||||
|                     console.warn('Macro processing errors:', processingResult.errors); |  | ||||||
|             } |             } | ||||||
|  |  | ||||||
|  |             // Step 0: Convert JSX-style syntax to HTML | ||||||
|  |             let processedContent = this.convertJSXToHTML(markdown); | ||||||
|  |  | ||||||
|  |             // Step 1: Process macros | ||||||
|  |             if (this.macroProcessor) { | ||||||
|  |                 const processingResult = await this.macroProcessor.processMacros(processedContent); | ||||||
|  |                 processedContent = processingResult.content; | ||||||
|             } |             } | ||||||
|  |  | ||||||
|             // Step 2: Parse markdown to HTML |             // Step 2: Parse markdown to HTML | ||||||
|             if (!this.marked) { |             if (!this.marked) { | ||||||
|                 console.error("Markdown parser (marked) not initialized."); |                 console.error("Markdown parser (marked) not initialized."); | ||||||
|                 previewDiv.innerHTML = `<div class="alert alert-danger">Preview engine not loaded.</div>`; |                 previewDiv.innerHTML = `<div class="alert alert-danger">Preview engine not loaded.</div>`; | ||||||
|  |                 if (this.previewSpinner) { | ||||||
|  |                     this.previewSpinner.hide(); | ||||||
|  |                 } | ||||||
|                 return; |                 return; | ||||||
|             } |             } | ||||||
|  |  | ||||||
| @@ -259,6 +514,13 @@ class MarkdownEditor { | |||||||
|                     console.warn('Mermaid rendering error:', error); |                     console.warn('Mermaid rendering error:', error); | ||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
|  |  | ||||||
|  |             // Hide preview loading spinner after a small delay to ensure rendering is complete | ||||||
|  |             setTimeout(() => { | ||||||
|  |                 if (this.previewSpinner) { | ||||||
|  |                     this.previewSpinner.hide(); | ||||||
|  |                 } | ||||||
|  |             }, 100); | ||||||
|         } catch (error) { |         } catch (error) { | ||||||
|             console.error('Preview rendering error:', error); |             console.error('Preview rendering error:', error); | ||||||
|             previewDiv.innerHTML = ` |             previewDiv.innerHTML = ` | ||||||
| @@ -267,6 +529,27 @@ class MarkdownEditor { | |||||||
|                     ${error.message} |                     ${error.message} | ||||||
|                 </div> |                 </div> | ||||||
|             `; |             `; | ||||||
|  |  | ||||||
|  |             // Hide loading spinner on error | ||||||
|  |             if (this.previewSpinner) { | ||||||
|  |                 this.previewSpinner.hide(); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Update preview (backward compatibility wrapper) | ||||||
|  |      * Calls renderPreview with content from editor | ||||||
|  |      */ | ||||||
|  |     async updatePreview() { | ||||||
|  |         // Skip auto-update if showing custom preview (e.g., binary files) | ||||||
|  |         if (this.isShowingCustomPreview) { | ||||||
|  |             Logger.debug('Skipping auto-update: showing custom preview'); | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (this.editor) { | ||||||
|  |             await this.renderPreview(); | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
| @@ -274,6 +557,8 @@ class MarkdownEditor { | |||||||
|      * Sync scroll between editor and preview |      * Sync scroll between editor and preview | ||||||
|      */ |      */ | ||||||
|     syncScroll() { |     syncScroll() { | ||||||
|  |         if (!this.editor) return; // Skip if no editor (view mode) | ||||||
|  |  | ||||||
|         const scrollInfo = this.editor.getScrollInfo(); |         const scrollInfo = this.editor.getScrollInfo(); | ||||||
|         const scrollPercent = scrollInfo.top / (scrollInfo.height - scrollInfo.clientHeight); |         const scrollPercent = scrollInfo.top / (scrollInfo.height - scrollInfo.clientHeight); | ||||||
|  |  | ||||||
| @@ -324,20 +609,7 @@ class MarkdownEditor { | |||||||
|         this.editor.setValue(content); |         this.editor.setValue(content); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     /** |     // Debounce function moved to TimingUtils in utils.js | ||||||
|      * Debounce function |  | ||||||
|      */ |  | ||||||
|     debounce(func, wait) { |  | ||||||
|         let timeout; |  | ||||||
|         return function executedFunction(...args) { |  | ||||||
|             const later = () => { |  | ||||||
|                 clearTimeout(timeout); |  | ||||||
|                 func(...args); |  | ||||||
|             }; |  | ||||||
|             clearTimeout(timeout); |  | ||||||
|             timeout = setTimeout(later, wait); |  | ||||||
|         }; |  | ||||||
|     } |  | ||||||
| } | } | ||||||
|  |  | ||||||
| // Export for use in other modules | // Export for use in other modules | ||||||
|   | |||||||
							
								
								
									
										126
									
								
								static/js/event-bus.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,126 @@ | |||||||
|  | /** | ||||||
|  |  * Event Bus Module | ||||||
|  |  * Provides a centralized event system for application-wide communication | ||||||
|  |  * Allows components to communicate without tight coupling | ||||||
|  |  */ | ||||||
|  |  | ||||||
|  | class EventBus { | ||||||
|  |     constructor() { | ||||||
|  |         /** | ||||||
|  |          * Map of event names to arrays of listener functions | ||||||
|  |          * @type {Object.<string, Function[]>} | ||||||
|  |          */ | ||||||
|  |         this.listeners = {}; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Register an event listener | ||||||
|  |      * @param {string} event - The event name to listen for | ||||||
|  |      * @param {Function} callback - The function to call when the event is dispatched | ||||||
|  |      * @returns {Function} A function to unregister this listener | ||||||
|  |      */ | ||||||
|  |     on(event, callback) { | ||||||
|  |         if (!this.listeners[event]) { | ||||||
|  |             this.listeners[event] = []; | ||||||
|  |         } | ||||||
|  |         this.listeners[event].push(callback); | ||||||
|  |  | ||||||
|  |         // Return unsubscribe function | ||||||
|  |         return () => this.off(event, callback); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Register a one-time event listener | ||||||
|  |      * The listener will be automatically removed after being called once | ||||||
|  |      * @param {string} event - The event name to listen for | ||||||
|  |      * @param {Function} callback - The function to call when the event is dispatched | ||||||
|  |      * @returns {Function} A function to unregister this listener | ||||||
|  |      */ | ||||||
|  |     once(event, callback) { | ||||||
|  |         const onceWrapper = (data) => { | ||||||
|  |             callback(data); | ||||||
|  |             this.off(event, onceWrapper); | ||||||
|  |         }; | ||||||
|  |         return this.on(event, onceWrapper); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Unregister an event listener | ||||||
|  |      * @param {string} event - The event name | ||||||
|  |      * @param {Function} callback - The callback function to remove | ||||||
|  |      */ | ||||||
|  |     off(event, callback) { | ||||||
|  |         if (!this.listeners[event]) { | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         this.listeners[event] = this.listeners[event].filter( | ||||||
|  |             listener => listener !== callback | ||||||
|  |         ); | ||||||
|  |  | ||||||
|  |         // Clean up empty listener arrays | ||||||
|  |         if (this.listeners[event].length === 0) { | ||||||
|  |             delete this.listeners[event]; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Dispatch an event to all registered listeners | ||||||
|  |      * @param {string} event - The event name to dispatch | ||||||
|  |      * @param {any} data - The data to pass to the listeners | ||||||
|  |      */ | ||||||
|  |     dispatch(event, data) { | ||||||
|  |         if (!this.listeners[event]) { | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // Create a copy of the listeners array to avoid issues if listeners are added/removed during dispatch | ||||||
|  |         const listeners = [...this.listeners[event]]; | ||||||
|  |          | ||||||
|  |         listeners.forEach(callback => { | ||||||
|  |             try { | ||||||
|  |                 callback(data); | ||||||
|  |             } catch (error) { | ||||||
|  |                 Logger.error(`Error in event listener for "${event}":`, error); | ||||||
|  |             } | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Remove all listeners for a specific event | ||||||
|  |      * If no event is specified, removes all listeners for all events | ||||||
|  |      * @param {string} [event] - The event name (optional) | ||||||
|  |      */ | ||||||
|  |     clear(event) { | ||||||
|  |         if (event) { | ||||||
|  |             delete this.listeners[event]; | ||||||
|  |         } else { | ||||||
|  |             this.listeners = {}; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Get the number of listeners for an event | ||||||
|  |      * @param {string} event - The event name | ||||||
|  |      * @returns {number} The number of listeners | ||||||
|  |      */ | ||||||
|  |     listenerCount(event) { | ||||||
|  |         return this.listeners[event] ? this.listeners[event].length : 0; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Get all event names that have listeners | ||||||
|  |      * @returns {string[]} Array of event names | ||||||
|  |      */ | ||||||
|  |     eventNames() { | ||||||
|  |         return Object.keys(this.listeners); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Create and export the global event bus instance | ||||||
|  | const eventBus = new EventBus(); | ||||||
|  |  | ||||||
|  | // Make it globally available | ||||||
|  | window.eventBus = eventBus; | ||||||
|  | window.EventBus = EventBus; | ||||||
|  |  | ||||||
| @@ -14,32 +14,10 @@ class FileTreeActions { | |||||||
|     /** |     /** | ||||||
|      * Validate and sanitize filename/folder name |      * Validate and sanitize filename/folder name | ||||||
|      * Returns { valid: boolean, sanitized: string, message: string } |      * Returns { valid: boolean, sanitized: string, message: string } | ||||||
|  |      * Now uses ValidationUtils from utils.js | ||||||
|      */ |      */ | ||||||
|     validateFileName(name, isFolder = false) { |     validateFileName(name, isFolder = false) { | ||||||
|         const type = isFolder ? 'folder' : 'file'; |         return ValidationUtils.validateFileName(name, isFolder); | ||||||
|          |  | ||||||
|         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: '' }; |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     async execute(action, targetPath, isDirectory) { |     async execute(action, targetPath, isDirectory) { | ||||||
| @@ -58,18 +36,24 @@ class FileTreeActions { | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     actions = { |     actions = { | ||||||
|         open: async function(path, isDir) { |         open: async function (path, isDir) { | ||||||
|             if (!isDir) { |             if (!isDir) { | ||||||
|                 await this.editor.loadFile(path); |                 await this.editor.loadFile(path); | ||||||
|             } |             } | ||||||
|         }, |         }, | ||||||
|  |  | ||||||
|         'new-file': async function(path, isDir) { |         'new-file': async function (path, isDir) { | ||||||
|             if (!isDir) return; |             if (!isDir) return; | ||||||
|  |  | ||||||
|             await this.showInputDialog('Enter filename (lowercase, underscore only):', 'new_file.md', async (filename) => { |             const filename = await window.ModalManager.prompt( | ||||||
|  |                 'Enter filename (lowercase, underscore only):', | ||||||
|  |                 'new_file.md', | ||||||
|  |                 'New File' | ||||||
|  |             ); | ||||||
|  |  | ||||||
|             if (!filename) return; |             if (!filename) return; | ||||||
|  |  | ||||||
|  |             let finalFilename = filename; | ||||||
|             const validation = this.validateFileName(filename, false); |             const validation = this.validateFileName(filename, false); | ||||||
|  |  | ||||||
|             if (!validation.valid) { |             if (!validation.valid) { | ||||||
| @@ -77,8 +61,13 @@ class FileTreeActions { | |||||||
|  |  | ||||||
|                 // Ask if user wants to use sanitized version |                 // Ask if user wants to use sanitized version | ||||||
|                 if (validation.sanitized) { |                 if (validation.sanitized) { | ||||||
|                         if (await this.showConfirmDialog('Use sanitized name?', `${filename} → ${validation.sanitized}`)) { |                     const useSanitized = await window.ModalManager.confirm( | ||||||
|                             filename = validation.sanitized; |                         `${filename} → ${validation.sanitized}`, | ||||||
|  |                         'Use sanitized name?', | ||||||
|  |                         false | ||||||
|  |                     ); | ||||||
|  |                     if (useSanitized) { | ||||||
|  |                         finalFilename = validation.sanitized; | ||||||
|                     } else { |                     } else { | ||||||
|                         return; |                         return; | ||||||
|                     } |                     } | ||||||
| @@ -87,28 +76,44 @@ class FileTreeActions { | |||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
|  |  | ||||||
|                 const fullPath = `${path}/${filename}`.replace(/\/+/g, '/'); |             const fullPath = `${path}/${finalFilename}`.replace(/\/+/g, '/'); | ||||||
|             await this.webdavClient.put(fullPath, '# New File\n\n'); |             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(); |             await this.fileTree.load(); | ||||||
|                 showNotification(`Created ${filename}`, 'success'); |             showNotification(`Created ${finalFilename}`, 'success'); | ||||||
|             await this.editor.loadFile(fullPath); |             await this.editor.loadFile(fullPath); | ||||||
|             }); |  | ||||||
|         }, |         }, | ||||||
|  |  | ||||||
|         'new-folder': async function(path, isDir) { |         'new-folder': async function (path, isDir) { | ||||||
|             if (!isDir) return; |             if (!isDir) return; | ||||||
|  |  | ||||||
|             await this.showInputDialog('Enter folder name (lowercase, underscore only):', 'new_folder', async (foldername) => { |             const foldername = await window.ModalManager.prompt( | ||||||
|  |                 'Enter folder name (lowercase, underscore only):', | ||||||
|  |                 'new_folder', | ||||||
|  |                 'New Folder' | ||||||
|  |             ); | ||||||
|  |  | ||||||
|             if (!foldername) return; |             if (!foldername) return; | ||||||
|  |  | ||||||
|  |             let finalFoldername = foldername; | ||||||
|             const validation = this.validateFileName(foldername, true); |             const validation = this.validateFileName(foldername, true); | ||||||
|  |  | ||||||
|             if (!validation.valid) { |             if (!validation.valid) { | ||||||
|                 showNotification(validation.message, 'warning'); |                 showNotification(validation.message, 'warning'); | ||||||
|  |  | ||||||
|                 if (validation.sanitized) { |                 if (validation.sanitized) { | ||||||
|                         if (await this.showConfirmDialog('Use sanitized name?', `${foldername} → ${validation.sanitized}`)) { |                     const useSanitized = await window.ModalManager.confirm( | ||||||
|                             foldername = validation.sanitized; |                         `${foldername} → ${validation.sanitized}`, | ||||||
|  |                         'Use sanitized name?', | ||||||
|  |                         false | ||||||
|  |                     ); | ||||||
|  |                     if (useSanitized) { | ||||||
|  |                         finalFoldername = validation.sanitized; | ||||||
|                     } else { |                     } else { | ||||||
|                         return; |                         return; | ||||||
|                     } |                     } | ||||||
| @@ -117,38 +122,54 @@ class FileTreeActions { | |||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
|  |  | ||||||
|                 const fullPath = `${path}/${foldername}`.replace(/\/+/g, '/'); |             const fullPath = `${path}/${finalFoldername}`.replace(/\/+/g, '/'); | ||||||
|             await this.webdavClient.mkcol(fullPath); |             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(); |             await this.fileTree.load(); | ||||||
|                 showNotification(`Created folder ${foldername}`, 'success'); |             showNotification(`Created folder ${finalFoldername}`, 'success'); | ||||||
|             }); |  | ||||||
|         }, |         }, | ||||||
|  |  | ||||||
|         rename: async function(path, isDir) { |         rename: async function (path, isDir) { | ||||||
|             const oldName = path.split('/').pop(); |             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) { |             if (newName && newName !== oldName) { | ||||||
|                 const parentPath = path.substring(0, path.lastIndexOf('/')); |                 const parentPath = path.substring(0, path.lastIndexOf('/')); | ||||||
|                 const newPath = parentPath ? `${parentPath}/${newName}` : newName; |                 const newPath = parentPath ? `${parentPath}/${newName}` : newName; | ||||||
|                 await this.webdavClient.move(path, newPath); |                 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(); |                 await this.fileTree.load(); | ||||||
|                 showNotification('Renamed', 'success'); |                 showNotification('Renamed', 'success'); | ||||||
|             } |             } | ||||||
|         }, |         }, | ||||||
|  |  | ||||||
|         copy: async function(path, isDir) { |         copy: async function (path, isDir) { | ||||||
|             this.clipboard = { path, operation: 'copy', isDirectory: 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(); |             this.updatePasteMenuItem(); | ||||||
|         }, |         }, | ||||||
|  |  | ||||||
|         cut: async function(path, isDir) { |         cut: async function (path, isDir) { | ||||||
|             this.clipboard = { path, operation: 'cut', isDirectory: 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(); |             this.updatePasteMenuItem(); | ||||||
|         }, |         }, | ||||||
|  |  | ||||||
|         paste: async function(targetPath, isDir) { |         paste: async function (targetPath, isDir) { | ||||||
|             if (!this.clipboard || !isDir) return; |             if (!this.clipboard || !isDir) return; | ||||||
|  |  | ||||||
|             const itemName = this.clipboard.path.split('/').pop(); |             const itemName = this.clipboard.path.split('/').pop(); | ||||||
| @@ -156,36 +177,87 @@ class FileTreeActions { | |||||||
|  |  | ||||||
|             if (this.clipboard.operation === 'copy') { |             if (this.clipboard.operation === 'copy') { | ||||||
|                 await this.webdavClient.copy(this.clipboard.path, destPath); |                 await this.webdavClient.copy(this.clipboard.path, destPath); | ||||||
|                 showNotification('Copied', 'success'); |                 // No notification for paste - file tree updates show the result | ||||||
|             } else { |             } else { | ||||||
|                 await this.webdavClient.move(this.clipboard.path, destPath); |                 await this.webdavClient.move(this.clipboard.path, destPath); | ||||||
|                 this.clipboard = null; |                 this.clipboard = null; | ||||||
|                 this.updatePasteMenuItem(); |                 this.updatePasteMenuItem(); | ||||||
|                 showNotification('Moved', 'success'); |                 // No notification for move - file tree updates show the result | ||||||
|             } |             } | ||||||
|  |  | ||||||
|             await this.fileTree.load(); |             await this.fileTree.load(); | ||||||
|         }, |         }, | ||||||
|  |  | ||||||
|         delete: async function(path, isDir) { |         delete: async function (path, isDir) { | ||||||
|             const name = path.split('/').pop(); |             const name = path.split('/').pop() || this.webdavClient.currentCollection; | ||||||
|             const type = isDir ? 'folder' : 'file'; |             const type = isDir ? 'folder' : 'file'; | ||||||
|  |  | ||||||
|             if (!await this.showConfirmDialog(`Delete this ${type}?`, `${name}`)) { |             // Check if this is a root-level collection (empty path or single-level path) | ||||||
|                 return; |             const pathParts = path.split('/').filter(p => p.length > 0); | ||||||
|  |             const isCollection = pathParts.length === 0; | ||||||
|  |  | ||||||
|  |             if (isCollection) { | ||||||
|  |                 // Deleting a collection - use backend API | ||||||
|  |                 const confirmed = await window.ModalManager.confirm( | ||||||
|  |                     `Are you sure you want to delete the collection "${name}"? This will delete all files and folders in it.`, | ||||||
|  |                     'Delete Collection?', | ||||||
|  |                     true | ||||||
|  |                 ); | ||||||
|  |  | ||||||
|  |                 if (!confirmed) return; | ||||||
|  |  | ||||||
|  |                 try { | ||||||
|  |                     // Call backend API to delete collection | ||||||
|  |                     const response = await fetch(`/api/collections/${name}`, { | ||||||
|  |                         method: 'DELETE' | ||||||
|  |                     }); | ||||||
|  |  | ||||||
|  |                     if (!response.ok) { | ||||||
|  |                         const error = await response.text(); | ||||||
|  |                         throw new Error(error || 'Failed to delete collection'); | ||||||
|                     } |                     } | ||||||
|  |  | ||||||
|  |                     showNotification(`Collection "${name}" deleted successfully`, 'success'); | ||||||
|  |  | ||||||
|  |                     // Reload the page to refresh collections list | ||||||
|  |                     window.location.href = '/'; | ||||||
|  |                 } catch (error) { | ||||||
|  |                     Logger.error('Failed to delete collection:', error); | ||||||
|  |                     showNotification(`Failed to delete collection: ${error.message}`, 'error'); | ||||||
|  |                 } | ||||||
|  |             } else { | ||||||
|  |                 // Deleting a regular file/folder - use WebDAV | ||||||
|  |                 const confirmed = await window.ModalManager.confirm( | ||||||
|  |                     `Are you sure you want to delete ${name}?`, | ||||||
|  |                     `Delete this ${type}?`, | ||||||
|  |                     true | ||||||
|  |                 ); | ||||||
|  |  | ||||||
|  |                 if (!confirmed) return; | ||||||
|  |  | ||||||
|                 await this.webdavClient.delete(path); |                 await this.webdavClient.delete(path); | ||||||
|  |  | ||||||
|  |                 // Clear undo history since manual delete occurred | ||||||
|  |                 if (this.fileTree.lastMoveOperation) { | ||||||
|  |                     this.fileTree.lastMoveOperation = null; | ||||||
|  |                 } | ||||||
|  |  | ||||||
|                 await this.fileTree.load(); |                 await this.fileTree.load(); | ||||||
|                 showNotification(`Deleted ${name}`, 'success'); |                 showNotification(`Deleted ${name}`, 'success'); | ||||||
|  |             } | ||||||
|         }, |         }, | ||||||
|  |  | ||||||
|         download: async function(path, isDir) { |         download: async function (path, isDir) { | ||||||
|             showNotification('Downloading...', 'info'); |             Logger.info(`Downloading ${isDir ? 'folder' : 'file'}: ${path}`); | ||||||
|             // Implementation here |  | ||||||
|  |             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; |             if (!isDir) return; | ||||||
|  |  | ||||||
|             const input = document.createElement('input'); |             const input = document.createElement('input'); | ||||||
| @@ -204,154 +276,60 @@ class FileTreeActions { | |||||||
|             }; |             }; | ||||||
|  |  | ||||||
|             input.click(); |             input.click(); | ||||||
|  |         }, | ||||||
|  |  | ||||||
|  |         'copy-to-collection': async function (path, isDir) { | ||||||
|  |             // Get list of available collections | ||||||
|  |             const collections = await this.webdavClient.getCollections(); | ||||||
|  |             const currentCollection = this.webdavClient.currentCollection; | ||||||
|  |  | ||||||
|  |             // Filter out current collection | ||||||
|  |             const otherCollections = collections.filter(c => c !== currentCollection); | ||||||
|  |  | ||||||
|  |             if (otherCollections.length === 0) { | ||||||
|  |                 showNotification('No other collections available', 'warning'); | ||||||
|  |                 return; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             // Show collection selection dialog | ||||||
|  |             const targetCollection = await this.showCollectionSelectionDialog( | ||||||
|  |                 otherCollections, | ||||||
|  |                 `Copy ${PathUtils.getFileName(path)} to collection:` | ||||||
|  |             ); | ||||||
|  |  | ||||||
|  |             if (!targetCollection) return; | ||||||
|  |  | ||||||
|  |             // Copy the file/folder | ||||||
|  |             await this.copyToCollection(path, isDir, currentCollection, targetCollection); | ||||||
|  |         }, | ||||||
|  |  | ||||||
|  |         'move-to-collection': async function (path, isDir) { | ||||||
|  |             // Get list of available collections | ||||||
|  |             const collections = await this.webdavClient.getCollections(); | ||||||
|  |             const currentCollection = this.webdavClient.currentCollection; | ||||||
|  |  | ||||||
|  |             // Filter out current collection | ||||||
|  |             const otherCollections = collections.filter(c => c !== currentCollection); | ||||||
|  |  | ||||||
|  |             if (otherCollections.length === 0) { | ||||||
|  |                 showNotification('No other collections available', 'warning'); | ||||||
|  |                 return; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             // Show collection selection dialog | ||||||
|  |             const targetCollection = await this.showCollectionSelectionDialog( | ||||||
|  |                 otherCollections, | ||||||
|  |                 `Move ${PathUtils.getFileName(path)} to collection:` | ||||||
|  |             ); | ||||||
|  |  | ||||||
|  |             if (!targetCollection) return; | ||||||
|  |  | ||||||
|  |             // Move the file/folder | ||||||
|  |             await this.moveToCollection(path, isDir, currentCollection, targetCollection); | ||||||
|         } |         } | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
|     // Modern dialog implementations |     // Old deprecated modal methods removed - all modals now use window.ModalManager | ||||||
|     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 = ` |  | ||||||
|             <div class="modal-dialog modal-dialog-centered"> |  | ||||||
|                 <div class="modal-content"> |  | ||||||
|                     <div class="modal-header"> |  | ||||||
|                         <h5 class="modal-title">${title}</h5> |  | ||||||
|                         <button type="button" class="btn-close" data-bs-dismiss="modal"></button> |  | ||||||
|                     </div> |  | ||||||
|                     <div class="modal-body"> |  | ||||||
|                         <input type="text" class="form-control" value="${placeholder}" autofocus> |  | ||||||
|                     </div> |  | ||||||
|                     <div class="modal-footer"> |  | ||||||
|                         <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> |  | ||||||
|                         <button type="button" class="btn btn-primary">OK</button> |  | ||||||
|                     </div> |  | ||||||
|                 </div> |  | ||||||
|             </div> |  | ||||||
|         `; |  | ||||||
|          |  | ||||||
|         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 = ` |  | ||||||
|             <div class="modal-dialog modal-dialog-centered"> |  | ||||||
|                 <div class="modal-content"> |  | ||||||
|                     <div class="modal-header border-danger"> |  | ||||||
|                         <h5 class="modal-title text-danger">${title}</h5> |  | ||||||
|                         <button type="button" class="btn-close" data-bs-dismiss="modal"></button> |  | ||||||
|                     </div> |  | ||||||
|                     <div class="modal-body"> |  | ||||||
|                         <p>${message}</p> |  | ||||||
|                         <p class="text-danger small">This action cannot be undone.</p> |  | ||||||
|                     </div> |  | ||||||
|                     <div class="modal-footer"> |  | ||||||
|                         <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> |  | ||||||
|                         <button type="button" class="btn btn-danger">Delete</button> |  | ||||||
|                     </div> |  | ||||||
|                 </div> |  | ||||||
|             </div> |  | ||||||
|         `; |  | ||||||
|          |  | ||||||
|         document.body.appendChild(backdrop); |  | ||||||
|         return dialog; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     updatePasteMenuItem() { |     updatePasteMenuItem() { | ||||||
|         const pasteItem = document.getElementById('pasteMenuItem'); |         const pasteItem = document.getElementById('pasteMenuItem'); | ||||||
| @@ -359,4 +337,268 @@ class FileTreeActions { | |||||||
|             pasteItem.style.display = this.clipboard ? 'flex' : 'none'; |             pasteItem.style.display = this.clipboard ? 'flex' : 'none'; | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Show a dialog to select a collection | ||||||
|  |      * @param {Array<string>} collections - List of collection names | ||||||
|  |      * @param {string} message - Dialog message | ||||||
|  |      * @returns {Promise<string|null>} Selected collection or null if cancelled | ||||||
|  |      */ | ||||||
|  |     async showCollectionSelectionDialog(collections, message) { | ||||||
|  |         // Prevent duplicate modals | ||||||
|  |         if (this._collectionModalShowing) { | ||||||
|  |             Logger.warn('Collection selection modal is already showing'); | ||||||
|  |             return null; | ||||||
|  |         } | ||||||
|  |         this._collectionModalShowing = true; | ||||||
|  |  | ||||||
|  |         // Create a custom modal with radio buttons for collection selection | ||||||
|  |         const modal = document.createElement('div'); | ||||||
|  |         modal.className = 'modal fade'; | ||||||
|  |         modal.innerHTML = ` | ||||||
|  |             <div class="modal-dialog modal-dialog-centered"> | ||||||
|  |                 <div class="modal-content"> | ||||||
|  |                     <div class="modal-header"> | ||||||
|  |                         <h5 class="modal-title"><i class="bi bi-folder-symlink"></i> Select Collection</h5> | ||||||
|  |                         <button type="button" class="btn-close" data-bs-dismiss="modal"></button> | ||||||
|  |                     </div> | ||||||
|  |                     <div class="modal-body"> | ||||||
|  |                         <p class="mb-3">${message}</p> | ||||||
|  |                         <div class="collection-list" style="max-height: 300px; overflow-y: auto;"> | ||||||
|  |                             ${collections.map((c, i) => ` | ||||||
|  |                                 <div class="form-check p-2 mb-2 rounded border collection-option" style="cursor: pointer; transition: all 0.2s;"> | ||||||
|  |                                     <input class="form-check-input" type="radio" name="collection" id="collection-${i}" value="${c}" ${i === 0 ? 'checked' : ''}> | ||||||
|  |                                     <label class="form-check-label w-100" for="collection-${i}" style="cursor: pointer;"> | ||||||
|  |                                         <i class="bi bi-folder"></i> <strong>${c}</strong> | ||||||
|  |                                     </label> | ||||||
|  |                                 </div> | ||||||
|  |                             `).join('')} | ||||||
|  |                         </div> | ||||||
|  |                         <div id="confirmationPreview" class="alert alert-info mt-3" style="display: none;"> | ||||||
|  |                             <i class="bi bi-info-circle"></i> <span id="confirmationText"></span> | ||||||
|  |                         </div> | ||||||
|  |                     </div> | ||||||
|  |                     <div class="modal-footer"> | ||||||
|  |                         <button type="button" class="btn-flat btn-flat-secondary" data-bs-dismiss="modal"> | ||||||
|  |                             <i class="bi bi-x-circle"></i> Cancel | ||||||
|  |                         </button> | ||||||
|  |                         <button type="button" class="btn-flat btn-flat-primary" id="confirmCollectionBtn"> | ||||||
|  |                             <i class="bi bi-check-circle"></i> OK | ||||||
|  |                         </button> | ||||||
|  |                     </div> | ||||||
|  |                 </div> | ||||||
|  |             </div> | ||||||
|  |         `; | ||||||
|  |  | ||||||
|  |         document.body.appendChild(modal); | ||||||
|  |         const bsModal = new bootstrap.Modal(modal); | ||||||
|  |  | ||||||
|  |         // Extract file name and action from message | ||||||
|  |         // Message format: "Copy filename to collection:" or "Move filename to collection:" | ||||||
|  |         const messageMatch = message.match(/(Copy|Move)\s+(.+?)\s+to collection:/); | ||||||
|  |         const action = messageMatch ? messageMatch[1].toLowerCase() : 'copy'; | ||||||
|  |         const fileName = messageMatch ? messageMatch[2] : 'item'; | ||||||
|  |  | ||||||
|  |         // Get confirmation preview elements | ||||||
|  |         const confirmationPreview = modal.querySelector('#confirmationPreview'); | ||||||
|  |         const confirmationText = modal.querySelector('#confirmationText'); | ||||||
|  |  | ||||||
|  |         // Function to update confirmation message | ||||||
|  |         const updateConfirmation = (collectionName) => { | ||||||
|  |             confirmationText.textContent = `"${fileName}" will be ${action}d to "${collectionName}"`; | ||||||
|  |             confirmationPreview.style.display = 'block'; | ||||||
|  |         }; | ||||||
|  |  | ||||||
|  |         // Add hover effects and click handlers for collection options | ||||||
|  |         const collectionOptions = modal.querySelectorAll('.collection-option'); | ||||||
|  |         collectionOptions.forEach(option => { | ||||||
|  |             // Hover effect | ||||||
|  |             option.addEventListener('mouseenter', () => { | ||||||
|  |                 option.style.backgroundColor = 'var(--bs-light)'; | ||||||
|  |                 option.style.borderColor = 'var(--bs-primary)'; | ||||||
|  |             }); | ||||||
|  |             option.addEventListener('mouseleave', () => { | ||||||
|  |                 const radio = option.querySelector('input[type="radio"]'); | ||||||
|  |                 if (!radio.checked) { | ||||||
|  |                     option.style.backgroundColor = ''; | ||||||
|  |                     option.style.borderColor = ''; | ||||||
|  |                 } | ||||||
|  |             }); | ||||||
|  |  | ||||||
|  |             // Click on the whole div to select | ||||||
|  |             option.addEventListener('click', () => { | ||||||
|  |                 const radio = option.querySelector('input[type="radio"]'); | ||||||
|  |                 radio.checked = true; | ||||||
|  |  | ||||||
|  |                 // Update confirmation message | ||||||
|  |                 updateConfirmation(radio.value); | ||||||
|  |  | ||||||
|  |                 // Update all options styling | ||||||
|  |                 collectionOptions.forEach(opt => { | ||||||
|  |                     const r = opt.querySelector('input[type="radio"]'); | ||||||
|  |                     if (r.checked) { | ||||||
|  |                         opt.style.backgroundColor = 'var(--bs-primary-bg-subtle)'; | ||||||
|  |                         opt.style.borderColor = 'var(--bs-primary)'; | ||||||
|  |                     } else { | ||||||
|  |                         opt.style.backgroundColor = ''; | ||||||
|  |                         opt.style.borderColor = ''; | ||||||
|  |                     } | ||||||
|  |                 }); | ||||||
|  |             }); | ||||||
|  |  | ||||||
|  |             // Set initial styling for checked option | ||||||
|  |             const radio = option.querySelector('input[type="radio"]'); | ||||||
|  |             if (radio.checked) { | ||||||
|  |                 option.style.backgroundColor = 'var(--bs-primary-bg-subtle)'; | ||||||
|  |                 option.style.borderColor = 'var(--bs-primary)'; | ||||||
|  |                 // Show initial confirmation | ||||||
|  |                 updateConfirmation(radio.value); | ||||||
|  |             } | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         return new Promise((resolve) => { | ||||||
|  |             const confirmBtn = modal.querySelector('#confirmCollectionBtn'); | ||||||
|  |  | ||||||
|  |             confirmBtn.addEventListener('click', () => { | ||||||
|  |                 const selected = modal.querySelector('input[name="collection"]:checked'); | ||||||
|  |                 this._collectionModalShowing = false; | ||||||
|  |                 bsModal.hide(); | ||||||
|  |                 resolve(selected ? selected.value : null); | ||||||
|  |             }); | ||||||
|  |  | ||||||
|  |             modal.addEventListener('hidden.bs.modal', () => { | ||||||
|  |                 modal.remove(); | ||||||
|  |                 this._collectionModalShowing = false; | ||||||
|  |                 resolve(null); | ||||||
|  |             }); | ||||||
|  |  | ||||||
|  |             bsModal.show(); | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Copy a file or folder to another collection | ||||||
|  |      */ | ||||||
|  |     async copyToCollection(path, isDir, sourceCollection, targetCollection) { | ||||||
|  |         try { | ||||||
|  |             Logger.info(`Copying ${path} from ${sourceCollection} to ${targetCollection}`); | ||||||
|  |  | ||||||
|  |             if (isDir) { | ||||||
|  |                 // Copy folder recursively | ||||||
|  |                 await this.copyFolderToCollection(path, sourceCollection, targetCollection); | ||||||
|  |             } else { | ||||||
|  |                 // Copy single file | ||||||
|  |                 await this.copyFileToCollection(path, sourceCollection, targetCollection); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             showNotification(`Copied to ${targetCollection}`, 'success'); | ||||||
|  |         } catch (error) { | ||||||
|  |             Logger.error('Failed to copy to collection:', error); | ||||||
|  |             showNotification('Failed to copy to collection', 'error'); | ||||||
|  |             throw error; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Move a file or folder to another collection | ||||||
|  |      */ | ||||||
|  |     async moveToCollection(path, isDir, sourceCollection, targetCollection) { | ||||||
|  |         try { | ||||||
|  |             Logger.info(`Moving ${path} from ${sourceCollection} to ${targetCollection}`); | ||||||
|  |  | ||||||
|  |             // First copy | ||||||
|  |             await this.copyToCollection(path, isDir, sourceCollection, targetCollection); | ||||||
|  |  | ||||||
|  |             // Then delete from source | ||||||
|  |             await this.webdavClient.delete(path); | ||||||
|  |             await this.fileTree.load(); | ||||||
|  |  | ||||||
|  |             showNotification(`Moved to ${targetCollection}`, 'success'); | ||||||
|  |         } catch (error) { | ||||||
|  |             Logger.error('Failed to move to collection:', error); | ||||||
|  |             showNotification('Failed to move to collection', 'error'); | ||||||
|  |             throw error; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Copy a single file to another collection | ||||||
|  |      */ | ||||||
|  |     async copyFileToCollection(path, sourceCollection, targetCollection) { | ||||||
|  |         // Read file from source collection | ||||||
|  |         const content = await this.webdavClient.get(path); | ||||||
|  |  | ||||||
|  |         // Write to target collection | ||||||
|  |         const originalCollection = this.webdavClient.currentCollection; | ||||||
|  |         this.webdavClient.setCollection(targetCollection); | ||||||
|  |  | ||||||
|  |         // Ensure parent directories exist in target collection | ||||||
|  |         await this.webdavClient.ensureParentDirectories(path); | ||||||
|  |  | ||||||
|  |         await this.webdavClient.put(path, content); | ||||||
|  |         this.webdavClient.setCollection(originalCollection); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Copy a folder recursively to another collection | ||||||
|  |      * @param {string} folderPath - Path of the folder to copy | ||||||
|  |      * @param {string} sourceCollection - Source collection name | ||||||
|  |      * @param {string} targetCollection - Target collection name | ||||||
|  |      * @param {Set} visitedPaths - Set of already visited paths to prevent infinite loops | ||||||
|  |      */ | ||||||
|  |     async copyFolderToCollection(folderPath, sourceCollection, targetCollection, visitedPaths = new Set()) { | ||||||
|  |         // Prevent infinite loops by tracking visited paths | ||||||
|  |         if (visitedPaths.has(folderPath)) { | ||||||
|  |             Logger.warn(`Skipping already visited path: ${folderPath}`); | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |         visitedPaths.add(folderPath); | ||||||
|  |  | ||||||
|  |         Logger.info(`Copying folder: ${folderPath} from ${sourceCollection} to ${targetCollection}`); | ||||||
|  |  | ||||||
|  |         // Set to source collection to list items | ||||||
|  |         const originalCollection = this.webdavClient.currentCollection; | ||||||
|  |         this.webdavClient.setCollection(sourceCollection); | ||||||
|  |  | ||||||
|  |         // Get only direct children (not recursive to avoid infinite loop) | ||||||
|  |         const items = await this.webdavClient.list(folderPath, false); | ||||||
|  |         Logger.debug(`Found ${items.length} items in ${folderPath}:`, items.map(i => i.path)); | ||||||
|  |  | ||||||
|  |         // Create the folder in target collection | ||||||
|  |         this.webdavClient.setCollection(targetCollection); | ||||||
|  |  | ||||||
|  |         try { | ||||||
|  |             // Ensure parent directories exist first | ||||||
|  |             await this.webdavClient.ensureParentDirectories(folderPath + '/dummy.txt'); | ||||||
|  |             // Then create the folder itself | ||||||
|  |             await this.webdavClient.createFolder(folderPath); | ||||||
|  |             Logger.debug(`Created folder: ${folderPath}`); | ||||||
|  |         } catch (error) { | ||||||
|  |             // Folder might already exist (405 Method Not Allowed), ignore error | ||||||
|  |             if (error.message && error.message.includes('405')) { | ||||||
|  |                 Logger.debug(`Folder ${folderPath} already exists (405)`); | ||||||
|  |             } else { | ||||||
|  |                 Logger.debug('Folder might already exist:', error); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // Copy all items | ||||||
|  |         for (const item of items) { | ||||||
|  |             if (item.isDirectory) { | ||||||
|  |                 // Recursively copy subdirectory | ||||||
|  |                 await this.copyFolderToCollection(item.path, sourceCollection, targetCollection, visitedPaths); | ||||||
|  |             } else { | ||||||
|  |                 // Copy file | ||||||
|  |                 this.webdavClient.setCollection(sourceCollection); | ||||||
|  |                 const content = await this.webdavClient.get(item.path); | ||||||
|  |                 this.webdavClient.setCollection(targetCollection); | ||||||
|  |                 // Ensure parent directories exist before copying file | ||||||
|  |                 await this.webdavClient.ensureParentDirectories(item.path); | ||||||
|  |                 await this.webdavClient.put(item.path, content); | ||||||
|  |                 Logger.debug(`Copied file: ${item.path}`); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         this.webdavClient.setCollection(originalCollection); | ||||||
|  |     } | ||||||
| } | } | ||||||
| @@ -4,29 +4,48 @@ | |||||||
|  */ |  */ | ||||||
|  |  | ||||||
| class FileTree { | class FileTree { | ||||||
|     constructor(containerId, webdavClient) { |     constructor(containerId, webdavClient, isEditMode = false) { | ||||||
|         this.container = document.getElementById(containerId); |         this.container = document.getElementById(containerId); | ||||||
|         this.webdavClient = webdavClient; |         this.webdavClient = webdavClient; | ||||||
|         this.tree = []; |         this.tree = []; | ||||||
|         this.selectedPath = null; |         this.selectedPath = null; | ||||||
|         this.onFileSelect = null; |         this.onFileSelect = null; | ||||||
|         this.onFolderSelect = null; |         this.onFolderSelect = null; | ||||||
|  |         this.filterImagesInViewMode = !isEditMode; // Track if we should filter images (true in view mode) | ||||||
|  |  | ||||||
|  |         // Drag and drop state | ||||||
|  |         this.draggedNode = null; | ||||||
|  |         this.draggedPath = null; | ||||||
|  |         this.draggedIsDir = false; | ||||||
|  |  | ||||||
|  |         // Long-press detection | ||||||
|  |         this.longPressTimer = null; | ||||||
|  |         this.longPressThreshold = Config.LONG_PRESS_THRESHOLD; | ||||||
|  |         this.isDraggingEnabled = false; | ||||||
|  |         this.mouseDownNode = null; | ||||||
|  |  | ||||||
|  |         // Undo functionality | ||||||
|  |         this.lastMoveOperation = null; | ||||||
|  |  | ||||||
|         this.setupEventListeners(); |         this.setupEventListeners(); | ||||||
|  |         this.setupUndoListener(); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     setupEventListeners() { |     setupEventListeners() { | ||||||
|         // Click handler for tree nodes |         // Click handler for tree nodes | ||||||
|         this.container.addEventListener('click', (e) => { |         this.container.addEventListener('click', (e) => { | ||||||
|             console.log('Container clicked', e.target); |  | ||||||
|             const node = e.target.closest('.tree-node'); |             const node = e.target.closest('.tree-node'); | ||||||
|             if (!node) return; |             if (!node) return; | ||||||
|  |  | ||||||
|             console.log('Node found', node); |  | ||||||
|             const path = node.dataset.path; |             const path = node.dataset.path; | ||||||
|             const isDir = node.dataset.isdir === 'true'; |             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 |             // Select node | ||||||
|             if (isDir) { |             if (isDir) { | ||||||
| @@ -36,8 +55,18 @@ class FileTree { | |||||||
|             } |             } | ||||||
|         }); |         }); | ||||||
|  |  | ||||||
|         // Context menu |         // Context menu (only in edit mode) | ||||||
|         this.container.addEventListener('contextmenu', (e) => { |         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'); |             const node = e.target.closest('.tree-node'); | ||||||
|             e.preventDefault(); |             e.preventDefault(); | ||||||
|  |  | ||||||
| @@ -51,6 +80,333 @@ class FileTree { | |||||||
|                 window.showContextMenu(e.clientX, e.clientY, { path: '', isDir: true }); |                 window.showContextMenu(e.clientX, e.clientY, { path: '', isDir: true }); | ||||||
|             } |             } | ||||||
|         }); |         }); | ||||||
|  |  | ||||||
|  |         // Drag and drop event listeners (only in edit mode) | ||||||
|  |         this.setupDragAndDrop(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     setupUndoListener() { | ||||||
|  |         // Listen for Ctrl+Z (Windows/Linux) or Cmd+Z (Mac) | ||||||
|  |         document.addEventListener('keydown', async (e) => { | ||||||
|  |             // Check for Ctrl+Z or Cmd+Z | ||||||
|  |             const isUndo = (e.ctrlKey || e.metaKey) && e.key === 'z'; | ||||||
|  |  | ||||||
|  |             if (isUndo && this.isEditMode() && this.lastMoveOperation) { | ||||||
|  |                 e.preventDefault(); | ||||||
|  |                 await this.undoLastMove(); | ||||||
|  |             } | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     async undoLastMove() { | ||||||
|  |         if (!this.lastMoveOperation) { | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         const { sourcePath, destPath, fileName, isDirectory } = this.lastMoveOperation; | ||||||
|  |  | ||||||
|  |         try { | ||||||
|  |             // Move the item back to its original location | ||||||
|  |             await this.webdavClient.move(destPath, sourcePath); | ||||||
|  |  | ||||||
|  |             // Get the parent folder name for the notification | ||||||
|  |             const sourceParent = PathUtils.getParentPath(sourcePath); | ||||||
|  |             const parentName = sourceParent ? sourceParent + '/' : 'root'; | ||||||
|  |  | ||||||
|  |             // Clear the undo history | ||||||
|  |             this.lastMoveOperation = null; | ||||||
|  |  | ||||||
|  |             // Reload the tree | ||||||
|  |             await this.load(); | ||||||
|  |  | ||||||
|  |             // Re-select the moved item | ||||||
|  |             this.selectAndExpandPath(sourcePath); | ||||||
|  |  | ||||||
|  |             showNotification(`Undo: Moved ${fileName} back to ${parentName}`, 'success'); | ||||||
|  |         } catch (error) { | ||||||
|  |             console.error('Failed to undo move:', error); | ||||||
|  |             showNotification('Failed to undo move: ' + error.message, 'danger'); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     setupDragAndDrop() { | ||||||
|  |         // Dragover event on container to allow dropping on root level | ||||||
|  |         this.container.addEventListener('dragover', (e) => { | ||||||
|  |             if (!this.isEditMode() || !this.draggedPath) return; | ||||||
|  |  | ||||||
|  |             const node = e.target.closest('.tree-node'); | ||||||
|  |             if (!node) { | ||||||
|  |                 // Hovering over empty space (root level) | ||||||
|  |                 e.preventDefault(); | ||||||
|  |                 e.dataTransfer.dropEffect = 'move'; | ||||||
|  |  | ||||||
|  |                 // Highlight the entire container as a drop target | ||||||
|  |                 this.container.classList.add('drag-over-root'); | ||||||
|  |             } | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         // Dragleave event on container to remove root-level highlighting | ||||||
|  |         this.container.addEventListener('dragleave', (e) => { | ||||||
|  |             if (!this.isEditMode()) return; | ||||||
|  |  | ||||||
|  |             // Only remove if we're actually leaving the container | ||||||
|  |             // Check if the related target is outside the container | ||||||
|  |             if (!this.container.contains(e.relatedTarget)) { | ||||||
|  |                 this.container.classList.remove('drag-over-root'); | ||||||
|  |             } | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         // Dragenter event to manage highlighting | ||||||
|  |         this.container.addEventListener('dragenter', (e) => { | ||||||
|  |             if (!this.isEditMode() || !this.draggedPath) return; | ||||||
|  |  | ||||||
|  |             const node = e.target.closest('.tree-node'); | ||||||
|  |             if (!node) { | ||||||
|  |                 // Entering empty space | ||||||
|  |                 this.container.classList.add('drag-over-root'); | ||||||
|  |             } else { | ||||||
|  |                 // Entering a node, remove root highlighting | ||||||
|  |                 this.container.classList.remove('drag-over-root'); | ||||||
|  |             } | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         // Drop event on container for root level drops | ||||||
|  |         this.container.addEventListener('drop', async (e) => { | ||||||
|  |             if (!this.isEditMode()) return; | ||||||
|  |  | ||||||
|  |             const node = e.target.closest('.tree-node'); | ||||||
|  |             if (!node && this.draggedPath) { | ||||||
|  |                 // Dropped on root level | ||||||
|  |                 e.preventDefault(); | ||||||
|  |                 this.container.classList.remove('drag-over-root'); | ||||||
|  |                 await this.handleDrop('', true); | ||||||
|  |             } | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     isEditMode() { | ||||||
|  |         return document.body.classList.contains('edit-mode'); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     setupNodeDragHandlers(nodeElement, node) { | ||||||
|  |         // Dragstart - when user starts dragging | ||||||
|  |         nodeElement.addEventListener('dragstart', (e) => { | ||||||
|  |             this.draggedNode = nodeElement; | ||||||
|  |             this.draggedPath = node.path; | ||||||
|  |             this.draggedIsDir = node.isDirectory; | ||||||
|  |  | ||||||
|  |             nodeElement.classList.add('dragging'); | ||||||
|  |             document.body.classList.add('dragging-active'); | ||||||
|  |             e.dataTransfer.effectAllowed = 'move'; | ||||||
|  |             e.dataTransfer.setData('text/plain', node.path); | ||||||
|  |  | ||||||
|  |             // Create a custom drag image with fixed width | ||||||
|  |             const dragImage = nodeElement.cloneNode(true); | ||||||
|  |             dragImage.style.position = 'absolute'; | ||||||
|  |             dragImage.style.top = '-9999px'; | ||||||
|  |             dragImage.style.left = '-9999px'; | ||||||
|  |             dragImage.style.width = `${Config.DRAG_PREVIEW_WIDTH}px`; | ||||||
|  |             dragImage.style.maxWidth = `${Config.DRAG_PREVIEW_WIDTH}px`; | ||||||
|  |             dragImage.style.opacity = Config.DRAG_PREVIEW_OPACITY; | ||||||
|  |             dragImage.style.backgroundColor = 'var(--bg-secondary)'; | ||||||
|  |             dragImage.style.border = '1px solid var(--border-color)'; | ||||||
|  |             dragImage.style.borderRadius = '4px'; | ||||||
|  |             dragImage.style.padding = '4px 8px'; | ||||||
|  |             dragImage.style.whiteSpace = 'nowrap'; | ||||||
|  |             dragImage.style.overflow = 'hidden'; | ||||||
|  |             dragImage.style.textOverflow = 'ellipsis'; | ||||||
|  |  | ||||||
|  |             document.body.appendChild(dragImage); | ||||||
|  |             e.dataTransfer.setDragImage(dragImage, 10, 10); | ||||||
|  |             setTimeout(() => { | ||||||
|  |                 if (dragImage.parentNode) { | ||||||
|  |                     document.body.removeChild(dragImage); | ||||||
|  |                 } | ||||||
|  |             }, 0); | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         // Dragend - when drag operation ends | ||||||
|  |         nodeElement.addEventListener('dragend', () => { | ||||||
|  |             nodeElement.classList.remove('dragging'); | ||||||
|  |             nodeElement.classList.remove('drag-ready'); | ||||||
|  |             document.body.classList.remove('dragging-active'); | ||||||
|  |             this.container.classList.remove('drag-over-root'); | ||||||
|  |             this.clearDragOverStates(); | ||||||
|  |  | ||||||
|  |             // Reset draggable state | ||||||
|  |             nodeElement.draggable = false; | ||||||
|  |             nodeElement.style.cursor = ''; | ||||||
|  |             this.isDraggingEnabled = false; | ||||||
|  |  | ||||||
|  |             this.draggedNode = null; | ||||||
|  |             this.draggedPath = null; | ||||||
|  |             this.draggedIsDir = false; | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         // Dragover - when dragging over this node | ||||||
|  |         nodeElement.addEventListener('dragover', (e) => { | ||||||
|  |             if (!this.draggedPath) return; | ||||||
|  |  | ||||||
|  |             const targetPath = node.path; | ||||||
|  |             const targetIsDir = node.isDirectory; | ||||||
|  |  | ||||||
|  |             // Only allow dropping on directories | ||||||
|  |             if (!targetIsDir) { | ||||||
|  |                 e.dataTransfer.dropEffect = 'none'; | ||||||
|  |                 return; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             // Check if this is a valid drop target | ||||||
|  |             if (this.isValidDropTarget(this.draggedPath, this.draggedIsDir, targetPath)) { | ||||||
|  |                 e.preventDefault(); | ||||||
|  |                 e.dataTransfer.dropEffect = 'move'; | ||||||
|  |                 nodeElement.classList.add('drag-over'); | ||||||
|  |             } else { | ||||||
|  |                 e.dataTransfer.dropEffect = 'none'; | ||||||
|  |             } | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         // Dragleave - when drag leaves this node | ||||||
|  |         nodeElement.addEventListener('dragleave', (e) => { | ||||||
|  |             // Only remove if we're actually leaving the node (not entering a child) | ||||||
|  |             if (e.target === nodeElement) { | ||||||
|  |                 nodeElement.classList.remove('drag-over'); | ||||||
|  |  | ||||||
|  |                 // If leaving a node and not entering another node, might be going to root | ||||||
|  |                 const relatedNode = e.relatedTarget?.closest('.tree-node'); | ||||||
|  |                 if (!relatedNode && this.container.contains(e.relatedTarget)) { | ||||||
|  |                     // Moving to empty space (root area) | ||||||
|  |                     this.container.classList.add('drag-over-root'); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         // Drop - when item is dropped on this node | ||||||
|  |         nodeElement.addEventListener('drop', async (e) => { | ||||||
|  |             e.preventDefault(); | ||||||
|  |             e.stopPropagation(); | ||||||
|  |  | ||||||
|  |             nodeElement.classList.remove('drag-over'); | ||||||
|  |  | ||||||
|  |             if (!this.draggedPath) return; | ||||||
|  |  | ||||||
|  |             const targetPath = node.path; | ||||||
|  |             const targetIsDir = node.isDirectory; | ||||||
|  |  | ||||||
|  |             if (targetIsDir && this.isValidDropTarget(this.draggedPath, this.draggedIsDir, targetPath)) { | ||||||
|  |                 await this.handleDrop(targetPath, targetIsDir); | ||||||
|  |             } | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     clearDragOverStates() { | ||||||
|  |         this.container.querySelectorAll('.drag-over').forEach(node => { | ||||||
|  |             node.classList.remove('drag-over'); | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     isValidDropTarget(sourcePath, sourceIsDir, targetPath) { | ||||||
|  |         // Can't drop on itself | ||||||
|  |         if (sourcePath === targetPath) { | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // If dragging a directory, can't drop into its own descendants | ||||||
|  |         if (sourceIsDir) { | ||||||
|  |             // Check if target is a descendant of source | ||||||
|  |             if (targetPath.startsWith(sourcePath + '/')) { | ||||||
|  |                 return false; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // Can't drop into the same parent directory | ||||||
|  |         const sourceParent = PathUtils.getParentPath(sourcePath); | ||||||
|  |         if (sourceParent === targetPath) { | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return true; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     async handleDrop(targetPath, targetIsDir) { | ||||||
|  |         if (!this.draggedPath) return; | ||||||
|  |  | ||||||
|  |         try { | ||||||
|  |             const sourcePath = this.draggedPath; | ||||||
|  |             const fileName = PathUtils.getFileName(sourcePath); | ||||||
|  |             const isDirectory = this.draggedIsDir; | ||||||
|  |  | ||||||
|  |             // Construct destination path | ||||||
|  |             let destPath; | ||||||
|  |             if (targetPath === '') { | ||||||
|  |                 // Dropping to root | ||||||
|  |                 destPath = fileName; | ||||||
|  |             } else { | ||||||
|  |                 destPath = `${targetPath}/${fileName}`; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             // Check if destination already exists | ||||||
|  |             const destNode = this.findNode(destPath); | ||||||
|  |             if (destNode) { | ||||||
|  |                 const overwrite = await window.ModalManager.confirm( | ||||||
|  |                     `A ${destNode.isDirectory ? 'folder' : 'file'} named "${fileName}" already exists in the destination. Do you want to overwrite it?`, | ||||||
|  |                     'Name Conflict', | ||||||
|  |                     true | ||||||
|  |                 ); | ||||||
|  |  | ||||||
|  |                 if (!overwrite) { | ||||||
|  |                     return; | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 // Delete existing item first | ||||||
|  |                 await this.webdavClient.delete(destPath); | ||||||
|  |  | ||||||
|  |                 // Clear undo history since we're overwriting | ||||||
|  |                 this.lastMoveOperation = null; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             // Perform the move | ||||||
|  |             await this.webdavClient.move(sourcePath, destPath); | ||||||
|  |  | ||||||
|  |             // Store undo information (only if not overwriting) | ||||||
|  |             if (!destNode) { | ||||||
|  |                 this.lastMoveOperation = { | ||||||
|  |                     sourcePath: sourcePath, | ||||||
|  |                     destPath: destPath, | ||||||
|  |                     fileName: fileName, | ||||||
|  |                     isDirectory: isDirectory | ||||||
|  |                 }; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             // If the moved item was the currently selected file, update the selection | ||||||
|  |             if (this.selectedPath === sourcePath) { | ||||||
|  |                 this.selectedPath = destPath; | ||||||
|  |  | ||||||
|  |                 // Update editor's current file path if it's the file being moved | ||||||
|  |                 if (!this.draggedIsDir && window.editor && window.editor.currentFile === sourcePath) { | ||||||
|  |                     window.editor.currentFile = destPath; | ||||||
|  |                     if (window.editor.filenameInput) { | ||||||
|  |                         window.editor.filenameInput.value = destPath; | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 // Notify file select callback if it's a file | ||||||
|  |                 if (!this.draggedIsDir && this.onFileSelect) { | ||||||
|  |                     this.onFileSelect({ path: destPath, isDirectory: false }); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             // Reload the tree | ||||||
|  |             await this.load(); | ||||||
|  |  | ||||||
|  |             // Re-select the moved item | ||||||
|  |             this.selectAndExpandPath(destPath); | ||||||
|  |  | ||||||
|  |             showNotification(`Moved ${fileName} successfully`, 'success'); | ||||||
|  |         } catch (error) { | ||||||
|  |             console.error('Failed to move item:', error); | ||||||
|  |             showNotification('Failed to move item: ' + error.message, 'danger'); | ||||||
|  |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     async load() { |     async load() { | ||||||
| @@ -71,6 +427,19 @@ class FileTree { | |||||||
|  |  | ||||||
|     renderNodes(nodes, parentElement, level) { |     renderNodes(nodes, parentElement, level) { | ||||||
|         nodes.forEach(node => { |         nodes.forEach(node => { | ||||||
|  |             // Filter out images and image directories in view mode | ||||||
|  |             if (this.filterImagesInViewMode) { | ||||||
|  |                 // Skip image files | ||||||
|  |                 if (!node.isDirectory && PathUtils.isBinaryFile(node.path)) { | ||||||
|  |                     return; | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 // Skip image directories | ||||||
|  |                 if (node.isDirectory && PathUtils.isImageDirectory(node.path)) { | ||||||
|  |                     return; | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|             const nodeWrapper = document.createElement('div'); |             const nodeWrapper = document.createElement('div'); | ||||||
|             nodeWrapper.className = 'tree-node-wrapper'; |             nodeWrapper.className = 'tree-node-wrapper'; | ||||||
|  |  | ||||||
| @@ -78,30 +447,46 @@ class FileTree { | |||||||
|             const nodeElement = this.createNodeElement(node, level); |             const nodeElement = this.createNodeElement(node, level); | ||||||
|             nodeWrapper.appendChild(nodeElement); |             nodeWrapper.appendChild(nodeElement); | ||||||
|  |  | ||||||
|             // Create children container ONLY if has children |             // Create children container for directories | ||||||
|             if (node.children && node.children.length > 0) { |             if (node.isDirectory) { | ||||||
|                 const childContainer = document.createElement('div'); |                 const childContainer = document.createElement('div'); | ||||||
|                 childContainer.className = 'tree-children'; |                 childContainer.className = 'tree-children'; | ||||||
|                 childContainer.style.display = 'none'; |                 childContainer.style.display = 'none'; | ||||||
|                 childContainer.dataset.parent = node.path; |                 childContainer.dataset.parent = node.path; | ||||||
|                 childContainer.style.marginLeft = `${(level + 1) * 12}px`; |                 childContainer.style.marginLeft = `${(level + 1) * 12}px`; | ||||||
|  |  | ||||||
|                 // Recursively render children |                 // Only render children if they exist | ||||||
|  |                 if (node.children && node.children.length > 0) { | ||||||
|                     this.renderNodes(node.children, childContainer, level + 1); |                     this.renderNodes(node.children, childContainer, level + 1); | ||||||
|  |                 } else { | ||||||
|  |                     // Empty directory - show empty state message | ||||||
|  |                     const emptyMessage = document.createElement('div'); | ||||||
|  |                     emptyMessage.className = 'tree-empty-message'; | ||||||
|  |                     emptyMessage.textContent = 'Empty folder'; | ||||||
|  |                     childContainer.appendChild(emptyMessage); | ||||||
|  |                 } | ||||||
|  |  | ||||||
|                 nodeWrapper.appendChild(childContainer); |                 nodeWrapper.appendChild(childContainer); | ||||||
|  |  | ||||||
|                 // Make toggle functional |                 // Make toggle functional for ALL directories (including empty ones) | ||||||
|                 const toggle = nodeElement.querySelector('.tree-node-toggle'); |                 const toggle = nodeElement.querySelector('.tree-node-toggle'); | ||||||
|                 if (toggle) { |                 if (toggle) { | ||||||
|                     toggle.addEventListener('click', (e) => { |                     const toggleHandler = (e) => { | ||||||
|                         console.log('Toggle clicked', e.target); |  | ||||||
|                         e.stopPropagation(); |                         e.stopPropagation(); | ||||||
|                         const isHidden = childContainer.style.display === 'none'; |                         const isHidden = childContainer.style.display === 'none'; | ||||||
|                         console.log('Is hidden?', isHidden); |  | ||||||
|                         childContainer.style.display = isHidden ? 'block' : 'none'; |                         childContainer.style.display = isHidden ? 'block' : 'none'; | ||||||
|                         toggle.innerHTML = isHidden ? '▼' : '▶'; |                         toggle.style.transform = isHidden ? 'rotate(90deg)' : 'rotate(0deg)'; | ||||||
|                         toggle.classList.toggle('expanded'); |                         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'; | ||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
|  |  | ||||||
| @@ -128,17 +513,110 @@ class FileTree { | |||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Find a node by path | ||||||
|  |      * @param {string} path - The path to find | ||||||
|  |      * @returns {Object|null} The node or null if not found | ||||||
|  |      */ | ||||||
|  |     findNode(path) { | ||||||
|  |         const search = (nodes, targetPath) => { | ||||||
|  |             for (const node of nodes) { | ||||||
|  |                 if (node.path === targetPath) { | ||||||
|  |                     return node; | ||||||
|  |                 } | ||||||
|  |                 if (node.children && node.children.length > 0) { | ||||||
|  |                     const found = search(node.children, targetPath); | ||||||
|  |                     if (found) return found; | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |             return null; | ||||||
|  |         }; | ||||||
|  |  | ||||||
|  |         return search(this.tree, path); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Get all files in a directory (direct children only) | ||||||
|  |      * @param {string} dirPath - The directory path | ||||||
|  |      * @returns {Array} Array of file nodes | ||||||
|  |      */ | ||||||
|  |     getDirectoryFiles(dirPath) { | ||||||
|  |         const dirNode = this.findNode(dirPath); | ||||||
|  |         if (dirNode && dirNode.children) { | ||||||
|  |             return dirNode.children.filter(child => !child.isDirectory); | ||||||
|  |         } | ||||||
|  |         return []; | ||||||
|  |     } | ||||||
|  |  | ||||||
|     updateSelection() { |     updateSelection() { | ||||||
|         // Remove previous selection |         // Remove previous selection | ||||||
|         this.container.querySelectorAll('.tree-node').forEach(node => { |         this.container.querySelectorAll('.tree-node').forEach(node => { | ||||||
|             node.classList.remove('selected'); |             node.classList.remove('active'); | ||||||
|         }); |         }); | ||||||
|  |  | ||||||
|         // Add selection to current |         // Add selection to current and all parent directories | ||||||
|         if (this.selectedPath) { |         if (this.selectedPath) { | ||||||
|  |             // Add active class to the selected file/folder | ||||||
|             const node = this.container.querySelector(`[data-path="${this.selectedPath}"]`); |             const node = this.container.querySelector(`[data-path="${this.selectedPath}"]`); | ||||||
|             if (node) { |             if (node) { | ||||||
|                 node.classList.add('selected'); |                 node.classList.add('active'); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             // Add active class to all parent directories | ||||||
|  |             const parts = this.selectedPath.split('/'); | ||||||
|  |             let currentPath = ''; | ||||||
|  |             for (let i = 0; i < parts.length - 1; i++) { | ||||||
|  |                 currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i]; | ||||||
|  |                 const parentNode = this.container.querySelector(`[data-path="${currentPath}"]`); | ||||||
|  |                 if (parentNode) { | ||||||
|  |                     parentNode.classList.add('active'); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Highlight a file as active and expand all parent directories | ||||||
|  |      * @param {string} path - The file path to highlight | ||||||
|  |      */ | ||||||
|  |     selectAndExpandPath(path) { | ||||||
|  |         this.selectedPath = path; | ||||||
|  |  | ||||||
|  |         // Expand all parent directories | ||||||
|  |         this.expandParentDirectories(path); | ||||||
|  |  | ||||||
|  |         // Update selection | ||||||
|  |         this.updateSelection(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Expand all parent directories of a given path | ||||||
|  |      * @param {string} path - The file path | ||||||
|  |      */ | ||||||
|  |     expandParentDirectories(path) { | ||||||
|  |         // Get all parent paths | ||||||
|  |         const parts = path.split('/'); | ||||||
|  |         let currentPath = ''; | ||||||
|  |  | ||||||
|  |         for (let i = 0; i < parts.length - 1; i++) { | ||||||
|  |             currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i]; | ||||||
|  |  | ||||||
|  |             // Find the node with this path | ||||||
|  |             const parentNode = this.container.querySelector(`[data-path="${currentPath}"]`); | ||||||
|  |             if (parentNode && parentNode.dataset.isdir === 'true') { | ||||||
|  |                 // Find the children container | ||||||
|  |                 const wrapper = parentNode.closest('.tree-node-wrapper'); | ||||||
|  |                 if (wrapper) { | ||||||
|  |                     const childContainer = wrapper.querySelector('.tree-children'); | ||||||
|  |                     if (childContainer && childContainer.style.display === 'none') { | ||||||
|  |                         // Expand it | ||||||
|  |                         childContainer.style.display = 'block'; | ||||||
|  |                         const toggle = parentNode.querySelector('.tree-node-toggle'); | ||||||
|  |                         if (toggle) { | ||||||
|  |                             toggle.classList.add('expanded'); | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| @@ -150,25 +628,111 @@ class FileTree { | |||||||
|         nodeElement.dataset.isdir = node.isDirectory; |         nodeElement.dataset.isdir = node.isDirectory; | ||||||
|         nodeElement.style.paddingLeft = `${level * 12}px`; |         nodeElement.style.paddingLeft = `${level * 12}px`; | ||||||
|  |  | ||||||
|         const icon = document.createElement('span'); |         // Enable drag and drop in edit mode with long-press detection | ||||||
|         icon.className = 'tree-node-icon'; |         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) { |         if (node.isDirectory) { | ||||||
|             icon.innerHTML = '▶'; // Collapsed by default |             // Create toggle icon for folders | ||||||
|             icon.classList.add('tree-node-toggle'); |             const toggle = document.createElement('i'); | ||||||
|  |             toggle.className = 'bi bi-chevron-right tree-node-toggle'; | ||||||
|  |             toggle.style.fontSize = '12px'; | ||||||
|  |             iconContainer.appendChild(toggle); | ||||||
|         } else { |         } 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'); |         const title = document.createElement('span'); | ||||||
|         title.className = 'tree-node-title'; |         title.className = 'tree-node-title'; | ||||||
|         title.textContent = node.name; |         title.textContent = node.name; | ||||||
|  |  | ||||||
|         nodeElement.appendChild(icon); |         nodeElement.appendChild(iconContainer); | ||||||
|         nodeElement.appendChild(title); |         nodeElement.appendChild(title); | ||||||
|  |  | ||||||
|         return nodeElement; |         return nodeElement; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     setupLongPressDetection(nodeElement, node) { | ||||||
|  |         // Mouse down - start long-press timer | ||||||
|  |         nodeElement.addEventListener('mousedown', (e) => { | ||||||
|  |             // Ignore if clicking on toggle button | ||||||
|  |             if (e.target.closest('.tree-node-toggle')) { | ||||||
|  |                 return; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             this.mouseDownNode = nodeElement; | ||||||
|  |  | ||||||
|  |             // Start timer for long-press | ||||||
|  |             this.longPressTimer = setTimeout(() => { | ||||||
|  |                 // Long-press threshold met - enable dragging | ||||||
|  |                 this.isDraggingEnabled = true; | ||||||
|  |                 nodeElement.draggable = true; | ||||||
|  |                 nodeElement.classList.add('drag-ready'); | ||||||
|  |  | ||||||
|  |                 // Change cursor to grab | ||||||
|  |                 nodeElement.style.cursor = 'grab'; | ||||||
|  |             }, this.longPressThreshold); | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         // Mouse up - cancel long-press timer | ||||||
|  |         nodeElement.addEventListener('mouseup', () => { | ||||||
|  |             this.clearLongPressTimer(); | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         // Mouse leave - cancel long-press timer | ||||||
|  |         nodeElement.addEventListener('mouseleave', () => { | ||||||
|  |             this.clearLongPressTimer(); | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         // Mouse move - cancel long-press if moved too much | ||||||
|  |         let startX, startY; | ||||||
|  |         nodeElement.addEventListener('mousedown', (e) => { | ||||||
|  |             startX = e.clientX; | ||||||
|  |             startY = e.clientY; | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         nodeElement.addEventListener('mousemove', (e) => { | ||||||
|  |             if (this.longPressTimer && !this.isDraggingEnabled) { | ||||||
|  |                 const deltaX = Math.abs(e.clientX - startX); | ||||||
|  |                 const deltaY = Math.abs(e.clientY - startY); | ||||||
|  |  | ||||||
|  |                 // If mouse moved more than threshold, cancel long-press | ||||||
|  |                 if (deltaX > Config.MOUSE_MOVE_THRESHOLD || deltaY > Config.MOUSE_MOVE_THRESHOLD) { | ||||||
|  |                     this.clearLongPressTimer(); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     clearLongPressTimer() { | ||||||
|  |         if (this.longPressTimer) { | ||||||
|  |             clearTimeout(this.longPressTimer); | ||||||
|  |             this.longPressTimer = null; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // Reset dragging state if not currently dragging | ||||||
|  |         if (!this.draggedPath && this.mouseDownNode) { | ||||||
|  |             this.mouseDownNode.draggable = false; | ||||||
|  |             this.mouseDownNode.classList.remove('drag-ready'); | ||||||
|  |             this.mouseDownNode.style.cursor = ''; | ||||||
|  |             this.isDraggingEnabled = false; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         this.mouseDownNode = null; | ||||||
|  |     } | ||||||
|  |  | ||||||
|     formatSize(bytes) { |     formatSize(bytes) { | ||||||
|         if (bytes === 0) return '0 B'; |         if (bytes === 0) return '0 B'; | ||||||
|         const k = 1024; |         const k = 1024; | ||||||
| @@ -233,8 +797,8 @@ class FileTree { | |||||||
|     async downloadFile(path) { |     async downloadFile(path) { | ||||||
|         try { |         try { | ||||||
|             const content = await this.webdavClient.get(path); |             const content = await this.webdavClient.get(path); | ||||||
|             const filename = path.split('/').pop(); |             const filename = PathUtils.getFileName(path); | ||||||
|             this.triggerDownload(content, filename); |             DownloadUtils.triggerDownload(content, filename); | ||||||
|             showNotification('Downloaded', 'success'); |             showNotification('Downloaded', 'success'); | ||||||
|         } catch (error) { |         } catch (error) { | ||||||
|             console.error('Failed to download file:', error); |             console.error('Failed to download file:', error); | ||||||
| @@ -256,7 +820,7 @@ class FileTree { | |||||||
|             } |             } | ||||||
|  |  | ||||||
|             const zip = new JSZip(); |             const zip = new JSZip(); | ||||||
|             const folder = zip.folder(path.split('/').pop() || 'download'); |             const folder = zip.folder(PathUtils.getFileName(path) || 'download'); | ||||||
|  |  | ||||||
|             // Add all files to zip |             // Add all files to zip | ||||||
|             for (const file of files) { |             for (const file of files) { | ||||||
| @@ -267,8 +831,8 @@ class FileTree { | |||||||
|  |  | ||||||
|             // Generate zip |             // Generate zip | ||||||
|             const zipBlob = await zip.generateAsync({ type: 'blob' }); |             const zipBlob = await zip.generateAsync({ type: 'blob' }); | ||||||
|             const zipFilename = `${path.split('/').pop() || 'download'}.zip`; |             const zipFilename = `${PathUtils.getFileName(path) || 'download'}.zip`; | ||||||
|             this.triggerDownload(zipBlob, zipFilename); |             DownloadUtils.triggerDownload(zipBlob, zipFilename); | ||||||
|             showNotification('Downloaded', 'success'); |             showNotification('Downloaded', 'success'); | ||||||
|         } catch (error) { |         } catch (error) { | ||||||
|             console.error('Failed to download folder:', error); |             console.error('Failed to download folder:', error); | ||||||
| @@ -276,16 +840,29 @@ class FileTree { | |||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     triggerDownload(content, filename) { |     // triggerDownload method moved to DownloadUtils in utils.js | ||||||
|         const blob = content instanceof Blob ? content : new Blob([content]); |  | ||||||
|         const url = URL.createObjectURL(blob); |     /** | ||||||
|         const a = document.createElement('a'); |      * Get the first markdown file in the tree | ||||||
|         a.href = url; |      * Returns the path of the first .md file found, or null if none exist | ||||||
|         a.download = filename; |      */ | ||||||
|         document.body.appendChild(a); |     getFirstMarkdownFile() { | ||||||
|         a.click(); |         const findFirstFile = (nodes) => { | ||||||
|         document.body.removeChild(a); |             for (const node of nodes) { | ||||||
|         URL.revokeObjectURL(url); |                 // If it's a file and ends with .md, return it | ||||||
|  |                 if (!node.isDirectory && node.path.endsWith('.md')) { | ||||||
|  |                     return node.path; | ||||||
|  |                 } | ||||||
|  |                 // If it's a directory with children, search recursively | ||||||
|  |                 if (node.isDirectory && node.children && node.children.length > 0) { | ||||||
|  |                     const found = findFirstFile(node.children); | ||||||
|  |                     if (found) return found; | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |             return null; | ||||||
|  |         }; | ||||||
|  |  | ||||||
|  |         return findFirstFile(this.tree); | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										37
									
								
								static/js/file-upload.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,37 @@ | |||||||
|  | /** | ||||||
|  |  * File Upload Module | ||||||
|  |  * Handles file upload dialog for uploading files to the file tree | ||||||
|  |  */ | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Show file upload dialog | ||||||
|  |  * @param {string} targetPath - The target directory path | ||||||
|  |  * @param {Function} onUpload - Callback function to handle file upload | ||||||
|  |  */ | ||||||
|  | function showFileUploadDialog(targetPath, onUpload) { | ||||||
|  |     const input = document.createElement('input'); | ||||||
|  |     input.type = 'file'; | ||||||
|  |     input.multiple = true; | ||||||
|  |  | ||||||
|  |     input.addEventListener('change', async (e) => { | ||||||
|  |         const files = Array.from(e.target.files); | ||||||
|  |         if (files.length === 0) return; | ||||||
|  |  | ||||||
|  |         for (const file of files) { | ||||||
|  |             try { | ||||||
|  |                 await onUpload(targetPath, file); | ||||||
|  |             } catch (error) { | ||||||
|  |                 Logger.error('Upload failed:', error); | ||||||
|  |                 if (window.showNotification) { | ||||||
|  |                     window.showNotification(`Failed to upload ${file.name}`, 'error'); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     input.click(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Make function globally available | ||||||
|  | window.showFileUploadDialog = showFileUploadDialog; | ||||||
|  |  | ||||||
							
								
								
									
										151
									
								
								static/js/loading-spinner.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,151 @@ | |||||||
|  | /** | ||||||
|  |  * Loading Spinner Component | ||||||
|  |  * Displays a loading overlay with spinner for async operations | ||||||
|  |  */ | ||||||
|  |  | ||||||
|  | class LoadingSpinner { | ||||||
|  |     /** | ||||||
|  |      * Create a loading spinner for a container | ||||||
|  |      * @param {string|HTMLElement} container - Container element or ID | ||||||
|  |      * @param {string} message - Optional loading message | ||||||
|  |      */ | ||||||
|  |     constructor(container, message = 'Loading...') { | ||||||
|  |         this.container = typeof container === 'string' | ||||||
|  |             ? document.getElementById(container) | ||||||
|  |             : container; | ||||||
|  |  | ||||||
|  |         if (!this.container) { | ||||||
|  |             Logger.error('LoadingSpinner: Container not found'); | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         this.message = message; | ||||||
|  |         this.overlay = null; | ||||||
|  |         this.isShowing = false; | ||||||
|  |         this.showTime = null; // Track when spinner was shown | ||||||
|  |         this.minDisplayTime = 300; // Minimum time to show spinner (ms) | ||||||
|  |  | ||||||
|  |         // Ensure container has position relative for absolute positioning | ||||||
|  |         const position = window.getComputedStyle(this.container).position; | ||||||
|  |         if (position === 'static') { | ||||||
|  |             this.container.style.position = 'relative'; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Show the loading spinner | ||||||
|  |      * @param {string} message - Optional custom message | ||||||
|  |      */ | ||||||
|  |     show(message = null) { | ||||||
|  |         if (this.isShowing) return; | ||||||
|  |  | ||||||
|  |         // Record when spinner was shown | ||||||
|  |         this.showTime = Date.now(); | ||||||
|  |  | ||||||
|  |         // Create overlay if it doesn't exist | ||||||
|  |         if (!this.overlay) { | ||||||
|  |             this.overlay = this.createOverlay(message || this.message); | ||||||
|  |             this.container.appendChild(this.overlay); | ||||||
|  |         } else { | ||||||
|  |             // Update message if provided | ||||||
|  |             if (message) { | ||||||
|  |                 const textElement = this.overlay.querySelector('.loading-text'); | ||||||
|  |                 if (textElement) { | ||||||
|  |                     textElement.textContent = message; | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |             this.overlay.classList.remove('hidden'); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         this.isShowing = true; | ||||||
|  |         Logger.debug(`Loading spinner shown: ${message || this.message}`); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Hide the loading spinner | ||||||
|  |      * Ensures minimum display time for better UX | ||||||
|  |      */ | ||||||
|  |     hide() { | ||||||
|  |         if (!this.isShowing || !this.overlay) return; | ||||||
|  |  | ||||||
|  |         // Calculate how long the spinner has been showing | ||||||
|  |         const elapsed = Date.now() - this.showTime; | ||||||
|  |         const remaining = Math.max(0, this.minDisplayTime - elapsed); | ||||||
|  |  | ||||||
|  |         // If minimum time hasn't elapsed, delay hiding | ||||||
|  |         if (remaining > 0) { | ||||||
|  |             setTimeout(() => { | ||||||
|  |                 this.overlay.classList.add('hidden'); | ||||||
|  |                 this.isShowing = false; | ||||||
|  |                 Logger.debug('Loading spinner hidden'); | ||||||
|  |             }, remaining); | ||||||
|  |         } else { | ||||||
|  |             this.overlay.classList.add('hidden'); | ||||||
|  |             this.isShowing = false; | ||||||
|  |             Logger.debug('Loading spinner hidden'); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Remove the loading spinner from DOM | ||||||
|  |      */ | ||||||
|  |     destroy() { | ||||||
|  |         if (this.overlay && this.overlay.parentNode) { | ||||||
|  |             this.overlay.parentNode.removeChild(this.overlay); | ||||||
|  |             this.overlay = null; | ||||||
|  |         } | ||||||
|  |         this.isShowing = false; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Create the overlay element | ||||||
|  |      * @param {string} message - Loading message | ||||||
|  |      * @returns {HTMLElement} The overlay element | ||||||
|  |      */ | ||||||
|  |     createOverlay(message) { | ||||||
|  |         const overlay = document.createElement('div'); | ||||||
|  |         overlay.className = 'loading-overlay'; | ||||||
|  |  | ||||||
|  |         const content = document.createElement('div'); | ||||||
|  |         content.className = 'loading-content'; | ||||||
|  |  | ||||||
|  |         const spinner = document.createElement('div'); | ||||||
|  |         spinner.className = 'loading-spinner'; | ||||||
|  |  | ||||||
|  |         const text = document.createElement('div'); | ||||||
|  |         text.className = 'loading-text'; | ||||||
|  |         text.textContent = message; | ||||||
|  |  | ||||||
|  |         content.appendChild(spinner); | ||||||
|  |         content.appendChild(text); | ||||||
|  |         overlay.appendChild(content); | ||||||
|  |  | ||||||
|  |         return overlay; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Update the loading message | ||||||
|  |      * @param {string} message - New message | ||||||
|  |      */ | ||||||
|  |     updateMessage(message) { | ||||||
|  |         this.message = message; | ||||||
|  |         if (this.overlay && this.isShowing) { | ||||||
|  |             const textElement = this.overlay.querySelector('.loading-text'); | ||||||
|  |             if (textElement) { | ||||||
|  |                 textElement.textContent = message; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Check if spinner is currently showing | ||||||
|  |      * @returns {boolean} True if showing | ||||||
|  |      */ | ||||||
|  |     isVisible() { | ||||||
|  |         return this.isShowing; | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Make LoadingSpinner globally available | ||||||
|  | window.LoadingSpinner = LoadingSpinner; | ||||||
|  |  | ||||||
							
								
								
									
										174
									
								
								static/js/logger.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,174 @@ | |||||||
|  | /** | ||||||
|  |  * Logger Module | ||||||
|  |  * Provides structured logging with different levels | ||||||
|  |  * Can be configured to show/hide different log levels | ||||||
|  |  */ | ||||||
|  |  | ||||||
|  | class Logger { | ||||||
|  |     /** | ||||||
|  |      * Log levels | ||||||
|  |      */ | ||||||
|  |     static LEVELS = { | ||||||
|  |         DEBUG: 0, | ||||||
|  |         INFO: 1, | ||||||
|  |         WARN: 2, | ||||||
|  |         ERROR: 3, | ||||||
|  |         NONE: 4 | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Current log level | ||||||
|  |      * Set to DEBUG by default, can be changed via setLevel() | ||||||
|  |      */ | ||||||
|  |     static currentLevel = Logger.LEVELS.DEBUG; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Enable/disable logging | ||||||
|  |      */ | ||||||
|  |     static enabled = true; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Set the minimum log level | ||||||
|  |      * @param {number} level - One of Logger.LEVELS | ||||||
|  |      */ | ||||||
|  |     static setLevel(level) { | ||||||
|  |         if (typeof level === 'number' && level >= 0 && level <= 4) { | ||||||
|  |             Logger.currentLevel = level; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Enable or disable logging | ||||||
|  |      * @param {boolean} enabled - Whether to enable logging | ||||||
|  |      */ | ||||||
|  |     static setEnabled(enabled) { | ||||||
|  |         Logger.enabled = enabled; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Log a debug message | ||||||
|  |      * @param {string} message - The message to log | ||||||
|  |      * @param {...any} args - Additional arguments to log | ||||||
|  |      */ | ||||||
|  |     static debug(message, ...args) { | ||||||
|  |         if (!Logger.enabled || Logger.currentLevel > Logger.LEVELS.DEBUG) { | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |         console.log(`[DEBUG] ${message}`, ...args); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Log an info message | ||||||
|  |      * @param {string} message - The message to log | ||||||
|  |      * @param {...any} args - Additional arguments to log | ||||||
|  |      */ | ||||||
|  |     static info(message, ...args) { | ||||||
|  |         if (!Logger.enabled || Logger.currentLevel > Logger.LEVELS.INFO) { | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |         console.info(`[INFO] ${message}`, ...args); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Log a warning message | ||||||
|  |      * @param {string} message - The message to log | ||||||
|  |      * @param {...any} args - Additional arguments to log | ||||||
|  |      */ | ||||||
|  |     static warn(message, ...args) { | ||||||
|  |         if (!Logger.enabled || Logger.currentLevel > Logger.LEVELS.WARN) { | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |         console.warn(`[WARN] ${message}`, ...args); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Log an error message | ||||||
|  |      * @param {string} message - The message to log | ||||||
|  |      * @param {...any} args - Additional arguments to log | ||||||
|  |      */ | ||||||
|  |     static error(message, ...args) { | ||||||
|  |         if (!Logger.enabled || Logger.currentLevel > Logger.LEVELS.ERROR) { | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |         console.error(`[ERROR] ${message}`, ...args); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Log a message with a custom prefix | ||||||
|  |      * @param {string} prefix - The prefix to use | ||||||
|  |      * @param {string} message - The message to log | ||||||
|  |      * @param {...any} args - Additional arguments to log | ||||||
|  |      */ | ||||||
|  |     static log(prefix, message, ...args) { | ||||||
|  |         if (!Logger.enabled) { | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |         console.log(`[${prefix}] ${message}`, ...args); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Group related log messages | ||||||
|  |      * @param {string} label - The group label | ||||||
|  |      */ | ||||||
|  |     static group(label) { | ||||||
|  |         if (!Logger.enabled) { | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |         console.group(label); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * End a log group | ||||||
|  |      */ | ||||||
|  |     static groupEnd() { | ||||||
|  |         if (!Logger.enabled) { | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |         console.groupEnd(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Log a table (useful for arrays of objects) | ||||||
|  |      * @param {any} data - The data to display as a table | ||||||
|  |      */ | ||||||
|  |     static table(data) { | ||||||
|  |         if (!Logger.enabled) { | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |         console.table(data); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Start a timer | ||||||
|  |      * @param {string} label - The timer label | ||||||
|  |      */ | ||||||
|  |     static time(label) { | ||||||
|  |         if (!Logger.enabled) { | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |         console.time(label); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * End a timer and log the elapsed time | ||||||
|  |      * @param {string} label - The timer label | ||||||
|  |      */ | ||||||
|  |     static timeEnd(label) { | ||||||
|  |         if (!Logger.enabled) { | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |         console.timeEnd(label); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Make Logger globally available | ||||||
|  | window.Logger = Logger; | ||||||
|  |  | ||||||
|  | // Set default log level based on environment | ||||||
|  | // In production, you might want to set this to WARN or ERROR | ||||||
|  | if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') { | ||||||
|  |     Logger.setLevel(Logger.LEVELS.DEBUG); | ||||||
|  | } else { | ||||||
|  |     Logger.setLevel(Logger.LEVELS.INFO); | ||||||
|  | } | ||||||
|  |  | ||||||
| @@ -25,20 +25,16 @@ class MacroProcessor { | |||||||
|      * Returns { success: boolean, content: string, errors: [] } |      * Returns { success: boolean, content: string, errors: [] } | ||||||
|      */ |      */ | ||||||
|     async processMacros(content) { |     async processMacros(content) { | ||||||
|         console.log('MacroProcessor: Starting macro processing for content:', content); |  | ||||||
|         const macros = MacroParser.extractMacros(content); |         const macros = MacroParser.extractMacros(content); | ||||||
|         console.log('MacroProcessor: Extracted macros:', macros); |  | ||||||
|         const errors = []; |         const errors = []; | ||||||
|         let processedContent = content; |         let processedContent = content; | ||||||
|  |  | ||||||
|         // Process macros in reverse order to preserve positions |         // Process macros in reverse order to preserve positions | ||||||
|         for (let i = macros.length - 1; i >= 0; i--) { |         for (let i = macros.length - 1; i >= 0; i--) { | ||||||
|             const macro = macros[i]; |             const macro = macros[i]; | ||||||
|             console.log('MacroProcessor: Processing macro:', macro); |  | ||||||
|  |  | ||||||
|             try { |             try { | ||||||
|                 const result = await this.processMacro(macro); |                 const result = await this.processMacro(macro); | ||||||
|                 console.log('MacroProcessor: Macro processing result:', result); |  | ||||||
|  |  | ||||||
|                 if (result.success) { |                 if (result.success) { | ||||||
|                     // Replace macro with result |                     // Replace macro with result | ||||||
| @@ -73,7 +69,6 @@ class MacroProcessor { | |||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         console.log('MacroProcessor: Final processed content:', processedContent); |  | ||||||
|         return { |         return { | ||||||
|             success: errors.length === 0, |             success: errors.length === 0, | ||||||
|             content: processedContent, |             content: processedContent, | ||||||
|   | |||||||
							
								
								
									
										77
									
								
								static/js/notification-service.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,77 @@ | |||||||
|  | /** | ||||||
|  |  * Notification Service | ||||||
|  |  * Provides a standardized way to show toast notifications | ||||||
|  |  * Wraps the showNotification function from ui-utils.js | ||||||
|  |  */ | ||||||
|  |  | ||||||
|  | class NotificationService { | ||||||
|  |     /** | ||||||
|  |      * Show a success notification | ||||||
|  |      * @param {string} message - The message to display | ||||||
|  |      */ | ||||||
|  |     static success(message) { | ||||||
|  |         if (window.showNotification) { | ||||||
|  |             window.showNotification(message, Config.NOTIFICATION_TYPES.SUCCESS); | ||||||
|  |         } else { | ||||||
|  |             Logger.warn('showNotification not available, falling back to console'); | ||||||
|  |             console.log(`✅ ${message}`); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Show an error notification | ||||||
|  |      * @param {string} message - The message to display | ||||||
|  |      */ | ||||||
|  |     static error(message) { | ||||||
|  |         if (window.showNotification) { | ||||||
|  |             window.showNotification(message, Config.NOTIFICATION_TYPES.ERROR); | ||||||
|  |         } else { | ||||||
|  |             Logger.warn('showNotification not available, falling back to console'); | ||||||
|  |             console.error(`❌ ${message}`); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Show a warning notification | ||||||
|  |      * @param {string} message - The message to display | ||||||
|  |      */ | ||||||
|  |     static warning(message) { | ||||||
|  |         if (window.showNotification) { | ||||||
|  |             window.showNotification(message, Config.NOTIFICATION_TYPES.WARNING); | ||||||
|  |         } else { | ||||||
|  |             Logger.warn('showNotification not available, falling back to console'); | ||||||
|  |             console.warn(`⚠️ ${message}`); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Show an info notification | ||||||
|  |      * @param {string} message - The message to display | ||||||
|  |      */ | ||||||
|  |     static info(message) { | ||||||
|  |         if (window.showNotification) { | ||||||
|  |             window.showNotification(message, Config.NOTIFICATION_TYPES.INFO); | ||||||
|  |         } else { | ||||||
|  |             Logger.warn('showNotification not available, falling back to console'); | ||||||
|  |             console.info(`ℹ️ ${message}`); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Show a notification with a custom type | ||||||
|  |      * @param {string} message - The message to display | ||||||
|  |      * @param {string} type - The notification type (success, danger, warning, primary, etc.) | ||||||
|  |      */ | ||||||
|  |     static show(message, type = 'primary') { | ||||||
|  |         if (window.showNotification) { | ||||||
|  |             window.showNotification(message, type); | ||||||
|  |         } else { | ||||||
|  |             Logger.warn('showNotification not available, falling back to console'); | ||||||
|  |             console.log(`[${type.toUpperCase()}] ${message}`); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Make NotificationService globally available | ||||||
|  | window.NotificationService = NotificationService; | ||||||
|  |  | ||||||
							
								
								
									
										114
									
								
								static/js/sidebar-toggle.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,114 @@ | |||||||
|  | /** | ||||||
|  |  * Sidebar Toggle Module | ||||||
|  |  * Manages sidebar collapse/expand functionality with localStorage persistence | ||||||
|  |  */ | ||||||
|  |  | ||||||
|  | class SidebarToggle { | ||||||
|  |     constructor(sidebarId, toggleButtonId) { | ||||||
|  |         this.sidebar = document.getElementById(sidebarId); | ||||||
|  |         this.toggleButton = document.getElementById(toggleButtonId); | ||||||
|  |         this.storageKey = Config.STORAGE_KEYS.SIDEBAR_COLLAPSED || 'sidebarCollapsed'; | ||||||
|  |         this.isCollapsed = localStorage.getItem(this.storageKey) === 'true'; | ||||||
|  |  | ||||||
|  |         this.init(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Initialize the sidebar toggle | ||||||
|  |      */ | ||||||
|  |     init() { | ||||||
|  |         // Apply initial state | ||||||
|  |         this.apply(); | ||||||
|  |  | ||||||
|  |         // Setup toggle button click handler | ||||||
|  |         if (this.toggleButton) { | ||||||
|  |             this.toggleButton.addEventListener('click', () => { | ||||||
|  |                 this.toggle(); | ||||||
|  |             }); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // Make mini sidebar clickable to expand | ||||||
|  |         if (this.sidebar) { | ||||||
|  |             this.sidebar.addEventListener('click', (e) => { | ||||||
|  |                 // Only expand if sidebar is collapsed and click is on the mini sidebar itself | ||||||
|  |                 // (not on the file tree content when expanded) | ||||||
|  |                 if (this.isCollapsed) { | ||||||
|  |                     this.expand(); | ||||||
|  |                 } | ||||||
|  |             }); | ||||||
|  |  | ||||||
|  |             // Add cursor pointer when collapsed | ||||||
|  |             this.sidebar.style.cursor = 'default'; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         Logger.debug(`Sidebar initialized: ${this.isCollapsed ? 'collapsed' : 'expanded'}`); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Toggle sidebar state | ||||||
|  |      */ | ||||||
|  |     toggle() { | ||||||
|  |         this.isCollapsed = !this.isCollapsed; | ||||||
|  |         localStorage.setItem(this.storageKey, this.isCollapsed); | ||||||
|  |         this.apply(); | ||||||
|  |  | ||||||
|  |         Logger.debug(`Sidebar ${this.isCollapsed ? 'collapsed' : 'expanded'}`); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Apply the current sidebar state | ||||||
|  |      */ | ||||||
|  |     apply() { | ||||||
|  |         if (this.sidebar) { | ||||||
|  |             if (this.isCollapsed) { | ||||||
|  |                 this.sidebar.classList.add('collapsed'); | ||||||
|  |                 this.sidebar.style.cursor = 'pointer'; // Make mini sidebar clickable | ||||||
|  |             } else { | ||||||
|  |                 this.sidebar.classList.remove('collapsed'); | ||||||
|  |                 this.sidebar.style.cursor = 'default'; // Normal cursor when expanded | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // Update toggle button icon | ||||||
|  |         if (this.toggleButton) { | ||||||
|  |             const icon = this.toggleButton.querySelector('i'); | ||||||
|  |             if (icon) { | ||||||
|  |                 if (this.isCollapsed) { | ||||||
|  |                     icon.className = 'bi bi-layout-sidebar-inset-reverse'; | ||||||
|  |                 } else { | ||||||
|  |                     icon.className = 'bi bi-layout-sidebar'; | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Collapse the sidebar | ||||||
|  |      */ | ||||||
|  |     collapse() { | ||||||
|  |         if (!this.isCollapsed) { | ||||||
|  |             this.toggle(); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Expand the sidebar | ||||||
|  |      */ | ||||||
|  |     expand() { | ||||||
|  |         if (this.isCollapsed) { | ||||||
|  |             this.toggle(); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Check if sidebar is currently collapsed | ||||||
|  |      * @returns {boolean} True if sidebar is collapsed | ||||||
|  |      */ | ||||||
|  |     isCollapsedState() { | ||||||
|  |         return this.isCollapsed; | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Make SidebarToggle globally available | ||||||
|  | window.SidebarToggle = SidebarToggle; | ||||||
|  |  | ||||||
| @@ -1,10 +1,19 @@ | |||||||
| /** | /** | ||||||
|  * UI Utilities Module |  * UI Utilities Module | ||||||
|  * Toast notifications, context menu, dark mode, file upload dialog |  * Toast notifications (kept for backward compatibility) | ||||||
|  |  * | ||||||
|  |  * Other utilities have been moved to separate modules: | ||||||
|  |  * - Context menu: context-menu.js | ||||||
|  |  * - File upload: file-upload.js | ||||||
|  |  * - Dark mode: dark-mode.js | ||||||
|  |  * - Collection selector: collection-selector.js | ||||||
|  |  * - Editor drop handler: editor-drop-handler.js | ||||||
|  */ |  */ | ||||||
|  |  | ||||||
| /** | /** | ||||||
|  * Show toast notification |  * Show toast notification | ||||||
|  |  * @param {string} message - The message to display | ||||||
|  |  * @param {string} type - The notification type (info, success, error, warning, danger, primary) | ||||||
|  */ |  */ | ||||||
| function showNotification(message, type = 'info') { | function showNotification(message, type = 'info') { | ||||||
|     const container = document.getElementById('toastContainer') || createToastContainer(); |     const container = document.getElementById('toastContainer') || createToastContainer(); | ||||||
| @@ -23,7 +32,7 @@ function showNotification(message, type = 'info') { | |||||||
|  |  | ||||||
|     container.appendChild(toast); |     container.appendChild(toast); | ||||||
|  |  | ||||||
|     const bsToast = new bootstrap.Toast(toast, { delay: 3000 }); |     const bsToast = new bootstrap.Toast(toast, { delay: Config.TOAST_DURATION }); | ||||||
|     bsToast.show(); |     bsToast.show(); | ||||||
|  |  | ||||||
|     toast.addEventListener('hidden.bs.toast', () => { |     toast.addEventListener('hidden.bs.toast', () => { | ||||||
| @@ -31,240 +40,21 @@ function showNotification(message, type = 'info') { | |||||||
|     }); |     }); | ||||||
| } | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Create the toast container if it doesn't exist | ||||||
|  |  * @returns {HTMLElement} The toast container element | ||||||
|  |  */ | ||||||
| function createToastContainer() { | function createToastContainer() { | ||||||
|     const container = document.createElement('div'); |     const container = document.createElement('div'); | ||||||
|     container.id = 'toastContainer'; |     container.id = 'toastContainer'; | ||||||
|     container.className = 'toast-container position-fixed top-0 end-0 p-3'; |     container.className = 'toast-container position-fixed top-0 end-0 p-3'; | ||||||
|     container.style.zIndex = '9999'; |     container.style.zIndex = Config.TOAST_Z_INDEX; | ||||||
|     document.body.appendChild(container); |     document.body.appendChild(container); | ||||||
|     return container; |     return container; | ||||||
| } | } | ||||||
|  |  | ||||||
| /** | // All other UI utilities have been moved to separate modules | ||||||
|  * Enhanced Context Menu | // See the module list at the top of this file | ||||||
|  */ |  | ||||||
| function showContextMenu(x, y, target) { |  | ||||||
|     const menu = document.getElementById('contextMenu'); |  | ||||||
|     if (!menu) return; |  | ||||||
|      |  | ||||||
|     // Store target 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'); |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         }); |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
|  | // Make showNotification globally available | ||||||
|  | window.showNotification = showNotification; | ||||||
|   | |||||||
							
								
								
									
										429
									
								
								static/js/utils.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,429 @@ | |||||||
|  | /** | ||||||
|  |  * Utilities Module | ||||||
|  |  * Common utility functions used throughout the application | ||||||
|  |  */ | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Path Utilities | ||||||
|  |  * Helper functions for path manipulation | ||||||
|  |  */ | ||||||
|  | const PathUtils = { | ||||||
|  |     /** | ||||||
|  |      * Get the filename from a path | ||||||
|  |      * @param {string} path - The file path | ||||||
|  |      * @returns {string} The filename | ||||||
|  |      * @example PathUtils.getFileName('folder/subfolder/file.md') // 'file.md' | ||||||
|  |      */ | ||||||
|  |     getFileName(path) { | ||||||
|  |         if (!path) return ''; | ||||||
|  |         return path.split('/').pop(); | ||||||
|  |     }, | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Get the parent directory path | ||||||
|  |      * @param {string} path - The file path | ||||||
|  |      * @returns {string} The parent directory path | ||||||
|  |      * @example PathUtils.getParentPath('folder/subfolder/file.md') // 'folder/subfolder' | ||||||
|  |      */ | ||||||
|  |     getParentPath(path) { | ||||||
|  |         if (!path) return ''; | ||||||
|  |         const lastSlash = path.lastIndexOf('/'); | ||||||
|  |         return lastSlash === -1 ? '' : path.substring(0, lastSlash); | ||||||
|  |     }, | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Normalize a path by removing duplicate slashes | ||||||
|  |      * @param {string} path - The path to normalize | ||||||
|  |      * @returns {string} The normalized path | ||||||
|  |      * @example PathUtils.normalizePath('folder//subfolder///file.md') // 'folder/subfolder/file.md' | ||||||
|  |      */ | ||||||
|  |     normalizePath(path) { | ||||||
|  |         if (!path) return ''; | ||||||
|  |         return path.replace(/\/+/g, '/'); | ||||||
|  |     }, | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Join multiple path segments | ||||||
|  |      * @param {...string} paths - Path segments to join | ||||||
|  |      * @returns {string} The joined path | ||||||
|  |      * @example PathUtils.joinPaths('folder', 'subfolder', 'file.md') // 'folder/subfolder/file.md' | ||||||
|  |      */ | ||||||
|  |     joinPaths(...paths) { | ||||||
|  |         return PathUtils.normalizePath(paths.filter(p => p).join('/')); | ||||||
|  |     }, | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Get the file extension | ||||||
|  |      * @param {string} path - The file path | ||||||
|  |      * @returns {string} The file extension (without dot) | ||||||
|  |      * @example PathUtils.getExtension('file.md') // 'md' | ||||||
|  |      */ | ||||||
|  |     getExtension(path) { | ||||||
|  |         if (!path) return ''; | ||||||
|  |         const fileName = PathUtils.getFileName(path); | ||||||
|  |         const lastDot = fileName.lastIndexOf('.'); | ||||||
|  |         return lastDot === -1 ? '' : fileName.substring(lastDot + 1); | ||||||
|  |     }, | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Check if a path is a descendant of another path | ||||||
|  |      * @param {string} path - The path to check | ||||||
|  |      * @param {string} ancestorPath - The potential ancestor path | ||||||
|  |      * @returns {boolean} True if path is a descendant of ancestorPath | ||||||
|  |      * @example PathUtils.isDescendant('folder/subfolder/file.md', 'folder') // true | ||||||
|  |      */ | ||||||
|  |     isDescendant(path, ancestorPath) { | ||||||
|  |         if (!path || !ancestorPath) return false; | ||||||
|  |         return path.startsWith(ancestorPath + '/'); | ||||||
|  |     }, | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Check if a file is a binary/non-editable file based on extension | ||||||
|  |      * @param {string} path - The file path | ||||||
|  |      * @returns {boolean} True if the file is binary/non-editable | ||||||
|  |      * @example PathUtils.isBinaryFile('image.png') // true | ||||||
|  |      * @example PathUtils.isBinaryFile('document.md') // false | ||||||
|  |      */ | ||||||
|  |     isBinaryFile(path) { | ||||||
|  |         const extension = PathUtils.getExtension(path).toLowerCase(); | ||||||
|  |         const binaryExtensions = [ | ||||||
|  |             // Images | ||||||
|  |             'png', 'jpg', 'jpeg', 'gif', 'bmp', 'ico', 'svg', 'webp', 'tiff', 'tif', | ||||||
|  |             // Documents | ||||||
|  |             'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', | ||||||
|  |             // Archives | ||||||
|  |             'zip', 'rar', '7z', 'tar', 'gz', 'bz2', | ||||||
|  |             // Executables | ||||||
|  |             'exe', 'dll', 'so', 'dylib', 'app', | ||||||
|  |             // Media | ||||||
|  |             'mp3', 'mp4', 'avi', 'mov', 'wmv', 'flv', 'wav', 'ogg', | ||||||
|  |             // Other binary formats | ||||||
|  |             'bin', 'dat', 'db', 'sqlite' | ||||||
|  |         ]; | ||||||
|  |         return binaryExtensions.includes(extension); | ||||||
|  |     }, | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Check if a directory is an image directory based on its name | ||||||
|  |      * @param {string} path - The directory path | ||||||
|  |      * @returns {boolean} True if the directory is for images | ||||||
|  |      * @example PathUtils.isImageDirectory('images') // true | ||||||
|  |      * @example PathUtils.isImageDirectory('assets/images') // true | ||||||
|  |      * @example PathUtils.isImageDirectory('docs') // false | ||||||
|  |      */ | ||||||
|  |     isImageDirectory(path) { | ||||||
|  |         const dirName = PathUtils.getFileName(path).toLowerCase(); | ||||||
|  |         const imageDirectoryNames = [ | ||||||
|  |             'images', | ||||||
|  |             'image', | ||||||
|  |             'img', | ||||||
|  |             'imgs', | ||||||
|  |             'pictures', | ||||||
|  |             'pics', | ||||||
|  |             'photos', | ||||||
|  |             'assets', | ||||||
|  |             'media', | ||||||
|  |             'static' | ||||||
|  |         ]; | ||||||
|  |         return imageDirectoryNames.includes(dirName); | ||||||
|  |     }, | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Get a human-readable file type description | ||||||
|  |      * @param {string} path - The file path | ||||||
|  |      * @returns {string} The file type description | ||||||
|  |      * @example PathUtils.getFileType('image.png') // 'Image' | ||||||
|  |      */ | ||||||
|  |     getFileType(path) { | ||||||
|  |         const extension = PathUtils.getExtension(path).toLowerCase(); | ||||||
|  |  | ||||||
|  |         const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'ico', 'svg', 'webp', 'tiff', 'tif']; | ||||||
|  |         const documentExtensions = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx']; | ||||||
|  |         const archiveExtensions = ['zip', 'rar', '7z', 'tar', 'gz', 'bz2']; | ||||||
|  |         const mediaExtensions = ['mp3', 'mp4', 'avi', 'mov', 'wmv', 'flv', 'wav', 'ogg']; | ||||||
|  |  | ||||||
|  |         if (imageExtensions.includes(extension)) return 'Image'; | ||||||
|  |         if (documentExtensions.includes(extension)) return 'Document'; | ||||||
|  |         if (archiveExtensions.includes(extension)) return 'Archive'; | ||||||
|  |         if (mediaExtensions.includes(extension)) return 'Media'; | ||||||
|  |         if (extension === 'pdf') return 'PDF'; | ||||||
|  |  | ||||||
|  |         return 'File'; | ||||||
|  |     } | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * DOM Utilities | ||||||
|  |  * Helper functions for DOM manipulation | ||||||
|  |  */ | ||||||
|  | const DOMUtils = { | ||||||
|  |     /** | ||||||
|  |      * Create an element with optional class and attributes | ||||||
|  |      * @param {string} tag - The HTML tag name | ||||||
|  |      * @param {string} [className] - Optional class name(s) | ||||||
|  |      * @param {Object} [attributes] - Optional attributes object | ||||||
|  |      * @returns {HTMLElement} The created element | ||||||
|  |      */ | ||||||
|  |     createElement(tag, className = '', attributes = {}) { | ||||||
|  |         const element = document.createElement(tag); | ||||||
|  |         if (className) { | ||||||
|  |             element.className = className; | ||||||
|  |         } | ||||||
|  |         Object.entries(attributes).forEach(([key, value]) => { | ||||||
|  |             element.setAttribute(key, value); | ||||||
|  |         }); | ||||||
|  |         return element; | ||||||
|  |     }, | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Remove all children from an element | ||||||
|  |      * @param {HTMLElement} element - The element to clear | ||||||
|  |      */ | ||||||
|  |     removeAllChildren(element) { | ||||||
|  |         while (element.firstChild) { | ||||||
|  |             element.removeChild(element.firstChild); | ||||||
|  |         } | ||||||
|  |     }, | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Toggle a class on an element | ||||||
|  |      * @param {HTMLElement} element - The element | ||||||
|  |      * @param {string} className - The class name | ||||||
|  |      * @param {boolean} [force] - Optional force add/remove | ||||||
|  |      */ | ||||||
|  |     toggleClass(element, className, force) { | ||||||
|  |         if (force !== undefined) { | ||||||
|  |             element.classList.toggle(className, force); | ||||||
|  |         } else { | ||||||
|  |             element.classList.toggle(className); | ||||||
|  |         } | ||||||
|  |     }, | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Query selector with error handling | ||||||
|  |      * @param {string} selector - The CSS selector | ||||||
|  |      * @param {HTMLElement} [parent] - Optional parent element | ||||||
|  |      * @returns {HTMLElement|null} The found element or null | ||||||
|  |      */ | ||||||
|  |     querySelector(selector, parent = document) { | ||||||
|  |         try { | ||||||
|  |             return parent.querySelector(selector); | ||||||
|  |         } catch (error) { | ||||||
|  |             Logger.error(`Invalid selector: ${selector}`, error); | ||||||
|  |             return null; | ||||||
|  |         } | ||||||
|  |     }, | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Query selector all with error handling | ||||||
|  |      * @param {string} selector - The CSS selector | ||||||
|  |      * @param {HTMLElement} [parent] - Optional parent element | ||||||
|  |      * @returns {NodeList|Array} The found elements or empty array | ||||||
|  |      */ | ||||||
|  |     querySelectorAll(selector, parent = document) { | ||||||
|  |         try { | ||||||
|  |             return parent.querySelectorAll(selector); | ||||||
|  |         } catch (error) { | ||||||
|  |             Logger.error(`Invalid selector: ${selector}`, error); | ||||||
|  |             return []; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Timing Utilities | ||||||
|  |  * Helper functions for timing and throttling | ||||||
|  |  */ | ||||||
|  | const TimingUtils = { | ||||||
|  |     /** | ||||||
|  |      * Debounce a function | ||||||
|  |      * @param {Function} func - The function to debounce | ||||||
|  |      * @param {number} wait - The wait time in milliseconds | ||||||
|  |      * @returns {Function} The debounced function | ||||||
|  |      */ | ||||||
|  |     debounce(func, wait) { | ||||||
|  |         let timeout; | ||||||
|  |         return function executedFunction(...args) { | ||||||
|  |             const later = () => { | ||||||
|  |                 clearTimeout(timeout); | ||||||
|  |                 func(...args); | ||||||
|  |             }; | ||||||
|  |             clearTimeout(timeout); | ||||||
|  |             timeout = setTimeout(later, wait); | ||||||
|  |         }; | ||||||
|  |     }, | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Throttle a function | ||||||
|  |      * @param {Function} func - The function to throttle | ||||||
|  |      * @param {number} wait - The wait time in milliseconds | ||||||
|  |      * @returns {Function} The throttled function | ||||||
|  |      */ | ||||||
|  |     throttle(func, wait) { | ||||||
|  |         let inThrottle; | ||||||
|  |         return function executedFunction(...args) { | ||||||
|  |             if (!inThrottle) { | ||||||
|  |                 func(...args); | ||||||
|  |                 inThrottle = true; | ||||||
|  |                 setTimeout(() => inThrottle = false, wait); | ||||||
|  |             } | ||||||
|  |         }; | ||||||
|  |     }, | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Delay execution | ||||||
|  |      * @param {number} ms - Milliseconds to delay | ||||||
|  |      * @returns {Promise} Promise that resolves after delay | ||||||
|  |      */ | ||||||
|  |     delay(ms) { | ||||||
|  |         return new Promise(resolve => setTimeout(resolve, ms)); | ||||||
|  |     } | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Download Utilities | ||||||
|  |  * Helper functions for file downloads | ||||||
|  |  */ | ||||||
|  | const DownloadUtils = { | ||||||
|  |     /** | ||||||
|  |      * Trigger a download in the browser | ||||||
|  |      * @param {string|Blob} content - The content to download | ||||||
|  |      * @param {string} filename - The filename for the download | ||||||
|  |      */ | ||||||
|  |     triggerDownload(content, filename) { | ||||||
|  |         const blob = content instanceof Blob ? content : new Blob([content]); | ||||||
|  |         const url = URL.createObjectURL(blob); | ||||||
|  |         const a = document.createElement('a'); | ||||||
|  |         a.href = url; | ||||||
|  |         a.download = filename; | ||||||
|  |         document.body.appendChild(a); | ||||||
|  |         a.click(); | ||||||
|  |         document.body.removeChild(a); | ||||||
|  |         URL.revokeObjectURL(url); | ||||||
|  |     }, | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Download content as a blob | ||||||
|  |      * @param {Blob} blob - The blob to download | ||||||
|  |      * @param {string} filename - The filename for the download | ||||||
|  |      */ | ||||||
|  |     downloadAsBlob(blob, filename) { | ||||||
|  |         DownloadUtils.triggerDownload(blob, filename); | ||||||
|  |     } | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Validation Utilities | ||||||
|  |  * Helper functions for input validation | ||||||
|  |  */ | ||||||
|  | const ValidationUtils = { | ||||||
|  |     /** | ||||||
|  |      * Validate and sanitize a filename | ||||||
|  |      * @param {string} name - The filename to validate | ||||||
|  |      * @param {boolean} [isFolder=false] - Whether this is a folder name | ||||||
|  |      * @returns {Object} Validation result with {valid, sanitized, message} | ||||||
|  |      */ | ||||||
|  |     validateFileName(name, isFolder = false) { | ||||||
|  |         const type = isFolder ? 'folder' : 'file'; | ||||||
|  |  | ||||||
|  |         if (!name || name.trim().length === 0) { | ||||||
|  |             return { valid: false, sanitized: '', message: `${type} name cannot be empty` }; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // Check for invalid characters using pattern from Config | ||||||
|  |         const validPattern = Config.FILENAME_PATTERN; | ||||||
|  |  | ||||||
|  |         if (!validPattern.test(name)) { | ||||||
|  |             const sanitized = ValidationUtils.sanitizeFileName(name); | ||||||
|  |  | ||||||
|  |             return { | ||||||
|  |                 valid: false, | ||||||
|  |                 sanitized, | ||||||
|  |                 message: `Invalid characters in ${type} name. Only lowercase letters, numbers, and underscores allowed.\n\nSuggestion: "${sanitized}"` | ||||||
|  |             }; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return { valid: true, sanitized: name, message: '' }; | ||||||
|  |     }, | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Sanitize a filename by removing/replacing invalid characters | ||||||
|  |      * @param {string} name - The filename to sanitize | ||||||
|  |      * @returns {string} The sanitized filename | ||||||
|  |      */ | ||||||
|  |     sanitizeFileName(name) { | ||||||
|  |         return name | ||||||
|  |             .toLowerCase() | ||||||
|  |             .replace(Config.FILENAME_INVALID_CHARS, '_') | ||||||
|  |             .replace(/_+/g, '_') | ||||||
|  |             .replace(/^_+|_+$/g, ''); | ||||||
|  |     }, | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Check if a string is empty or whitespace | ||||||
|  |      * @param {string} str - The string to check | ||||||
|  |      * @returns {boolean} True if empty or whitespace | ||||||
|  |      */ | ||||||
|  |     isEmpty(str) { | ||||||
|  |         return !str || str.trim().length === 0; | ||||||
|  |     }, | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Check if a value is a valid email | ||||||
|  |      * @param {string} email - The email to validate | ||||||
|  |      * @returns {boolean} True if valid email | ||||||
|  |      */ | ||||||
|  |     isValidEmail(email) { | ||||||
|  |         const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; | ||||||
|  |         return emailPattern.test(email); | ||||||
|  |     } | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * String Utilities | ||||||
|  |  * Helper functions for string manipulation | ||||||
|  |  */ | ||||||
|  | const StringUtils = { | ||||||
|  |     /** | ||||||
|  |      * Truncate a string to a maximum length | ||||||
|  |      * @param {string} str - The string to truncate | ||||||
|  |      * @param {number} maxLength - Maximum length | ||||||
|  |      * @param {string} [suffix='...'] - Suffix to add if truncated | ||||||
|  |      * @returns {string} The truncated string | ||||||
|  |      */ | ||||||
|  |     truncate(str, maxLength, suffix = '...') { | ||||||
|  |         if (!str || str.length <= maxLength) return str; | ||||||
|  |         return str.substring(0, maxLength - suffix.length) + suffix; | ||||||
|  |     }, | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Capitalize the first letter of a string | ||||||
|  |      * @param {string} str - The string to capitalize | ||||||
|  |      * @returns {string} The capitalized string | ||||||
|  |      */ | ||||||
|  |     capitalize(str) { | ||||||
|  |         if (!str) return ''; | ||||||
|  |         return str.charAt(0).toUpperCase() + str.slice(1); | ||||||
|  |     }, | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Convert a string to kebab-case | ||||||
|  |      * @param {string} str - The string to convert | ||||||
|  |      * @returns {string} The kebab-case string | ||||||
|  |      */ | ||||||
|  |     toKebabCase(str) { | ||||||
|  |         return str | ||||||
|  |             .replace(/([a-z])([A-Z])/g, '$1-$2') | ||||||
|  |             .replace(/[\s_]+/g, '-') | ||||||
|  |             .toLowerCase(); | ||||||
|  |     } | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | // Make utilities globally available | ||||||
|  | window.PathUtils = PathUtils; | ||||||
|  | window.DOMUtils = DOMUtils; | ||||||
|  | window.TimingUtils = TimingUtils; | ||||||
|  | window.DownloadUtils = DownloadUtils; | ||||||
|  | window.ValidationUtils = ValidationUtils; | ||||||
|  | window.StringUtils = StringUtils; | ||||||
|  |  | ||||||
| @@ -29,6 +29,24 @@ class WebDAVClient { | |||||||
|         return await response.json(); |         return await response.json(); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     async createCollection(collectionName) { | ||||||
|  |         // Use POST API to create collection (not MKCOL, as collections are managed by the server) | ||||||
|  |         const response = await fetch(this.baseUrl, { | ||||||
|  |             method: 'POST', | ||||||
|  |             headers: { | ||||||
|  |                 'Content-Type': 'application/json' | ||||||
|  |             }, | ||||||
|  |             body: JSON.stringify({ name: collectionName }) | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         if (!response.ok) { | ||||||
|  |             const errorData = await response.json().catch(() => ({ error: response.statusText })); | ||||||
|  |             throw new Error(errorData.error || `Failed to create collection: ${response.status} ${response.statusText}`); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return true; | ||||||
|  |     } | ||||||
|  |  | ||||||
|     async propfind(path = '', depth = '1') { |     async propfind(path = '', depth = '1') { | ||||||
|         const url = this.getFullUrl(path); |         const url = this.getFullUrl(path); | ||||||
|         const response = await fetch(url, { |         const response = await fetch(url, { | ||||||
| @@ -47,6 +65,33 @@ class WebDAVClient { | |||||||
|         return this.parseMultiStatus(xml); |         return this.parseMultiStatus(xml); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * List files and directories in a path | ||||||
|  |      * Returns only direct children (depth=1) to avoid infinite recursion | ||||||
|  |      * @param {string} path - Path to list | ||||||
|  |      * @param {boolean} recursive - If true, returns all nested items (depth=infinity) | ||||||
|  |      * @returns {Promise<Array>} Array of items | ||||||
|  |      */ | ||||||
|  |     async list(path = '', recursive = false) { | ||||||
|  |         const depth = recursive ? 'infinity' : '1'; | ||||||
|  |         const items = await this.propfind(path, depth); | ||||||
|  |  | ||||||
|  |         // If not recursive, filter to only direct children | ||||||
|  |         if (!recursive && path) { | ||||||
|  |             // Normalize path (remove trailing slash) | ||||||
|  |             const normalizedPath = path.endsWith('/') ? path.slice(0, -1) : path; | ||||||
|  |             const pathDepth = normalizedPath.split('/').length; | ||||||
|  |  | ||||||
|  |             // Filter items to only include direct children | ||||||
|  |             return items.filter(item => { | ||||||
|  |                 const itemDepth = item.path.split('/').length; | ||||||
|  |                 return itemDepth === pathDepth + 1; | ||||||
|  |             }); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return items; | ||||||
|  |     } | ||||||
|  |  | ||||||
|     async get(path) { |     async get(path) { | ||||||
|         const url = this.getFullUrl(path); |         const url = this.getFullUrl(path); | ||||||
|         const response = await fetch(url); |         const response = await fetch(url); | ||||||
| @@ -162,6 +207,41 @@ class WebDAVClient { | |||||||
|         return true; |         return true; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     // Alias for mkcol | ||||||
|  |     async createFolder(path) { | ||||||
|  |         return await this.mkcol(path); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Ensure all parent directories exist for a given path | ||||||
|  |      * Creates missing parent directories recursively | ||||||
|  |      */ | ||||||
|  |     async ensureParentDirectories(filePath) { | ||||||
|  |         const parts = filePath.split('/'); | ||||||
|  |  | ||||||
|  |         // Remove the filename (last part) | ||||||
|  |         parts.pop(); | ||||||
|  |  | ||||||
|  |         // If no parent directories, nothing to do | ||||||
|  |         if (parts.length === 0) { | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // Create each parent directory level | ||||||
|  |         let currentPath = ''; | ||||||
|  |         for (const part of parts) { | ||||||
|  |             currentPath = currentPath ? `${currentPath}/${part}` : part; | ||||||
|  |  | ||||||
|  |             try { | ||||||
|  |                 await this.mkcol(currentPath); | ||||||
|  |             } catch (error) { | ||||||
|  |                 // Ignore errors - directory might already exist | ||||||
|  |                 // Only log for debugging | ||||||
|  |                 console.debug(`Directory ${currentPath} might already exist:`, error.message); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|     async includeFile(path) { |     async includeFile(path) { | ||||||
|         try { |         try { | ||||||
|             // Parse path: "collection:path/to/file" or "path/to/file" |             // Parse path: "collection:path/to/file" or "path/to/file" | ||||||
|   | |||||||
| @@ -33,7 +33,8 @@ body.dark-mode { | |||||||
| } | } | ||||||
|  |  | ||||||
| /* Global styles */ | /* Global styles */ | ||||||
| html, body { | html, | ||||||
|  | body { | ||||||
|     height: 100%; |     height: 100%; | ||||||
|     margin: 0; |     margin: 0; | ||||||
|     padding: 0; |     padding: 0; | ||||||
| @@ -48,12 +49,6 @@ body { | |||||||
|     transition: background-color 0.3s ease, color 0.3s ease; |     transition: background-color 0.3s ease, color 0.3s ease; | ||||||
| } | } | ||||||
|  |  | ||||||
| .container-fluid { |  | ||||||
|     flex: 1; |  | ||||||
|     padding: 0; |  | ||||||
|     overflow: hidden; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| .row { | .row { | ||||||
|     margin: 0; |     margin: 0; | ||||||
| } | } | ||||||
| @@ -206,7 +201,12 @@ body.dark-mode .CodeMirror-linenumber { | |||||||
| } | } | ||||||
|  |  | ||||||
| /* Markdown preview styles */ | /* Markdown preview styles */ | ||||||
| #preview h1, #preview h2, #preview h3, #preview h4, #preview h5, #preview h6 { | #preview h1, | ||||||
|  | #preview h2, | ||||||
|  | #preview h3, | ||||||
|  | #preview h4, | ||||||
|  | #preview h5, | ||||||
|  | #preview h6 { | ||||||
|     margin-top: 24px; |     margin-top: 24px; | ||||||
|     margin-bottom: 16px; |     margin-bottom: 16px; | ||||||
|     font-weight: 600; |     font-weight: 600; | ||||||
| @@ -286,7 +286,8 @@ body.dark-mode .CodeMirror-linenumber { | |||||||
|     margin-bottom: 16px; |     margin-bottom: 16px; | ||||||
| } | } | ||||||
|  |  | ||||||
| #preview ul, #preview ol { | #preview ul, | ||||||
|  | #preview ol { | ||||||
|     margin-bottom: 16px; |     margin-bottom: 16px; | ||||||
|     padding-left: 2em; |     padding-left: 2em; | ||||||
| } | } | ||||||
| @@ -591,4 +592,3 @@ body.dark-mode .sidebar h6 { | |||||||
| body.dark-mode .tree-children { | body.dark-mode .tree-children { | ||||||
|     border-left-color: var(--border-color); |     border-left-color: var(--border-color); | ||||||
| } | } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -30,20 +30,47 @@ | |||||||
|     <!-- Navbar --> |     <!-- Navbar --> | ||||||
|     <nav class="navbar navbar-expand-lg"> |     <nav class="navbar navbar-expand-lg"> | ||||||
|         <div class="container-fluid"> |         <div class="container-fluid"> | ||||||
|             <span class="navbar-brand"> |             <!-- Left: Sidebar Toggle + Logo and Title --> | ||||||
|  |             <div class="d-flex align-items-center gap-2"> | ||||||
|  |                 <!-- Sidebar Toggle Button --> | ||||||
|  |                 <button id="sidebarToggleBtn" class="btn-flat btn-flat-secondary" title="Toggle Sidebar"> | ||||||
|  |                     <i class="bi bi-layout-sidebar"></i> | ||||||
|  |                 </button> | ||||||
|  |  | ||||||
|  |                 <!-- Logo and Title (Clickable) --> | ||||||
|  |                 <a href="/" class="navbar-brand mb-0" id="navbarBrand" style="cursor: pointer; text-decoration: none;"> | ||||||
|                     <i class="bi bi-markdown"></i> Markdown Editor |                     <i class="bi bi-markdown"></i> Markdown Editor | ||||||
|             </span> |                 </a> | ||||||
|             <div class="d-flex gap-2"> |             </div> | ||||||
|                 <button id="newBtn" class="btn btn-success btn-sm"> |  | ||||||
|  |             <!-- Right: All Buttons --> | ||||||
|  |             <div class="ms-auto d-flex gap-2 align-items-center"> | ||||||
|  |                 <!-- View Mode Button --> | ||||||
|  |                 <button id="editModeBtn" class="btn-flat btn-flat" style="display: none;"> | ||||||
|  |                     <i class="bi bi-pencil-square"></i> Edit this file | ||||||
|  |                 </button> | ||||||
|  |  | ||||||
|  |                 <!-- Edit Mode Buttons --> | ||||||
|  |                 <button id="newBtn" class="btn-flat btn-flat-success"> | ||||||
|                     <i class="bi bi-file-plus"></i> New |                     <i class="bi bi-file-plus"></i> New | ||||||
|                 </button> |                 </button> | ||||||
|                 <button id="saveBtn" class="btn btn-primary btn-sm"> |                 <button id="saveBtn" class="btn-flat btn-flat-primary"> | ||||||
|                     <i class="bi bi-save"></i> Save |                     <i class="bi bi-save"></i> Save | ||||||
|                 </button> |                 </button> | ||||||
|                 <button id="deleteBtn" class="btn btn-danger btn-sm"> |                 <button id="deleteBtn" class="btn-flat btn-flat-danger"> | ||||||
|                     <i class="bi bi-trash"></i> Delete |                     <i class="bi bi-trash"></i> Delete | ||||||
|                 </button> |                 </button> | ||||||
|                 <button id="darkModeBtn" class="btn btn-secondary btn-sm">🌙</button> |                 <button id="exitEditModeBtn" class="btn-flat btn-flat-secondary"> | ||||||
|  |                     <i class="bi bi-eye"></i> Exit Edit Mode | ||||||
|  |                 </button> | ||||||
|  |  | ||||||
|  |                 <!-- Divider --> | ||||||
|  |                 <div class="vr" style="height: 40px;"></div> | ||||||
|  |  | ||||||
|  |                 <!-- Dark Mode Toggle --> | ||||||
|  |                 <button id="darkModeBtn" class="btn-flat btn-flat-secondary"> | ||||||
|  |                     <i class="bi bi-moon-fill"></i> | ||||||
|  |                 </button> | ||||||
|             </div> |             </div> | ||||||
|         </div> |         </div> | ||||||
|     </nav> |     </nav> | ||||||
| @@ -56,7 +83,13 @@ | |||||||
|                 <!-- Collection Selector --> |                 <!-- Collection Selector --> | ||||||
|                 <div class="collection-selector"> |                 <div class="collection-selector"> | ||||||
|                     <label class="form-label small">Collection:</label> |                     <label class="form-label small">Collection:</label> | ||||||
|                     <select id="collectionSelect" class="form-select form-select-sm"></select> |                     <div class="d-flex gap-1"> | ||||||
|  |                         <select id="collectionSelect" class="form-select form-select-sm flex-grow-1"></select> | ||||||
|  |                         <button id="newCollectionBtn" class="btn btn-sm new-collection-btn" | ||||||
|  |                             title="Create New Collection"> | ||||||
|  |                             <i class="bi bi-plus-lg"></i> | ||||||
|  |                         </button> | ||||||
|  |                     </div> | ||||||
|                 </div> |                 </div> | ||||||
|                 <!-- File Tree --> |                 <!-- File Tree --> | ||||||
|                 <div id="fileTree" class="file-tree"></div> |                 <div id="fileTree" class="file-tree"></div> | ||||||
| @@ -120,13 +153,21 @@ | |||||||
|             <i class="bi bi-clipboard"></i> Paste |             <i class="bi bi-clipboard"></i> Paste | ||||||
|         </div> |         </div> | ||||||
|         <div class="context-menu-divider"></div> |         <div class="context-menu-divider"></div> | ||||||
|  |         <div class="context-menu-item" data-action="copy-to-collection"> | ||||||
|  |             <i class="bi bi-box-arrow-right"></i> Copy to Collection... | ||||||
|  |         </div> | ||||||
|  |         <div class="context-menu-item" data-action="move-to-collection"> | ||||||
|  |             <i class="bi bi-arrow-right-square"></i> Move to Collection... | ||||||
|  |         </div> | ||||||
|  |         <div class="context-menu-divider"></div> | ||||||
|         <div class="context-menu-item text-danger" data-action="delete"> |         <div class="context-menu-item text-danger" data-action="delete"> | ||||||
|             <i class="bi bi-trash"></i> Delete |             <i class="bi bi-trash"></i> Delete | ||||||
|         </div> |         </div> | ||||||
|     </div> |     </div> | ||||||
|  |  | ||||||
|     <!-- Confirmation Modal --> |     <!-- Confirmation Modal --> | ||||||
|     <div class="modal fade" id="confirmationModal" tabindex="-1" aria-labelledby="confirmationModalLabel" aria-hidden="true"> |     <div class="modal fade" id="confirmationModal" tabindex="-1" aria-labelledby="confirmationModalLabel" | ||||||
|  |         aria-hidden="true"> | ||||||
|         <div class="modal-dialog"> |         <div class="modal-dialog"> | ||||||
|             <div class="modal-content"> |             <div class="modal-content"> | ||||||
|                 <div class="modal-header"> |                 <div class="modal-header"> | ||||||
| @@ -138,8 +179,12 @@ | |||||||
|                     <input type="text" id="confirmationInput" class="form-control" style="display: none;"> |                     <input type="text" id="confirmationInput" class="form-control" style="display: none;"> | ||||||
|                 </div> |                 </div> | ||||||
|                 <div class="modal-footer"> |                 <div class="modal-footer"> | ||||||
|                     <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> |                     <button type="button" class="btn-flat btn-flat-secondary" data-bs-dismiss="modal"> | ||||||
|                     <button type="button" class="btn btn-primary" id="confirmButton">OK</button> |                         <i class="bi bi-x-circle"></i> Cancel | ||||||
|  |                     </button> | ||||||
|  |                     <button type="button" class="btn-flat btn-flat-primary" id="confirmButton"> | ||||||
|  |                         <i class="bi bi-check-circle"></i> OK | ||||||
|  |                     </button> | ||||||
|                 </div> |                 </div> | ||||||
|             </div> |             </div> | ||||||
|         </div> |         </div> | ||||||
| @@ -178,10 +223,27 @@ | |||||||
|     <!-- Mermaid for diagrams --> |     <!-- Mermaid for diagrams --> | ||||||
|     <script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script> |     <script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script> | ||||||
|  |  | ||||||
|  |     <!-- Application Configuration (must load first) --> | ||||||
|  |     <script src="/static/js/config.js"></script> | ||||||
|  |     <script src="/static/js/logger.js"></script> | ||||||
|  |     <script src="/static/js/event-bus.js"></script> | ||||||
|  |     <script src="/static/js/utils.js"></script> | ||||||
|  |     <script src="/static/js/notification-service.js"></script> | ||||||
|  |  | ||||||
|  |     <!-- UI Components --> | ||||||
|  |     <script src="/static/js/ui-utils.js" defer></script> | ||||||
|  |     <script src="/static/js/context-menu.js" defer></script> | ||||||
|  |     <script src="/static/js/file-upload.js" defer></script> | ||||||
|  |     <script src="/static/js/dark-mode.js" defer></script> | ||||||
|  |     <script src="/static/js/sidebar-toggle.js" defer></script> | ||||||
|  |     <script src="/static/js/collection-selector.js" defer></script> | ||||||
|  |     <script src="/static/js/editor-drop-handler.js" defer></script> | ||||||
|  |     <script src="/static/js/loading-spinner.js" defer></script> | ||||||
|  |  | ||||||
|  |     <!-- Core Application Modules --> | ||||||
|     <script src="/static/js/webdav-client.js" defer></script> |     <script src="/static/js/webdav-client.js" defer></script> | ||||||
|     <script src="/static/js/file-tree.js" defer></script> |     <script src="/static/js/file-tree.js" defer></script> | ||||||
|     <script src="/static/js/editor.js" defer></script> |     <script src="/static/js/editor.js" defer></script> | ||||||
|     <script src="/static/js/ui-utils.js" defer></script> |  | ||||||
|     <script src="/static/js/confirmation.js" defer></script> |     <script src="/static/js/confirmation.js" defer></script> | ||||||
|     <script src="/static/js/file-tree-actions.js" defer></script> |     <script src="/static/js/file-tree-actions.js" defer></script> | ||||||
|     <script src="/static/js/column-resizer.js" defer></script> |     <script src="/static/js/column-resizer.js" defer></script> | ||||||
|   | |||||||