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: | ||||
|   documents: | ||||
|     path: "./collections/documents" | ||||
|     description: "General documents and notes" | ||||
|  | ||||
|     path: ./collections/documents | ||||
|     description: General documents and notes | ||||
|   notes: | ||||
|     path: "./collections/notes" | ||||
|     description: "Personal notes and drafts" | ||||
|  | ||||
|     path: ./collections/notes | ||||
|     description: Personal notes and drafts | ||||
|   projects: | ||||
|     path: "./collections/projects" | ||||
|     description: "Project documentation" | ||||
|  | ||||
| # Server settings | ||||
|     path: ./collections/projects | ||||
|     description: Project documentation | ||||
|   7madah: | ||||
|     path: collections/7madah | ||||
|     description: 'User-created collection: 7madah' | ||||
|   tech: | ||||
|     path: collections/tech | ||||
|     description: 'User-created collection: tech' | ||||
| server: | ||||
|   host: "localhost" | ||||
|   host: localhost | ||||
|   port: 8004 | ||||
|  | ||||
| # WebDAV settings | ||||
| webdav: | ||||
|   verbose: 1 | ||||
|   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): | ||||
|         """Load configuration from YAML file""" | ||||
|         self.config_path = config_path | ||||
|         with open(config_path, 'r') as 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): | ||||
|         """Create collection directories if they don't exist""" | ||||
|         for name, config in self.collections.items(): | ||||
| @@ -92,13 +100,30 @@ class MarkdownEditorApp: | ||||
|         if path == '/fs/' and method == 'GET': | ||||
|             return self.handle_collections_list(environ, start_response) | ||||
|  | ||||
|         # API to create new collection | ||||
|         if path == '/fs/' and method == 'POST': | ||||
|             return self.handle_create_collection(environ, start_response) | ||||
|  | ||||
|         # API to delete a collection | ||||
|         if path.startswith('/api/collections/') and method == 'DELETE': | ||||
|             return self.handle_delete_collection(environ, start_response) | ||||
|  | ||||
|         # Check if path starts with a collection name (for SPA routing) | ||||
|         # This handles URLs like /notes/ttt or /documents/file.md | ||||
|         # MUST be checked BEFORE WebDAV routing to prevent WebDAV from intercepting SPA routes | ||||
|         path_parts = path.strip('/').split('/') | ||||
|         if path_parts and path_parts[0] in self.collections: | ||||
|             # This is a SPA route for a collection, serve index.html | ||||
|             # The client-side router will handle the path | ||||
|             return self.handle_index(environ, start_response) | ||||
|  | ||||
|         # All other /fs/ requests go to WebDAV | ||||
|         if path.startswith('/fs/'): | ||||
|             return self.webdav_app(environ, start_response) | ||||
|  | ||||
|         # Fallback for anything else (shouldn't happen with correct linking) | ||||
|         start_response('404 Not Found', [('Content-Type', 'text/plain')]) | ||||
|         return [b'Not Found'] | ||||
|         # Fallback: Serve index.html for all other routes (SPA routing) | ||||
|         # This allows client-side routing to handle any other paths | ||||
|         return self.handle_index(environ, start_response) | ||||
|      | ||||
|     def handle_collections_list(self, environ, start_response): | ||||
|         """Return list of available collections""" | ||||
| @@ -113,6 +138,139 @@ class MarkdownEditorApp: | ||||
|  | ||||
|         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): | ||||
|         """Serve static files""" | ||||
|         path = environ.get('PATH_INFO', '')[1:]  # Remove leading / | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| // Markdown Editor Application with File Tree | ||||
| (function() { | ||||
| (function () { | ||||
|     'use strict'; | ||||
|  | ||||
|     // State management | ||||
| @@ -24,7 +24,7 @@ | ||||
|     function enableDarkMode() { | ||||
|         isDarkMode = true; | ||||
|         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'); | ||||
|  | ||||
|         mermaid.initialize({ | ||||
| @@ -41,7 +41,7 @@ | ||||
|     function disableDarkMode() { | ||||
|         isDarkMode = false; | ||||
|         document.body.classList.remove('dark-mode'); | ||||
|         document.getElementById('darkModeIcon').textContent = '🌙'; | ||||
|         // document.getElementById('darkModeIcon').textContent = '🌙'; | ||||
|         localStorage.setItem('darkMode', 'false'); | ||||
|  | ||||
|         mermaid.initialize({ | ||||
| @@ -189,8 +189,8 @@ | ||||
|             lineWrapping: true, | ||||
|             autofocus: true, | ||||
|             extraKeys: { | ||||
|                 'Ctrl-S': function() { saveFile(); }, | ||||
|                 'Cmd-S': function() { saveFile(); } | ||||
|                 'Ctrl-S': function () { saveFile(); }, | ||||
|                 'Cmd-S': function () { saveFile(); } | ||||
|             } | ||||
|         }); | ||||
|  | ||||
| @@ -338,7 +338,6 @@ | ||||
|         if (node.type === 'directory') { | ||||
|             const toggle = document.createElement('span'); | ||||
|             toggle.className = 'tree-node-toggle'; | ||||
|             toggle.innerHTML = '▶'; | ||||
|             toggle.addEventListener('click', (e) => { | ||||
|                 e.stopPropagation(); | ||||
|                 toggleNode(nodeDiv); | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| // Markdown Editor Application | ||||
| (function() { | ||||
| (function () { | ||||
|     'use strict'; | ||||
|  | ||||
|     // State management | ||||
| @@ -21,7 +21,7 @@ | ||||
|     function enableDarkMode() { | ||||
|         isDarkMode = true; | ||||
|         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'); | ||||
|  | ||||
|         // Update mermaid theme | ||||
| @@ -40,7 +40,7 @@ | ||||
|     function disableDarkMode() { | ||||
|         isDarkMode = false; | ||||
|         document.body.classList.remove('dark-mode'); | ||||
|         document.getElementById('darkModeIcon').textContent = '🌙'; | ||||
|         // document.getElementById('darkModeIcon').textContent = '🌙'; | ||||
|         localStorage.setItem('darkMode', 'false'); | ||||
|  | ||||
|         // Update mermaid theme | ||||
| @@ -198,8 +198,8 @@ | ||||
|             lineWrapping: true, | ||||
|             autofocus: true, | ||||
|             extraKeys: { | ||||
|                 'Ctrl-S': function() { saveFile(); }, | ||||
|                 'Cmd-S': function() { saveFile(); } | ||||
|                 'Ctrl-S': function () { saveFile(); }, | ||||
|                 'Cmd-S': function () { saveFile(); } | ||||
|             } | ||||
|         }); | ||||
|  | ||||
|   | ||||
| @@ -2,10 +2,21 @@ | ||||
| .preview-pane { | ||||
|     font-size: 16px; | ||||
|     line-height: 1.6; | ||||
|     color: var(--text-primary); | ||||
|     background-color: var(--bg-primary); | ||||
| } | ||||
|  | ||||
| .preview-pane h1, .preview-pane h2, .preview-pane h3, | ||||
| .preview-pane h4, .preview-pane h5, .preview-pane h6 { | ||||
| #preview { | ||||
|     color: var(--text-primary); | ||||
|     background-color: var(--bg-primary); | ||||
| } | ||||
|  | ||||
| .preview-pane h1, | ||||
| .preview-pane h2, | ||||
| .preview-pane h3, | ||||
| .preview-pane h4, | ||||
| .preview-pane h5, | ||||
| .preview-pane h6 { | ||||
|     margin-top: 24px; | ||||
|     margin-bottom: 16px; | ||||
|     font-weight: 600; | ||||
| @@ -132,11 +143,21 @@ body.dark-mode .context-menu { | ||||
|     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 { | ||||
|     from { | ||||
|         transform: translateX(400px); | ||||
|         opacity: 0; | ||||
|     } | ||||
|  | ||||
|     to { | ||||
|         transform: translateX(0); | ||||
|         opacity: 1; | ||||
| @@ -152,6 +173,7 @@ body.dark-mode .context-menu { | ||||
|         transform: translateX(0); | ||||
|         opacity: 1; | ||||
|     } | ||||
|  | ||||
|     to { | ||||
|         transform: translateX(400px); | ||||
|         opacity: 0; | ||||
| @@ -206,3 +228,226 @@ body.dark-mode .modal-footer { | ||||
|     border-color: var(--link-color); | ||||
|     box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25); | ||||
| } | ||||
|  | ||||
| /* Directory Preview Styles */ | ||||
| .directory-preview { | ||||
|     padding: 20px; | ||||
| } | ||||
|  | ||||
| .directory-preview h2 { | ||||
|     margin-bottom: 20px; | ||||
|     /* color: var(--text-primary); */ | ||||
| } | ||||
|  | ||||
| .directory-files { | ||||
|     display: grid; | ||||
|     grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); | ||||
|     gap: 16px; | ||||
|     margin-top: 20px; | ||||
| } | ||||
|  | ||||
| .file-card { | ||||
|     background-color: var(--bg-tertiary); | ||||
|     border: 1px solid var(--border-color); | ||||
|     border-radius: 8px; | ||||
|     padding: 16px; | ||||
|     cursor: pointer; | ||||
|     transition: all 0.2s ease; | ||||
| } | ||||
|  | ||||
| .file-card:hover { | ||||
|     background-color: var(--bg-secondary); | ||||
|     border-color: var(--link-color); | ||||
|     transform: translateY(-2px); | ||||
|     box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); | ||||
| } | ||||
|  | ||||
| .file-card-header { | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     gap: 8px; | ||||
|     margin-bottom: 8px; | ||||
| } | ||||
|  | ||||
| .file-card-header i { | ||||
|     color: var(--link-color); | ||||
|     font-size: 18px; | ||||
| } | ||||
|  | ||||
| .file-card-name { | ||||
|     font-weight: 500; | ||||
|     color: var(--text-primary); | ||||
|     word-break: break-word; | ||||
| } | ||||
|  | ||||
| .file-card-description { | ||||
|     font-size: 13px; | ||||
|     color: var(--text-secondary); | ||||
|     line-height: 1.4; | ||||
|     margin-top: 8px; | ||||
| } | ||||
|  | ||||
| /* Flat Button Styles */ | ||||
| .btn-flat { | ||||
|     border: none; | ||||
|     border-radius: 0; | ||||
|     padding: 6px 12px; | ||||
|     font-size: 14px; | ||||
|     font-weight: 500; | ||||
|     transition: all 0.2s ease; | ||||
|     background-color: transparent; | ||||
|     color: var(--text-primary); | ||||
|     position: relative; | ||||
| } | ||||
|  | ||||
| .btn-flat:hover { | ||||
|     background-color: var(--bg-tertiary); | ||||
| } | ||||
|  | ||||
| .btn-flat:active { | ||||
|     transform: scale(0.95); | ||||
| } | ||||
|  | ||||
| /* Flat button variants */ | ||||
| .btn-flat-primary { | ||||
|     color: #0d6efd; | ||||
| } | ||||
|  | ||||
| .btn-flat-primary:hover { | ||||
|     background-color: rgba(13, 110, 253, 0.1); | ||||
| } | ||||
|  | ||||
| .btn-flat-success { | ||||
|     color: #198754; | ||||
| } | ||||
|  | ||||
| .btn-flat-success:hover { | ||||
|     background-color: rgba(25, 135, 84, 0.1); | ||||
| } | ||||
|  | ||||
| .btn-flat-danger { | ||||
|     color: #dc3545; | ||||
| } | ||||
|  | ||||
| .btn-flat-danger:hover { | ||||
|     background-color: rgba(220, 53, 69, 0.1); | ||||
| } | ||||
|  | ||||
| .btn-flat-warning { | ||||
|     color: #ffc107; | ||||
| } | ||||
|  | ||||
| .btn-flat-warning:hover { | ||||
|     background-color: rgba(255, 193, 7, 0.1); | ||||
| } | ||||
|  | ||||
| .btn-flat-secondary { | ||||
|     color: var(--text-secondary); | ||||
| } | ||||
|  | ||||
| .btn-flat-secondary:hover { | ||||
|     background-color: var(--bg-tertiary); | ||||
| } | ||||
|  | ||||
| /* Dark mode adjustments */ | ||||
| body.dark-mode .btn-flat-primary { | ||||
|     color: #6ea8fe; | ||||
| } | ||||
|  | ||||
| body.dark-mode .btn-flat-success { | ||||
|     color: #75b798; | ||||
| } | ||||
|  | ||||
| body.dark-mode .btn-flat-danger { | ||||
|     color: #ea868f; | ||||
| } | ||||
|  | ||||
| body.dark-mode .btn-flat-warning { | ||||
|     color: #ffda6a; | ||||
| } | ||||
|  | ||||
| /* Dark Mode Button Icon Styles */ | ||||
| #darkModeBtn i { | ||||
|     font-size: 16px; | ||||
|     color: inherit; | ||||
|     /* Inherit color from parent button */ | ||||
| } | ||||
|  | ||||
| /* Light mode: moon icon */ | ||||
| body:not(.dark-mode) #darkModeBtn i { | ||||
|     color: var(--text-secondary); | ||||
| } | ||||
|  | ||||
| /* Dark mode: sun icon */ | ||||
| body.dark-mode #darkModeBtn i { | ||||
|     color: #ffc107; | ||||
|     /* Warm sun color */ | ||||
| } | ||||
|  | ||||
| /* Hover effects */ | ||||
| #darkModeBtn:hover i { | ||||
|     color: inherit; | ||||
|     /* Inherit hover color from parent */ | ||||
| } | ||||
|  | ||||
| /* =================================== | ||||
|    Loading Spinner Component | ||||
|    =================================== */ | ||||
|  | ||||
| /* Loading overlay - covers the target container */ | ||||
| .loading-overlay { | ||||
|     position: absolute; | ||||
|     top: 0; | ||||
|     left: 0; | ||||
|     right: 0; | ||||
|     bottom: 0; | ||||
|     background: var(--bg-primary); | ||||
|     opacity: 0.95; | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     justify-content: center; | ||||
|     z-index: 1000; | ||||
|     transition: opacity 0.2s ease; | ||||
| } | ||||
|  | ||||
| /* Loading spinner */ | ||||
| .loading-spinner { | ||||
|     width: 48px; | ||||
|     height: 48px; | ||||
|     border: 4px solid var(--border-color); | ||||
|     border-top-color: var(--primary-color); | ||||
|     border-radius: 50%; | ||||
|     animation: spin 0.8s linear infinite; | ||||
| } | ||||
|  | ||||
| /* Spinner animation */ | ||||
| @keyframes spin { | ||||
|     to { | ||||
|         transform: rotate(360deg); | ||||
|     } | ||||
| } | ||||
|  | ||||
| /* Loading text */ | ||||
| .loading-text { | ||||
|     margin-top: 16px; | ||||
|     color: var(--text-secondary); | ||||
|     font-size: 14px; | ||||
|     text-align: center; | ||||
| } | ||||
|  | ||||
| /* Loading container with spinner and text */ | ||||
| .loading-content { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     align-items: center; | ||||
|     gap: 12px; | ||||
| } | ||||
|  | ||||
| /* Hide loading overlay by default */ | ||||
| .loading-overlay.hidden { | ||||
|     display: none; | ||||
| } | ||||
|  | ||||
| .language-bash { | ||||
|     color: var(--text-primary) !important; | ||||
| } | ||||
| @@ -6,6 +6,8 @@ | ||||
|     display: flex; | ||||
|     gap: 10px; | ||||
|     align-items: center; | ||||
|     flex-shrink: 0; | ||||
|     /* Prevent header from shrinking */ | ||||
| } | ||||
|  | ||||
| .editor-header input { | ||||
| @@ -19,18 +21,42 @@ | ||||
|  | ||||
| .editor-container { | ||||
|     flex: 1; | ||||
|     /* Take remaining space */ | ||||
|     overflow: hidden; | ||||
|     /* Prevent container overflow, CodeMirror handles its own scrolling */ | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     min-height: 0; | ||||
|     /* Important: allows flex child to shrink below content size */ | ||||
|     position: relative; | ||||
| } | ||||
|  | ||||
| #editor { | ||||
|     flex: 1; | ||||
|     /* Take all available space */ | ||||
|     min-height: 0; | ||||
|     /* Allow shrinking */ | ||||
|     overflow: hidden; | ||||
|     /* CodeMirror will handle scrolling */ | ||||
| } | ||||
|  | ||||
| /* CodeMirror customization */ | ||||
| .CodeMirror { | ||||
|     height: 100%; | ||||
|     height: 100% !important; | ||||
|     /* Force full height */ | ||||
|     font-family: 'Consolas', 'Monaco', 'Courier New', monospace; | ||||
|     font-size: 14px; | ||||
|     background-color: var(--bg-primary); | ||||
|     color: var(--text-primary); | ||||
| } | ||||
|  | ||||
| .CodeMirror-scroll { | ||||
|     overflow-y: auto !important; | ||||
|     /* Ensure vertical scrolling is enabled */ | ||||
|     overflow-x: auto !important; | ||||
|     /* Ensure horizontal scrolling is enabled */ | ||||
| } | ||||
|  | ||||
| body.dark-mode .CodeMirror { | ||||
|     background-color: #1c2128; | ||||
|     color: #e6edf3; | ||||
| @@ -72,4 +98,3 @@ body.dark-mode .CodeMirror-gutters { | ||||
|     pointer-events: none; | ||||
|     z-index: 1000; | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -20,8 +20,9 @@ | ||||
|     color: var(--text-primary); | ||||
|     transition: all 0.15s ease; | ||||
|     white-space: nowrap; | ||||
|     overflow: hidden; | ||||
|     overflow: visible; | ||||
|     text-overflow: ellipsis; | ||||
|     min-height: 28px; | ||||
| } | ||||
|  | ||||
| .tree-node:hover { | ||||
| @@ -29,14 +30,16 @@ | ||||
| } | ||||
|  | ||||
| .tree-node.active { | ||||
|     background-color: var(--link-color); | ||||
|     color: white; | ||||
|     color: var(--link-color); | ||||
|     font-weight: 500; | ||||
| } | ||||
|  | ||||
| .tree-node.active:hover { | ||||
|     background-color: var(--link-color); | ||||
|     filter: brightness(1.1); | ||||
|     filter: brightness(1.2); | ||||
| } | ||||
|  | ||||
| .tree-node.active .tree-node-icon { | ||||
|     color: var(--link-color); | ||||
| } | ||||
|  | ||||
| /* Toggle arrow */ | ||||
| @@ -46,16 +49,25 @@ | ||||
|     justify-content: center; | ||||
|     width: 16px; | ||||
|     height: 16px; | ||||
|     font-size: 10px; | ||||
|     min-width: 16px; | ||||
|     min-height: 16px; | ||||
|     color: var(--text-secondary); | ||||
|     flex-shrink: 0; | ||||
|     transition: transform 0.2s ease; | ||||
|     position: relative; | ||||
|     z-index: 1; | ||||
|     overflow: visible; | ||||
|     cursor: pointer; | ||||
| } | ||||
|  | ||||
| .tree-node-toggle.expanded { | ||||
|     transform: rotate(90deg); | ||||
| } | ||||
|  | ||||
| .tree-node-toggle:hover { | ||||
|     color: var(--link-color); | ||||
| } | ||||
|  | ||||
| /* Icon styling */ | ||||
| .tree-node-icon { | ||||
|     width: 16px; | ||||
| @@ -67,10 +79,6 @@ | ||||
|     color: var(--text-secondary); | ||||
| } | ||||
|  | ||||
| .tree-node.active .tree-node-icon { | ||||
|     color: white; | ||||
| } | ||||
|  | ||||
| /* Content wrapper */ | ||||
| .tree-node-content { | ||||
|     display: flex; | ||||
| @@ -112,13 +120,54 @@ | ||||
| } | ||||
|  | ||||
| /* Drag and drop */ | ||||
| /* Default cursor is pointer, not grab (only show grab after long-press) */ | ||||
| .tree-node { | ||||
|     cursor: pointer; | ||||
| } | ||||
|  | ||||
| /* Show grab cursor only when drag is ready (after long-press) */ | ||||
| .tree-node.drag-ready { | ||||
|     cursor: grab !important; | ||||
| } | ||||
|  | ||||
| .tree-node.drag-ready:active { | ||||
|     cursor: grabbing !important; | ||||
| } | ||||
|  | ||||
| .tree-node.dragging { | ||||
|     opacity: 0.5; | ||||
|     opacity: 0.4; | ||||
|     background-color: var(--bg-tertiary); | ||||
|     cursor: grabbing !important; | ||||
| } | ||||
|  | ||||
| .tree-node.drag-over { | ||||
|     background-color: rgba(13, 110, 253, 0.2); | ||||
|     border: 1px dashed var(--link-color); | ||||
|     background-color: rgba(13, 110, 253, 0.15) !important; | ||||
|     border: 2px dashed var(--link-color) !important; | ||||
|     box-shadow: 0 0 8px rgba(13, 110, 253, 0.3); | ||||
| } | ||||
|  | ||||
| /* Root-level drop target highlighting */ | ||||
| .file-tree.drag-over-root { | ||||
|     background-color: rgba(13, 110, 253, 0.08); | ||||
|     border: 2px dashed var(--link-color); | ||||
|     border-radius: 6px; | ||||
|     box-shadow: inset 0 0 12px rgba(13, 110, 253, 0.2); | ||||
|     margin: 4px; | ||||
|     padding: 4px; | ||||
| } | ||||
|  | ||||
| /* Only show drag cursor on directories when dragging */ | ||||
| body.dragging-active .tree-node[data-isdir="true"] { | ||||
|     cursor: copy; | ||||
| } | ||||
|  | ||||
| body.dragging-active .tree-node[data-isdir="false"] { | ||||
|     cursor: no-drop; | ||||
| } | ||||
|  | ||||
| /* Show move cursor when hovering over root-level empty space */ | ||||
| body.dragging-active .file-tree.drag-over-root { | ||||
|     cursor: move; | ||||
| } | ||||
|  | ||||
| /* Collection selector - Bootstrap styled */ | ||||
| @@ -156,13 +205,34 @@ body.dark-mode .tree-node:hover { | ||||
| } | ||||
|  | ||||
| body.dark-mode .tree-node.active { | ||||
|     background-color: var(--link-color); | ||||
|     color: var(--link-color); | ||||
| } | ||||
|  | ||||
| body.dark-mode .tree-node.active .tree-node-icon { | ||||
|     color: var(--link-color); | ||||
| } | ||||
|  | ||||
| body.dark-mode .tree-node.active .tree-node-icon .tree-node-toggle { | ||||
|     color: var(--link-color); | ||||
| } | ||||
|  | ||||
| body.dark-mode .tree-children { | ||||
|     border-left-color: var(--border-color); | ||||
| } | ||||
|  | ||||
| /* Empty directory message */ | ||||
| .tree-empty-message { | ||||
|     padding: 8px 12px; | ||||
|     color: var(--text-secondary); | ||||
|     font-size: 12px; | ||||
|     font-style: italic; | ||||
|     user-select: none; | ||||
| } | ||||
|  | ||||
| body.dark-mode .tree-empty-message { | ||||
|     color: var(--text-secondary); | ||||
| } | ||||
|  | ||||
| /* Scrollbar in sidebar */ | ||||
| .sidebar::-webkit-scrollbar-thumb { | ||||
|     background-color: var(--border-color); | ||||
| @@ -171,3 +241,13 @@ body.dark-mode .tree-children { | ||||
| .sidebar::-webkit-scrollbar-thumb:hover { | ||||
|     background-color: var(--text-secondary); | ||||
| } | ||||
|  | ||||
| .new-collection-btn { | ||||
|     padding: 0.375rem 0.75rem; | ||||
|     font-size: 1rem; | ||||
|     border-radius: 0.25rem; | ||||
|     transition: all 0.2s ease; | ||||
|     color: var(--text-primary); | ||||
|     border: 1px solid var(--border-color); | ||||
|     background-color: transparent; | ||||
| } | ||||
| @@ -1,14 +1,22 @@ | ||||
| /* Base layout styles */ | ||||
| html, body { | ||||
|     height: 100%; | ||||
| html, | ||||
| body { | ||||
|     height: 100vh; | ||||
|     margin: 0; | ||||
|     padding: 0; | ||||
|     overflow: hidden; | ||||
|     /* Prevent page-level scrolling */ | ||||
|     font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif; | ||||
|     background-color: var(--bg-primary); | ||||
|     color: var(--text-primary); | ||||
|     transition: background-color 0.3s ease, color 0.3s ease; | ||||
| } | ||||
|  | ||||
| body { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
| } | ||||
|  | ||||
| /* Column Resizer */ | ||||
| .column-resizer { | ||||
|     width: 1px; | ||||
| @@ -17,14 +25,21 @@ html, body { | ||||
|     transition: background-color 0.2s ease, width 0.2s ease, box-shadow 0.2s ease; | ||||
|     user-select: none; | ||||
|     flex-shrink: 0; | ||||
|     padding: 0 3px;  /* Add invisible padding for easier grab */ | ||||
|     margin: 0 -3px;  /* Compensate for padding */ | ||||
|     padding: 0 3px; | ||||
|     /* Add invisible padding for easier grab */ | ||||
|     margin: 0 -3px; | ||||
|     /* Compensate for padding */ | ||||
|     height: 100%; | ||||
|     /* Take full height of parent */ | ||||
|     align-self: stretch; | ||||
|     /* Ensure it stretches to full height */ | ||||
| } | ||||
|  | ||||
| .column-resizer:hover { | ||||
|     background-color: var(--link-color); | ||||
|     width: 1px; | ||||
|     box-shadow: 0 0 6px rgba(13, 110, 253, 0.3);  /* Visual feedback instead of width change */ | ||||
|     box-shadow: 0 0 6px rgba(13, 110, 253, 0.3); | ||||
|     /* Visual feedback instead of width change */ | ||||
| } | ||||
|  | ||||
| .column-resizer.dragging { | ||||
| @@ -36,12 +51,59 @@ html, body { | ||||
|     background-color: var(--link-color); | ||||
| } | ||||
|  | ||||
| /* Adjust container for flex layout */ | ||||
| .container-fluid { | ||||
| /* Navbar */ | ||||
| .navbar { | ||||
|     background-color: var(--bg-secondary); | ||||
|     border-bottom: 1px solid var(--border-color); | ||||
|     transition: background-color 0.3s ease; | ||||
|     flex-shrink: 0; | ||||
|     /* Prevent navbar from shrinking */ | ||||
|     padding: 0.5rem 1rem; | ||||
| } | ||||
|  | ||||
| .navbar .container-fluid { | ||||
|     display: flex; | ||||
|     flex-direction: row; | ||||
|     height: calc(100% - 56px); | ||||
|     align-items: center; | ||||
|     justify-content: space-between; | ||||
|     padding: 0; | ||||
|     overflow: visible; | ||||
|     /* Override the hidden overflow for navbar */ | ||||
| } | ||||
|  | ||||
| .navbar-brand { | ||||
|     color: var(--text-primary) !important; | ||||
|     font-weight: 600; | ||||
|     font-size: 1.1rem; | ||||
|     margin: 0; | ||||
|     flex-shrink: 0; | ||||
| } | ||||
|  | ||||
| .navbar-brand i { | ||||
|     font-size: 1.2rem; | ||||
|     margin-right: 0.5rem; | ||||
| } | ||||
|  | ||||
| .navbar-center { | ||||
|     flex: 1; | ||||
|     display: flex; | ||||
|     justify-content: center; | ||||
|     align-items: center; | ||||
| } | ||||
|  | ||||
| .navbar-right { | ||||
|     flex-shrink: 0; | ||||
| } | ||||
|  | ||||
| /* Adjust container for flex layout */ | ||||
| .container-fluid { | ||||
|     flex: 1; | ||||
|     /* Take remaining space after navbar */ | ||||
|     padding: 0; | ||||
|     overflow: hidden; | ||||
|     /* Prevent container scrolling */ | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
| } | ||||
|  | ||||
| .row { | ||||
| @@ -50,13 +112,75 @@ html, body { | ||||
|     flex-direction: row; | ||||
|     margin: 0; | ||||
|     height: 100%; | ||||
|     overflow: hidden; | ||||
|     /* Prevent row scrolling */ | ||||
| } | ||||
|  | ||||
| #sidebarPane { | ||||
|     flex: 0 0 20%; | ||||
|     min-width: 150px; | ||||
|     max-width: 40%; | ||||
|     max-width: 20%; | ||||
|     padding: 0; | ||||
|     height: 100%; | ||||
|     overflow: hidden; | ||||
|     /* Prevent pane scrolling */ | ||||
|     transition: flex 0.3s ease, min-width 0.3s ease, max-width 0.3s ease; | ||||
| } | ||||
|  | ||||
| /* Collapsed sidebar state - mini sidebar */ | ||||
| #sidebarPane.collapsed { | ||||
|     flex: 0 0 50px; | ||||
|     min-width: 50px; | ||||
|     max-width: 50px; | ||||
|     border-right: 1px solid var(--border-color); | ||||
|     position: relative; | ||||
|     cursor: pointer; | ||||
| } | ||||
|  | ||||
| /* Hide file tree content when collapsed */ | ||||
| #sidebarPane.collapsed #fileTree { | ||||
|     display: none; | ||||
| } | ||||
|  | ||||
| /* Hide collection selector when collapsed */ | ||||
| #sidebarPane.collapsed .collection-selector { | ||||
|     display: none; | ||||
| } | ||||
|  | ||||
| /* Visual indicator in the mini sidebar */ | ||||
| #sidebarPane.collapsed::before { | ||||
|     content: ''; | ||||
|     display: block; | ||||
|     width: 100%; | ||||
|     height: 100%; | ||||
|     background: var(--bg-secondary); | ||||
|     transition: background 0.2s ease; | ||||
| } | ||||
|  | ||||
| /* Hover effect on mini sidebar */ | ||||
| #sidebarPane.collapsed:hover::before { | ||||
|     background: var(--hover-bg); | ||||
| } | ||||
|  | ||||
| /* Right arrow icon in the center of mini sidebar */ | ||||
| #sidebarPane.collapsed::after { | ||||
|     content: '\F285'; | ||||
|     /* Bootstrap icon chevron-right */ | ||||
|     font-family: 'bootstrap-icons'; | ||||
|     position: absolute; | ||||
|     top: 50%; | ||||
|     left: 50%; | ||||
|     transform: translate(-50%, -50%); | ||||
|     font-size: 20px; | ||||
|     color: var(--text-secondary); | ||||
|     pointer-events: none; | ||||
|     opacity: 0.5; | ||||
|     transition: opacity 0.2s ease; | ||||
|     cursor: pointer; | ||||
| } | ||||
|  | ||||
| #sidebarPane.collapsed:hover::after { | ||||
|     opacity: 1; | ||||
| } | ||||
|  | ||||
| #editorPane { | ||||
| @@ -64,25 +188,23 @@ html, body { | ||||
|     min-width: 250px; | ||||
|     max-width: 70%; | ||||
|     padding: 0; | ||||
| } | ||||
|  | ||||
| #previewPane { | ||||
|     flex: 1 1 40%; | ||||
|     min-width: 250px; | ||||
|     max-width: 70%; | ||||
|     padding: 0; | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     height: 100%; | ||||
|     overflow: hidden; | ||||
|     /* Prevent pane scrolling */ | ||||
| } | ||||
|  | ||||
| /* Sidebar - improved */ | ||||
| .sidebar { | ||||
|     background-color: var(--bg-secondary); | ||||
|     border-right: 1px solid var(--border-color); | ||||
|     overflow-y: auto; | ||||
|     overflow-x: hidden; | ||||
|     height: 100%; | ||||
|     transition: background-color 0.3s ease; | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     overflow: hidden; | ||||
|     /* Prevent sidebar container scrolling */ | ||||
| } | ||||
|  | ||||
| .sidebar h6 { | ||||
| @@ -92,25 +214,27 @@ html, body { | ||||
|     color: var(--text-secondary); | ||||
|     text-transform: uppercase; | ||||
|     letter-spacing: 0.5px; | ||||
|     flex-shrink: 0; | ||||
|     /* Prevent header from shrinking */ | ||||
| } | ||||
|  | ||||
| /* Collection selector - fixed height */ | ||||
| .collection-selector { | ||||
|     flex-shrink: 0; | ||||
|     /* Prevent selector from shrinking */ | ||||
|     padding: 12px 10px; | ||||
|     background-color: var(--bg-secondary); | ||||
| } | ||||
|  | ||||
| #fileTree { | ||||
|     flex: 1; | ||||
|     /* Take remaining space */ | ||||
|     overflow-y: auto; | ||||
|     /* Enable vertical scrolling */ | ||||
|     overflow-x: hidden; | ||||
|     padding: 4px 0; | ||||
| } | ||||
|  | ||||
| /* Navbar */ | ||||
| .navbar { | ||||
|     background-color: var(--bg-secondary); | ||||
|     border-bottom: 1px solid var(--border-color); | ||||
|     transition: background-color 0.3s ease; | ||||
| } | ||||
|  | ||||
| .navbar-brand { | ||||
|     color: var(--text-primary) !important; | ||||
|     font-weight: 600; | ||||
|     padding: 4px 10px; | ||||
|     min-height: 0; | ||||
|     /* Important: allows flex child to shrink below content size */ | ||||
| } | ||||
|  | ||||
| /* Scrollbar styling */ | ||||
| @@ -135,28 +259,86 @@ html, body { | ||||
|  | ||||
| /* Preview Pane Styling */ | ||||
| #previewPane { | ||||
|     flex: 1 1 40%; | ||||
|     min-width: 250px; | ||||
|     max-width: 70%; | ||||
|     padding: 0; | ||||
|     overflow-y: auto; | ||||
|     overflow-x: hidden; | ||||
|     background-color: var(--bg-primary); | ||||
|     border-left: 1px solid var(--border-color); | ||||
|     flex: 1; | ||||
|     height: 100%; | ||||
|     overflow-y: auto; | ||||
|     /* Enable vertical scrolling for preview pane */ | ||||
|     overflow-x: hidden; | ||||
| } | ||||
|  | ||||
| #preview { | ||||
|     padding: 20px; | ||||
|     min-height: 100%; | ||||
|     overflow-wrap: break-word; | ||||
|     word-wrap: break-word; | ||||
|     color: var(--text-primary); | ||||
|     min-height: 100%; | ||||
|     /* Ensure content fills at least the full height */ | ||||
| } | ||||
|  | ||||
| #preview > p:first-child { | ||||
| #preview>p:first-child { | ||||
|     margin-top: 0; | ||||
| } | ||||
|  | ||||
| #preview > h1:first-child, | ||||
| #preview > h2:first-child { | ||||
| #preview>h1:first-child, | ||||
| #preview>h2:first-child { | ||||
|     margin-top: 0; | ||||
| } | ||||
|  | ||||
| /* 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 currentFilePath = null; | ||||
|  | ||||
| // Simple event bus | ||||
| const eventBus = { | ||||
|     listeners: {}, | ||||
|     on(event, callback) { | ||||
|         if (!this.listeners[event]) { | ||||
|             this.listeners[event] = []; | ||||
| // Event bus is now loaded from event-bus.js module | ||||
| // No need to define it here - it's available as window.eventBus | ||||
|  | ||||
| /** | ||||
|  * Auto-load page in view mode | ||||
|  * Tries to load the last viewed page, falls back to first file if none saved | ||||
|  */ | ||||
| async function autoLoadPageInViewMode() { | ||||
|     if (!editor || !fileTree) return; | ||||
|  | ||||
|     try { | ||||
|         // Try to get last viewed page | ||||
|         let pageToLoad = editor.getLastViewedPage(); | ||||
|  | ||||
|         // If no last viewed page, get the first markdown file | ||||
|         if (!pageToLoad) { | ||||
|             pageToLoad = fileTree.getFirstMarkdownFile(); | ||||
|         } | ||||
|         this.listeners[event].push(callback); | ||||
|     }, | ||||
|     dispatch(event, data) { | ||||
|         if (this.listeners[event]) { | ||||
|             this.listeners[event].forEach(callback => callback(data)); | ||||
|  | ||||
|         // If we found a page to load, load it | ||||
|         if (pageToLoad) { | ||||
|             // Use fileTree.onFileSelect to handle both text and binary files | ||||
|             if (fileTree.onFileSelect) { | ||||
|                 fileTree.onFileSelect({ path: pageToLoad, isDirectory: false }); | ||||
|             } else { | ||||
|                 // Fallback to direct loading (for text files only) | ||||
|                 await editor.loadFile(pageToLoad); | ||||
|                 fileTree.selectAndExpandPath(pageToLoad); | ||||
|             } | ||||
|         } else { | ||||
|             // No files found, show empty state message | ||||
|             editor.previewElement.innerHTML = ` | ||||
|                 <div class="text-muted text-center mt-5"> | ||||
|                     <p>No content available</p> | ||||
|                 </div> | ||||
|             `; | ||||
|         } | ||||
|     } catch (error) { | ||||
|         console.error('Failed to auto-load page in view mode:', error); | ||||
|         editor.previewElement.innerHTML = ` | ||||
|             <div class="alert alert-danger"> | ||||
|                 <p>Failed to load content</p> | ||||
|             </div> | ||||
|         `; | ||||
|     } | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Show directory preview with list of files | ||||
|  * @param {string} dirPath - The directory path | ||||
|  */ | ||||
| async function showDirectoryPreview(dirPath) { | ||||
|     if (!editor || !fileTree || !webdavClient) return; | ||||
|  | ||||
|     try { | ||||
|         const dirName = dirPath.split('/').pop() || dirPath; | ||||
|         const files = fileTree.getDirectoryFiles(dirPath); | ||||
|  | ||||
|         // Start building the preview HTML | ||||
|         let html = `<div class="directory-preview">`; | ||||
|         html += `<h2>${dirName}</h2>`; | ||||
|  | ||||
|         if (files.length === 0) { | ||||
|             html += `<p>This directory is empty</p>`; | ||||
|         } else { | ||||
|             html += `<div class="directory-files">`; | ||||
|  | ||||
|             // Create cards for each file | ||||
|             for (const file of files) { | ||||
|                 const fileName = file.name; | ||||
|                 let fileDescription = ''; | ||||
|  | ||||
|                 // Try to get file description from markdown files | ||||
|                 if (file.name.endsWith('.md')) { | ||||
|                     try { | ||||
|                         const content = await webdavClient.get(file.path); | ||||
|                         // Extract first heading or first line as description | ||||
|                         const lines = content.split('\n'); | ||||
|                         for (const line of lines) { | ||||
|                             if (line.trim().startsWith('#')) { | ||||
|                                 fileDescription = line.replace(/^#+\s*/, '').trim(); | ||||
|                                 break; | ||||
|                             } else if (line.trim() && !line.startsWith('---')) { | ||||
|                                 fileDescription = line.trim().substring(0, 100); | ||||
|                                 break; | ||||
|                             } | ||||
|                         } | ||||
| }; | ||||
| window.eventBus = eventBus; | ||||
|                     } catch (error) { | ||||
|                         console.error('Failed to read file description:', error); | ||||
|                     } | ||||
|                 } | ||||
|  | ||||
|                 html += ` | ||||
|                     <div class="file-card" data-path="${file.path}"> | ||||
|                         <div class="file-card-header"> | ||||
|                             <i class="bi bi-file-earmark-text"></i> | ||||
|                             <span class="file-card-name">${fileName}</span> | ||||
|                         </div> | ||||
|                         ${fileDescription ? `<div class="file-card-description">${fileDescription}</div>` : ''} | ||||
|                     </div> | ||||
|                 `; | ||||
|             } | ||||
|  | ||||
|             html += `</div>`; | ||||
|         } | ||||
|  | ||||
|         html += `</div>`; | ||||
|  | ||||
|         // Set the preview content | ||||
|         editor.previewElement.innerHTML = html; | ||||
|  | ||||
|         // Add click handlers to file cards | ||||
|         editor.previewElement.querySelectorAll('.file-card').forEach(card => { | ||||
|             card.addEventListener('click', async () => { | ||||
|                 const filePath = card.dataset.path; | ||||
|                 await editor.loadFile(filePath); | ||||
|                 fileTree.selectAndExpandPath(filePath); | ||||
|             }); | ||||
|         }); | ||||
|     } catch (error) { | ||||
|         console.error('Failed to show directory preview:', error); | ||||
|         editor.previewElement.innerHTML = ` | ||||
|             <div class="alert alert-danger"> | ||||
|                 <p>Failed to load directory preview</p> | ||||
|             </div> | ||||
|         `; | ||||
|     } | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Parse URL to extract collection and file path | ||||
|  * URL format: /<collection>/<file_path> or /<collection>/<dir>/<file> | ||||
|  * @returns {Object} {collection, filePath} or {collection, null} if only collection | ||||
|  */ | ||||
| function parseURLPath() { | ||||
|     const pathname = window.location.pathname; | ||||
|     const parts = pathname.split('/').filter(p => p); // Remove empty parts | ||||
|  | ||||
|     if (parts.length === 0) { | ||||
|         return { collection: null, filePath: null }; | ||||
|     } | ||||
|  | ||||
|     const collection = parts[0]; | ||||
|     const filePath = parts.length > 1 ? parts.slice(1).join('/') : null; | ||||
|  | ||||
|     return { collection, filePath }; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Update URL based on current collection and file | ||||
|  * @param {string} collection - The collection name | ||||
|  * @param {string} filePath - The file path (optional) | ||||
|  * @param {boolean} isEditMode - Whether in edit mode | ||||
|  */ | ||||
| function updateURL(collection, filePath, isEditMode) { | ||||
|     let url = `/${collection}`; | ||||
|     if (filePath) { | ||||
|         url += `/${filePath}`; | ||||
|     } | ||||
|     if (isEditMode) { | ||||
|         url += '?edit=true'; | ||||
|     } | ||||
|  | ||||
|     // Use pushState to update URL without reloading | ||||
|     window.history.pushState({ collection, filePath }, '', url); | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Load file from URL path | ||||
|  * Assumes the collection is already set and file tree is loaded | ||||
|  * @param {string} collection - The collection name (for validation) | ||||
|  * @param {string} filePath - The file path | ||||
|  */ | ||||
| async function loadFileFromURL(collection, filePath) { | ||||
|     console.log('[loadFileFromURL] Called with:', { collection, filePath }); | ||||
|  | ||||
|     if (!fileTree || !editor || !collectionSelector) { | ||||
|         console.error('[loadFileFromURL] Missing dependencies:', { fileTree: !!fileTree, editor: !!editor, collectionSelector: !!collectionSelector }); | ||||
|         return; | ||||
|     } | ||||
|  | ||||
|     try { | ||||
|         // Verify we're on the right collection | ||||
|         const currentCollection = collectionSelector.getCurrentCollection(); | ||||
|         if (currentCollection !== collection) { | ||||
|             console.error(`[loadFileFromURL] Collection mismatch: expected ${collection}, got ${currentCollection}`); | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         // Load the file or directory | ||||
|         if (filePath) { | ||||
|             // Check if the path is a directory or a file | ||||
|             const node = fileTree.findNode(filePath); | ||||
|             console.log('[loadFileFromURL] Found node:', node); | ||||
|  | ||||
|             if (node && node.isDirectory) { | ||||
|                 // It's a directory, show directory preview | ||||
|                 console.log('[loadFileFromURL] Loading directory preview'); | ||||
|                 await showDirectoryPreview(filePath); | ||||
|                 fileTree.selectAndExpandPath(filePath); | ||||
|             } else if (node) { | ||||
|                 // It's a file, check if it's binary | ||||
|                 console.log('[loadFileFromURL] Loading file'); | ||||
|  | ||||
|                 // Use the fileTree.onFileSelect callback to handle both text and binary files | ||||
|                 if (fileTree.onFileSelect) { | ||||
|                     fileTree.onFileSelect({ path: filePath, isDirectory: false }); | ||||
|                 } else { | ||||
|                     // Fallback to direct loading | ||||
|                     await editor.loadFile(filePath); | ||||
|                     fileTree.selectAndExpandPath(filePath); | ||||
|                 } | ||||
|             } else { | ||||
|                 console.error(`[loadFileFromURL] Path not found in file tree: ${filePath}`); | ||||
|             } | ||||
|         } | ||||
|     } catch (error) { | ||||
|         console.error('[loadFileFromURL] Failed to load file from URL:', error); | ||||
|     } | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Handle browser back/forward navigation | ||||
|  */ | ||||
| function setupPopStateListener() { | ||||
|     window.addEventListener('popstate', async (event) => { | ||||
|         const { collection, filePath } = parseURLPath(); | ||||
|         if (collection) { | ||||
|             // Ensure the collection is set | ||||
|             const currentCollection = collectionSelector.getCurrentCollection(); | ||||
|             if (currentCollection !== collection) { | ||||
|                 await collectionSelector.setCollection(collection); | ||||
|                 await fileTree.load(); | ||||
|             } | ||||
|  | ||||
|             // Load the file/directory | ||||
|             await loadFileFromURL(collection, filePath); | ||||
|         } | ||||
|     }); | ||||
| } | ||||
|  | ||||
| // Initialize application | ||||
| document.addEventListener('DOMContentLoaded', async () => { | ||||
|     // Determine view mode from URL parameter | ||||
|     const urlParams = new URLSearchParams(window.location.search); | ||||
|     const isEditMode = urlParams.get('edit') === 'true'; | ||||
|  | ||||
|     // Set view mode class on body | ||||
|     if (isEditMode) { | ||||
|         document.body.classList.add('edit-mode'); | ||||
|         document.body.classList.remove('view-mode'); | ||||
|     } else { | ||||
|         document.body.classList.add('view-mode'); | ||||
|         document.body.classList.remove('edit-mode'); | ||||
|     } | ||||
|  | ||||
|     // Initialize WebDAV client | ||||
|     webdavClient = new WebDAVClient('/fs/'); | ||||
|  | ||||
| @@ -40,24 +277,241 @@ document.addEventListener('DOMContentLoaded', async () => { | ||||
|         darkMode.toggle(); | ||||
|     }); | ||||
|  | ||||
|     // Initialize file tree | ||||
|     fileTree = new FileTree('fileTree', webdavClient); | ||||
|     fileTree.onFileSelect = async (item) => { | ||||
|         await editor.loadFile(item.path); | ||||
|     }; | ||||
|     // Initialize sidebar toggle | ||||
|     const sidebarToggle = new SidebarToggle('sidebarPane', 'sidebarToggleBtn'); | ||||
|  | ||||
|     // Initialize collection selector | ||||
|     // Initialize collection selector (always needed) | ||||
|     collectionSelector = new CollectionSelector('collectionSelect', webdavClient); | ||||
|     collectionSelector.onChange = async (collection) => { | ||||
|         await fileTree.load(); | ||||
|     }; | ||||
|     await collectionSelector.load(); | ||||
|     await fileTree.load(); | ||||
|  | ||||
|     // Initialize editor | ||||
|     editor = new MarkdownEditor('editor', 'preview', 'filenameInput'); | ||||
|     // Setup New Collection button | ||||
|     document.getElementById('newCollectionBtn').addEventListener('click', async () => { | ||||
|         try { | ||||
|             const collectionName = await window.ModalManager.prompt( | ||||
|                 'Enter new collection name (lowercase, underscore only):', | ||||
|                 'new_collection' | ||||
|             ); | ||||
|  | ||||
|             if (!collectionName) return; | ||||
|  | ||||
|             // Validate collection name | ||||
|             const validation = ValidationUtils.validateFileName(collectionName, true); | ||||
|             if (!validation.valid) { | ||||
|                 window.showNotification(validation.message, 'warning'); | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             // Create the collection | ||||
|             await webdavClient.createCollection(validation.sanitized); | ||||
|  | ||||
|             // Reload collections and switch to the new one | ||||
|             await collectionSelector.load(); | ||||
|             await collectionSelector.setCollection(validation.sanitized); | ||||
|  | ||||
|             window.showNotification(`Collection "${validation.sanitized}" created`, 'success'); | ||||
|         } catch (error) { | ||||
|             Logger.error('Failed to create collection:', error); | ||||
|             window.showNotification('Failed to create collection', 'error'); | ||||
|         } | ||||
|     }); | ||||
|  | ||||
|     // Setup URL routing | ||||
|     setupPopStateListener(); | ||||
|  | ||||
|     // Initialize editor (always needed for preview) | ||||
|     // In view mode, editor is read-only | ||||
|     editor = new MarkdownEditor('editor', 'preview', 'filenameInput', !isEditMode); | ||||
|     editor.setWebDAVClient(webdavClient); | ||||
|  | ||||
|     // Initialize file tree (needed in both modes) | ||||
|     // Pass isEditMode to control image filtering (hide images only in view mode) | ||||
|     fileTree = new FileTree('fileTree', webdavClient, isEditMode); | ||||
|     fileTree.onFileSelect = async (item) => { | ||||
|         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 | ||||
|         setTimeout(() => { | ||||
|             if (!editor.editor.getValue()) { | ||||
| @@ -70,7 +524,11 @@ document.addEventListener('DOMContentLoaded', async () => { | ||||
|         const editorDropHandler = new EditorDropHandler( | ||||
|             document.querySelector('.editor-container'), | ||||
|             async (file) => { | ||||
|                 try { | ||||
|                     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 () => { | ||||
|             try { | ||||
|                 await editor.save(); | ||||
|             } catch (error) { | ||||
|                 Logger.error('Failed to save file:', error); | ||||
|                 if (window.showNotification) { | ||||
|                     window.showNotification('Failed to save file', 'error'); | ||||
|                 } | ||||
|             } | ||||
|         }); | ||||
|  | ||||
|         document.getElementById('deleteBtn').addEventListener('click', async () => { | ||||
|             try { | ||||
|                 await editor.deleteFile(); | ||||
|             } catch (error) { | ||||
|                 Logger.error('Failed to delete file:', error); | ||||
|                 if (window.showNotification) { | ||||
|                     window.showNotification('Failed to delete file', 'error'); | ||||
|                 } | ||||
|             } | ||||
|         }); | ||||
|  | ||||
|         // Setup context menu handlers | ||||
|         setupContextMenuHandlers(); | ||||
|  | ||||
|     // Initialize mermaid | ||||
|     mermaid.initialize({ startOnLoad: true, theme: darkMode.isDark ? 'dark' : 'default' }); | ||||
|  | ||||
|         // Initialize file tree actions manager | ||||
|         window.fileTreeActions = new FileTreeActions(webdavClient, fileTree, editor); | ||||
|  | ||||
|         // Setup Exit Edit Mode button | ||||
|         document.getElementById('exitEditModeBtn').addEventListener('click', () => { | ||||
|             // Switch to view mode by removing edit=true from URL | ||||
|             const url = new URL(window.location.href); | ||||
|             url.searchParams.delete('edit'); | ||||
|             window.location.href = url.toString(); | ||||
|         }); | ||||
|  | ||||
|         // Hide Edit Mode button in edit mode | ||||
|         document.getElementById('editModeBtn').style.display = 'none'; | ||||
|     } else { | ||||
|         // In view mode, hide editor buttons | ||||
|         document.getElementById('newBtn').style.display = 'none'; | ||||
|         document.getElementById('saveBtn').style.display = 'none'; | ||||
|         document.getElementById('deleteBtn').style.display = 'none'; | ||||
|         document.getElementById('exitEditModeBtn').style.display = 'none'; | ||||
|  | ||||
|         // Show Edit Mode button in view mode | ||||
|         document.getElementById('editModeBtn').style.display = 'block'; | ||||
|  | ||||
|         // Setup Edit Mode button | ||||
|         document.getElementById('editModeBtn').addEventListener('click', () => { | ||||
|             // Switch to edit mode by adding edit=true to URL | ||||
|             const url = new URL(window.location.href); | ||||
|             url.searchParams.set('edit', 'true'); | ||||
|             window.location.href = url.toString(); | ||||
|         }); | ||||
|  | ||||
|         // Auto-load last viewed page or first file | ||||
|         await autoLoadPageInViewMode(); | ||||
|     } | ||||
|  | ||||
|     // Setup clickable navbar brand (logo/title) | ||||
|     const navbarBrand = document.getElementById('navbarBrand'); | ||||
|     if (navbarBrand) { | ||||
|         navbarBrand.addEventListener('click', (e) => { | ||||
|             e.preventDefault(); | ||||
|             const currentCollection = collectionSelector ? collectionSelector.getCurrentCollection() : null; | ||||
|             if (currentCollection) { | ||||
|                 // Navigate to collection root | ||||
|                 window.location.href = `/${currentCollection}/`; | ||||
|             } else { | ||||
|                 // Navigate to home page | ||||
|                 window.location.href = '/'; | ||||
|             } | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     // Initialize mermaid (always needed) | ||||
|     mermaid.initialize({ startOnLoad: true, theme: darkMode.isDark ? 'dark' : 'default' }); | ||||
|     // Listen for file-saved event to reload file tree | ||||
|     window.eventBus.on('file-saved', async (path) => { | ||||
|         try { | ||||
|             if (fileTree) { | ||||
|                 await fileTree.load(); | ||||
|                 fileTree.selectNode(path); | ||||
|             } | ||||
|         } catch (error) { | ||||
|             Logger.error('Failed to reload file tree after save:', error); | ||||
|         } | ||||
|     }); | ||||
|  | ||||
|     window.eventBus.on('file-deleted', async () => { | ||||
|         try { | ||||
|             if (fileTree) { | ||||
|                 await fileTree.load(); | ||||
|             } | ||||
|         } catch (error) { | ||||
|             Logger.error('Failed to reload file tree after delete:', error); | ||||
|         } | ||||
|     }); | ||||
| }); | ||||
|  | ||||
| @@ -168,10 +696,11 @@ async function handleEditorFileDrop(file) { | ||||
|         const uploadedPath = await fileTree.uploadFile(targetDir, file); | ||||
|  | ||||
|         // 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 link = isImage | ||||
|             ? `` | ||||
|             : `[${file.name}](/${webdavClient.currentCollection}/${uploadedPath})`; | ||||
|             ? `` | ||||
|             : `[${file.name}](${uploadedPath})`; | ||||
|  | ||||
|         editor.insertAtCursor(link); | ||||
|         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; | ||||
|  | ||||
|                 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); | ||||
|             this.sidebarPane.style.flex = `0 0 ${sidebar}px`; | ||||
|             this.editorPane.style.flex = `0 0 ${editor}px`; | ||||
|             this.previewPane.style.flex = `1 1 auto`; | ||||
|         } catch (error) { | ||||
|             console.error('Failed to load column dimensions:', error); | ||||
|         } | ||||
|   | ||||
							
								
								
									
										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. | ||||
|  * Uses a single reusable modal element to prevent double-opening issues. | ||||
|  */ | ||||
| class Confirmation { | ||||
| class ModalManager { | ||||
|     constructor(modalId) { | ||||
|         this.modalElement = document.getElementById(modalId); | ||||
|         this.modal = new bootstrap.Modal(this.modalElement); | ||||
|         if (!this.modalElement) { | ||||
|             console.error(`Modal element with id "${modalId}" not found`); | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         this.modal = new bootstrap.Modal(this.modalElement, { | ||||
|             backdrop: 'static', | ||||
|             keyboard: true | ||||
|         }); | ||||
|  | ||||
|         this.messageElement = this.modalElement.querySelector('#confirmationMessage'); | ||||
|         this.inputElement = this.modalElement.querySelector('#confirmationInput'); | ||||
|         this.confirmButton = this.modalElement.querySelector('#confirmButton'); | ||||
|         this.cancelButton = this.modalElement.querySelector('[data-bs-dismiss="modal"]'); | ||||
|         this.titleElement = this.modalElement.querySelector('.modal-title'); | ||||
|         this.currentResolver = null; | ||||
|         this.isShowing = false; | ||||
|     } | ||||
|  | ||||
|     _show(message, title, showInput = false, defaultValue = '') { | ||||
|     /** | ||||
|      * Show a confirmation dialog | ||||
|      * @param {string} message - The message to display | ||||
|      * @param {string} title - The dialog title | ||||
|      * @param {boolean} isDangerous - Whether this is a dangerous action (shows red button) | ||||
|      * @returns {Promise<boolean>} - Resolves to true if confirmed, false/null if cancelled | ||||
|      */ | ||||
|     confirm(message, title = 'Confirmation', isDangerous = false) { | ||||
|         return new Promise((resolve) => { | ||||
|             // Prevent double-opening | ||||
|             if (this.isShowing) { | ||||
|                 console.warn('Modal is already showing, ignoring duplicate request'); | ||||
|                 resolve(null); | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             this.isShowing = true; | ||||
|             this.currentResolver = resolve; | ||||
|             this.titleElement.textContent = title; | ||||
|             this.messageElement.textContent = message; | ||||
|  | ||||
|             if (showInput) { | ||||
|                 this.inputElement.style.display = 'block'; | ||||
|                 this.inputElement.value = defaultValue; | ||||
|                 this.inputElement.focus(); | ||||
|             } else { | ||||
|             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); | ||||
|             this.modalElement.addEventListener('hidden.bs.modal', () => this._handleCancel(), { once: true }); | ||||
|             // Set up event handlers | ||||
|             this.confirmButton.onclick = (e) => { | ||||
|                 e.preventDefault(); | ||||
|                 e.stopPropagation(); | ||||
|                 this._handleConfirm(false); | ||||
|             }; | ||||
|  | ||||
|             // Handle modal hidden event for cleanup | ||||
|             this.modalElement.addEventListener('hidden.bs.modal', () => { | ||||
|                 if (this.currentResolver) { | ||||
|                     this._handleCancel(); | ||||
|                 } | ||||
|             }, { once: true }); | ||||
|  | ||||
|             // Remove aria-hidden before showing to prevent accessibility warning | ||||
|             this.modalElement.removeAttribute('aria-hidden'); | ||||
|  | ||||
|             this.modal.show(); | ||||
|  | ||||
|             // Focus confirm button after modal is shown | ||||
|             this.modalElement.addEventListener('shown.bs.modal', () => { | ||||
|                 this.confirmButton.focus(); | ||||
|             }, { once: true }); | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Show a prompt dialog (input dialog) | ||||
|      * @param {string} message - The message/label to display | ||||
|      * @param {string} defaultValue - The default input value | ||||
|      * @param {string} title - The dialog title | ||||
|      * @returns {Promise<string|null>} - Resolves to input value if confirmed, null if cancelled | ||||
|      */ | ||||
|     prompt(message, defaultValue = '', title = 'Input') { | ||||
|         return new Promise((resolve) => { | ||||
|             // Prevent double-opening | ||||
|             if (this.isShowing) { | ||||
|                 console.warn('Modal is already showing, ignoring duplicate request'); | ||||
|                 resolve(null); | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             this.isShowing = true; | ||||
|             this.currentResolver = resolve; | ||||
|             this.titleElement.textContent = title; | ||||
|             this.messageElement.textContent = message; | ||||
|             this.inputElement.style.display = 'block'; | ||||
|             this.inputElement.value = defaultValue; | ||||
|  | ||||
|             // Reset button to primary style for prompts | ||||
|             this.confirmButton.className = 'btn-flat btn-flat-primary'; | ||||
|             this.confirmButton.innerHTML = '<i class="bi bi-check-circle"></i> OK'; | ||||
|  | ||||
|             // Set up event handlers | ||||
|             this.confirmButton.onclick = (e) => { | ||||
|                 e.preventDefault(); | ||||
|                 e.stopPropagation(); | ||||
|                 this._handleConfirm(true); | ||||
|             }; | ||||
|  | ||||
|             // Handle Enter key in input | ||||
|             this.inputElement.onkeydown = (e) => { | ||||
|                 if (e.key === 'Enter') { | ||||
|                     e.preventDefault(); | ||||
|                     this._handleConfirm(true); | ||||
|                 } | ||||
|             }; | ||||
|  | ||||
|             // Handle modal hidden event for cleanup | ||||
|             this.modalElement.addEventListener('hidden.bs.modal', () => { | ||||
|                 if (this.currentResolver) { | ||||
|                     this._handleCancel(); | ||||
|                 } | ||||
|             }, { once: true }); | ||||
|  | ||||
|             // Remove aria-hidden before showing to prevent accessibility warning | ||||
|             this.modalElement.removeAttribute('aria-hidden'); | ||||
|  | ||||
|             this.modal.show(); | ||||
|  | ||||
|             // Focus and select input after modal is shown | ||||
|             this.modalElement.addEventListener('shown.bs.modal', () => { | ||||
|                 this.inputElement.focus(); | ||||
|                 this.inputElement.select(); | ||||
|             }, { once: true }); | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     _handleConfirm(isPrompt) { | ||||
|         if (this.currentResolver) { | ||||
|             const value = isPrompt ? this.inputElement.value : true; | ||||
|             this.currentResolver(value); | ||||
|             const value = isPrompt ? this.inputElement.value.trim() : true; | ||||
|             const resolver = this.currentResolver; | ||||
|             this._cleanup(); | ||||
|             resolver(value); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     _handleCancel() { | ||||
|         if (this.currentResolver) { | ||||
|             this.currentResolver(null); // Resolve with null for cancellation | ||||
|             const resolver = this.currentResolver; | ||||
|             this._cleanup(); | ||||
|             resolver(null); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     _cleanup() { | ||||
|         this.confirmButton.onclick = null; | ||||
|         this.modal.hide(); | ||||
|         this.inputElement.onkeydown = null; | ||||
|         this.currentResolver = null; | ||||
|     } | ||||
|         this.isShowing = false; | ||||
|         this.modal.hide(); | ||||
|  | ||||
|     confirm(message, title = 'Confirmation') { | ||||
|         return this._show(message, title, false); | ||||
|     } | ||||
|  | ||||
|     prompt(message, defaultValue = '', title = 'Prompt') { | ||||
|         return this._show(message, title, true, defaultValue); | ||||
|         // Restore aria-hidden after modal is hidden | ||||
|         this.modalElement.addEventListener('hidden.bs.modal', () => { | ||||
|             this.modalElement.setAttribute('aria-hidden', 'true'); | ||||
|         }, { once: true }); | ||||
|     } | ||||
| } | ||||
|  | ||||
| // Make it globally available | ||||
| window.ConfirmationManager = new 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 { | ||||
|     constructor(editorId, previewId, filenameInputId) { | ||||
|     constructor(editorId, previewId, filenameInputId, readOnly = false) { | ||||
|         this.editorElement = document.getElementById(editorId); | ||||
|         this.previewElement = document.getElementById(previewId); | ||||
|         this.filenameInput = document.getElementById(filenameInputId); | ||||
|         this.currentFile = null; | ||||
|         this.webdavClient = null; | ||||
|         this.macroProcessor = new MacroProcessor(null); // Will be set later | ||||
|         this.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.initMarkdown(); | ||||
|         this.initMermaid(); | ||||
|     } | ||||
| @@ -21,22 +32,27 @@ class MarkdownEditor { | ||||
|      * Initialize CodeMirror | ||||
|      */ | ||||
|     initCodeMirror() { | ||||
|         // Determine theme based on dark mode | ||||
|         const isDarkMode = document.body.classList.contains('dark-mode'); | ||||
|         const theme = isDarkMode ? 'monokai' : 'default'; | ||||
|  | ||||
|         this.editor = CodeMirror(this.editorElement, { | ||||
|             mode: 'markdown', | ||||
|             theme: 'monokai', | ||||
|             theme: theme, | ||||
|             lineNumbers: true, | ||||
|             lineWrapping: true, | ||||
|             autofocus: true, | ||||
|             extraKeys: { | ||||
|             autofocus: !this.readOnly, // Don't autofocus in read-only mode | ||||
|             readOnly: this.readOnly, // Set read-only mode | ||||
|             extraKeys: this.readOnly ? {} : { | ||||
|                 'Ctrl-S': () => this.save(), | ||||
|                 'Cmd-S': () => this.save() | ||||
|             } | ||||
|         }); | ||||
|  | ||||
|         // Update preview on change with debouncing | ||||
|         this.editor.on('change', this.debounce(() => { | ||||
|         this.editor.on('change', TimingUtils.debounce(() => { | ||||
|             this.updatePreview(); | ||||
|         }, 300)); | ||||
|         }, Config.DEBOUNCE_DELAY)); | ||||
|  | ||||
|         // Initial preview render | ||||
|         setTimeout(() => { | ||||
| @@ -47,6 +63,27 @@ class MarkdownEditor { | ||||
|         this.editor.on('scroll', () => { | ||||
|             this.syncScroll(); | ||||
|         }); | ||||
|  | ||||
|         // Listen for dark mode changes | ||||
|         this.setupThemeListener(); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Setup listener for dark mode changes | ||||
|      */ | ||||
|     setupThemeListener() { | ||||
|         // Watch for dark mode class changes | ||||
|         const observer = new MutationObserver((mutations) => { | ||||
|             mutations.forEach((mutation) => { | ||||
|                 if (mutation.attributeName === 'class') { | ||||
|                     const isDarkMode = document.body.classList.contains('dark-mode'); | ||||
|                     const newTheme = isDarkMode ? 'monokai' : 'default'; | ||||
|                     this.editor.setOption('theme', newTheme); | ||||
|                 } | ||||
|             }); | ||||
|         }); | ||||
|  | ||||
|         observer.observe(document.body, { attributes: true }); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @@ -55,9 +92,88 @@ class MarkdownEditor { | ||||
|     initMarkdown() { | ||||
|         if (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({ | ||||
|                 breaks: true, | ||||
|                 gfm: true, | ||||
|                 renderer: renderer, | ||||
|                 highlight: (code, lang) => { | ||||
|                     if (lang && window.Prism.languages[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 | ||||
|      */ | ||||
|     async loadFile(path) { | ||||
|         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); | ||||
|             this.currentFile = path; | ||||
|             this.filenameInput.value = path; | ||||
|             this.editor.setValue(content); | ||||
|             this.updatePreview(); | ||||
|  | ||||
|             if (window.showNotification) { | ||||
|                 window.showNotification(`Loaded ${path}`, 'info'); | ||||
|             // Update filename input if it exists | ||||
|             if (this.filenameInput) { | ||||
|                 this.filenameInput.value = path; | ||||
|             } | ||||
|  | ||||
|             // Update editor if it exists (edit mode) | ||||
|             if (this.editor) { | ||||
|                 this.editor.setValue(content); | ||||
|             } | ||||
|  | ||||
|             // Update preview with the loaded content | ||||
|             await this.renderPreview(content); | ||||
|  | ||||
|             // Save as last viewed page | ||||
|             this.saveLastViewedPage(path); | ||||
|  | ||||
|             // 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) { | ||||
|             // Hide loading spinners on error | ||||
|             if (this.editorSpinner) { | ||||
|                 this.editorSpinner.hide(); | ||||
|             } | ||||
|             if (this.previewSpinner) { | ||||
|                 this.previewSpinner.hide(); | ||||
|             } | ||||
|  | ||||
|             console.error('Failed to load file:', error); | ||||
|             if (window.showNotification) { | ||||
|                 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 | ||||
|      */ | ||||
| @@ -159,10 +354,7 @@ class MarkdownEditor { | ||||
|         this.filenameInput.focus(); | ||||
|         this.editor.setValue('# New File\n\nStart typing...\n'); | ||||
|         this.updatePreview(); | ||||
|  | ||||
|         if (window.showNotification) { | ||||
|             window.showNotification('Enter filename and start typing', 'info'); | ||||
|         } | ||||
|         // No notification needed - UI is self-explanatory | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @@ -174,7 +366,7 @@ class MarkdownEditor { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         const confirmed = await window.ConfirmationManager.confirm(`Are you sure you want to delete ${this.currentFile}?`, 'Delete File'); | ||||
|         const confirmed = await window.ConfirmationManager.confirm(`Are you sure you want to delete ${this.currentFile}?`, 'Delete File', true); | ||||
|         if (confirmed) { | ||||
|             try { | ||||
|                 await this.webdavClient.delete(this.currentFile); | ||||
| @@ -189,10 +381,66 @@ class MarkdownEditor { | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Update preview | ||||
|      * Convert JSX-style attributes to HTML attributes | ||||
|      * Handles style={{...}} and boolean attributes like allowFullScreen={true} | ||||
|      */ | ||||
|     async updatePreview() { | ||||
|         const markdown = this.editor.getValue(); | ||||
|     convertJSXToHTML(content) { | ||||
|         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; | ||||
|  | ||||
|         if (!markdown || !markdown.trim()) { | ||||
| @@ -205,23 +453,30 @@ class MarkdownEditor { | ||||
|         } | ||||
|  | ||||
|         try { | ||||
|             // Step 1: Process macros | ||||
|             let processedContent = markdown; | ||||
|             // Initialize loading spinners if not already done | ||||
|             this.initLoadingSpinners(); | ||||
|  | ||||
|             if (this.macroProcessor) { | ||||
|                 const processingResult = await this.macroProcessor.processMacros(markdown); | ||||
|                 processedContent = processingResult.content; | ||||
|                  | ||||
|                 // Log errors if any | ||||
|                 if (processingResult.errors.length > 0) { | ||||
|                     console.warn('Macro processing errors:', processingResult.errors); | ||||
|             // Show preview loading spinner (only if not already shown by loadFile) | ||||
|             if (this.previewSpinner && !this.previewSpinner.isVisible()) { | ||||
|                 this.previewSpinner.show('Rendering preview...'); | ||||
|             } | ||||
|  | ||||
|             // Step 0: Convert JSX-style syntax to HTML | ||||
|             let processedContent = this.convertJSXToHTML(markdown); | ||||
|  | ||||
|             // Step 1: Process macros | ||||
|             if (this.macroProcessor) { | ||||
|                 const processingResult = await this.macroProcessor.processMacros(processedContent); | ||||
|                 processedContent = processingResult.content; | ||||
|             } | ||||
|  | ||||
|             // Step 2: Parse markdown to HTML | ||||
|             if (!this.marked) { | ||||
|                 console.error("Markdown parser (marked) not initialized."); | ||||
|                 previewDiv.innerHTML = `<div class="alert alert-danger">Preview engine not loaded.</div>`; | ||||
|                 if (this.previewSpinner) { | ||||
|                     this.previewSpinner.hide(); | ||||
|                 } | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
| @@ -259,6 +514,13 @@ class MarkdownEditor { | ||||
|                     console.warn('Mermaid rendering error:', error); | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             // Hide preview loading spinner after a small delay to ensure rendering is complete | ||||
|             setTimeout(() => { | ||||
|                 if (this.previewSpinner) { | ||||
|                     this.previewSpinner.hide(); | ||||
|                 } | ||||
|             }, 100); | ||||
|         } catch (error) { | ||||
|             console.error('Preview rendering error:', error); | ||||
|             previewDiv.innerHTML = ` | ||||
| @@ -267,6 +529,27 @@ class MarkdownEditor { | ||||
|                     ${error.message} | ||||
|                 </div> | ||||
|             `; | ||||
|  | ||||
|             // Hide loading spinner on error | ||||
|             if (this.previewSpinner) { | ||||
|                 this.previewSpinner.hide(); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Update preview (backward compatibility wrapper) | ||||
|      * Calls renderPreview with content from editor | ||||
|      */ | ||||
|     async updatePreview() { | ||||
|         // Skip auto-update if showing custom preview (e.g., binary files) | ||||
|         if (this.isShowingCustomPreview) { | ||||
|             Logger.debug('Skipping auto-update: showing custom preview'); | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         if (this.editor) { | ||||
|             await this.renderPreview(); | ||||
|         } | ||||
|     } | ||||
|  | ||||
| @@ -274,6 +557,8 @@ class MarkdownEditor { | ||||
|      * Sync scroll between editor and preview | ||||
|      */ | ||||
|     syncScroll() { | ||||
|         if (!this.editor) return; // Skip if no editor (view mode) | ||||
|  | ||||
|         const scrollInfo = this.editor.getScrollInfo(); | ||||
|         const scrollPercent = scrollInfo.top / (scrollInfo.height - scrollInfo.clientHeight); | ||||
|  | ||||
| @@ -324,20 +609,7 @@ class MarkdownEditor { | ||||
|         this.editor.setValue(content); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Debounce function | ||||
|      */ | ||||
|     debounce(func, wait) { | ||||
|         let timeout; | ||||
|         return function executedFunction(...args) { | ||||
|             const later = () => { | ||||
|                 clearTimeout(timeout); | ||||
|                 func(...args); | ||||
|             }; | ||||
|             clearTimeout(timeout); | ||||
|             timeout = setTimeout(later, wait); | ||||
|         }; | ||||
|     } | ||||
|     // Debounce function moved to TimingUtils in utils.js | ||||
| } | ||||
|  | ||||
| // Export for use in other modules | ||||
|   | ||||
							
								
								
									
										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 | ||||
|      * Returns { valid: boolean, sanitized: string, message: string } | ||||
|      * Now uses ValidationUtils from utils.js | ||||
|      */ | ||||
|     validateFileName(name, isFolder = false) { | ||||
|         const type = isFolder ? 'folder' : 'file'; | ||||
|          | ||||
|         if (!name || name.trim().length === 0) { | ||||
|             return { valid: false, message: `${type} name cannot be empty` }; | ||||
|         } | ||||
|          | ||||
|         // Check for invalid characters | ||||
|         const validPattern = /^[a-z0-9_]+(\.[a-z0-9_]+)*$/; | ||||
|          | ||||
|         if (!validPattern.test(name)) { | ||||
|             const sanitized = name | ||||
|                 .toLowerCase() | ||||
|                 .replace(/[^a-z0-9_.]/g, '_') | ||||
|                 .replace(/_+/g, '_') | ||||
|                 .replace(/^_+|_+$/g, ''); | ||||
|              | ||||
|             return { | ||||
|                 valid: false, | ||||
|                 sanitized, | ||||
|                 message: `Invalid characters in ${type} name. Only lowercase letters, numbers, and underscores allowed.\n\nSuggestion: "${sanitized}"` | ||||
|             }; | ||||
|         } | ||||
|          | ||||
|         return { valid: true, sanitized: name, message: '' }; | ||||
|         return ValidationUtils.validateFileName(name, isFolder); | ||||
|     } | ||||
|  | ||||
|     async execute(action, targetPath, isDirectory) { | ||||
| @@ -58,18 +36,24 @@ class FileTreeActions { | ||||
|     } | ||||
|  | ||||
|     actions = { | ||||
|         open: async function(path, isDir) { | ||||
|         open: async function (path, isDir) { | ||||
|             if (!isDir) { | ||||
|                 await this.editor.loadFile(path); | ||||
|             } | ||||
|         }, | ||||
|  | ||||
|         'new-file': async function(path, isDir) { | ||||
|         'new-file': async function (path, isDir) { | ||||
|             if (!isDir) return; | ||||
|  | ||||
|             await this.showInputDialog('Enter filename (lowercase, underscore only):', 'new_file.md', async (filename) => { | ||||
|             const filename = await window.ModalManager.prompt( | ||||
|                 'Enter filename (lowercase, underscore only):', | ||||
|                 'new_file.md', | ||||
|                 'New File' | ||||
|             ); | ||||
|  | ||||
|             if (!filename) return; | ||||
|  | ||||
|             let finalFilename = filename; | ||||
|             const validation = this.validateFileName(filename, false); | ||||
|  | ||||
|             if (!validation.valid) { | ||||
| @@ -77,8 +61,13 @@ class FileTreeActions { | ||||
|  | ||||
|                 // Ask if user wants to use sanitized version | ||||
|                 if (validation.sanitized) { | ||||
|                         if (await this.showConfirmDialog('Use sanitized name?', `${filename} → ${validation.sanitized}`)) { | ||||
|                             filename = validation.sanitized; | ||||
|                     const useSanitized = await window.ModalManager.confirm( | ||||
|                         `${filename} → ${validation.sanitized}`, | ||||
|                         'Use sanitized name?', | ||||
|                         false | ||||
|                     ); | ||||
|                     if (useSanitized) { | ||||
|                         finalFilename = validation.sanitized; | ||||
|                     } else { | ||||
|                         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'); | ||||
|  | ||||
|             // Clear undo history since new file was created | ||||
|             if (this.fileTree.lastMoveOperation) { | ||||
|                 this.fileTree.lastMoveOperation = null; | ||||
|             } | ||||
|  | ||||
|             await this.fileTree.load(); | ||||
|                 showNotification(`Created ${filename}`, 'success'); | ||||
|             showNotification(`Created ${finalFilename}`, 'success'); | ||||
|             await this.editor.loadFile(fullPath); | ||||
|             }); | ||||
|         }, | ||||
|  | ||||
|         'new-folder': async function(path, isDir) { | ||||
|         'new-folder': async function (path, isDir) { | ||||
|             if (!isDir) return; | ||||
|  | ||||
|             await this.showInputDialog('Enter folder name (lowercase, underscore only):', 'new_folder', async (foldername) => { | ||||
|             const foldername = await window.ModalManager.prompt( | ||||
|                 'Enter folder name (lowercase, underscore only):', | ||||
|                 'new_folder', | ||||
|                 'New Folder' | ||||
|             ); | ||||
|  | ||||
|             if (!foldername) return; | ||||
|  | ||||
|             let finalFoldername = foldername; | ||||
|             const validation = this.validateFileName(foldername, true); | ||||
|  | ||||
|             if (!validation.valid) { | ||||
|                 showNotification(validation.message, 'warning'); | ||||
|  | ||||
|                 if (validation.sanitized) { | ||||
|                         if (await this.showConfirmDialog('Use sanitized name?', `${foldername} → ${validation.sanitized}`)) { | ||||
|                             foldername = validation.sanitized; | ||||
|                     const useSanitized = await window.ModalManager.confirm( | ||||
|                         `${foldername} → ${validation.sanitized}`, | ||||
|                         'Use sanitized name?', | ||||
|                         false | ||||
|                     ); | ||||
|                     if (useSanitized) { | ||||
|                         finalFoldername = validation.sanitized; | ||||
|                     } else { | ||||
|                         return; | ||||
|                     } | ||||
| @@ -117,38 +122,54 @@ class FileTreeActions { | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|                 const fullPath = `${path}/${foldername}`.replace(/\/+/g, '/'); | ||||
|             const fullPath = `${path}/${finalFoldername}`.replace(/\/+/g, '/'); | ||||
|             await this.webdavClient.mkcol(fullPath); | ||||
|  | ||||
|             // Clear undo history since new folder was created | ||||
|             if (this.fileTree.lastMoveOperation) { | ||||
|                 this.fileTree.lastMoveOperation = null; | ||||
|             } | ||||
|  | ||||
|             await this.fileTree.load(); | ||||
|                 showNotification(`Created folder ${foldername}`, 'success'); | ||||
|             }); | ||||
|             showNotification(`Created folder ${finalFoldername}`, 'success'); | ||||
|         }, | ||||
|  | ||||
|         rename: async function(path, isDir) { | ||||
|         rename: async function (path, isDir) { | ||||
|             const oldName = path.split('/').pop(); | ||||
|             const newName = await this.showInputDialog('Rename to:', oldName); | ||||
|             const newName = await window.ModalManager.prompt( | ||||
|                 'Rename to:', | ||||
|                 oldName, | ||||
|                 'Rename' | ||||
|             ); | ||||
|  | ||||
|             if (newName && newName !== oldName) { | ||||
|                 const parentPath = path.substring(0, path.lastIndexOf('/')); | ||||
|                 const newPath = parentPath ? `${parentPath}/${newName}` : newName; | ||||
|                 await this.webdavClient.move(path, newPath); | ||||
|  | ||||
|                 // Clear undo history since manual rename occurred | ||||
|                 if (this.fileTree.lastMoveOperation) { | ||||
|                     this.fileTree.lastMoveOperation = null; | ||||
|                 } | ||||
|  | ||||
|                 await this.fileTree.load(); | ||||
|                 showNotification('Renamed', 'success'); | ||||
|             } | ||||
|         }, | ||||
|  | ||||
|         copy: async function(path, isDir) { | ||||
|         copy: async function (path, isDir) { | ||||
|             this.clipboard = { path, operation: 'copy', isDirectory: isDir }; | ||||
|             showNotification(`Copied: ${path.split('/').pop()}`, 'info'); | ||||
|             // No notification for copy - it's a quick operation | ||||
|             this.updatePasteMenuItem(); | ||||
|         }, | ||||
|  | ||||
|         cut: async function(path, isDir) { | ||||
|         cut: async function (path, isDir) { | ||||
|             this.clipboard = { path, operation: 'cut', isDirectory: isDir }; | ||||
|             showNotification(`Cut: ${path.split('/').pop()}`, 'warning'); | ||||
|             // No notification for cut - it's a quick operation | ||||
|             this.updatePasteMenuItem(); | ||||
|         }, | ||||
|  | ||||
|         paste: async function(targetPath, isDir) { | ||||
|         paste: async function (targetPath, isDir) { | ||||
|             if (!this.clipboard || !isDir) return; | ||||
|  | ||||
|             const itemName = this.clipboard.path.split('/').pop(); | ||||
| @@ -156,36 +177,87 @@ class FileTreeActions { | ||||
|  | ||||
|             if (this.clipboard.operation === 'copy') { | ||||
|                 await this.webdavClient.copy(this.clipboard.path, destPath); | ||||
|                 showNotification('Copied', 'success'); | ||||
|                 // No notification for paste - file tree updates show the result | ||||
|             } else { | ||||
|                 await this.webdavClient.move(this.clipboard.path, destPath); | ||||
|                 this.clipboard = null; | ||||
|                 this.updatePasteMenuItem(); | ||||
|                 showNotification('Moved', 'success'); | ||||
|                 // No notification for move - file tree updates show the result | ||||
|             } | ||||
|  | ||||
|             await this.fileTree.load(); | ||||
|         }, | ||||
|  | ||||
|         delete: async function(path, isDir) { | ||||
|             const name = path.split('/').pop(); | ||||
|         delete: async function (path, isDir) { | ||||
|             const name = path.split('/').pop() || this.webdavClient.currentCollection; | ||||
|             const type = isDir ? 'folder' : 'file'; | ||||
|  | ||||
|             if (!await this.showConfirmDialog(`Delete this ${type}?`, `${name}`)) { | ||||
|                 return; | ||||
|             // Check if this is a root-level collection (empty path or single-level path) | ||||
|             const pathParts = path.split('/').filter(p => p.length > 0); | ||||
|             const isCollection = pathParts.length === 0; | ||||
|  | ||||
|             if (isCollection) { | ||||
|                 // Deleting a collection - use backend API | ||||
|                 const confirmed = await window.ModalManager.confirm( | ||||
|                     `Are you sure you want to delete the collection "${name}"? This will delete all files and folders in it.`, | ||||
|                     'Delete Collection?', | ||||
|                     true | ||||
|                 ); | ||||
|  | ||||
|                 if (!confirmed) return; | ||||
|  | ||||
|                 try { | ||||
|                     // Call backend API to delete collection | ||||
|                     const response = await fetch(`/api/collections/${name}`, { | ||||
|                         method: 'DELETE' | ||||
|                     }); | ||||
|  | ||||
|                     if (!response.ok) { | ||||
|                         const error = await response.text(); | ||||
|                         throw new Error(error || 'Failed to delete collection'); | ||||
|                     } | ||||
|  | ||||
|                     showNotification(`Collection "${name}" deleted successfully`, 'success'); | ||||
|  | ||||
|                     // Reload the page to refresh collections list | ||||
|                     window.location.href = '/'; | ||||
|                 } catch (error) { | ||||
|                     Logger.error('Failed to delete collection:', error); | ||||
|                     showNotification(`Failed to delete collection: ${error.message}`, 'error'); | ||||
|                 } | ||||
|             } else { | ||||
|                 // Deleting a regular file/folder - use WebDAV | ||||
|                 const confirmed = await window.ModalManager.confirm( | ||||
|                     `Are you sure you want to delete ${name}?`, | ||||
|                     `Delete this ${type}?`, | ||||
|                     true | ||||
|                 ); | ||||
|  | ||||
|                 if (!confirmed) return; | ||||
|  | ||||
|                 await this.webdavClient.delete(path); | ||||
|  | ||||
|                 // Clear undo history since manual delete occurred | ||||
|                 if (this.fileTree.lastMoveOperation) { | ||||
|                     this.fileTree.lastMoveOperation = null; | ||||
|                 } | ||||
|  | ||||
|                 await this.fileTree.load(); | ||||
|                 showNotification(`Deleted ${name}`, 'success'); | ||||
|             } | ||||
|         }, | ||||
|  | ||||
|         download: async function(path, isDir) { | ||||
|             showNotification('Downloading...', 'info'); | ||||
|             // Implementation here | ||||
|         download: async function (path, isDir) { | ||||
|             Logger.info(`Downloading ${isDir ? 'folder' : 'file'}: ${path}`); | ||||
|  | ||||
|             if (isDir) { | ||||
|                 await this.fileTree.downloadFolder(path); | ||||
|             } else { | ||||
|                 await this.fileTree.downloadFile(path); | ||||
|             } | ||||
|         }, | ||||
|  | ||||
|         upload: async function(path, isDir) { | ||||
|         upload: async function (path, isDir) { | ||||
|             if (!isDir) return; | ||||
|  | ||||
|             const input = document.createElement('input'); | ||||
| @@ -204,154 +276,60 @@ class FileTreeActions { | ||||
|             }; | ||||
|  | ||||
|             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 | ||||
|     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; | ||||
|     } | ||||
|     // Old deprecated modal methods removed - all modals now use window.ModalManager | ||||
|  | ||||
|     updatePasteMenuItem() { | ||||
|         const pasteItem = document.getElementById('pasteMenuItem'); | ||||
| @@ -359,4 +337,268 @@ class FileTreeActions { | ||||
|             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 { | ||||
|     constructor(containerId, webdavClient) { | ||||
|     constructor(containerId, webdavClient, isEditMode = false) { | ||||
|         this.container = document.getElementById(containerId); | ||||
|         this.webdavClient = webdavClient; | ||||
|         this.tree = []; | ||||
|         this.selectedPath = null; | ||||
|         this.onFileSelect = null; | ||||
|         this.onFolderSelect = null; | ||||
|         this.filterImagesInViewMode = !isEditMode; // Track if we should filter images (true in view mode) | ||||
|  | ||||
|         // Drag and drop state | ||||
|         this.draggedNode = null; | ||||
|         this.draggedPath = null; | ||||
|         this.draggedIsDir = false; | ||||
|  | ||||
|         // Long-press detection | ||||
|         this.longPressTimer = null; | ||||
|         this.longPressThreshold = Config.LONG_PRESS_THRESHOLD; | ||||
|         this.isDraggingEnabled = false; | ||||
|         this.mouseDownNode = null; | ||||
|  | ||||
|         // Undo functionality | ||||
|         this.lastMoveOperation = null; | ||||
|  | ||||
|         this.setupEventListeners(); | ||||
|         this.setupUndoListener(); | ||||
|     } | ||||
|  | ||||
|     setupEventListeners() { | ||||
|         // Click handler for tree nodes | ||||
|         this.container.addEventListener('click', (e) => { | ||||
|             console.log('Container clicked', e.target); | ||||
|             const node = e.target.closest('.tree-node'); | ||||
|             if (!node) return; | ||||
|  | ||||
|             console.log('Node found', node); | ||||
|             const path = node.dataset.path; | ||||
|             const isDir = node.dataset.isdir === 'true'; | ||||
|  | ||||
|             // The toggle is handled inside renderNodes now | ||||
|             // Check if toggle was clicked (icon or toggle button) | ||||
|             const toggle = e.target.closest('.tree-node-toggle'); | ||||
|             if (toggle) { | ||||
|                 // Toggle is handled by its own click listener in renderNodes | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             // Select node | ||||
|             if (isDir) { | ||||
| @@ -36,8 +55,18 @@ class FileTree { | ||||
|             } | ||||
|         }); | ||||
|  | ||||
|         // Context menu | ||||
|         // Context menu (only in edit mode) | ||||
|         this.container.addEventListener('contextmenu', (e) => { | ||||
|             // Check if we're in edit mode | ||||
|             const isEditMode = document.body.classList.contains('edit-mode'); | ||||
|  | ||||
|             // In view mode, disable custom context menu entirely | ||||
|             if (!isEditMode) { | ||||
|                 e.preventDefault(); // Prevent default browser context menu | ||||
|                 return; // Don't show custom context menu | ||||
|             } | ||||
|  | ||||
|             // Edit mode: show custom context menu | ||||
|             const node = e.target.closest('.tree-node'); | ||||
|             e.preventDefault(); | ||||
|  | ||||
| @@ -51,6 +80,333 @@ class FileTree { | ||||
|                 window.showContextMenu(e.clientX, e.clientY, { path: '', isDir: true }); | ||||
|             } | ||||
|         }); | ||||
|  | ||||
|         // Drag and drop event listeners (only in edit mode) | ||||
|         this.setupDragAndDrop(); | ||||
|     } | ||||
|  | ||||
|     setupUndoListener() { | ||||
|         // Listen for Ctrl+Z (Windows/Linux) or Cmd+Z (Mac) | ||||
|         document.addEventListener('keydown', async (e) => { | ||||
|             // Check for Ctrl+Z or Cmd+Z | ||||
|             const isUndo = (e.ctrlKey || e.metaKey) && e.key === 'z'; | ||||
|  | ||||
|             if (isUndo && this.isEditMode() && this.lastMoveOperation) { | ||||
|                 e.preventDefault(); | ||||
|                 await this.undoLastMove(); | ||||
|             } | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     async undoLastMove() { | ||||
|         if (!this.lastMoveOperation) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         const { sourcePath, destPath, fileName, isDirectory } = this.lastMoveOperation; | ||||
|  | ||||
|         try { | ||||
|             // Move the item back to its original location | ||||
|             await this.webdavClient.move(destPath, sourcePath); | ||||
|  | ||||
|             // Get the parent folder name for the notification | ||||
|             const sourceParent = PathUtils.getParentPath(sourcePath); | ||||
|             const parentName = sourceParent ? sourceParent + '/' : 'root'; | ||||
|  | ||||
|             // Clear the undo history | ||||
|             this.lastMoveOperation = null; | ||||
|  | ||||
|             // Reload the tree | ||||
|             await this.load(); | ||||
|  | ||||
|             // Re-select the moved item | ||||
|             this.selectAndExpandPath(sourcePath); | ||||
|  | ||||
|             showNotification(`Undo: Moved ${fileName} back to ${parentName}`, 'success'); | ||||
|         } catch (error) { | ||||
|             console.error('Failed to undo move:', error); | ||||
|             showNotification('Failed to undo move: ' + error.message, 'danger'); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     setupDragAndDrop() { | ||||
|         // Dragover event on container to allow dropping on root level | ||||
|         this.container.addEventListener('dragover', (e) => { | ||||
|             if (!this.isEditMode() || !this.draggedPath) return; | ||||
|  | ||||
|             const node = e.target.closest('.tree-node'); | ||||
|             if (!node) { | ||||
|                 // Hovering over empty space (root level) | ||||
|                 e.preventDefault(); | ||||
|                 e.dataTransfer.dropEffect = 'move'; | ||||
|  | ||||
|                 // Highlight the entire container as a drop target | ||||
|                 this.container.classList.add('drag-over-root'); | ||||
|             } | ||||
|         }); | ||||
|  | ||||
|         // Dragleave event on container to remove root-level highlighting | ||||
|         this.container.addEventListener('dragleave', (e) => { | ||||
|             if (!this.isEditMode()) return; | ||||
|  | ||||
|             // Only remove if we're actually leaving the container | ||||
|             // Check if the related target is outside the container | ||||
|             if (!this.container.contains(e.relatedTarget)) { | ||||
|                 this.container.classList.remove('drag-over-root'); | ||||
|             } | ||||
|         }); | ||||
|  | ||||
|         // Dragenter event to manage highlighting | ||||
|         this.container.addEventListener('dragenter', (e) => { | ||||
|             if (!this.isEditMode() || !this.draggedPath) return; | ||||
|  | ||||
|             const node = e.target.closest('.tree-node'); | ||||
|             if (!node) { | ||||
|                 // Entering empty space | ||||
|                 this.container.classList.add('drag-over-root'); | ||||
|             } else { | ||||
|                 // Entering a node, remove root highlighting | ||||
|                 this.container.classList.remove('drag-over-root'); | ||||
|             } | ||||
|         }); | ||||
|  | ||||
|         // Drop event on container for root level drops | ||||
|         this.container.addEventListener('drop', async (e) => { | ||||
|             if (!this.isEditMode()) return; | ||||
|  | ||||
|             const node = e.target.closest('.tree-node'); | ||||
|             if (!node && this.draggedPath) { | ||||
|                 // Dropped on root level | ||||
|                 e.preventDefault(); | ||||
|                 this.container.classList.remove('drag-over-root'); | ||||
|                 await this.handleDrop('', true); | ||||
|             } | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     isEditMode() { | ||||
|         return document.body.classList.contains('edit-mode'); | ||||
|     } | ||||
|  | ||||
|     setupNodeDragHandlers(nodeElement, node) { | ||||
|         // Dragstart - when user starts dragging | ||||
|         nodeElement.addEventListener('dragstart', (e) => { | ||||
|             this.draggedNode = nodeElement; | ||||
|             this.draggedPath = node.path; | ||||
|             this.draggedIsDir = node.isDirectory; | ||||
|  | ||||
|             nodeElement.classList.add('dragging'); | ||||
|             document.body.classList.add('dragging-active'); | ||||
|             e.dataTransfer.effectAllowed = 'move'; | ||||
|             e.dataTransfer.setData('text/plain', node.path); | ||||
|  | ||||
|             // Create a custom drag image with fixed width | ||||
|             const dragImage = nodeElement.cloneNode(true); | ||||
|             dragImage.style.position = 'absolute'; | ||||
|             dragImage.style.top = '-9999px'; | ||||
|             dragImage.style.left = '-9999px'; | ||||
|             dragImage.style.width = `${Config.DRAG_PREVIEW_WIDTH}px`; | ||||
|             dragImage.style.maxWidth = `${Config.DRAG_PREVIEW_WIDTH}px`; | ||||
|             dragImage.style.opacity = Config.DRAG_PREVIEW_OPACITY; | ||||
|             dragImage.style.backgroundColor = 'var(--bg-secondary)'; | ||||
|             dragImage.style.border = '1px solid var(--border-color)'; | ||||
|             dragImage.style.borderRadius = '4px'; | ||||
|             dragImage.style.padding = '4px 8px'; | ||||
|             dragImage.style.whiteSpace = 'nowrap'; | ||||
|             dragImage.style.overflow = 'hidden'; | ||||
|             dragImage.style.textOverflow = 'ellipsis'; | ||||
|  | ||||
|             document.body.appendChild(dragImage); | ||||
|             e.dataTransfer.setDragImage(dragImage, 10, 10); | ||||
|             setTimeout(() => { | ||||
|                 if (dragImage.parentNode) { | ||||
|                     document.body.removeChild(dragImage); | ||||
|                 } | ||||
|             }, 0); | ||||
|         }); | ||||
|  | ||||
|         // Dragend - when drag operation ends | ||||
|         nodeElement.addEventListener('dragend', () => { | ||||
|             nodeElement.classList.remove('dragging'); | ||||
|             nodeElement.classList.remove('drag-ready'); | ||||
|             document.body.classList.remove('dragging-active'); | ||||
|             this.container.classList.remove('drag-over-root'); | ||||
|             this.clearDragOverStates(); | ||||
|  | ||||
|             // Reset draggable state | ||||
|             nodeElement.draggable = false; | ||||
|             nodeElement.style.cursor = ''; | ||||
|             this.isDraggingEnabled = false; | ||||
|  | ||||
|             this.draggedNode = null; | ||||
|             this.draggedPath = null; | ||||
|             this.draggedIsDir = false; | ||||
|         }); | ||||
|  | ||||
|         // Dragover - when dragging over this node | ||||
|         nodeElement.addEventListener('dragover', (e) => { | ||||
|             if (!this.draggedPath) return; | ||||
|  | ||||
|             const targetPath = node.path; | ||||
|             const targetIsDir = node.isDirectory; | ||||
|  | ||||
|             // Only allow dropping on directories | ||||
|             if (!targetIsDir) { | ||||
|                 e.dataTransfer.dropEffect = 'none'; | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             // Check if this is a valid drop target | ||||
|             if (this.isValidDropTarget(this.draggedPath, this.draggedIsDir, targetPath)) { | ||||
|                 e.preventDefault(); | ||||
|                 e.dataTransfer.dropEffect = 'move'; | ||||
|                 nodeElement.classList.add('drag-over'); | ||||
|             } else { | ||||
|                 e.dataTransfer.dropEffect = 'none'; | ||||
|             } | ||||
|         }); | ||||
|  | ||||
|         // Dragleave - when drag leaves this node | ||||
|         nodeElement.addEventListener('dragleave', (e) => { | ||||
|             // Only remove if we're actually leaving the node (not entering a child) | ||||
|             if (e.target === nodeElement) { | ||||
|                 nodeElement.classList.remove('drag-over'); | ||||
|  | ||||
|                 // If leaving a node and not entering another node, might be going to root | ||||
|                 const relatedNode = e.relatedTarget?.closest('.tree-node'); | ||||
|                 if (!relatedNode && this.container.contains(e.relatedTarget)) { | ||||
|                     // Moving to empty space (root area) | ||||
|                     this.container.classList.add('drag-over-root'); | ||||
|                 } | ||||
|             } | ||||
|         }); | ||||
|  | ||||
|         // Drop - when item is dropped on this node | ||||
|         nodeElement.addEventListener('drop', async (e) => { | ||||
|             e.preventDefault(); | ||||
|             e.stopPropagation(); | ||||
|  | ||||
|             nodeElement.classList.remove('drag-over'); | ||||
|  | ||||
|             if (!this.draggedPath) return; | ||||
|  | ||||
|             const targetPath = node.path; | ||||
|             const targetIsDir = node.isDirectory; | ||||
|  | ||||
|             if (targetIsDir && this.isValidDropTarget(this.draggedPath, this.draggedIsDir, targetPath)) { | ||||
|                 await this.handleDrop(targetPath, targetIsDir); | ||||
|             } | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     clearDragOverStates() { | ||||
|         this.container.querySelectorAll('.drag-over').forEach(node => { | ||||
|             node.classList.remove('drag-over'); | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     isValidDropTarget(sourcePath, sourceIsDir, targetPath) { | ||||
|         // Can't drop on itself | ||||
|         if (sourcePath === targetPath) { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         // If dragging a directory, can't drop into its own descendants | ||||
|         if (sourceIsDir) { | ||||
|             // Check if target is a descendant of source | ||||
|             if (targetPath.startsWith(sourcePath + '/')) { | ||||
|                 return false; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         // Can't drop into the same parent directory | ||||
|         const sourceParent = PathUtils.getParentPath(sourcePath); | ||||
|         if (sourceParent === targetPath) { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         return true; | ||||
|     } | ||||
|  | ||||
|     async handleDrop(targetPath, targetIsDir) { | ||||
|         if (!this.draggedPath) return; | ||||
|  | ||||
|         try { | ||||
|             const sourcePath = this.draggedPath; | ||||
|             const fileName = PathUtils.getFileName(sourcePath); | ||||
|             const isDirectory = this.draggedIsDir; | ||||
|  | ||||
|             // Construct destination path | ||||
|             let destPath; | ||||
|             if (targetPath === '') { | ||||
|                 // Dropping to root | ||||
|                 destPath = fileName; | ||||
|             } else { | ||||
|                 destPath = `${targetPath}/${fileName}`; | ||||
|             } | ||||
|  | ||||
|             // Check if destination already exists | ||||
|             const destNode = this.findNode(destPath); | ||||
|             if (destNode) { | ||||
|                 const overwrite = await window.ModalManager.confirm( | ||||
|                     `A ${destNode.isDirectory ? 'folder' : 'file'} named "${fileName}" already exists in the destination. Do you want to overwrite it?`, | ||||
|                     'Name Conflict', | ||||
|                     true | ||||
|                 ); | ||||
|  | ||||
|                 if (!overwrite) { | ||||
|                     return; | ||||
|                 } | ||||
|  | ||||
|                 // Delete existing item first | ||||
|                 await this.webdavClient.delete(destPath); | ||||
|  | ||||
|                 // Clear undo history since we're overwriting | ||||
|                 this.lastMoveOperation = null; | ||||
|             } | ||||
|  | ||||
|             // Perform the move | ||||
|             await this.webdavClient.move(sourcePath, destPath); | ||||
|  | ||||
|             // Store undo information (only if not overwriting) | ||||
|             if (!destNode) { | ||||
|                 this.lastMoveOperation = { | ||||
|                     sourcePath: sourcePath, | ||||
|                     destPath: destPath, | ||||
|                     fileName: fileName, | ||||
|                     isDirectory: isDirectory | ||||
|                 }; | ||||
|             } | ||||
|  | ||||
|             // If the moved item was the currently selected file, update the selection | ||||
|             if (this.selectedPath === sourcePath) { | ||||
|                 this.selectedPath = destPath; | ||||
|  | ||||
|                 // Update editor's current file path if it's the file being moved | ||||
|                 if (!this.draggedIsDir && window.editor && window.editor.currentFile === sourcePath) { | ||||
|                     window.editor.currentFile = destPath; | ||||
|                     if (window.editor.filenameInput) { | ||||
|                         window.editor.filenameInput.value = destPath; | ||||
|                     } | ||||
|                 } | ||||
|  | ||||
|                 // Notify file select callback if it's a file | ||||
|                 if (!this.draggedIsDir && this.onFileSelect) { | ||||
|                     this.onFileSelect({ path: destPath, isDirectory: false }); | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             // Reload the tree | ||||
|             await this.load(); | ||||
|  | ||||
|             // Re-select the moved item | ||||
|             this.selectAndExpandPath(destPath); | ||||
|  | ||||
|             showNotification(`Moved ${fileName} successfully`, 'success'); | ||||
|         } catch (error) { | ||||
|             console.error('Failed to move item:', error); | ||||
|             showNotification('Failed to move item: ' + error.message, 'danger'); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     async load() { | ||||
| @@ -71,6 +427,19 @@ class FileTree { | ||||
|  | ||||
|     renderNodes(nodes, parentElement, level) { | ||||
|         nodes.forEach(node => { | ||||
|             // Filter out images and image directories in view mode | ||||
|             if (this.filterImagesInViewMode) { | ||||
|                 // Skip image files | ||||
|                 if (!node.isDirectory && PathUtils.isBinaryFile(node.path)) { | ||||
|                     return; | ||||
|                 } | ||||
|  | ||||
|                 // Skip image directories | ||||
|                 if (node.isDirectory && PathUtils.isImageDirectory(node.path)) { | ||||
|                     return; | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             const nodeWrapper = document.createElement('div'); | ||||
|             nodeWrapper.className = 'tree-node-wrapper'; | ||||
|  | ||||
| @@ -78,30 +447,46 @@ class FileTree { | ||||
|             const nodeElement = this.createNodeElement(node, level); | ||||
|             nodeWrapper.appendChild(nodeElement); | ||||
|  | ||||
|             // Create children container ONLY if has children | ||||
|             if (node.children && node.children.length > 0) { | ||||
|             // Create children container for directories | ||||
|             if (node.isDirectory) { | ||||
|                 const childContainer = document.createElement('div'); | ||||
|                 childContainer.className = 'tree-children'; | ||||
|                 childContainer.style.display = 'none'; | ||||
|                 childContainer.dataset.parent = node.path; | ||||
|                 childContainer.style.marginLeft = `${(level + 1) * 12}px`; | ||||
|  | ||||
|                 // Recursively render children | ||||
|                 // Only render children if they exist | ||||
|                 if (node.children && node.children.length > 0) { | ||||
|                     this.renderNodes(node.children, childContainer, level + 1); | ||||
|                 } else { | ||||
|                     // Empty directory - show empty state message | ||||
|                     const emptyMessage = document.createElement('div'); | ||||
|                     emptyMessage.className = 'tree-empty-message'; | ||||
|                     emptyMessage.textContent = 'Empty folder'; | ||||
|                     childContainer.appendChild(emptyMessage); | ||||
|                 } | ||||
|  | ||||
|                 nodeWrapper.appendChild(childContainer); | ||||
|  | ||||
|                 // Make toggle functional | ||||
|                 // Make toggle functional for ALL directories (including empty ones) | ||||
|                 const toggle = nodeElement.querySelector('.tree-node-toggle'); | ||||
|                 if (toggle) { | ||||
|                     toggle.addEventListener('click', (e) => { | ||||
|                         console.log('Toggle clicked', e.target); | ||||
|                     const toggleHandler = (e) => { | ||||
|                         e.stopPropagation(); | ||||
|                         const isHidden = childContainer.style.display === 'none'; | ||||
|                         console.log('Is hidden?', isHidden); | ||||
|                         childContainer.style.display = isHidden ? 'block' : 'none'; | ||||
|                         toggle.innerHTML = isHidden ? '▼' : '▶'; | ||||
|                         toggle.style.transform = isHidden ? 'rotate(90deg)' : 'rotate(0deg)'; | ||||
|                         toggle.classList.toggle('expanded'); | ||||
|                     }); | ||||
|                     }; | ||||
|  | ||||
|                     // Add click listener to toggle icon | ||||
|                     toggle.addEventListener('click', toggleHandler); | ||||
|  | ||||
|                     // Also allow double-click on the node to toggle | ||||
|                     nodeElement.addEventListener('dblclick', toggleHandler); | ||||
|  | ||||
|                     // Make toggle cursor pointer for all directories | ||||
|                     toggle.style.cursor = 'pointer'; | ||||
|                 } | ||||
|             } | ||||
|  | ||||
| @@ -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() { | ||||
|         // Remove previous selection | ||||
|         this.container.querySelectorAll('.tree-node').forEach(node => { | ||||
|             node.classList.remove('selected'); | ||||
|             node.classList.remove('active'); | ||||
|         }); | ||||
|  | ||||
|         // Add selection to current | ||||
|         // Add selection to current and all parent directories | ||||
|         if (this.selectedPath) { | ||||
|             // Add active class to the selected file/folder | ||||
|             const node = this.container.querySelector(`[data-path="${this.selectedPath}"]`); | ||||
|             if (node) { | ||||
|                 node.classList.add('selected'); | ||||
|                 node.classList.add('active'); | ||||
|             } | ||||
|  | ||||
|             // Add active class to all parent directories | ||||
|             const parts = this.selectedPath.split('/'); | ||||
|             let currentPath = ''; | ||||
|             for (let i = 0; i < parts.length - 1; i++) { | ||||
|                 currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i]; | ||||
|                 const parentNode = this.container.querySelector(`[data-path="${currentPath}"]`); | ||||
|                 if (parentNode) { | ||||
|                     parentNode.classList.add('active'); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Highlight a file as active and expand all parent directories | ||||
|      * @param {string} path - The file path to highlight | ||||
|      */ | ||||
|     selectAndExpandPath(path) { | ||||
|         this.selectedPath = path; | ||||
|  | ||||
|         // Expand all parent directories | ||||
|         this.expandParentDirectories(path); | ||||
|  | ||||
|         // Update selection | ||||
|         this.updateSelection(); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Expand all parent directories of a given path | ||||
|      * @param {string} path - The file path | ||||
|      */ | ||||
|     expandParentDirectories(path) { | ||||
|         // Get all parent paths | ||||
|         const parts = path.split('/'); | ||||
|         let currentPath = ''; | ||||
|  | ||||
|         for (let i = 0; i < parts.length - 1; i++) { | ||||
|             currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i]; | ||||
|  | ||||
|             // Find the node with this path | ||||
|             const parentNode = this.container.querySelector(`[data-path="${currentPath}"]`); | ||||
|             if (parentNode && parentNode.dataset.isdir === 'true') { | ||||
|                 // Find the children container | ||||
|                 const wrapper = parentNode.closest('.tree-node-wrapper'); | ||||
|                 if (wrapper) { | ||||
|                     const childContainer = wrapper.querySelector('.tree-children'); | ||||
|                     if (childContainer && childContainer.style.display === 'none') { | ||||
|                         // Expand it | ||||
|                         childContainer.style.display = 'block'; | ||||
|                         const toggle = parentNode.querySelector('.tree-node-toggle'); | ||||
|                         if (toggle) { | ||||
|                             toggle.classList.add('expanded'); | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| @@ -150,25 +628,111 @@ class FileTree { | ||||
|         nodeElement.dataset.isdir = node.isDirectory; | ||||
|         nodeElement.style.paddingLeft = `${level * 12}px`; | ||||
|  | ||||
|         const icon = document.createElement('span'); | ||||
|         icon.className = 'tree-node-icon'; | ||||
|         // Enable drag and drop in edit mode with long-press detection | ||||
|         if (this.isEditMode()) { | ||||
|             // Start with draggable disabled | ||||
|             nodeElement.draggable = false; | ||||
|             this.setupNodeDragHandlers(nodeElement, node); | ||||
|             this.setupLongPressDetection(nodeElement, node); | ||||
|         } | ||||
|  | ||||
|         // Create toggle/icon container | ||||
|         const iconContainer = document.createElement('span'); | ||||
|         iconContainer.className = 'tree-node-icon'; | ||||
|  | ||||
|         if (node.isDirectory) { | ||||
|             icon.innerHTML = '▶'; // Collapsed by default | ||||
|             icon.classList.add('tree-node-toggle'); | ||||
|             // Create toggle icon for folders | ||||
|             const toggle = document.createElement('i'); | ||||
|             toggle.className = 'bi bi-chevron-right tree-node-toggle'; | ||||
|             toggle.style.fontSize = '12px'; | ||||
|             iconContainer.appendChild(toggle); | ||||
|         } else { | ||||
|             icon.innerHTML = '●'; // File icon | ||||
|             // Create file icon | ||||
|             const fileIcon = document.createElement('i'); | ||||
|             fileIcon.className = 'bi bi-file-earmark-text'; | ||||
|             fileIcon.style.fontSize = '14px'; | ||||
|             iconContainer.appendChild(fileIcon); | ||||
|         } | ||||
|  | ||||
|         const title = document.createElement('span'); | ||||
|         title.className = 'tree-node-title'; | ||||
|         title.textContent = node.name; | ||||
|  | ||||
|         nodeElement.appendChild(icon); | ||||
|         nodeElement.appendChild(iconContainer); | ||||
|         nodeElement.appendChild(title); | ||||
|  | ||||
|         return nodeElement; | ||||
|     } | ||||
|  | ||||
|     setupLongPressDetection(nodeElement, node) { | ||||
|         // Mouse down - start long-press timer | ||||
|         nodeElement.addEventListener('mousedown', (e) => { | ||||
|             // Ignore if clicking on toggle button | ||||
|             if (e.target.closest('.tree-node-toggle')) { | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             this.mouseDownNode = nodeElement; | ||||
|  | ||||
|             // Start timer for long-press | ||||
|             this.longPressTimer = setTimeout(() => { | ||||
|                 // Long-press threshold met - enable dragging | ||||
|                 this.isDraggingEnabled = true; | ||||
|                 nodeElement.draggable = true; | ||||
|                 nodeElement.classList.add('drag-ready'); | ||||
|  | ||||
|                 // Change cursor to grab | ||||
|                 nodeElement.style.cursor = 'grab'; | ||||
|             }, this.longPressThreshold); | ||||
|         }); | ||||
|  | ||||
|         // Mouse up - cancel long-press timer | ||||
|         nodeElement.addEventListener('mouseup', () => { | ||||
|             this.clearLongPressTimer(); | ||||
|         }); | ||||
|  | ||||
|         // Mouse leave - cancel long-press timer | ||||
|         nodeElement.addEventListener('mouseleave', () => { | ||||
|             this.clearLongPressTimer(); | ||||
|         }); | ||||
|  | ||||
|         // Mouse move - cancel long-press if moved too much | ||||
|         let startX, startY; | ||||
|         nodeElement.addEventListener('mousedown', (e) => { | ||||
|             startX = e.clientX; | ||||
|             startY = e.clientY; | ||||
|         }); | ||||
|  | ||||
|         nodeElement.addEventListener('mousemove', (e) => { | ||||
|             if (this.longPressTimer && !this.isDraggingEnabled) { | ||||
|                 const deltaX = Math.abs(e.clientX - startX); | ||||
|                 const deltaY = Math.abs(e.clientY - startY); | ||||
|  | ||||
|                 // If mouse moved more than threshold, cancel long-press | ||||
|                 if (deltaX > Config.MOUSE_MOVE_THRESHOLD || deltaY > Config.MOUSE_MOVE_THRESHOLD) { | ||||
|                     this.clearLongPressTimer(); | ||||
|                 } | ||||
|             } | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     clearLongPressTimer() { | ||||
|         if (this.longPressTimer) { | ||||
|             clearTimeout(this.longPressTimer); | ||||
|             this.longPressTimer = null; | ||||
|         } | ||||
|  | ||||
|         // Reset dragging state if not currently dragging | ||||
|         if (!this.draggedPath && this.mouseDownNode) { | ||||
|             this.mouseDownNode.draggable = false; | ||||
|             this.mouseDownNode.classList.remove('drag-ready'); | ||||
|             this.mouseDownNode.style.cursor = ''; | ||||
|             this.isDraggingEnabled = false; | ||||
|         } | ||||
|  | ||||
|         this.mouseDownNode = null; | ||||
|     } | ||||
|  | ||||
|     formatSize(bytes) { | ||||
|         if (bytes === 0) return '0 B'; | ||||
|         const k = 1024; | ||||
| @@ -233,8 +797,8 @@ class FileTree { | ||||
|     async downloadFile(path) { | ||||
|         try { | ||||
|             const content = await this.webdavClient.get(path); | ||||
|             const filename = path.split('/').pop(); | ||||
|             this.triggerDownload(content, filename); | ||||
|             const filename = PathUtils.getFileName(path); | ||||
|             DownloadUtils.triggerDownload(content, filename); | ||||
|             showNotification('Downloaded', 'success'); | ||||
|         } catch (error) { | ||||
|             console.error('Failed to download file:', error); | ||||
| @@ -256,7 +820,7 @@ class FileTree { | ||||
|             } | ||||
|  | ||||
|             const zip = new JSZip(); | ||||
|             const folder = zip.folder(path.split('/').pop() || 'download'); | ||||
|             const folder = zip.folder(PathUtils.getFileName(path) || 'download'); | ||||
|  | ||||
|             // Add all files to zip | ||||
|             for (const file of files) { | ||||
| @@ -267,8 +831,8 @@ class FileTree { | ||||
|  | ||||
|             // Generate zip | ||||
|             const zipBlob = await zip.generateAsync({ type: 'blob' }); | ||||
|             const zipFilename = `${path.split('/').pop() || 'download'}.zip`; | ||||
|             this.triggerDownload(zipBlob, zipFilename); | ||||
|             const zipFilename = `${PathUtils.getFileName(path) || 'download'}.zip`; | ||||
|             DownloadUtils.triggerDownload(zipBlob, zipFilename); | ||||
|             showNotification('Downloaded', 'success'); | ||||
|         } catch (error) { | ||||
|             console.error('Failed to download folder:', error); | ||||
| @@ -276,16 +840,29 @@ class FileTree { | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     triggerDownload(content, filename) { | ||||
|         const blob = content instanceof Blob ? content : new Blob([content]); | ||||
|         const url = URL.createObjectURL(blob); | ||||
|         const a = document.createElement('a'); | ||||
|         a.href = url; | ||||
|         a.download = filename; | ||||
|         document.body.appendChild(a); | ||||
|         a.click(); | ||||
|         document.body.removeChild(a); | ||||
|         URL.revokeObjectURL(url); | ||||
|     // triggerDownload method moved to DownloadUtils in utils.js | ||||
|  | ||||
|     /** | ||||
|      * Get the first markdown file in the tree | ||||
|      * Returns the path of the first .md file found, or null if none exist | ||||
|      */ | ||||
|     getFirstMarkdownFile() { | ||||
|         const findFirstFile = (nodes) => { | ||||
|             for (const node of nodes) { | ||||
|                 // If it's a file and ends with .md, return it | ||||
|                 if (!node.isDirectory && node.path.endsWith('.md')) { | ||||
|                     return node.path; | ||||
|                 } | ||||
|                 // If it's a directory with children, search recursively | ||||
|                 if (node.isDirectory && node.children && node.children.length > 0) { | ||||
|                     const found = findFirstFile(node.children); | ||||
|                     if (found) return found; | ||||
|                 } | ||||
|             } | ||||
|             return null; | ||||
|         }; | ||||
|  | ||||
|         return findFirstFile(this.tree); | ||||
|     } | ||||
| } | ||||
|  | ||||
|   | ||||
							
								
								
									
										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: [] } | ||||
|      */ | ||||
|     async processMacros(content) { | ||||
|         console.log('MacroProcessor: Starting macro processing for content:', content); | ||||
|         const macros = MacroParser.extractMacros(content); | ||||
|         console.log('MacroProcessor: Extracted macros:', macros); | ||||
|         const errors = []; | ||||
|         let processedContent = content; | ||||
|  | ||||
|         // Process macros in reverse order to preserve positions | ||||
|         for (let i = macros.length - 1; i >= 0; i--) { | ||||
|             const macro = macros[i]; | ||||
|             console.log('MacroProcessor: Processing macro:', macro); | ||||
|  | ||||
|             try { | ||||
|                 const result = await this.processMacro(macro); | ||||
|                 console.log('MacroProcessor: Macro processing result:', result); | ||||
|  | ||||
|                 if (result.success) { | ||||
|                     // Replace macro with result | ||||
| @@ -73,7 +69,6 @@ class MacroProcessor { | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         console.log('MacroProcessor: Final processed content:', processedContent); | ||||
|         return { | ||||
|             success: errors.length === 0, | ||||
|             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 | ||||
|  * Toast notifications, context menu, dark mode, file upload dialog | ||||
|  * Toast notifications (kept for backward compatibility) | ||||
|  * | ||||
|  * Other utilities have been moved to separate modules: | ||||
|  * - Context menu: context-menu.js | ||||
|  * - File upload: file-upload.js | ||||
|  * - Dark mode: dark-mode.js | ||||
|  * - Collection selector: collection-selector.js | ||||
|  * - Editor drop handler: editor-drop-handler.js | ||||
|  */ | ||||
|  | ||||
| /** | ||||
|  * Show toast notification | ||||
|  * @param {string} message - The message to display | ||||
|  * @param {string} type - The notification type (info, success, error, warning, danger, primary) | ||||
|  */ | ||||
| function showNotification(message, type = 'info') { | ||||
|     const container = document.getElementById('toastContainer') || createToastContainer(); | ||||
| @@ -23,7 +32,7 @@ function showNotification(message, type = 'info') { | ||||
|  | ||||
|     container.appendChild(toast); | ||||
|  | ||||
|     const bsToast = new bootstrap.Toast(toast, { delay: 3000 }); | ||||
|     const bsToast = new bootstrap.Toast(toast, { delay: Config.TOAST_DURATION }); | ||||
|     bsToast.show(); | ||||
|  | ||||
|     toast.addEventListener('hidden.bs.toast', () => { | ||||
| @@ -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() { | ||||
|     const container = document.createElement('div'); | ||||
|     container.id = 'toastContainer'; | ||||
|     container.className = 'toast-container position-fixed top-0 end-0 p-3'; | ||||
|     container.style.zIndex = '9999'; | ||||
|     container.style.zIndex = Config.TOAST_Z_INDEX; | ||||
|     document.body.appendChild(container); | ||||
|     return container; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Enhanced Context Menu | ||||
|  */ | ||||
| function showContextMenu(x, y, target) { | ||||
|     const menu = document.getElementById('contextMenu'); | ||||
|     if (!menu) return; | ||||
|      | ||||
|     // Store target data | ||||
|     menu.dataset.targetPath = target.path; | ||||
|     menu.dataset.targetIsDir = target.isDir; | ||||
|      | ||||
|     // Show/hide menu items based on target type | ||||
|     const items = { | ||||
|         'new-file': target.isDir, | ||||
|         'new-folder': target.isDir, | ||||
|         'upload': target.isDir, | ||||
|         'download': true, | ||||
|         'paste': target.isDir && window.fileTreeActions?.clipboard, | ||||
|         'open': !target.isDir | ||||
|     }; | ||||
|      | ||||
|     Object.entries(items).forEach(([action, show]) => { | ||||
|         const item = menu.querySelector(`[data-action="${action}"]`); | ||||
|         if (item) { | ||||
|             item.style.display = show ? 'flex' : 'none'; | ||||
|         } | ||||
|     }); | ||||
|      | ||||
|     // Position menu | ||||
|     menu.style.display = 'block'; | ||||
|     menu.style.left = x + 'px'; | ||||
|     menu.style.top = y + 'px'; | ||||
|      | ||||
|     // Adjust if off-screen | ||||
|     setTimeout(() => { | ||||
|         const rect = menu.getBoundingClientRect(); | ||||
|         if (rect.right > window.innerWidth) { | ||||
|             menu.style.left = (window.innerWidth - rect.width - 10) + 'px'; | ||||
|         } | ||||
|         if (rect.bottom > window.innerHeight) { | ||||
|             menu.style.top = (window.innerHeight - rect.height - 10) + 'px'; | ||||
|         } | ||||
|     }, 0); | ||||
| } | ||||
|  | ||||
| function hideContextMenu() { | ||||
|     const menu = document.getElementById('contextMenu'); | ||||
|     if (menu) { | ||||
|         menu.style.display = 'none'; | ||||
|     } | ||||
| } | ||||
|  | ||||
| // Combined click handler for context menu and outside clicks | ||||
| document.addEventListener('click', async (e) => { | ||||
|     const menuItem = e.target.closest('.context-menu-item'); | ||||
|      | ||||
|     if (menuItem) { | ||||
|         // Handle context menu item click | ||||
|         const action = menuItem.dataset.action; | ||||
|         const menu = document.getElementById('contextMenu'); | ||||
|         const targetPath = menu.dataset.targetPath; | ||||
|         const isDir = menu.dataset.targetIsDir === 'true'; | ||||
|  | ||||
|         hideContextMenu(); | ||||
|  | ||||
|         if (window.fileTreeActions) { | ||||
|             await window.fileTreeActions.execute(action, targetPath, isDir); | ||||
|         } | ||||
|     } else if (!e.target.closest('#contextMenu') && !e.target.closest('.tree-node')) { | ||||
|         // Hide on outside click | ||||
|         hideContextMenu(); | ||||
|     } | ||||
| }); | ||||
|  | ||||
| /** | ||||
|  * File Upload Dialog | ||||
|  */ | ||||
| function showFileUploadDialog(targetPath, onUpload) { | ||||
|     const input = document.createElement('input'); | ||||
|     input.type = 'file'; | ||||
|     input.multiple = true; | ||||
|      | ||||
|     input.addEventListener('change', async (e) => { | ||||
|         const files = Array.from(e.target.files); | ||||
|         if (files.length === 0) return; | ||||
|          | ||||
|         for (const file of files) { | ||||
|             try { | ||||
|                 await onUpload(targetPath, file); | ||||
|             } catch (error) { | ||||
|                 console.error('Upload failed:', error); | ||||
|             } | ||||
|         } | ||||
|     }); | ||||
|      | ||||
|     input.click(); | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Dark Mode Manager | ||||
|  */ | ||||
| class DarkMode { | ||||
|     constructor() { | ||||
|         this.isDark = localStorage.getItem('darkMode') === 'true'; | ||||
|         this.apply(); | ||||
|     } | ||||
|      | ||||
|     toggle() { | ||||
|         this.isDark = !this.isDark; | ||||
|         localStorage.setItem('darkMode', this.isDark); | ||||
|         this.apply(); | ||||
|     } | ||||
|      | ||||
|     apply() { | ||||
|         if (this.isDark) { | ||||
|             document.body.classList.add('dark-mode'); | ||||
|             const btn = document.getElementById('darkModeBtn'); | ||||
|             if (btn) btn.textContent = '☀️'; | ||||
|              | ||||
|             // Update mermaid theme | ||||
|             if (window.mermaid) { | ||||
|                 mermaid.initialize({ theme: 'dark' }); | ||||
|             } | ||||
|         } else { | ||||
|             document.body.classList.remove('dark-mode'); | ||||
|             const btn = document.getElementById('darkModeBtn'); | ||||
|             if (btn) btn.textContent = '🌙'; | ||||
|              | ||||
|             // Update mermaid theme | ||||
|             if (window.mermaid) { | ||||
|                 mermaid.initialize({ theme: 'default' }); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Collection Selector | ||||
|  */ | ||||
| class CollectionSelector { | ||||
|     constructor(selectId, webdavClient) { | ||||
|         this.select = document.getElementById(selectId); | ||||
|         this.webdavClient = webdavClient; | ||||
|         this.onChange = null; | ||||
|     } | ||||
|      | ||||
|     async load() { | ||||
|         try { | ||||
|             const collections = await this.webdavClient.getCollections(); | ||||
|             this.select.innerHTML = ''; | ||||
|              | ||||
|             collections.forEach(collection => { | ||||
|                 const option = document.createElement('option'); | ||||
|                 option.value = collection; | ||||
|                 option.textContent = collection; | ||||
|                 this.select.appendChild(option); | ||||
|             }); | ||||
|              | ||||
|             // Select first collection | ||||
|             if (collections.length > 0) { | ||||
|                 this.select.value = collections[0]; | ||||
|                 this.webdavClient.setCollection(collections[0]); | ||||
|                 if (this.onChange) { | ||||
|                     this.onChange(collections[0]); | ||||
|                 } | ||||
|             } | ||||
|              | ||||
|             // Add change listener | ||||
|             this.select.addEventListener('change', () => { | ||||
|                 const collection = this.select.value; | ||||
|                 this.webdavClient.setCollection(collection); | ||||
|                 if (this.onChange) { | ||||
|                     this.onChange(collection); | ||||
|                 } | ||||
|             }); | ||||
|         } catch (error) { | ||||
|             console.error('Failed to load collections:', error); | ||||
|             showNotification('Failed to load collections', 'error'); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Editor Drop Handler | ||||
|  * Handles file drops into the editor | ||||
|  */ | ||||
| class EditorDropHandler { | ||||
|     constructor(editorElement, onFileDrop) { | ||||
|         this.editorElement = editorElement; | ||||
|         this.onFileDrop = onFileDrop; | ||||
|         this.setupHandlers(); | ||||
|     } | ||||
|      | ||||
|     setupHandlers() { | ||||
|         this.editorElement.addEventListener('dragover', (e) => { | ||||
|             e.preventDefault(); | ||||
|             e.stopPropagation(); | ||||
|             this.editorElement.classList.add('drag-over'); | ||||
|         }); | ||||
|          | ||||
|         this.editorElement.addEventListener('dragleave', (e) => { | ||||
|             e.preventDefault(); | ||||
|             e.stopPropagation(); | ||||
|             this.editorElement.classList.remove('drag-over'); | ||||
|         }); | ||||
|          | ||||
|         this.editorElement.addEventListener('drop', async (e) => { | ||||
|             e.preventDefault(); | ||||
|             e.stopPropagation(); | ||||
|             this.editorElement.classList.remove('drag-over'); | ||||
|              | ||||
|             const files = Array.from(e.dataTransfer.files); | ||||
|             if (files.length === 0) return; | ||||
|              | ||||
|             for (const file of files) { | ||||
|                 try { | ||||
|                     if (this.onFileDrop) { | ||||
|                         await this.onFileDrop(file); | ||||
|                     } | ||||
|                 } catch (error) { | ||||
|                     console.error('Drop failed:', error); | ||||
|                     showNotification(`Failed to upload ${file.name}`, 'error'); | ||||
|                 } | ||||
|             } | ||||
|         }); | ||||
|     } | ||||
| } | ||||
| // All other UI utilities have been moved to separate modules | ||||
| // See the module list at the top of this file | ||||
|  | ||||
| // Make showNotification globally available | ||||
| window.showNotification = showNotification; | ||||
|   | ||||
							
								
								
									
										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(); | ||||
|     } | ||||
|  | ||||
|     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') { | ||||
|         const url = this.getFullUrl(path); | ||||
|         const response = await fetch(url, { | ||||
| @@ -47,6 +65,33 @@ class WebDAVClient { | ||||
|         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) { | ||||
|         const url = this.getFullUrl(path); | ||||
|         const response = await fetch(url); | ||||
| @@ -162,6 +207,41 @@ class WebDAVClient { | ||||
|         return true; | ||||
|     } | ||||
|  | ||||
|     // Alias for mkcol | ||||
|     async createFolder(path) { | ||||
|         return await this.mkcol(path); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Ensure all parent directories exist for a given path | ||||
|      * Creates missing parent directories recursively | ||||
|      */ | ||||
|     async ensureParentDirectories(filePath) { | ||||
|         const parts = filePath.split('/'); | ||||
|  | ||||
|         // Remove the filename (last part) | ||||
|         parts.pop(); | ||||
|  | ||||
|         // If no parent directories, nothing to do | ||||
|         if (parts.length === 0) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         // Create each parent directory level | ||||
|         let currentPath = ''; | ||||
|         for (const part of parts) { | ||||
|             currentPath = currentPath ? `${currentPath}/${part}` : part; | ||||
|  | ||||
|             try { | ||||
|                 await this.mkcol(currentPath); | ||||
|             } catch (error) { | ||||
|                 // Ignore errors - directory might already exist | ||||
|                 // Only log for debugging | ||||
|                 console.debug(`Directory ${currentPath} might already exist:`, error.message); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     async includeFile(path) { | ||||
|         try { | ||||
|             // Parse path: "collection:path/to/file" or "path/to/file" | ||||
|   | ||||
| @@ -33,7 +33,8 @@ body.dark-mode { | ||||
| } | ||||
|  | ||||
| /* Global styles */ | ||||
| html, body { | ||||
| html, | ||||
| body { | ||||
|     height: 100%; | ||||
|     margin: 0; | ||||
|     padding: 0; | ||||
| @@ -48,12 +49,6 @@ body { | ||||
|     transition: background-color 0.3s ease, color 0.3s ease; | ||||
| } | ||||
|  | ||||
| .container-fluid { | ||||
|     flex: 1; | ||||
|     padding: 0; | ||||
|     overflow: hidden; | ||||
| } | ||||
|  | ||||
| .row { | ||||
|     margin: 0; | ||||
| } | ||||
| @@ -206,7 +201,12 @@ body.dark-mode .CodeMirror-linenumber { | ||||
| } | ||||
|  | ||||
| /* Markdown preview styles */ | ||||
| #preview h1, #preview h2, #preview h3, #preview h4, #preview h5, #preview h6 { | ||||
| #preview h1, | ||||
| #preview h2, | ||||
| #preview h3, | ||||
| #preview h4, | ||||
| #preview h5, | ||||
| #preview h6 { | ||||
|     margin-top: 24px; | ||||
|     margin-bottom: 16px; | ||||
|     font-weight: 600; | ||||
| @@ -286,7 +286,8 @@ body.dark-mode .CodeMirror-linenumber { | ||||
|     margin-bottom: 16px; | ||||
| } | ||||
|  | ||||
| #preview ul, #preview ol { | ||||
| #preview ul, | ||||
| #preview ol { | ||||
|     margin-bottom: 16px; | ||||
|     padding-left: 2em; | ||||
| } | ||||
| @@ -591,4 +592,3 @@ body.dark-mode .sidebar h6 { | ||||
| body.dark-mode .tree-children { | ||||
|     border-left-color: var(--border-color); | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -30,20 +30,47 @@ | ||||
|     <!-- Navbar --> | ||||
|     <nav class="navbar navbar-expand-lg"> | ||||
|         <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 | ||||
|             </span> | ||||
|             <div class="d-flex gap-2"> | ||||
|                 <button id="newBtn" class="btn btn-success btn-sm"> | ||||
|                 </a> | ||||
|             </div> | ||||
|  | ||||
|             <!-- 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 | ||||
|                 </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 | ||||
|                 </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 | ||||
|                 </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> | ||||
|     </nav> | ||||
| @@ -56,7 +83,13 @@ | ||||
|                 <!-- Collection Selector --> | ||||
|                 <div class="collection-selector"> | ||||
|                     <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> | ||||
|                 <!-- File Tree --> | ||||
|                 <div id="fileTree" class="file-tree"></div> | ||||
| @@ -120,13 +153,21 @@ | ||||
|             <i class="bi bi-clipboard"></i> Paste | ||||
|         </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"> | ||||
|             <i class="bi bi-trash"></i> Delete | ||||
|         </div> | ||||
|     </div> | ||||
|  | ||||
|     <!-- 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-content"> | ||||
|                 <div class="modal-header"> | ||||
| @@ -138,8 +179,12 @@ | ||||
|                     <input type="text" id="confirmationInput" class="form-control" style="display: none;"> | ||||
|                 </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" id="confirmButton">OK</button> | ||||
|                     <button type="button" class="btn-flat btn-flat-secondary" data-bs-dismiss="modal"> | ||||
|                         <i class="bi bi-x-circle"></i> Cancel | ||||
|                     </button> | ||||
|                     <button type="button" class="btn-flat btn-flat-primary" id="confirmButton"> | ||||
|                         <i class="bi bi-check-circle"></i> OK | ||||
|                     </button> | ||||
|                 </div> | ||||
|             </div> | ||||
|         </div> | ||||
| @@ -178,10 +223,27 @@ | ||||
|     <!-- Mermaid for diagrams --> | ||||
|     <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/file-tree.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/file-tree-actions.js" defer></script> | ||||
|     <script src="/static/js/column-resizer.js" defer></script> | ||||
|   | ||||