diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1d17dae --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.venv diff --git a/collections/7madah/tests/sub_tests/file1.md b/collections/7madah/tests/sub_tests/file1.md new file mode 100644 index 0000000..c83a7dc --- /dev/null +++ b/collections/7madah/tests/sub_tests/file1.md @@ -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]; +``` + diff --git a/collections/notes/ttt/test.md b/collections/7madah/tests/test.md similarity index 100% rename from collections/notes/ttt/test.md rename to collections/7madah/tests/test.md diff --git a/collections/notes/ttt/test2.md b/collections/7madah/tests/test2.md similarity index 100% rename from collections/notes/ttt/test2.md rename to collections/7madah/tests/test2.md diff --git a/collections/7madah/tests/test3.md b/collections/7madah/tests/test3.md new file mode 100644 index 0000000..06bcd72 --- /dev/null +++ b/collections/7madah/tests/test3.md @@ -0,0 +1,426 @@ +# UI Code Refactoring Plan + +**Project:** Markdown Editor +**Date:** 2025-10-26 +**Status:** In Progress + +--- + +## Executive Summary + +This document outlines a comprehensive refactoring plan for the UI codebase to improve maintainability, remove dead code, extract utilities, and standardize patterns. The refactoring is organized into 6 phases with 14 tasks, prioritized by risk and impact. + +**Key Metrics:** + +- Total Lines of Code: ~3,587 +- Dead Code to Remove: 213 lines (6%) +- Estimated Effort: 5-8 days +- Risk Level: Mostly LOW to MEDIUM + +--- + +## Phase 1: Analysis Summary + +### Files Reviewed + +**JavaScript Files (10):** + +- `/static/js/app.js` (484 lines) +- `/static/js/column-resizer.js` (100 lines) +- `/static/js/confirmation.js` (170 lines) +- `/static/js/editor.js` (420 lines) +- `/static/js/file-tree-actions.js` (482 lines) +- `/static/js/file-tree.js` (865 lines) +- `/static/js/macro-parser.js` (103 lines) +- `/static/js/macro-processor.js` (157 lines) +- `/static/js/ui-utils.js` (305 lines) +- `/static/js/webdav-client.js` (266 lines) + +**CSS Files (6):** + +- `/static/css/variables.css` (32 lines) +- `/static/css/layout.css` +- `/static/css/file-tree.css` +- `/static/css/editor.css` +- `/static/css/components.css` +- `/static/css/modal.css` + +**HTML Templates (1):** + +- `/templates/index.html` (203 lines) + +--- + +## Issues Found + +### 🔴 HIGH PRIORITY + +1. **Deprecated Modal Code (Dead Code)** + - Location: `/static/js/file-tree-actions.js` lines 262-474 + - Impact: 213 lines of unused code (44% of file) + - Risk: LOW to remove + +2. **Duplicated Event Bus Implementation** + - Location: `/static/js/app.js` lines 16-30 + - Should be extracted to reusable module + +3. **Duplicated Debounce Function** + - Location: `/static/js/editor.js` lines 404-414 + - Should be shared utility + +4. **Inconsistent Notification Usage** + - Mixed usage of `window.showNotification` vs `showNotification` + +5. **Duplicated File Download Logic** + - Location: `/static/js/file-tree.js` lines 829-839 + - Should be shared utility + +6. **Hard-coded Values** + - Long-press threshold: 400ms + - Debounce delay: 300ms + - Drag preview width: 200px + - Toast delay: 3000ms + +### 🟡 MEDIUM PRIORITY + +7. **Global State Management** + - Location: `/static/js/app.js` lines 6-13 + - Makes testing difficult + +8. **Duplicated Path Manipulation** + - `path.split('/').pop()` appears 10+ times + - `path.substring(0, path.lastIndexOf('/'))` appears 5+ times + +9. **Mixed Responsibility in ui-utils.js** + - Contains 6 different classes/utilities + - Should be split into separate modules + +10. **Deprecated Event Handler** + - Location: `/static/js/file-tree-actions.js` line 329 + - Uses deprecated `onkeypress` + +### 🟢 LOW PRIORITY + +11. **Unused Function Parameters** +12. **Magic Numbers in Styling** +13. **Inconsistent Comment Styles** +14. **Console.log Statements** + +--- + +## Phase 2: Proposed Reusable Components + +### 1. Config Module (`/static/js/config.js`) + +Centralize all configuration values: + +```javascript +export const Config = { + // Timing + LONG_PRESS_THRESHOLD: 400, + DEBOUNCE_DELAY: 300, + TOAST_DURATION: 3000, + + // UI + DRAG_PREVIEW_WIDTH: 200, + TREE_INDENT_PX: 12, + MOUSE_MOVE_THRESHOLD: 5, + + // Validation + FILENAME_PATTERN: /^[a-z0-9_]+(\.[a-z0-9_]+)*$/, + + // Storage Keys + STORAGE_KEYS: { + DARK_MODE: 'darkMode', + SELECTED_COLLECTION: 'selectedCollection', + LAST_VIEWED_PAGE: 'lastViewedPage', + COLUMN_DIMENSIONS: 'columnDimensions' + } +}; +``` + +### 2. Logger Module (`/static/js/logger.js`) + +Structured logging with levels: + +```javascript +export class Logger { + static debug(message, ...args) + static info(message, ...args) + static warn(message, ...args) + static error(message, ...args) + static setLevel(level) +} +``` + +### 3. Event Bus Module (`/static/js/event-bus.js`) + +Centralized event system: + +```javascript +export class EventBus { + on(event, callback) + off(event, callback) + once(event, callback) + dispatch(event, data) + clear(event) +} +``` + +### 4. Utilities Module (`/static/js/utils.js`) + +Common utility functions: + +```javascript +export const PathUtils = { + getFileName(path), + getParentPath(path), + normalizePath(path), + joinPaths(...paths), + getExtension(path) +}; + +export const TimingUtils = { + debounce(func, wait), + throttle(func, wait) +}; + +export const DownloadUtils = { + triggerDownload(content, filename), + downloadAsBlob(blob, filename) +}; + +export const ValidationUtils = { + validateFileName(name, isFolder), + sanitizeFileName(name) +}; +``` + +### 5. Notification Service (`/static/js/notification-service.js`) + +Standardized notifications: + +```javascript +export class NotificationService { + static success(message) + static error(message) + static warning(message) + static info(message) +} +``` + +--- + +## Phase 3: Refactoring Tasks + +### 🔴 HIGH PRIORITY + +**Task 1: Remove Dead Code** + +- Files: `/static/js/file-tree-actions.js` +- Lines: 262-474 (213 lines) +- Risk: LOW +- Dependencies: None + +**Task 2: Extract Event Bus** + +- Files: NEW `/static/js/event-bus.js`, MODIFY `app.js`, `editor.js` +- Risk: MEDIUM +- Dependencies: None + +**Task 3: Create Utilities Module** + +- Files: NEW `/static/js/utils.js`, MODIFY multiple files +- Risk: MEDIUM +- Dependencies: None + +**Task 4: Create Config Module** + +- Files: NEW `/static/js/config.js`, MODIFY multiple files +- Risk: LOW +- Dependencies: None + +**Task 5: Standardize Notification Usage** + +- Files: NEW `/static/js/notification-service.js`, MODIFY multiple files +- Risk: LOW +- Dependencies: None + +### 🟡 MEDIUM PRIORITY + +**Task 6: Fix Deprecated Event Handler** + +- Files: `/static/js/file-tree-actions.js` line 329 +- Risk: LOW +- Dependencies: None + +**Task 7: Refactor ui-utils.js** + +- Files: DELETE `ui-utils.js`, CREATE 5 new modules +- Risk: HIGH +- Dependencies: Task 5 + +**Task 8: Standardize Class Export Pattern** + +- Files: All class files +- Risk: MEDIUM +- Dependencies: None + +**Task 9: Create Logger Module** + +- Files: NEW `/static/js/logger.js`, MODIFY multiple files +- Risk: LOW +- Dependencies: None + +**Task 10: Implement Download Action** + +- Files: `/static/js/file-tree-actions.js` +- Risk: LOW +- Dependencies: Task 3 + +### 🟢 LOW PRIORITY + +**Task 11: Standardize JSDoc Comments** +**Task 12: Extract Magic Numbers to CSS** +**Task 13: Add Error Boundaries** +**Task 14: Cache DOM Elements** + +--- + +## Phase 4: Implementation Order + +### Step 1: Foundation (Do First) + +1. Create Config Module (Task 4) +2. Create Logger Module (Task 9) +3. Create Event Bus Module (Task 2) + +### Step 2: Utilities (Do Second) + +4. Create Utilities Module (Task 3) +5. Create Notification Service (Task 5) + +### Step 3: Cleanup (Do Third) + +6. Remove Dead Code (Task 1) +7. Fix Deprecated Event Handler (Task 6) + +### Step 4: Restructuring (Do Fourth) + +8. Refactor ui-utils.js (Task 7) +9. Standardize Class Export Pattern (Task 8) + +### Step 5: Enhancements (Do Fifth) + +10. Implement Download Action (Task 10) +11. Add Error Boundaries (Task 13) + +### Step 6: Polish (Do Last) + +12. Standardize JSDoc Comments (Task 11) +13. Extract Magic Numbers to CSS (Task 12) +14. Cache DOM Elements (Task 14) + +--- + +## Phase 5: Testing Checklist + +### Core Functionality + +- [ ] File tree loads and displays correctly +- [ ] Files can be selected and opened +- [ ] Folders can be expanded/collapsed +- [ ] Editor loads file content +- [ ] Preview renders markdown correctly +- [ ] Save button saves files +- [ ] Delete button deletes files +- [ ] New button creates new files + +### Context Menu Actions + +- [ ] Right-click shows context menu +- [ ] New file action works +- [ ] New folder action works +- [ ] Rename action works +- [ ] Delete action works +- [ ] Copy/Cut/Paste actions work +- [ ] Upload action works + +### Drag and Drop + +- [ ] Long-press detection works +- [ ] Drag preview appears correctly +- [ ] Drop targets highlight properly +- [ ] Files can be moved +- [ ] Undo (Ctrl+Z) works + +### Modals + +- [ ] Confirmation modals appear +- [ ] Prompt modals appear +- [ ] Modals don't double-open +- [ ] Enter/Escape keys work + +### UI Features + +- [ ] Dark mode toggle works +- [ ] Collection selector works +- [ ] Column resizers work +- [ ] Notifications appear +- [ ] URL routing works +- [ ] View/Edit modes work + +--- + +## Recommendations + +### Immediate Actions (Before Production) + +1. Remove dead code (Task 1) +2. Fix deprecated event handler (Task 6) +3. Create config module (Task 4) + +### Short-term Actions (Next Sprint) + +4. Extract utilities (Task 3) +5. Standardize notifications (Task 5) +6. Create event bus (Task 2) + +### Medium-term Actions (Future Sprints) + +7. Refactor ui-utils.js (Task 7) +8. Add logger (Task 9) +9. Standardize exports (Task 8) + +--- + +## Success Metrics + +**Before Refactoring:** + +- Total Lines: ~3,587 +- Dead Code: 213 lines (6%) +- Duplicated Code: ~50 lines +- Hard-coded Values: 15+ + +**After Refactoring:** + +- Total Lines: ~3,400 (-5%) +- Dead Code: 0 lines +- Duplicated Code: 0 lines +- Hard-coded Values: 0 + +**Estimated Effort:** 5-8 days + +--- + +## Conclusion + +The UI codebase is generally well-structured. Main improvements needed: + +1. Remove dead code +2. Extract duplicated utilities +3. Centralize configuration +4. Standardize patterns + +Start with high-impact, low-risk changes first to ensure production readiness. diff --git a/collections/documents/docusaurus.md b/collections/documents/docusaurus.md new file mode 100644 index 0000000..74a9422 --- /dev/null +++ b/collections/documents/docusaurus.md @@ -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. \ No newline at end of file diff --git a/collections/documents/getting_started/hero_docker.md b/collections/documents/getting_started/hero_docker.md new file mode 100644 index 0000000..6488df8 --- /dev/null +++ b/collections/documents/getting_started/hero_docker.md @@ -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 +``` \ No newline at end of file diff --git a/collections/documents/getting_started/hero_native.md b/collections/documents/getting_started/hero_native.md new file mode 100644 index 0000000..9555bb6 --- /dev/null +++ b/collections/documents/getting_started/hero_native.md @@ -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). \ No newline at end of file diff --git a/collections/documents/intro.md b/collections/documents/intro.md new file mode 100644 index 0000000..4f2e13f --- /dev/null +++ b/collections/documents/intro.md @@ -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. \ No newline at end of file diff --git a/collections/documents/support.md b/collections/documents/support.md new file mode 100644 index 0000000..1dadeae --- /dev/null +++ b/collections/documents/support.md @@ -0,0 +1 @@ +If you need help with Hero, reach out to the ThreeFold Support team [here](https://threefoldfaq.crisp.help/en/). \ No newline at end of file diff --git a/collections/notes/images/logo-blue.png b/collections/notes/images/logo-blue.png new file mode 100644 index 0000000..7790f52 Binary files /dev/null and b/collections/notes/images/logo-blue.png differ diff --git a/collections/notes/introduction.md b/collections/notes/introduction.md new file mode 100644 index 0000000..6cbbf1e --- /dev/null +++ b/collections/notes/introduction.md @@ -0,0 +1,18 @@ +# Introduction + +### This is an introduction + + +* **This is an internal image** + +--- + +![My company logo](/images/logo-blue.png "Company Logo") + +--- + +* **This is an external image** + +![My company logo](https://images.pexels.com/photos/1054655/pexels-photo-1054655.jpeg "Another image") + +--- diff --git a/collections/notes/new_folder/zeko.md b/collections/notes/new_folder/zeko.md new file mode 100644 index 0000000..09f37b3 --- /dev/null +++ b/collections/notes/new_folder/zeko.md @@ -0,0 +1,2 @@ +# New File + diff --git a/collections/notes/presentation.md b/collections/notes/presentation.md new file mode 100644 index 0000000..d9efbc8 --- /dev/null +++ b/collections/notes/presentation.md @@ -0,0 +1,40 @@ +## Mycelium Product Presentation + +This document provides an overview of the Mycelium technology stack (as commercially sold my our company GeoMind). + +
+ +
+ +
+ + Geomind Product Intro 2025 (based on mycelium technology) + +
diff --git a/collections/notes/test.md b/collections/notes/test.md deleted file mode 100644 index 43df96f..0000000 --- a/collections/notes/test.md +++ /dev/null @@ -1,10 +0,0 @@ - -# test - -- 1 -- 2 - -[2025 SeaweedFS Intro Slides.pdf](/notes/2025 SeaweedFS Intro Slides.pdf) - - - diff --git a/collections/notes/tests/test.md b/collections/notes/tests/test.md new file mode 100644 index 0000000..dc493b0 --- /dev/null +++ b/collections/notes/tests/test.md @@ -0,0 +1,9 @@ + +# test + +- 1 +- 2 + + + +!!include path:test2.md diff --git a/collections/notes/tests/test2.md b/collections/notes/tests/test2.md new file mode 100644 index 0000000..cd8e39b --- /dev/null +++ b/collections/notes/tests/test2.md @@ -0,0 +1,12 @@ + +## test2 + +- something +- another thing + + + + + + + diff --git a/collections/notes/tests/test3.md b/collections/notes/tests/test3.md new file mode 100644 index 0000000..06bcd72 --- /dev/null +++ b/collections/notes/tests/test3.md @@ -0,0 +1,426 @@ +# UI Code Refactoring Plan + +**Project:** Markdown Editor +**Date:** 2025-10-26 +**Status:** In Progress + +--- + +## Executive Summary + +This document outlines a comprehensive refactoring plan for the UI codebase to improve maintainability, remove dead code, extract utilities, and standardize patterns. The refactoring is organized into 6 phases with 14 tasks, prioritized by risk and impact. + +**Key Metrics:** + +- Total Lines of Code: ~3,587 +- Dead Code to Remove: 213 lines (6%) +- Estimated Effort: 5-8 days +- Risk Level: Mostly LOW to MEDIUM + +--- + +## Phase 1: Analysis Summary + +### Files Reviewed + +**JavaScript Files (10):** + +- `/static/js/app.js` (484 lines) +- `/static/js/column-resizer.js` (100 lines) +- `/static/js/confirmation.js` (170 lines) +- `/static/js/editor.js` (420 lines) +- `/static/js/file-tree-actions.js` (482 lines) +- `/static/js/file-tree.js` (865 lines) +- `/static/js/macro-parser.js` (103 lines) +- `/static/js/macro-processor.js` (157 lines) +- `/static/js/ui-utils.js` (305 lines) +- `/static/js/webdav-client.js` (266 lines) + +**CSS Files (6):** + +- `/static/css/variables.css` (32 lines) +- `/static/css/layout.css` +- `/static/css/file-tree.css` +- `/static/css/editor.css` +- `/static/css/components.css` +- `/static/css/modal.css` + +**HTML Templates (1):** + +- `/templates/index.html` (203 lines) + +--- + +## Issues Found + +### 🔴 HIGH PRIORITY + +1. **Deprecated Modal Code (Dead Code)** + - Location: `/static/js/file-tree-actions.js` lines 262-474 + - Impact: 213 lines of unused code (44% of file) + - Risk: LOW to remove + +2. **Duplicated Event Bus Implementation** + - Location: `/static/js/app.js` lines 16-30 + - Should be extracted to reusable module + +3. **Duplicated Debounce Function** + - Location: `/static/js/editor.js` lines 404-414 + - Should be shared utility + +4. **Inconsistent Notification Usage** + - Mixed usage of `window.showNotification` vs `showNotification` + +5. **Duplicated File Download Logic** + - Location: `/static/js/file-tree.js` lines 829-839 + - Should be shared utility + +6. **Hard-coded Values** + - Long-press threshold: 400ms + - Debounce delay: 300ms + - Drag preview width: 200px + - Toast delay: 3000ms + +### 🟡 MEDIUM PRIORITY + +7. **Global State Management** + - Location: `/static/js/app.js` lines 6-13 + - Makes testing difficult + +8. **Duplicated Path Manipulation** + - `path.split('/').pop()` appears 10+ times + - `path.substring(0, path.lastIndexOf('/'))` appears 5+ times + +9. **Mixed Responsibility in ui-utils.js** + - Contains 6 different classes/utilities + - Should be split into separate modules + +10. **Deprecated Event Handler** + - Location: `/static/js/file-tree-actions.js` line 329 + - Uses deprecated `onkeypress` + +### 🟢 LOW PRIORITY + +11. **Unused Function Parameters** +12. **Magic Numbers in Styling** +13. **Inconsistent Comment Styles** +14. **Console.log Statements** + +--- + +## Phase 2: Proposed Reusable Components + +### 1. Config Module (`/static/js/config.js`) + +Centralize all configuration values: + +```javascript +export const Config = { + // Timing + LONG_PRESS_THRESHOLD: 400, + DEBOUNCE_DELAY: 300, + TOAST_DURATION: 3000, + + // UI + DRAG_PREVIEW_WIDTH: 200, + TREE_INDENT_PX: 12, + MOUSE_MOVE_THRESHOLD: 5, + + // Validation + FILENAME_PATTERN: /^[a-z0-9_]+(\.[a-z0-9_]+)*$/, + + // Storage Keys + STORAGE_KEYS: { + DARK_MODE: 'darkMode', + SELECTED_COLLECTION: 'selectedCollection', + LAST_VIEWED_PAGE: 'lastViewedPage', + COLUMN_DIMENSIONS: 'columnDimensions' + } +}; +``` + +### 2. Logger Module (`/static/js/logger.js`) + +Structured logging with levels: + +```javascript +export class Logger { + static debug(message, ...args) + static info(message, ...args) + static warn(message, ...args) + static error(message, ...args) + static setLevel(level) +} +``` + +### 3. Event Bus Module (`/static/js/event-bus.js`) + +Centralized event system: + +```javascript +export class EventBus { + on(event, callback) + off(event, callback) + once(event, callback) + dispatch(event, data) + clear(event) +} +``` + +### 4. Utilities Module (`/static/js/utils.js`) + +Common utility functions: + +```javascript +export const PathUtils = { + getFileName(path), + getParentPath(path), + normalizePath(path), + joinPaths(...paths), + getExtension(path) +}; + +export const TimingUtils = { + debounce(func, wait), + throttle(func, wait) +}; + +export const DownloadUtils = { + triggerDownload(content, filename), + downloadAsBlob(blob, filename) +}; + +export const ValidationUtils = { + validateFileName(name, isFolder), + sanitizeFileName(name) +}; +``` + +### 5. Notification Service (`/static/js/notification-service.js`) + +Standardized notifications: + +```javascript +export class NotificationService { + static success(message) + static error(message) + static warning(message) + static info(message) +} +``` + +--- + +## Phase 3: Refactoring Tasks + +### 🔴 HIGH PRIORITY + +**Task 1: Remove Dead Code** + +- Files: `/static/js/file-tree-actions.js` +- Lines: 262-474 (213 lines) +- Risk: LOW +- Dependencies: None + +**Task 2: Extract Event Bus** + +- Files: NEW `/static/js/event-bus.js`, MODIFY `app.js`, `editor.js` +- Risk: MEDIUM +- Dependencies: None + +**Task 3: Create Utilities Module** + +- Files: NEW `/static/js/utils.js`, MODIFY multiple files +- Risk: MEDIUM +- Dependencies: None + +**Task 4: Create Config Module** + +- Files: NEW `/static/js/config.js`, MODIFY multiple files +- Risk: LOW +- Dependencies: None + +**Task 5: Standardize Notification Usage** + +- Files: NEW `/static/js/notification-service.js`, MODIFY multiple files +- Risk: LOW +- Dependencies: None + +### 🟡 MEDIUM PRIORITY + +**Task 6: Fix Deprecated Event Handler** + +- Files: `/static/js/file-tree-actions.js` line 329 +- Risk: LOW +- Dependencies: None + +**Task 7: Refactor ui-utils.js** + +- Files: DELETE `ui-utils.js`, CREATE 5 new modules +- Risk: HIGH +- Dependencies: Task 5 + +**Task 8: Standardize Class Export Pattern** + +- Files: All class files +- Risk: MEDIUM +- Dependencies: None + +**Task 9: Create Logger Module** + +- Files: NEW `/static/js/logger.js`, MODIFY multiple files +- Risk: LOW +- Dependencies: None + +**Task 10: Implement Download Action** + +- Files: `/static/js/file-tree-actions.js` +- Risk: LOW +- Dependencies: Task 3 + +### 🟢 LOW PRIORITY + +**Task 11: Standardize JSDoc Comments** +**Task 12: Extract Magic Numbers to CSS** +**Task 13: Add Error Boundaries** +**Task 14: Cache DOM Elements** + +--- + +## Phase 4: Implementation Order + +### Step 1: Foundation (Do First) + +1. Create Config Module (Task 4) +2. Create Logger Module (Task 9) +3. Create Event Bus Module (Task 2) + +### Step 2: Utilities (Do Second) + +4. Create Utilities Module (Task 3) +5. Create Notification Service (Task 5) + +### Step 3: Cleanup (Do Third) + +6. Remove Dead Code (Task 1) +7. Fix Deprecated Event Handler (Task 6) + +### Step 4: Restructuring (Do Fourth) + +8. Refactor ui-utils.js (Task 7) +9. Standardize Class Export Pattern (Task 8) + +### Step 5: Enhancements (Do Fifth) + +10. Implement Download Action (Task 10) +11. Add Error Boundaries (Task 13) + +### Step 6: Polish (Do Last) + +12. Standardize JSDoc Comments (Task 11) +13. Extract Magic Numbers to CSS (Task 12) +14. Cache DOM Elements (Task 14) + +--- + +## Phase 5: Testing Checklist + +### Core Functionality + +- [ ] File tree loads and displays correctly +- [ ] Files can be selected and opened +- [ ] Folders can be expanded/collapsed +- [ ] Editor loads file content +- [ ] Preview renders markdown correctly +- [ ] Save button saves files +- [ ] Delete button deletes files +- [ ] New button creates new files + +### Context Menu Actions + +- [ ] Right-click shows context menu +- [ ] New file action works +- [ ] New folder action works +- [ ] Rename action works +- [ ] Delete action works +- [ ] Copy/Cut/Paste actions work +- [ ] Upload action works + +### Drag and Drop + +- [ ] Long-press detection works +- [ ] Drag preview appears correctly +- [ ] Drop targets highlight properly +- [ ] Files can be moved +- [ ] Undo (Ctrl+Z) works + +### Modals + +- [ ] Confirmation modals appear +- [ ] Prompt modals appear +- [ ] Modals don't double-open +- [ ] Enter/Escape keys work + +### UI Features + +- [ ] Dark mode toggle works +- [ ] Collection selector works +- [ ] Column resizers work +- [ ] Notifications appear +- [ ] URL routing works +- [ ] View/Edit modes work + +--- + +## Recommendations + +### Immediate Actions (Before Production) + +1. Remove dead code (Task 1) +2. Fix deprecated event handler (Task 6) +3. Create config module (Task 4) + +### Short-term Actions (Next Sprint) + +4. Extract utilities (Task 3) +5. Standardize notifications (Task 5) +6. Create event bus (Task 2) + +### Medium-term Actions (Future Sprints) + +7. Refactor ui-utils.js (Task 7) +8. Add logger (Task 9) +9. Standardize exports (Task 8) + +--- + +## Success Metrics + +**Before Refactoring:** + +- Total Lines: ~3,587 +- Dead Code: 213 lines (6%) +- Duplicated Code: ~50 lines +- Hard-coded Values: 15+ + +**After Refactoring:** + +- Total Lines: ~3,400 (-5%) +- Dead Code: 0 lines +- Duplicated Code: 0 lines +- Hard-coded Values: 0 + +**Estimated Effort:** 5-8 days + +--- + +## Conclusion + +The UI codebase is generally well-structured. Main improvements needed: + +1. Remove dead code +2. Extract duplicated utilities +3. Centralize configuration +4. Standardize patterns + +Start with high-impact, low-risk changes first to ensure production readiness. diff --git a/collections/notes/why.md b/collections/notes/why.md new file mode 100644 index 0000000..8b57321 --- /dev/null +++ b/collections/notes/why.md @@ -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.% \ No newline at end of file diff --git a/collections/tech/images/arch.png b/collections/tech/images/arch.png new file mode 100644 index 0000000..984fa4e Binary files /dev/null and b/collections/tech/images/arch.png differ diff --git a/collections/tech/images/dashboard.png b/collections/tech/images/dashboard.png new file mode 100644 index 0000000..8d706ba Binary files /dev/null and b/collections/tech/images/dashboard.png differ diff --git a/collections/tech/images/letsfix.png b/collections/tech/images/letsfix.png new file mode 100644 index 0000000..acc44d9 Binary files /dev/null and b/collections/tech/images/letsfix.png differ diff --git a/collections/tech/images/opportunity.png b/collections/tech/images/opportunity.png new file mode 100644 index 0000000..43f35b2 Binary files /dev/null and b/collections/tech/images/opportunity.png differ diff --git a/collections/tech/images/status.png b/collections/tech/images/status.png new file mode 100644 index 0000000..399db64 Binary files /dev/null and b/collections/tech/images/status.png differ diff --git a/collections/tech/images/unique.png b/collections/tech/images/unique.png new file mode 100644 index 0000000..d24d701 Binary files /dev/null and b/collections/tech/images/unique.png differ diff --git a/collections/tech/images/usable_by_all.png b/collections/tech/images/usable_by_all.png new file mode 100644 index 0000000..40c13e4 Binary files /dev/null and b/collections/tech/images/usable_by_all.png differ diff --git a/collections/tech/images/web4.png b/collections/tech/images/web4.png new file mode 100644 index 0000000..65e4c37 Binary files /dev/null and b/collections/tech/images/web4.png differ diff --git a/collections/tech/introduction.md b/collections/tech/introduction.md new file mode 100644 index 0000000..c086dc4 --- /dev/null +++ b/collections/tech/introduction.md @@ -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. \ No newline at end of file diff --git a/collections/tech/presentation.md b/collections/tech/presentation.md new file mode 100644 index 0000000..33223f2 --- /dev/null +++ b/collections/tech/presentation.md @@ -0,0 +1,42 @@ +## Mycelium Product Presentation + +This document provides an overview of the Mycelium technology stack (as commercially sold my our company GeoMind). + + + +
+ +
+ +
+ + Geomind Product Intro 2025 (based on mycelium technology) + +
diff --git a/collections/tech/roadmap/enterprise_roadmap.md b/collections/tech/roadmap/enterprise_roadmap.md new file mode 100644 index 0000000..ea68351 --- /dev/null +++ b/collections/tech/roadmap/enterprise_roadmap.md @@ -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. diff --git a/collections/tech/roadmap/hero_roadmap.md b/collections/tech/roadmap/hero_roadmap.md new file mode 100644 index 0000000..d7c6bc9 --- /dev/null +++ b/collections/tech/roadmap/hero_roadmap.md @@ -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. diff --git a/collections/tech/roadmap/high_level.md b/collections/tech/roadmap/high_level.md new file mode 100644 index 0000000..b753ba4 --- /dev/null +++ b/collections/tech/roadmap/high_level.md @@ -0,0 +1,40 @@ + +![](img/roadmap.jpg) + +# 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 diff --git a/collections/tech/roadmap/img/roadmap.jpg b/collections/tech/roadmap/img/roadmap.jpg new file mode 100644 index 0000000..5390f6d Binary files /dev/null and b/collections/tech/roadmap/img/roadmap.jpg differ diff --git a/collections/tech/roadmap/tfgrid_roadmap.md b/collections/tech/roadmap/tfgrid_roadmap.md new file mode 100644 index 0000000..57e492d --- /dev/null +++ b/collections/tech/roadmap/tfgrid_roadmap.md @@ -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 | diff --git a/collections/tech/status.md b/collections/tech/status.md new file mode 100644 index 0000000..e067552 --- /dev/null +++ b/collections/tech/status.md @@ -0,0 +1,21 @@ +## Technology Status + +![](images/status.png) + +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. + +![](images/dashboard.png) + +## Usable for Any Infrastructure Use Case + +![](images/usable_by_all.png) + +Mycelium is designed to support any infrastructure workload - from traditional cloud applications to edge computing, AI services, and decentralized applications. + +## Differentiated Architecture + +![](images/unique.png) + +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. \ No newline at end of file diff --git a/collections/tech/vision.md b/collections/tech/vision.md new file mode 100644 index 0000000..ae22249 --- /dev/null +++ b/collections/tech/vision.md @@ -0,0 +1,21 @@ +![](images/opportunity.png) + +## 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 + +![alt text](images/letsfix.png) + +**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 \ No newline at end of file diff --git a/collections/tech/what.md b/collections/tech/what.md new file mode 100644 index 0000000..dcfc74f --- /dev/null +++ b/collections/tech/what.md @@ -0,0 +1,43 @@ + +## What do we do? + +![alt text](images/arch.png) + +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 | diff --git a/config.yaml b/config.yaml index 4cfec89..1c0a6da 100644 --- a/config.yaml +++ b/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: [] diff --git a/refactor-plan.md b/refactor-plan.md new file mode 100644 index 0000000..06bcd72 --- /dev/null +++ b/refactor-plan.md @@ -0,0 +1,426 @@ +# UI Code Refactoring Plan + +**Project:** Markdown Editor +**Date:** 2025-10-26 +**Status:** In Progress + +--- + +## Executive Summary + +This document outlines a comprehensive refactoring plan for the UI codebase to improve maintainability, remove dead code, extract utilities, and standardize patterns. The refactoring is organized into 6 phases with 14 tasks, prioritized by risk and impact. + +**Key Metrics:** + +- Total Lines of Code: ~3,587 +- Dead Code to Remove: 213 lines (6%) +- Estimated Effort: 5-8 days +- Risk Level: Mostly LOW to MEDIUM + +--- + +## Phase 1: Analysis Summary + +### Files Reviewed + +**JavaScript Files (10):** + +- `/static/js/app.js` (484 lines) +- `/static/js/column-resizer.js` (100 lines) +- `/static/js/confirmation.js` (170 lines) +- `/static/js/editor.js` (420 lines) +- `/static/js/file-tree-actions.js` (482 lines) +- `/static/js/file-tree.js` (865 lines) +- `/static/js/macro-parser.js` (103 lines) +- `/static/js/macro-processor.js` (157 lines) +- `/static/js/ui-utils.js` (305 lines) +- `/static/js/webdav-client.js` (266 lines) + +**CSS Files (6):** + +- `/static/css/variables.css` (32 lines) +- `/static/css/layout.css` +- `/static/css/file-tree.css` +- `/static/css/editor.css` +- `/static/css/components.css` +- `/static/css/modal.css` + +**HTML Templates (1):** + +- `/templates/index.html` (203 lines) + +--- + +## Issues Found + +### 🔴 HIGH PRIORITY + +1. **Deprecated Modal Code (Dead Code)** + - Location: `/static/js/file-tree-actions.js` lines 262-474 + - Impact: 213 lines of unused code (44% of file) + - Risk: LOW to remove + +2. **Duplicated Event Bus Implementation** + - Location: `/static/js/app.js` lines 16-30 + - Should be extracted to reusable module + +3. **Duplicated Debounce Function** + - Location: `/static/js/editor.js` lines 404-414 + - Should be shared utility + +4. **Inconsistent Notification Usage** + - Mixed usage of `window.showNotification` vs `showNotification` + +5. **Duplicated File Download Logic** + - Location: `/static/js/file-tree.js` lines 829-839 + - Should be shared utility + +6. **Hard-coded Values** + - Long-press threshold: 400ms + - Debounce delay: 300ms + - Drag preview width: 200px + - Toast delay: 3000ms + +### 🟡 MEDIUM PRIORITY + +7. **Global State Management** + - Location: `/static/js/app.js` lines 6-13 + - Makes testing difficult + +8. **Duplicated Path Manipulation** + - `path.split('/').pop()` appears 10+ times + - `path.substring(0, path.lastIndexOf('/'))` appears 5+ times + +9. **Mixed Responsibility in ui-utils.js** + - Contains 6 different classes/utilities + - Should be split into separate modules + +10. **Deprecated Event Handler** + - Location: `/static/js/file-tree-actions.js` line 329 + - Uses deprecated `onkeypress` + +### 🟢 LOW PRIORITY + +11. **Unused Function Parameters** +12. **Magic Numbers in Styling** +13. **Inconsistent Comment Styles** +14. **Console.log Statements** + +--- + +## Phase 2: Proposed Reusable Components + +### 1. Config Module (`/static/js/config.js`) + +Centralize all configuration values: + +```javascript +export const Config = { + // Timing + LONG_PRESS_THRESHOLD: 400, + DEBOUNCE_DELAY: 300, + TOAST_DURATION: 3000, + + // UI + DRAG_PREVIEW_WIDTH: 200, + TREE_INDENT_PX: 12, + MOUSE_MOVE_THRESHOLD: 5, + + // Validation + FILENAME_PATTERN: /^[a-z0-9_]+(\.[a-z0-9_]+)*$/, + + // Storage Keys + STORAGE_KEYS: { + DARK_MODE: 'darkMode', + SELECTED_COLLECTION: 'selectedCollection', + LAST_VIEWED_PAGE: 'lastViewedPage', + COLUMN_DIMENSIONS: 'columnDimensions' + } +}; +``` + +### 2. Logger Module (`/static/js/logger.js`) + +Structured logging with levels: + +```javascript +export class Logger { + static debug(message, ...args) + static info(message, ...args) + static warn(message, ...args) + static error(message, ...args) + static setLevel(level) +} +``` + +### 3. Event Bus Module (`/static/js/event-bus.js`) + +Centralized event system: + +```javascript +export class EventBus { + on(event, callback) + off(event, callback) + once(event, callback) + dispatch(event, data) + clear(event) +} +``` + +### 4. Utilities Module (`/static/js/utils.js`) + +Common utility functions: + +```javascript +export const PathUtils = { + getFileName(path), + getParentPath(path), + normalizePath(path), + joinPaths(...paths), + getExtension(path) +}; + +export const TimingUtils = { + debounce(func, wait), + throttle(func, wait) +}; + +export const DownloadUtils = { + triggerDownload(content, filename), + downloadAsBlob(blob, filename) +}; + +export const ValidationUtils = { + validateFileName(name, isFolder), + sanitizeFileName(name) +}; +``` + +### 5. Notification Service (`/static/js/notification-service.js`) + +Standardized notifications: + +```javascript +export class NotificationService { + static success(message) + static error(message) + static warning(message) + static info(message) +} +``` + +--- + +## Phase 3: Refactoring Tasks + +### 🔴 HIGH PRIORITY + +**Task 1: Remove Dead Code** + +- Files: `/static/js/file-tree-actions.js` +- Lines: 262-474 (213 lines) +- Risk: LOW +- Dependencies: None + +**Task 2: Extract Event Bus** + +- Files: NEW `/static/js/event-bus.js`, MODIFY `app.js`, `editor.js` +- Risk: MEDIUM +- Dependencies: None + +**Task 3: Create Utilities Module** + +- Files: NEW `/static/js/utils.js`, MODIFY multiple files +- Risk: MEDIUM +- Dependencies: None + +**Task 4: Create Config Module** + +- Files: NEW `/static/js/config.js`, MODIFY multiple files +- Risk: LOW +- Dependencies: None + +**Task 5: Standardize Notification Usage** + +- Files: NEW `/static/js/notification-service.js`, MODIFY multiple files +- Risk: LOW +- Dependencies: None + +### 🟡 MEDIUM PRIORITY + +**Task 6: Fix Deprecated Event Handler** + +- Files: `/static/js/file-tree-actions.js` line 329 +- Risk: LOW +- Dependencies: None + +**Task 7: Refactor ui-utils.js** + +- Files: DELETE `ui-utils.js`, CREATE 5 new modules +- Risk: HIGH +- Dependencies: Task 5 + +**Task 8: Standardize Class Export Pattern** + +- Files: All class files +- Risk: MEDIUM +- Dependencies: None + +**Task 9: Create Logger Module** + +- Files: NEW `/static/js/logger.js`, MODIFY multiple files +- Risk: LOW +- Dependencies: None + +**Task 10: Implement Download Action** + +- Files: `/static/js/file-tree-actions.js` +- Risk: LOW +- Dependencies: Task 3 + +### 🟢 LOW PRIORITY + +**Task 11: Standardize JSDoc Comments** +**Task 12: Extract Magic Numbers to CSS** +**Task 13: Add Error Boundaries** +**Task 14: Cache DOM Elements** + +--- + +## Phase 4: Implementation Order + +### Step 1: Foundation (Do First) + +1. Create Config Module (Task 4) +2. Create Logger Module (Task 9) +3. Create Event Bus Module (Task 2) + +### Step 2: Utilities (Do Second) + +4. Create Utilities Module (Task 3) +5. Create Notification Service (Task 5) + +### Step 3: Cleanup (Do Third) + +6. Remove Dead Code (Task 1) +7. Fix Deprecated Event Handler (Task 6) + +### Step 4: Restructuring (Do Fourth) + +8. Refactor ui-utils.js (Task 7) +9. Standardize Class Export Pattern (Task 8) + +### Step 5: Enhancements (Do Fifth) + +10. Implement Download Action (Task 10) +11. Add Error Boundaries (Task 13) + +### Step 6: Polish (Do Last) + +12. Standardize JSDoc Comments (Task 11) +13. Extract Magic Numbers to CSS (Task 12) +14. Cache DOM Elements (Task 14) + +--- + +## Phase 5: Testing Checklist + +### Core Functionality + +- [ ] File tree loads and displays correctly +- [ ] Files can be selected and opened +- [ ] Folders can be expanded/collapsed +- [ ] Editor loads file content +- [ ] Preview renders markdown correctly +- [ ] Save button saves files +- [ ] Delete button deletes files +- [ ] New button creates new files + +### Context Menu Actions + +- [ ] Right-click shows context menu +- [ ] New file action works +- [ ] New folder action works +- [ ] Rename action works +- [ ] Delete action works +- [ ] Copy/Cut/Paste actions work +- [ ] Upload action works + +### Drag and Drop + +- [ ] Long-press detection works +- [ ] Drag preview appears correctly +- [ ] Drop targets highlight properly +- [ ] Files can be moved +- [ ] Undo (Ctrl+Z) works + +### Modals + +- [ ] Confirmation modals appear +- [ ] Prompt modals appear +- [ ] Modals don't double-open +- [ ] Enter/Escape keys work + +### UI Features + +- [ ] Dark mode toggle works +- [ ] Collection selector works +- [ ] Column resizers work +- [ ] Notifications appear +- [ ] URL routing works +- [ ] View/Edit modes work + +--- + +## Recommendations + +### Immediate Actions (Before Production) + +1. Remove dead code (Task 1) +2. Fix deprecated event handler (Task 6) +3. Create config module (Task 4) + +### Short-term Actions (Next Sprint) + +4. Extract utilities (Task 3) +5. Standardize notifications (Task 5) +6. Create event bus (Task 2) + +### Medium-term Actions (Future Sprints) + +7. Refactor ui-utils.js (Task 7) +8. Add logger (Task 9) +9. Standardize exports (Task 8) + +--- + +## Success Metrics + +**Before Refactoring:** + +- Total Lines: ~3,587 +- Dead Code: 213 lines (6%) +- Duplicated Code: ~50 lines +- Hard-coded Values: 15+ + +**After Refactoring:** + +- Total Lines: ~3,400 (-5%) +- Dead Code: 0 lines +- Duplicated Code: 0 lines +- Hard-coded Values: 0 + +**Estimated Effort:** 5-8 days + +--- + +## Conclusion + +The UI codebase is generally well-structured. Main improvements needed: + +1. Remove dead code +2. Extract duplicated utilities +3. Centralize configuration +4. Standardize patterns + +Start with high-impact, low-risk changes first to ensure production readiness. diff --git a/server_debug.log b/server_debug.log new file mode 100644 index 0000000..0e0487f --- /dev/null +++ b/server_debug.log @@ -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... diff --git a/server_webdav.py b/server_webdav.py index ac52ab1..4f39c48 100755 --- a/server_webdav.py +++ b/server_webdav.py @@ -28,8 +28,16 @@ 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""" @@ -78,41 +86,191 @@ class MarkdownEditorApp: # Root and index.html if path == '/' or path == '/index.html': return self.handle_index(environ, start_response) - + # Static files if path.startswith('/static/'): return self.handle_static(environ, start_response) - + # Health check if path == '/health' and method == 'GET': start_response('200 OK', [('Content-Type', 'text/plain')]) return [b'OK'] - + # API for collections if path == '/fs/' and method == 'GET': return self.handle_collections_list(environ, start_response) + # 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""" collections = list(self.collections.keys()) response_body = json.dumps(collections).encode('utf-8') - + start_response('200 OK', [ ('Content-Type', 'application/json'), ('Content-Length', str(len(response_body))), ('Access-Control-Allow-Origin', '*') ]) - + 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 / diff --git a/static/app-tree.js b/static/app-tree.js index 0fdea6d..547ad71 100644 --- a/static/app-tree.js +++ b/static/app-tree.js @@ -1,5 +1,5 @@ // Markdown Editor Application with File Tree -(function() { +(function () { 'use strict'; // State management @@ -24,15 +24,15 @@ function enableDarkMode() { isDarkMode = true; document.body.classList.add('dark-mode'); - document.getElementById('darkModeIcon').textContent = '☀️'; + document.getElementById('darkModeIcon').innerHTML = ''; localStorage.setItem('darkMode', 'true'); - - mermaid.initialize({ + + mermaid.initialize({ startOnLoad: false, theme: 'dark', securityLevel: 'loose' }); - + if (editor && editor.getValue()) { updatePreview(); } @@ -41,15 +41,15 @@ function disableDarkMode() { isDarkMode = false; document.body.classList.remove('dark-mode'); - document.getElementById('darkModeIcon').textContent = '🌙'; + // document.getElementById('darkModeIcon').textContent = '🌙'; localStorage.setItem('darkMode', 'false'); - - mermaid.initialize({ + + mermaid.initialize({ startOnLoad: false, theme: 'default', securityLevel: 'loose' }); - + if (editor && editor.getValue()) { updatePreview(); } @@ -64,7 +64,7 @@ } // Initialize Mermaid - mermaid.initialize({ + mermaid.initialize({ startOnLoad: false, theme: 'default', securityLevel: 'loose' @@ -86,15 +86,15 @@ async function uploadImage(file) { const formData = new FormData(); formData.append('file', file); - + try { const response = await fetch('/api/upload-image', { method: 'POST', body: formData }); - + if (!response.ok) throw new Error('Upload failed'); - + const result = await response.json(); return result.url; } catch (error) { @@ -107,44 +107,44 @@ // Handle drag and drop for images function setupDragAndDrop() { const editorElement = document.querySelector('.CodeMirror'); - + ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => { editorElement.addEventListener(eventName, preventDefaults, false); }); - + function preventDefaults(e) { e.preventDefault(); e.stopPropagation(); } - + ['dragenter', 'dragover'].forEach(eventName => { editorElement.addEventListener(eventName, () => { editorElement.classList.add('drag-over'); }, false); }); - + ['dragleave', 'drop'].forEach(eventName => { editorElement.addEventListener(eventName, () => { editorElement.classList.remove('drag-over'); }, false); }); - + editorElement.addEventListener('drop', async (e) => { const files = e.dataTransfer.files; - + if (files.length === 0) return; - - const imageFiles = Array.from(files).filter(file => + + const imageFiles = Array.from(files).filter(file => file.type.startsWith('image/') ); - + if (imageFiles.length === 0) { showNotification('Please drop image files only', 'warning'); return; } - + showNotification(`Uploading ${imageFiles.length} image(s)...`, 'info'); - + for (const file of imageFiles) { const url = await uploadImage(file); if (url) { @@ -156,11 +156,11 @@ } } }, false); - + editorElement.addEventListener('paste', async (e) => { const items = e.clipboardData?.items; if (!items) return; - + for (const item of items) { if (item.type.startsWith('image/')) { e.preventDefault(); @@ -189,15 +189,15 @@ lineWrapping: true, autofocus: true, extraKeys: { - 'Ctrl-S': function() { saveFile(); }, - 'Cmd-S': function() { saveFile(); } + 'Ctrl-S': function () { saveFile(); }, + 'Cmd-S': function () { saveFile(); } } }); editor.on('change', debounce(updatePreview, 300)); - + setTimeout(setupDragAndDrop, 100); - + setupScrollSync(); } @@ -217,13 +217,13 @@ // Setup synchronized scrolling function setupScrollSync() { const previewDiv = document.getElementById('preview'); - + editor.on('scroll', () => { if (!isScrollingSynced) return; - + const scrollInfo = editor.getScrollInfo(); const scrollPercentage = scrollInfo.top / (scrollInfo.height - scrollInfo.clientHeight); - + const previewScrollHeight = previewDiv.scrollHeight - previewDiv.clientHeight; previewDiv.scrollTop = previewScrollHeight * scrollPercentage; }); @@ -233,7 +233,7 @@ async function updatePreview() { const markdown = editor.getValue(); const previewDiv = document.getElementById('preview'); - + if (!markdown.trim()) { previewDiv.innerHTML = `
@@ -243,17 +243,17 @@ `; return; } - + try { let html = marked.parse(markdown); - + html = html.replace( /
([\s\S]*?)<\/code><\/pre>/g,
                 '
$1
' ); - + previewDiv.innerHTML = html; - + const codeBlocks = previewDiv.querySelectorAll('pre code'); codeBlocks.forEach(block => { const languageClass = Array.from(block.classList).find(cls => cls.startsWith('language-')); @@ -261,7 +261,7 @@ Prism.highlightElement(block); } }); - + const mermaidElements = previewDiv.querySelectorAll('.mermaid'); if (mermaidElements.length > 0) { try { @@ -291,7 +291,7 @@ try { const response = await fetch('/api/tree'); if (!response.ok) throw new Error('Failed to load file tree'); - + fileTree = await response.json(); renderFileTree(); } catch (error) { @@ -303,12 +303,12 @@ function renderFileTree() { const container = document.getElementById('fileTree'); container.innerHTML = ''; - + if (fileTree.length === 0) { container.innerHTML = '
No files yet
'; return; } - + fileTree.forEach(node => { container.appendChild(createTreeNode(node)); }); @@ -317,13 +317,13 @@ function createTreeNode(node, level = 0) { const nodeDiv = document.createElement('div'); nodeDiv.className = 'tree-node-wrapper'; - + const nodeContent = document.createElement('div'); nodeContent.className = 'tree-node'; nodeContent.dataset.path = node.path; nodeContent.dataset.type = node.type; nodeContent.dataset.name = node.name; - + // Make draggable nodeContent.draggable = true; nodeContent.addEventListener('dragstart', handleDragStart); @@ -331,14 +331,13 @@ nodeContent.addEventListener('dragover', handleDragOver); nodeContent.addEventListener('dragleave', handleDragLeave); nodeContent.addEventListener('drop', handleDrop); - + const contentWrapper = document.createElement('div'); contentWrapper.className = 'tree-node-content'; - + if (node.type === 'directory') { const toggle = document.createElement('span'); toggle.className = 'tree-node-toggle'; - toggle.innerHTML = '▶'; toggle.addEventListener('click', (e) => { e.stopPropagation(); toggleNode(nodeDiv); @@ -349,56 +348,56 @@ spacer.style.width = '16px'; contentWrapper.appendChild(spacer); } - + const icon = document.createElement('i'); icon.className = node.type === 'directory' ? 'bi bi-folder tree-node-icon' : 'bi bi-file-earmark-text tree-node-icon'; contentWrapper.appendChild(icon); - + const name = document.createElement('span'); name.className = 'tree-node-name'; name.textContent = node.name; contentWrapper.appendChild(name); - + if (node.type === 'file' && node.size) { const size = document.createElement('span'); size.className = 'file-size-badge'; size.textContent = formatFileSize(node.size); contentWrapper.appendChild(size); } - + nodeContent.appendChild(contentWrapper); - + nodeContent.addEventListener('click', (e) => { if (node.type === 'file') { loadFile(node.path); } }); - + nodeContent.addEventListener('contextmenu', (e) => { e.preventDefault(); showContextMenu(e, node); }); - + nodeDiv.appendChild(nodeContent); - + if (node.children && node.children.length > 0) { const childrenDiv = document.createElement('div'); childrenDiv.className = 'tree-children collapsed'; - + node.children.forEach(child => { childrenDiv.appendChild(createTreeNode(child, level + 1)); }); - + nodeDiv.appendChild(childrenDiv); } - + return nodeDiv; } function toggleNode(nodeWrapper) { const toggle = nodeWrapper.querySelector('.tree-node-toggle'); const children = nodeWrapper.querySelector('.tree-children'); - + if (children) { children.classList.toggle('collapsed'); toggle.classList.toggle('expanded'); @@ -437,10 +436,10 @@ function handleDragOver(e) { if (!draggedNode) return; - + e.preventDefault(); e.dataTransfer.dropEffect = 'move'; - + const targetType = e.currentTarget.dataset.type; if (targetType === 'directory') { e.currentTarget.classList.add('drag-over'); @@ -454,18 +453,18 @@ async function handleDrop(e) { e.preventDefault(); e.currentTarget.classList.remove('drag-over'); - + if (!draggedNode) return; - + const targetPath = e.currentTarget.dataset.path; const targetType = e.currentTarget.dataset.type; - + if (targetType !== 'directory') return; if (draggedNode.path === targetPath) return; - + const sourcePath = draggedNode.path; const destPath = targetPath + '/' + draggedNode.name; - + try { const response = await fetch('/api/file/move', { method: 'POST', @@ -475,16 +474,16 @@ destination: destPath }) }); - + if (!response.ok) throw new Error('Move failed'); - + showNotification(`Moved ${draggedNode.name}`, 'success'); loadFileTree(); } catch (error) { console.error('Error moving file:', error); showNotification('Error moving file', 'danger'); } - + draggedNode = null; } @@ -496,18 +495,18 @@ contextMenuTarget = node; const menu = document.getElementById('contextMenu'); const pasteItem = document.getElementById('pasteMenuItem'); - + // Show paste option only if clipboard has something and target is a directory if (clipboard && node.type === 'directory') { pasteItem.style.display = 'flex'; } else { pasteItem.style.display = 'none'; } - + menu.style.display = 'block'; menu.style.left = e.pageX + 'px'; menu.style.top = e.pageY + 'px'; - + document.addEventListener('click', hideContextMenu); } @@ -525,20 +524,20 @@ try { const response = await fetch(`/api/file?path=${encodeURIComponent(path)}`); if (!response.ok) throw new Error('Failed to load file'); - + const data = await response.json(); currentFile = data.filename; currentFilePath = path; - + document.getElementById('filenameInput').value = path; editor.setValue(data.content); updatePreview(); - + document.querySelectorAll('.tree-node').forEach(node => { node.classList.remove('active'); }); document.querySelector(`[data-path="${path}"]`)?.classList.add('active'); - + showNotification(`Loaded ${data.filename}`, 'info'); } catch (error) { console.error('Error loading file:', error); @@ -548,27 +547,27 @@ async function saveFile() { const path = document.getElementById('filenameInput').value.trim(); - + if (!path) { showNotification('Please enter a filename', 'warning'); return; } - + const content = editor.getValue(); - + try { const response = await fetch('/api/file', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path, content }) }); - + if (!response.ok) throw new Error('Failed to save file'); - + const result = await response.json(); currentFile = path.split('/').pop(); currentFilePath = result.path; - + showNotification(`Saved ${currentFile}`, 'success'); loadFileTree(); } catch (error) { @@ -582,26 +581,26 @@ showNotification('No file selected', 'warning'); return; } - + if (!confirm(`Are you sure you want to delete ${currentFile}?`)) { return; } - + try { const response = await fetch(`/api/file?path=${encodeURIComponent(currentFilePath)}`, { method: 'DELETE' }); - + if (!response.ok) throw new Error('Failed to delete file'); - + showNotification(`Deleted ${currentFile}`, 'success'); - + currentFile = null; currentFilePath = null; document.getElementById('filenameInput').value = ''; editor.setValue(''); updatePreview(); - + loadFileTree(); } catch (error) { console.error('Error deleting file:', error); @@ -617,27 +616,27 @@ document.getElementById('filenameInput').focus(); editor.setValue(''); updatePreview(); - + document.querySelectorAll('.tree-node').forEach(node => { node.classList.remove('active'); }); - + showNotification('Enter filename and start typing', 'info'); } async function createFolder() { const folderName = prompt('Enter folder name:'); if (!folderName) return; - + try { const response = await fetch('/api/directory', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: folderName }) }); - + if (!response.ok) throw new Error('Failed to create folder'); - + showNotification(`Created folder ${folderName}`, 'success'); loadFileTree(); } catch (error) { @@ -652,32 +651,32 @@ async function handleContextMenuAction(action) { if (!contextMenuTarget) return; - + switch (action) { case 'open': if (contextMenuTarget.type === 'file') { loadFile(contextMenuTarget.path); } break; - + case 'rename': await renameItem(); break; - + case 'copy': clipboard = { ...contextMenuTarget, operation: 'copy' }; showNotification(`Copied ${contextMenuTarget.name}`, 'info'); break; - + case 'move': clipboard = { ...contextMenuTarget, operation: 'move' }; showNotification(`Cut ${contextMenuTarget.name}`, 'info'); break; - + case 'paste': await pasteItem(); break; - + case 'delete': await deleteItem(); break; @@ -687,10 +686,10 @@ async function renameItem() { const newName = prompt(`Rename ${contextMenuTarget.name}:`, contextMenuTarget.name); if (!newName || newName === contextMenuTarget.name) return; - + const oldPath = contextMenuTarget.path; const newPath = oldPath.substring(0, oldPath.lastIndexOf('/') + 1) + newName; - + try { const endpoint = contextMenuTarget.type === 'directory' ? '/api/directory/rename' : '/api/file/rename'; const response = await fetch(endpoint, { @@ -701,9 +700,9 @@ new_path: newPath }) }); - + if (!response.ok) throw new Error('Rename failed'); - + showNotification(`Renamed to ${newName}`, 'success'); loadFileTree(); } catch (error) { @@ -714,12 +713,12 @@ async function pasteItem() { if (!clipboard) return; - + const destDir = contextMenuTarget.path; const sourcePath = clipboard.path; const fileName = clipboard.name; const destPath = destDir + '/' + fileName; - + try { if (clipboard.operation === 'copy') { // Copy operation @@ -731,7 +730,7 @@ destination: destPath }) }); - + if (!response.ok) throw new Error('Copy failed'); showNotification(`Copied ${fileName} to ${contextMenuTarget.name}`, 'success'); } else if (clipboard.operation === 'move') { @@ -744,12 +743,12 @@ destination: destPath }) }); - + if (!response.ok) throw new Error('Move failed'); showNotification(`Moved ${fileName} to ${contextMenuTarget.name}`, 'success'); clipboard = null; // Clear clipboard after move } - + loadFileTree(); } catch (error) { console.error('Error pasting:', error); @@ -761,7 +760,7 @@ if (!confirm(`Are you sure you want to delete ${contextMenuTarget.name}?`)) { return; } - + try { let response; if (contextMenuTarget.type === 'directory') { @@ -773,9 +772,9 @@ method: 'DELETE' }); } - + if (!response.ok) throw new Error('Delete failed'); - + showNotification(`Deleted ${contextMenuTarget.name}`, 'success'); loadFileTree(); } catch (error) { @@ -793,7 +792,7 @@ if (!toastContainer) { toastContainer = createToastContainer(); } - + const toast = document.createElement('div'); toast.className = `toast align-items-center text-white bg-${type} border-0`; toast.setAttribute('role', 'alert'); @@ -803,12 +802,12 @@
`; - + toastContainer.appendChild(toast); - + const bsToast = new bootstrap.Toast(toast, { delay: 3000 }); bsToast.show(); - + toast.addEventListener('hidden.bs.toast', () => { toast.remove(); }); @@ -831,13 +830,13 @@ initDarkMode(); initEditor(); loadFileTree(); - + document.getElementById('saveBtn').addEventListener('click', saveFile); document.getElementById('deleteBtn').addEventListener('click', deleteFile); document.getElementById('newFileBtn').addEventListener('click', newFile); document.getElementById('newFolderBtn').addEventListener('click', createFolder); document.getElementById('darkModeToggle').addEventListener('click', toggleDarkMode); - + // Context menu actions document.querySelectorAll('.context-menu-item').forEach(item => { item.addEventListener('click', () => { @@ -846,14 +845,14 @@ hideContextMenu(); }); }); - + document.addEventListener('keydown', (e) => { if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); saveFile(); } }); - + console.log('Markdown Editor with File Tree initialized'); } diff --git a/static/app.js b/static/app.js index 7f18ca4..c31a82f 100644 --- a/static/app.js +++ b/static/app.js @@ -1,5 +1,5 @@ // Markdown Editor Application -(function() { +(function () { 'use strict'; // State management @@ -21,16 +21,16 @@ function enableDarkMode() { isDarkMode = true; document.body.classList.add('dark-mode'); - document.getElementById('darkModeIcon').textContent = '☀️'; + document.getElementById('darkModeIcon').innerHTML = ''; localStorage.setItem('darkMode', 'true'); - + // Update mermaid theme - mermaid.initialize({ + mermaid.initialize({ startOnLoad: false, theme: 'dark', securityLevel: 'loose' }); - + // Re-render preview if there's content if (editor && editor.getValue()) { updatePreview(); @@ -40,16 +40,16 @@ 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 - mermaid.initialize({ + mermaid.initialize({ startOnLoad: false, theme: 'default', securityLevel: 'loose' }); - + // Re-render preview if there's content if (editor && editor.getValue()) { updatePreview(); @@ -65,7 +65,7 @@ } // Initialize Mermaid - mermaid.initialize({ + mermaid.initialize({ startOnLoad: false, theme: 'default', securityLevel: 'loose' @@ -87,15 +87,15 @@ async function uploadImage(file) { const formData = new FormData(); formData.append('file', file); - + try { const response = await fetch('/api/upload-image', { method: 'POST', body: formData }); - + if (!response.ok) throw new Error('Upload failed'); - + const result = await response.json(); return result.url; } catch (error) { @@ -108,48 +108,48 @@ // Handle drag and drop function setupDragAndDrop() { const editorElement = document.querySelector('.CodeMirror'); - + // Prevent default drag behavior ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => { editorElement.addEventListener(eventName, preventDefaults, false); }); - + function preventDefaults(e) { e.preventDefault(); e.stopPropagation(); } - + // Highlight drop zone ['dragenter', 'dragover'].forEach(eventName => { editorElement.addEventListener(eventName, () => { editorElement.classList.add('drag-over'); }, false); }); - + ['dragleave', 'drop'].forEach(eventName => { editorElement.addEventListener(eventName, () => { editorElement.classList.remove('drag-over'); }, false); }); - + // Handle drop editorElement.addEventListener('drop', async (e) => { const files = e.dataTransfer.files; - + if (files.length === 0) return; - + // Filter for images only - const imageFiles = Array.from(files).filter(file => + const imageFiles = Array.from(files).filter(file => file.type.startsWith('image/') ); - + if (imageFiles.length === 0) { showNotification('Please drop image files only', 'warning'); return; } - + showNotification(`Uploading ${imageFiles.length} image(s)...`, 'info'); - + // Upload images for (const file of imageFiles) { const url = await uploadImage(file); @@ -163,12 +163,12 @@ } } }, false); - + // Also handle paste events for images editorElement.addEventListener('paste', async (e) => { const items = e.clipboardData?.items; if (!items) return; - + for (const item of items) { if (item.type.startsWith('image/')) { e.preventDefault(); @@ -198,17 +198,17 @@ lineWrapping: true, autofocus: true, extraKeys: { - 'Ctrl-S': function() { saveFile(); }, - 'Cmd-S': function() { saveFile(); } + 'Ctrl-S': function () { saveFile(); }, + 'Cmd-S': function () { saveFile(); } } }); // Update preview on change editor.on('change', debounce(updatePreview, 300)); - + // Setup drag and drop after editor is ready setTimeout(setupDragAndDrop, 100); - + // Sync scroll editor.on('scroll', handleEditorScroll); } @@ -230,7 +230,7 @@ async function updatePreview() { const content = editor.getValue(); const previewDiv = document.getElementById('preview'); - + if (!content.trim()) { previewDiv.innerHTML = `
@@ -244,15 +244,15 @@ try { // Parse markdown to HTML let html = marked.parse(content); - + // Replace mermaid code blocks with div containers html = html.replace( /
([\s\S]*?)<\/code><\/pre>/g,
                 '
$1
' ); - + previewDiv.innerHTML = html; - + // Apply syntax highlighting to code blocks const codeBlocks = previewDiv.querySelectorAll('pre code'); codeBlocks.forEach(block => { @@ -262,7 +262,7 @@ Prism.highlightElement(block); } }); - + // Render mermaid diagrams const mermaidElements = previewDiv.querySelectorAll('.mermaid'); if (mermaidElements.length > 0) { @@ -288,15 +288,15 @@ // Handle editor scroll for synchronized scrolling function handleEditorScroll() { if (!isScrollingSynced) return; - + clearTimeout(scrollTimeout); scrollTimeout = setTimeout(() => { const editorScrollInfo = editor.getScrollInfo(); const editorScrollPercentage = editorScrollInfo.top / (editorScrollInfo.height - editorScrollInfo.clientHeight); - + const previewPane = document.querySelector('.preview-pane'); const previewScrollHeight = previewPane.scrollHeight - previewPane.clientHeight; - + if (previewScrollHeight > 0) { previewPane.scrollTop = editorScrollPercentage * previewScrollHeight; } @@ -308,22 +308,22 @@ try { const response = await fetch('/api/files'); if (!response.ok) throw new Error('Failed to load file list'); - + const files = await response.json(); const fileListDiv = document.getElementById('fileList'); - + if (files.length === 0) { fileListDiv.innerHTML = '
No files yet
'; return; } - + fileListDiv.innerHTML = files.map(file => ` ${file.filename} ${formatFileSize(file.size)} `).join(''); - + // Add click handlers document.querySelectorAll('.file-item').forEach(item => { item.addEventListener('click', (e) => { @@ -343,19 +343,19 @@ try { const response = await fetch(`/api/files/${filename}`); if (!response.ok) throw new Error('Failed to load file'); - + const data = await response.json(); currentFile = data.filename; - + // Update UI document.getElementById('filenameInput').value = data.filename; editor.setValue(data.content); - + // Update active state in file list document.querySelectorAll('.file-item').forEach(item => { item.classList.toggle('active', item.dataset.filename === filename); }); - + updatePreview(); showNotification(`Loaded ${filename}`, 'success'); } catch (error) { @@ -367,14 +367,14 @@ // Save current file async function saveFile() { const filename = document.getElementById('filenameInput').value.trim(); - + if (!filename) { showNotification('Please enter a filename', 'warning'); return; } - + const content = editor.getValue(); - + try { const response = await fetch('/api/files', { method: 'POST', @@ -383,12 +383,12 @@ }, body: JSON.stringify({ filename, content }) }); - + if (!response.ok) throw new Error('Failed to save file'); - + const result = await response.json(); currentFile = result.filename; - + showNotification(`Saved ${result.filename}`, 'success'); loadFileList(); } catch (error) { @@ -400,31 +400,31 @@ // Delete current file async function deleteFile() { const filename = document.getElementById('filenameInput').value.trim(); - + if (!filename) { showNotification('No file selected', 'warning'); return; } - + if (!confirm(`Are you sure you want to delete ${filename}?`)) { return; } - + try { const response = await fetch(`/api/files/${filename}`, { method: 'DELETE' }); - + if (!response.ok) throw new Error('Failed to delete file'); - + showNotification(`Deleted ${filename}`, 'success'); - + // Clear editor currentFile = null; document.getElementById('filenameInput').value = ''; editor.setValue(''); updatePreview(); - + loadFileList(); } catch (error) { console.error('Error deleting file:', error); @@ -438,12 +438,12 @@ document.getElementById('filenameInput').value = ''; editor.setValue(''); updatePreview(); - + // Remove active state from all file items document.querySelectorAll('.file-item').forEach(item => { item.classList.remove('active'); }); - + showNotification('New file created', 'info'); } @@ -460,25 +460,25 @@ function showNotification(message, type = 'info') { // Create toast notification const toastContainer = document.getElementById('toastContainer') || createToastContainer(); - + const toast = document.createElement('div'); toast.className = `toast align-items-center text-white bg-${type} border-0`; toast.setAttribute('role', 'alert'); toast.setAttribute('aria-live', 'assertive'); toast.setAttribute('aria-atomic', 'true'); - + toast.innerHTML = `
${message}
`; - + toastContainer.appendChild(toast); - + const bsToast = new bootstrap.Toast(toast, { delay: 3000 }); bsToast.show(); - + toast.addEventListener('hidden.bs.toast', () => { toast.remove(); }); @@ -499,13 +499,13 @@ initDarkMode(); initEditor(); loadFileList(); - + // Set up event listeners document.getElementById('saveBtn').addEventListener('click', saveFile); document.getElementById('deleteBtn').addEventListener('click', deleteFile); document.getElementById('newFileBtn').addEventListener('click', newFile); document.getElementById('darkModeToggle').addEventListener('click', toggleDarkMode); - + // Keyboard shortcuts document.addEventListener('keydown', (e) => { if ((e.ctrlKey || e.metaKey) && e.key === 's') { @@ -513,7 +513,7 @@ saveFile(); } }); - + console.log('Markdown Editor initialized'); } diff --git a/static/css/components.css b/static/css/components.css index 1caaf93..f524953 100644 --- a/static/css/components.css +++ b/static/css/components.css @@ -2,10 +2,21 @@ .preview-pane { font-size: 16px; line-height: 1.6; + color: var(--text-primary); + background-color: var(--bg-primary); } -.preview-pane h1, .preview-pane h2, .preview-pane h3, -.preview-pane h4, .preview-pane h5, .preview-pane h6 { +#preview { + color: var(--text-primary); + background-color: var(--bg-primary); +} + +.preview-pane h1, +.preview-pane h2, +.preview-pane h3, +.preview-pane h4, +.preview-pane h5, +.preview-pane h6 { margin-top: 24px; margin-bottom: 16px; font-weight: 600; @@ -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; @@ -205,4 +227,227 @@ body.dark-mode .modal-footer { color: var(--text-primary); border-color: var(--link-color); box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25); +} + +/* Directory Preview Styles */ +.directory-preview { + padding: 20px; +} + +.directory-preview h2 { + margin-bottom: 20px; + /* color: var(--text-primary); */ +} + +.directory-files { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + gap: 16px; + margin-top: 20px; +} + +.file-card { + background-color: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 16px; + cursor: pointer; + transition: all 0.2s ease; +} + +.file-card:hover { + background-color: var(--bg-secondary); + border-color: var(--link-color); + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +} + +.file-card-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; +} + +.file-card-header i { + color: var(--link-color); + font-size: 18px; +} + +.file-card-name { + font-weight: 500; + color: var(--text-primary); + word-break: break-word; +} + +.file-card-description { + font-size: 13px; + color: var(--text-secondary); + line-height: 1.4; + margin-top: 8px; +} + +/* 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; } \ No newline at end of file diff --git a/static/css/editor.css b/static/css/editor.css index 6ee84a6..8ba1d70 100644 --- a/static/css/editor.css +++ b/static/css/editor.css @@ -6,6 +6,8 @@ display: flex; gap: 10px; align-items: center; + flex-shrink: 0; + /* Prevent header from shrinking */ } .editor-header input { @@ -19,18 +21,42 @@ .editor-container { flex: 1; + /* Take remaining space */ overflow: hidden; + /* Prevent container overflow, CodeMirror handles its own scrolling */ + display: flex; + flex-direction: column; + min-height: 0; + /* Important: allows flex child to shrink below content size */ + position: relative; +} + +#editor { + flex: 1; + /* Take all available space */ + min-height: 0; + /* Allow shrinking */ + overflow: hidden; + /* CodeMirror will handle scrolling */ } /* CodeMirror customization */ .CodeMirror { - height: 100%; + height: 100% !important; + /* Force full height */ font-family: 'Consolas', 'Monaco', 'Courier New', monospace; font-size: 14px; background-color: var(--bg-primary); color: var(--text-primary); } +.CodeMirror-scroll { + overflow-y: auto !important; + /* Ensure vertical scrolling is enabled */ + overflow-x: auto !important; + /* Ensure horizontal scrolling is enabled */ +} + body.dark-mode .CodeMirror { background-color: #1c2128; color: #e6edf3; @@ -71,5 +97,4 @@ body.dark-mode .CodeMirror-gutters { color: var(--info-color); pointer-events: none; z-index: 1000; -} - +} \ No newline at end of file diff --git a/static/css/file-tree.css b/static/css/file-tree.css index 13cbf87..ab5844e 100644 --- a/static/css/file-tree.css +++ b/static/css/file-tree.css @@ -20,8 +20,9 @@ color: var(--text-primary); transition: all 0.15s ease; white-space: nowrap; - overflow: hidden; + overflow: visible; text-overflow: ellipsis; + min-height: 28px; } .tree-node:hover { @@ -29,14 +30,16 @@ } .tree-node.active { - background-color: var(--link-color); - color: white; + color: var(--link-color); font-weight: 500; } .tree-node.active:hover { - background-color: var(--link-color); - filter: brightness(1.1); + filter: brightness(1.2); +} + +.tree-node.active .tree-node-icon { + color: var(--link-color); } /* Toggle arrow */ @@ -46,16 +49,25 @@ justify-content: center; width: 16px; height: 16px; - font-size: 10px; + min-width: 16px; + min-height: 16px; color: var(--text-secondary); flex-shrink: 0; transition: transform 0.2s ease; + position: relative; + z-index: 1; + overflow: visible; + cursor: pointer; } .tree-node-toggle.expanded { transform: rotate(90deg); } +.tree-node-toggle:hover { + color: var(--link-color); +} + /* Icon styling */ .tree-node-icon { width: 16px; @@ -67,10 +79,6 @@ color: var(--text-secondary); } -.tree-node.active .tree-node-icon { - color: white; -} - /* Content wrapper */ .tree-node-content { display: flex; @@ -112,13 +120,54 @@ } /* Drag and drop */ +/* Default cursor is pointer, not grab (only show grab after long-press) */ +.tree-node { + cursor: pointer; +} + +/* Show grab cursor only when drag is ready (after long-press) */ +.tree-node.drag-ready { + cursor: grab !important; +} + +.tree-node.drag-ready:active { + cursor: grabbing !important; +} + .tree-node.dragging { - opacity: 0.5; + opacity: 0.4; + background-color: var(--bg-tertiary); + cursor: grabbing !important; } .tree-node.drag-over { - background-color: rgba(13, 110, 253, 0.2); - border: 1px dashed var(--link-color); + background-color: rgba(13, 110, 253, 0.15) !important; + border: 2px dashed var(--link-color) !important; + box-shadow: 0 0 8px rgba(13, 110, 253, 0.3); +} + +/* Root-level drop target highlighting */ +.file-tree.drag-over-root { + background-color: rgba(13, 110, 253, 0.08); + border: 2px dashed var(--link-color); + border-radius: 6px; + box-shadow: inset 0 0 12px rgba(13, 110, 253, 0.2); + margin: 4px; + padding: 4px; +} + +/* Only show drag cursor on directories when dragging */ +body.dragging-active .tree-node[data-isdir="true"] { + cursor: copy; +} + +body.dragging-active .tree-node[data-isdir="false"] { + cursor: no-drop; +} + +/* Show move cursor when hovering over root-level empty space */ +body.dragging-active .file-tree.drag-over-root { + cursor: move; } /* Collection selector - Bootstrap styled */ @@ -156,13 +205,34 @@ body.dark-mode .tree-node:hover { } body.dark-mode .tree-node.active { - background-color: var(--link-color); + color: var(--link-color); +} + +body.dark-mode .tree-node.active .tree-node-icon { + color: var(--link-color); +} + +body.dark-mode .tree-node.active .tree-node-icon .tree-node-toggle { + color: var(--link-color); } body.dark-mode .tree-children { border-left-color: var(--border-color); } +/* Empty directory message */ +.tree-empty-message { + padding: 8px 12px; + color: var(--text-secondary); + font-size: 12px; + font-style: italic; + user-select: none; +} + +body.dark-mode .tree-empty-message { + color: var(--text-secondary); +} + /* Scrollbar in sidebar */ .sidebar::-webkit-scrollbar-thumb { background-color: var(--border-color); @@ -170,4 +240,14 @@ 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; } \ No newline at end of file diff --git a/static/css/layout.css b/static/css/layout.css index 08ba397..192d5ed 100644 --- a/static/css/layout.css +++ b/static/css/layout.css @@ -1,14 +1,22 @@ /* Base layout styles */ -html, body { - height: 100%; +html, +body { + height: 100vh; margin: 0; padding: 0; + overflow: hidden; + /* Prevent page-level scrolling */ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif; background-color: var(--bg-primary); color: var(--text-primary); transition: background-color 0.3s ease, color 0.3s ease; } +body { + display: flex; + flex-direction: column; +} + /* Column Resizer */ .column-resizer { width: 1px; @@ -17,14 +25,21 @@ html, body { transition: background-color 0.2s ease, width 0.2s ease, box-shadow 0.2s ease; user-select: none; flex-shrink: 0; - padding: 0 3px; /* Add invisible padding for easier grab */ - margin: 0 -3px; /* Compensate for padding */ + padding: 0 3px; + /* Add invisible padding for easier grab */ + margin: 0 -3px; + /* Compensate for padding */ + height: 100%; + /* Take full height of parent */ + align-self: stretch; + /* Ensure it stretches to full height */ } .column-resizer:hover { background-color: var(--link-color); width: 1px; - box-shadow: 0 0 6px rgba(13, 110, 253, 0.3); /* Visual feedback instead of width change */ + box-shadow: 0 0 6px rgba(13, 110, 253, 0.3); + /* Visual feedback instead of width change */ } .column-resizer.dragging { @@ -36,12 +51,59 @@ html, body { background-color: var(--link-color); } -/* Adjust container for flex layout */ -.container-fluid { +/* Navbar */ +.navbar { + background-color: var(--bg-secondary); + border-bottom: 1px solid var(--border-color); + transition: background-color 0.3s ease; + flex-shrink: 0; + /* Prevent navbar from shrinking */ + padding: 0.5rem 1rem; +} + +.navbar .container-fluid { display: flex; flex-direction: row; - height: calc(100% - 56px); + align-items: center; + justify-content: space-between; padding: 0; + overflow: visible; + /* Override the hidden overflow for navbar */ +} + +.navbar-brand { + color: var(--text-primary) !important; + font-weight: 600; + font-size: 1.1rem; + margin: 0; + flex-shrink: 0; +} + +.navbar-brand i { + font-size: 1.2rem; + margin-right: 0.5rem; +} + +.navbar-center { + flex: 1; + display: flex; + justify-content: center; + align-items: center; +} + +.navbar-right { + flex-shrink: 0; +} + +/* Adjust container for flex layout */ +.container-fluid { + flex: 1; + /* Take remaining space after navbar */ + padding: 0; + overflow: hidden; + /* Prevent container scrolling */ + display: flex; + flex-direction: column; } .row { @@ -50,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%; } \ No newline at end of file diff --git a/static/js/app.js b/static/js/app.js index c608778..5660ffe 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -12,100 +12,628 @@ 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 = ` +
+

No content available

+
+ `; } + } catch (error) { + console.error('Failed to auto-load page in view mode:', error); + editor.previewElement.innerHTML = ` +
+

Failed to load content

+
+ `; } -}; -window.eventBus = eventBus; +} + +/** + * Show directory preview with list of files + * @param {string} dirPath - The directory path + */ +async function showDirectoryPreview(dirPath) { + if (!editor || !fileTree || !webdavClient) return; + + try { + const dirName = dirPath.split('/').pop() || dirPath; + const files = fileTree.getDirectoryFiles(dirPath); + + // Start building the preview HTML + let html = `
`; + html += `

${dirName}

`; + + if (files.length === 0) { + html += `

This directory is empty

`; + } else { + html += `
`; + + // Create cards for each file + for (const file of files) { + const fileName = file.name; + let fileDescription = ''; + + // Try to get file description from markdown files + if (file.name.endsWith('.md')) { + try { + const content = await webdavClient.get(file.path); + // Extract first heading or first line as description + const lines = content.split('\n'); + for (const line of lines) { + if (line.trim().startsWith('#')) { + fileDescription = line.replace(/^#+\s*/, '').trim(); + break; + } else if (line.trim() && !line.startsWith('---')) { + fileDescription = line.trim().substring(0, 100); + break; + } + } + } catch (error) { + console.error('Failed to read file description:', error); + } + } + + html += ` +
+
+ + ${fileName} +
+ ${fileDescription ? `
${fileDescription}
` : ''} +
+ `; + } + + html += `
`; + } + + html += `
`; + + // Set the preview content + editor.previewElement.innerHTML = html; + + // Add click handlers to file cards + editor.previewElement.querySelectorAll('.file-card').forEach(card => { + card.addEventListener('click', async () => { + const filePath = card.dataset.path; + await editor.loadFile(filePath); + fileTree.selectAndExpandPath(filePath); + }); + }); + } catch (error) { + console.error('Failed to show directory preview:', error); + editor.previewElement.innerHTML = ` +
+

Failed to load directory preview

+
+ `; + } +} + +/** + * Parse URL to extract collection and file path + * URL format: // or /// + * @returns {Object} {collection, filePath} or {collection, null} if only collection + */ +function parseURLPath() { + const pathname = window.location.pathname; + const parts = pathname.split('/').filter(p => p); // Remove empty parts + + if (parts.length === 0) { + return { collection: null, filePath: null }; + } + + const collection = parts[0]; + const filePath = parts.length > 1 ? parts.slice(1).join('/') : null; + + return { collection, filePath }; +} + +/** + * Update URL based on current collection and file + * @param {string} collection - The collection name + * @param {string} filePath - The file path (optional) + * @param {boolean} isEditMode - Whether in edit mode + */ +function updateURL(collection, filePath, isEditMode) { + let url = `/${collection}`; + if (filePath) { + url += `/${filePath}`; + } + if (isEditMode) { + url += '?edit=true'; + } + + // Use pushState to update URL without reloading + window.history.pushState({ collection, filePath }, '', url); +} + +/** + * Load file from URL path + * Assumes the collection is already set and file tree is loaded + * @param {string} collection - The collection name (for validation) + * @param {string} filePath - The file path + */ +async function loadFileFromURL(collection, filePath) { + console.log('[loadFileFromURL] Called with:', { collection, filePath }); + + if (!fileTree || !editor || !collectionSelector) { + console.error('[loadFileFromURL] Missing dependencies:', { fileTree: !!fileTree, editor: !!editor, collectionSelector: !!collectionSelector }); + return; + } + + try { + // Verify we're on the right collection + const currentCollection = collectionSelector.getCurrentCollection(); + if (currentCollection !== collection) { + console.error(`[loadFileFromURL] Collection mismatch: expected ${collection}, got ${currentCollection}`); + return; + } + + // Load the file or directory + if (filePath) { + // Check if the path is a directory or a file + const node = fileTree.findNode(filePath); + console.log('[loadFileFromURL] Found node:', node); + + if (node && node.isDirectory) { + // It's a directory, show directory preview + console.log('[loadFileFromURL] Loading directory preview'); + await showDirectoryPreview(filePath); + fileTree.selectAndExpandPath(filePath); + } else if (node) { + // It's a file, 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/'); - + // Initialize dark mode darkMode = new DarkMode(); document.getElementById('darkModeBtn').addEventListener('click', () => { darkMode.toggle(); }); - - // Initialize file tree - fileTree = new FileTree('fileTree', webdavClient); - fileTree.onFileSelect = async (item) => { - await editor.loadFile(item.path); - }; - - // Initialize collection selector + + // Initialize sidebar toggle + const sidebarToggle = new SidebarToggle('sidebarPane', 'sidebarToggleBtn'); + + // 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); - // Add test content to verify preview works - setTimeout(() => { - if (!editor.editor.getValue()) { - editor.editor.setValue('# Welcome to Markdown Editor\n\nStart typing to see preview...\n'); - editor.updatePreview(); - } - }, 200); - - // Setup editor drop handler - const editorDropHandler = new EditorDropHandler( - document.querySelector('.editor-container'), - async (file) => { - await handleEditorFileDrop(file); - } - ); - - // Setup button handlers - document.getElementById('newBtn').addEventListener('click', () => { - editor.newFile(); - }); - - document.getElementById('saveBtn').addEventListener('click', async () => { - await editor.save(); - }); - - document.getElementById('deleteBtn').addEventListener('click', async () => { - await editor.deleteFile(); - }); - - // Setup context menu handlers - setupContextMenuHandlers(); - - // Initialize mermaid - mermaid.initialize({ startOnLoad: true, theme: darkMode.isDark ? 'dark' : 'default' }); + // Initialize file tree (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(); - // Initialize file tree actions manager - window.fileTreeActions = new FileTreeActions(webdavClient, fileTree, editor); + // 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 = ` +
+

${fileName}

+

Image Preview (Read-only)

+ ${fileName} +
+ `; + } else if (fileType === 'PDF') { + // Preview PDFs + previewHtml = ` +
+

${fileName}

+

PDF Preview (Read-only)

+ +
+ `; + } else { + // For other binary files, show download link + previewHtml = ` +
+

${fileName}

+

${fileType} File (Read-only)

+

This file cannot be previewed in the browser.

+ Download ${fileName} +
+ `; + } + + // 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()) { + editor.editor.setValue('# Welcome to Markdown Editor\n\nStart typing to see preview...\n'); + editor.updatePreview(); + } + }, 200); + + // Setup editor drop handler + const editorDropHandler = new EditorDropHandler( + document.querySelector('.editor-container'), + async (file) => { + try { + await handleEditorFileDrop(file); + } catch (error) { + Logger.error('Failed to handle file drop:', error); + } + } + ); + + // Setup button handlers + document.getElementById('newBtn').addEventListener('click', () => { + editor.newFile(); + }); + + document.getElementById('saveBtn').addEventListener('click', async () => { + try { + await editor.save(); + } catch (error) { + Logger.error('Failed to save file:', error); + if (window.showNotification) { + window.showNotification('Failed to save file', 'error'); + } + } + }); + + document.getElementById('deleteBtn').addEventListener('click', async () => { + try { + await editor.deleteFile(); + } catch (error) { + Logger.error('Failed to delete file:', error); + if (window.showNotification) { + window.showNotification('Failed to delete file', 'error'); + } + } + }); + + // Setup context menu handlers + setupContextMenuHandlers(); + + // Initialize file tree actions manager + window.fileTreeActions = new FileTreeActions(webdavClient, fileTree, editor); + + // 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) => { - if (fileTree) { - await fileTree.load(); - fileTree.selectNode(path); + try { + if (fileTree) { + await fileTree.load(); + fileTree.selectNode(path); + } + } catch (error) { + Logger.error('Failed to reload file tree after save:', error); } }); window.eventBus.on('file-deleted', async () => { - if (fileTree) { - await fileTree.load(); + try { + if (fileTree) { + await fileTree.load(); + } + } catch (error) { + Logger.error('Failed to reload file tree after delete:', error); } }); }); @@ -126,17 +654,17 @@ window.addEventListener('column-resize', () => { */ function setupContextMenuHandlers() { const menu = document.getElementById('contextMenu'); - + menu.addEventListener('click', async (e) => { const item = e.target.closest('.context-menu-item'); if (!item) return; - + const action = item.dataset.action; const targetPath = menu.dataset.targetPath; const isDir = menu.dataset.targetIsDir === 'true'; - + hideContextMenu(); - + await window.fileTreeActions.execute(action, targetPath, isDir); }); } @@ -163,16 +691,17 @@ async function handleEditorFileDrop(file) { parts.pop(); // Remove filename targetDir = parts.join('/'); } - + // Upload file const uploadedPath = await fileTree.uploadFile(targetDir, file); - + // Insert markdown link at cursor + // 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}](/${webdavClient.currentCollection}/${uploadedPath})`; - + const link = isImage + ? `![${file.name}](${uploadedPath})` + : `[${file.name}](${uploadedPath})`; + editor.insertAtCursor(link); showNotification(`Uploaded and inserted link`, 'success'); } catch (error) { diff --git a/static/js/collection-selector.js b/static/js/collection-selector.js new file mode 100644 index 0000000..3c24665 --- /dev/null +++ b/static/js/collection-selector.js @@ -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: // or // + * @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; + diff --git a/static/js/column-resizer.js b/static/js/column-resizer.js index c00ef06..f571eec 100644 --- a/static/js/column-resizer.js +++ b/static/js/column-resizer.js @@ -10,68 +10,67 @@ class ColumnResizer { this.sidebarPane = document.getElementById('sidebarPane'); this.editorPane = document.getElementById('editorPane'); this.previewPane = document.getElementById('previewPane'); - + // Load saved dimensions this.loadDimensions(); - + // Setup listeners this.setupResizers(); } - + setupResizers() { this.resizer1.addEventListener('mousedown', (e) => this.startResize(e, 1)); this.resizer2.addEventListener('mousedown', (e) => this.startResize(e, 2)); } - + startResize(e, resizerId) { e.preventDefault(); - + const startX = e.clientX; const startWidth1 = this.sidebarPane.offsetWidth; const startWidth2 = this.editorPane.offsetWidth; const containerWidth = this.sidebarPane.parentElement.offsetWidth; - + const resizer = resizerId === 1 ? this.resizer1 : this.resizer2; resizer.classList.add('dragging'); - + const handleMouseMove = (moveEvent) => { const deltaX = moveEvent.clientX - startX; - + if (resizerId === 1) { // Resize sidebar and editor const newWidth1 = Math.max(150, Math.min(40 * containerWidth / 100, startWidth1 + deltaX)); const newWidth2 = startWidth2 - (newWidth1 - startWidth1); - + this.sidebarPane.style.flex = `0 0 ${newWidth1}px`; this.editorPane.style.flex = `1 1 ${newWidth2}px`; } else if (resizerId === 2) { // Resize editor and preview const newWidth2 = Math.max(250, Math.min(70 * containerWidth / 100, startWidth2 + deltaX)); const containerFlex = this.sidebarPane.offsetWidth; - + this.editorPane.style.flex = `0 0 ${newWidth2}px`; - this.previewPane.style.flex = `1 1 auto`; } }; - + const handleMouseUp = () => { resizer.classList.remove('dragging'); document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); - + // Save dimensions this.saveDimensions(); - + // Trigger editor resize if (window.editor && window.editor.editor) { window.editor.editor.refresh(); } }; - + document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); } - + saveDimensions() { const dimensions = { sidebar: this.sidebarPane.offsetWidth, @@ -80,16 +79,15 @@ class ColumnResizer { }; localStorage.setItem('columnDimensions', JSON.stringify(dimensions)); } - + loadDimensions() { const saved = localStorage.getItem('columnDimensions'); if (!saved) return; - + try { const { sidebar, editor, preview } = JSON.parse(saved); this.sidebarPane.style.flex = `0 0 ${sidebar}px`; this.editorPane.style.flex = `0 0 ${editor}px`; - this.previewPane.style.flex = `1 1 auto`; } catch (error) { console.error('Failed to load column dimensions:', error); } diff --git a/static/js/config.js b/static/js/config.js new file mode 100644 index 0000000..219a6c2 --- /dev/null +++ b/static/js/config.js @@ -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; + diff --git a/static/js/confirmation.js b/static/js/confirmation.js index 6582ac6..1d412ef 100644 --- a/static/js/confirmation.js +++ b/static/js/confirmation.js @@ -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} - Resolves to true if confirmed, false/null if cancelled + */ + confirm(message, title = 'Confirmation', isDangerous = false) { return new Promise((resolve) => { + // Prevent double-opening + if (this.isShowing) { + console.warn('Modal is already showing, ignoring duplicate request'); + resolve(null); + return; + } + + this.isShowing = true; this.currentResolver = resolve; this.titleElement.textContent = title; this.messageElement.textContent = message; + this.inputElement.style.display = 'none'; - if (showInput) { - this.inputElement.style.display = 'block'; - this.inputElement.value = defaultValue; - this.inputElement.focus(); + // Update button styling based on danger level + if (isDangerous) { + this.confirmButton.className = 'btn-flat btn-flat-danger'; + this.confirmButton.innerHTML = ' Delete'; } else { - this.inputElement.style.display = 'none'; + this.confirmButton.className = 'btn-flat btn-flat-primary'; + this.confirmButton.innerHTML = ' 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} - 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 = ' 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 diff --git a/static/js/context-menu.js b/static/js/context-menu.js new file mode 100644 index 0000000..27b8722 --- /dev/null +++ b/static/js/context-menu.js @@ -0,0 +1,89 @@ +/** + * Context Menu Module + * Handles the right-click context menu for file tree items + */ + +/** + * Show context menu at specified position + * @param {number} x - X coordinate + * @param {number} y - Y coordinate + * @param {Object} target - Target object with path and isDir properties + */ +function showContextMenu(x, y, target) { + const menu = document.getElementById('contextMenu'); + if (!menu) return; + + // Store target data + menu.dataset.targetPath = target.path; + menu.dataset.targetIsDir = target.isDir; + + // Show/hide menu items based on target type + const items = { + 'new-file': target.isDir, + 'new-folder': target.isDir, + 'upload': target.isDir, + 'download': true, + 'paste': target.isDir && window.fileTreeActions?.clipboard, + 'open': !target.isDir + }; + + Object.entries(items).forEach(([action, show]) => { + const item = menu.querySelector(`[data-action="${action}"]`); + if (item) { + item.style.display = show ? 'flex' : 'none'; + } + }); + + // Position menu + menu.style.display = 'block'; + menu.style.left = x + 'px'; + menu.style.top = y + 'px'; + + // Adjust if off-screen + setTimeout(() => { + const rect = menu.getBoundingClientRect(); + if (rect.right > window.innerWidth) { + menu.style.left = (window.innerWidth - rect.width - 10) + 'px'; + } + if (rect.bottom > window.innerHeight) { + menu.style.top = (window.innerHeight - rect.height - 10) + 'px'; + } + }, 0); +} + +/** + * Hide the context menu + */ +function hideContextMenu() { + const menu = document.getElementById('contextMenu'); + if (menu) { + menu.style.display = 'none'; + } +} + +// Combined click handler for context menu and outside clicks +document.addEventListener('click', async (e) => { + const menuItem = e.target.closest('.context-menu-item'); + + if (menuItem) { + // Handle context menu item click + const action = menuItem.dataset.action; + const menu = document.getElementById('contextMenu'); + const targetPath = menu.dataset.targetPath; + const isDir = menu.dataset.targetIsDir === 'true'; + + hideContextMenu(); + + if (window.fileTreeActions) { + await window.fileTreeActions.execute(action, targetPath, isDir); + } + } else if (!e.target.closest('#contextMenu') && !e.target.closest('.tree-node')) { + // Hide on outside click + hideContextMenu(); + } +}); + +// Make functions globally available +window.showContextMenu = showContextMenu; +window.hideContextMenu = hideContextMenu; + diff --git a/static/js/dark-mode.js b/static/js/dark-mode.js new file mode 100644 index 0000000..8798467 --- /dev/null +++ b/static/js/dark-mode.js @@ -0,0 +1,77 @@ +/** + * Dark Mode Module + * Manages dark mode theme switching and persistence + */ + +class DarkMode { + constructor() { + this.isDark = localStorage.getItem(Config.STORAGE_KEYS.DARK_MODE) === 'true'; + this.apply(); + } + + /** + * Toggle dark mode on/off + */ + toggle() { + this.isDark = !this.isDark; + localStorage.setItem(Config.STORAGE_KEYS.DARK_MODE, this.isDark); + this.apply(); + + Logger.debug(`Dark mode ${this.isDark ? 'enabled' : 'disabled'}`); + } + + /** + * Apply the current dark mode state + */ + apply() { + if (this.isDark) { + document.body.classList.add('dark-mode'); + const btn = document.getElementById('darkModeBtn'); + if (btn) btn.innerHTML = ''; + + // 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 = ''; + + // Update mermaid theme + if (window.mermaid) { + mermaid.initialize({ theme: Config.MERMAID_THEME_LIGHT }); + } + } + } + + /** + * Check if dark mode is currently enabled + * @returns {boolean} True if dark mode is enabled + */ + isEnabled() { + return this.isDark; + } + + /** + * Enable dark mode + */ + enable() { + if (!this.isDark) { + this.toggle(); + } + } + + /** + * Disable dark mode + */ + disable() { + if (this.isDark) { + this.toggle(); + } + } +} + +// Make DarkMode globally available +window.DarkMode = DarkMode; + diff --git a/static/js/editor-drop-handler.js b/static/js/editor-drop-handler.js new file mode 100644 index 0000000..cb8312f --- /dev/null +++ b/static/js/editor-drop-handler.js @@ -0,0 +1,67 @@ +/** + * Editor Drop Handler Module + * Handles file drops into the editor for uploading + */ + +class EditorDropHandler { + constructor(editorElement, onFileDrop) { + this.editorElement = editorElement; + this.onFileDrop = onFileDrop; + this.setupHandlers(); + } + + /** + * Setup drag and drop event handlers + */ + setupHandlers() { + this.editorElement.addEventListener('dragover', (e) => { + e.preventDefault(); + e.stopPropagation(); + this.editorElement.classList.add('drag-over'); + }); + + this.editorElement.addEventListener('dragleave', (e) => { + e.preventDefault(); + e.stopPropagation(); + this.editorElement.classList.remove('drag-over'); + }); + + this.editorElement.addEventListener('drop', async (e) => { + e.preventDefault(); + e.stopPropagation(); + this.editorElement.classList.remove('drag-over'); + + const files = Array.from(e.dataTransfer.files); + if (files.length === 0) return; + + Logger.debug(`Dropped ${files.length} file(s) into editor`); + + for (const file of files) { + try { + if (this.onFileDrop) { + await this.onFileDrop(file); + } + } catch (error) { + Logger.error('Drop failed:', error); + if (window.showNotification) { + window.showNotification(`Failed to upload ${file.name}`, 'error'); + } + } + } + }); + } + + /** + * Remove event handlers + */ + destroy() { + // Note: We can't easily remove the event listeners without keeping references + // This is a limitation of the current implementation + // In a future refactor, we could store the bound handlers + Logger.debug('EditorDropHandler destroyed'); + } +} + +// Make EditorDropHandler globally available +window.EditorDropHandler = EditorDropHandler; + diff --git a/static/js/editor.js b/static/js/editor.js index c7042ca..d718c48 100644 --- a/static/js/editor.js +++ b/static/js/editor.js @@ -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.initCodeMirror(); + this.lastViewedStorageKey = 'lastViewedPage'; // localStorage key for tracking last viewed page + this.readOnly = readOnly; // Whether editor is in read-only mode + this.editor = null; // Will be initialized later + 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 `
⚠️ Invalid image data detected. Please re-upload the image.
`; + } + + // 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 ``; + }; + 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); @@ -87,28 +203,81 @@ class MarkdownEditor { */ setWebDAVClient(client) { this.webdavClient = client; - + // Update macro processor with client if (this.macroProcessor) { this.macroProcessor.webdavClient = client; } } + /** + * Initialize loading spinners (lazy initialization) + */ + initLoadingSpinners() { + if (!this.editorSpinner && !this.readOnly && this.editorElement) { + this.editorSpinner = new LoadingSpinner(this.editorElement, 'Loading file...'); + } + if (!this.previewSpinner && this.previewElement) { + this.previewSpinner = new LoadingSpinner(this.previewElement, 'Rendering preview...'); + } + } + /** * 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 */ @@ -133,7 +328,7 @@ class MarkdownEditor { try { await this.webdavClient.put(path, content); this.currentFile = path; - + if (window.showNotification) { window.showNotification('✅ Saved', 'success'); } @@ -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,26 +453,33 @@ class MarkdownEditor { } try { - // Step 1: Process macros - let processedContent = markdown; - - if (this.macroProcessor) { - const processingResult = await this.macroProcessor.processMacros(markdown); - processedContent = processingResult.content; - - // Log errors if any - if (processingResult.errors.length > 0) { - console.warn('Macro processing errors:', processingResult.errors); - } + // Initialize loading spinners if not already done + this.initLoadingSpinners(); + + // Show preview loading spinner (only if not already shown by loadFile) + if (this.previewSpinner && !this.previewSpinner.isVisible()) { + this.previewSpinner.show('Rendering preview...'); } - + + // Step 0: Convert JSX-style syntax to HTML + let processedContent = this.convertJSXToHTML(markdown); + + // Step 1: Process macros + if (this.macroProcessor) { + const processingResult = await this.macroProcessor.processMacros(processedContent); + processedContent = processingResult.content; + } + // Step 2: Parse markdown to HTML if (!this.marked) { console.error("Markdown parser (marked) not initialized."); previewDiv.innerHTML = `
Preview engine not loaded.
`; + if (this.previewSpinner) { + this.previewSpinner.hide(); + } return; } - + let html = this.marked.parse(processedContent); // Replace mermaid code blocks @@ -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}
`; + + // 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,9 +557,11 @@ 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); - + const previewHeight = this.previewElement.scrollHeight - this.previewElement.clientHeight; this.previewElement.scrollTop = previewHeight * scrollPercent; } @@ -289,10 +574,10 @@ class MarkdownEditor { const filename = await this.webdavClient.uploadImage(file); const imageUrl = `/fs/${this.webdavClient.currentCollection}/images/${filename}`; const markdown = `![${file.name}](${imageUrl})`; - + // Insert at cursor this.editor.replaceSelection(markdown); - + if (window.showNotification) { window.showNotification('Image uploaded', 'success'); } @@ -310,7 +595,7 @@ class MarkdownEditor { getValue() { return this.editor.getValue(); } - + insertAtCursor(text) { const doc = this.editor.getDoc(); const cursor = doc.getCursor(); @@ -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 diff --git a/static/js/event-bus.js b/static/js/event-bus.js new file mode 100644 index 0000000..5e986c8 --- /dev/null +++ b/static/js/event-bus.js @@ -0,0 +1,126 @@ +/** + * Event Bus Module + * Provides a centralized event system for application-wide communication + * Allows components to communicate without tight coupling + */ + +class EventBus { + constructor() { + /** + * Map of event names to arrays of listener functions + * @type {Object.} + */ + this.listeners = {}; + } + + /** + * Register an event listener + * @param {string} event - The event name to listen for + * @param {Function} callback - The function to call when the event is dispatched + * @returns {Function} A function to unregister this listener + */ + on(event, callback) { + if (!this.listeners[event]) { + this.listeners[event] = []; + } + this.listeners[event].push(callback); + + // Return unsubscribe function + return () => this.off(event, callback); + } + + /** + * Register a one-time event listener + * The listener will be automatically removed after being called once + * @param {string} event - The event name to listen for + * @param {Function} callback - The function to call when the event is dispatched + * @returns {Function} A function to unregister this listener + */ + once(event, callback) { + const onceWrapper = (data) => { + callback(data); + this.off(event, onceWrapper); + }; + return this.on(event, onceWrapper); + } + + /** + * Unregister an event listener + * @param {string} event - The event name + * @param {Function} callback - The callback function to remove + */ + off(event, callback) { + if (!this.listeners[event]) { + return; + } + + this.listeners[event] = this.listeners[event].filter( + listener => listener !== callback + ); + + // Clean up empty listener arrays + if (this.listeners[event].length === 0) { + delete this.listeners[event]; + } + } + + /** + * Dispatch an event to all registered listeners + * @param {string} event - The event name to dispatch + * @param {any} data - The data to pass to the listeners + */ + dispatch(event, data) { + if (!this.listeners[event]) { + return; + } + + // Create a copy of the listeners array to avoid issues if listeners are added/removed during dispatch + const listeners = [...this.listeners[event]]; + + listeners.forEach(callback => { + try { + callback(data); + } catch (error) { + Logger.error(`Error in event listener for "${event}":`, error); + } + }); + } + + /** + * Remove all listeners for a specific event + * If no event is specified, removes all listeners for all events + * @param {string} [event] - The event name (optional) + */ + clear(event) { + if (event) { + delete this.listeners[event]; + } else { + this.listeners = {}; + } + } + + /** + * Get the number of listeners for an event + * @param {string} event - The event name + * @returns {number} The number of listeners + */ + listenerCount(event) { + return this.listeners[event] ? this.listeners[event].length : 0; + } + + /** + * Get all event names that have listeners + * @returns {string[]} Array of event names + */ + eventNames() { + return Object.keys(this.listeners); + } +} + +// Create and export the global event bus instance +const eventBus = new EventBus(); + +// Make it globally available +window.eventBus = eventBus; +window.EventBus = EventBus; + diff --git a/static/js/file-tree-actions.js b/static/js/file-tree-actions.js index 399a1c1..dac99c4 100644 --- a/static/js/file-tree-actions.js +++ b/static/js/file-tree-actions.js @@ -14,32 +14,10 @@ class FileTreeActions { /** * Validate and sanitize filename/folder name * Returns { valid: boolean, sanitized: string, message: string } + * Now uses ValidationUtils from utils.js */ validateFileName(name, isFolder = false) { - const type = isFolder ? 'folder' : 'file'; - - if (!name || name.trim().length === 0) { - return { valid: false, message: `${type} name cannot be empty` }; - } - - // Check for invalid characters - const validPattern = /^[a-z0-9_]+(\.[a-z0-9_]+)*$/; - - if (!validPattern.test(name)) { - const sanitized = name - .toLowerCase() - .replace(/[^a-z0-9_.]/g, '_') - .replace(/_+/g, '_') - .replace(/^_+|_+$/g, ''); - - return { - valid: false, - sanitized, - message: `Invalid characters in ${type} name. Only lowercase letters, numbers, and underscores allowed.\n\nSuggestion: "${sanitized}"` - }; - } - - return { valid: true, sanitized: name, message: '' }; + return ValidationUtils.validateFileName(name, isFolder); } async execute(action, targetPath, isDirectory) { @@ -48,7 +26,7 @@ class FileTreeActions { console.error(`Unknown action: ${action}`); return; } - + try { await handler.call(this, targetPath, isDirectory); } catch (error) { @@ -58,140 +36,234 @@ class FileTreeActions { } actions = { - open: async function(path, isDir) { + open: async function (path, isDir) { if (!isDir) { await this.editor.loadFile(path); } }, - 'new-file': async function(path, isDir) { + 'new-file': async function (path, isDir) { if (!isDir) return; - - await this.showInputDialog('Enter filename (lowercase, underscore only):', 'new_file.md', async (filename) => { - if (!filename) return; - - const validation = this.validateFileName(filename, false); - - if (!validation.valid) { - showNotification(validation.message, 'warning'); - - // Ask if user wants to use sanitized version - if (validation.sanitized) { - if (await this.showConfirmDialog('Use sanitized name?', `${filename} → ${validation.sanitized}`)) { - filename = validation.sanitized; - } else { - return; - } + + const filename = await window.ModalManager.prompt( + 'Enter filename (lowercase, underscore only):', + 'new_file.md', + 'New File' + ); + + if (!filename) return; + + let finalFilename = filename; + const validation = this.validateFileName(filename, false); + + if (!validation.valid) { + showNotification(validation.message, 'warning'); + + // Ask if user wants to use sanitized version + if (validation.sanitized) { + const useSanitized = await window.ModalManager.confirm( + `${filename} → ${validation.sanitized}`, + 'Use sanitized name?', + false + ); + if (useSanitized) { + finalFilename = validation.sanitized; } else { return; } + } else { + return; } - - const fullPath = `${path}/${filename}`.replace(/\/+/g, '/'); - await this.webdavClient.put(fullPath, '# New File\n\n'); - await this.fileTree.load(); - showNotification(`Created ${filename}`, 'success'); - await this.editor.loadFile(fullPath); - }); + } + + const fullPath = `${path}/${finalFilename}`.replace(/\/+/g, '/'); + await this.webdavClient.put(fullPath, '# New File\n\n'); + + // Clear undo history since new file was created + if (this.fileTree.lastMoveOperation) { + this.fileTree.lastMoveOperation = null; + } + + await this.fileTree.load(); + showNotification(`Created ${finalFilename}`, 'success'); + await this.editor.loadFile(fullPath); }, - 'new-folder': async function(path, isDir) { + 'new-folder': async function (path, isDir) { if (!isDir) return; - - await this.showInputDialog('Enter folder name (lowercase, underscore only):', 'new_folder', async (foldername) => { - if (!foldername) return; - - const validation = this.validateFileName(foldername, true); - - if (!validation.valid) { - showNotification(validation.message, 'warning'); - - if (validation.sanitized) { - if (await this.showConfirmDialog('Use sanitized name?', `${foldername} → ${validation.sanitized}`)) { - foldername = validation.sanitized; - } else { - return; - } + + const foldername = await window.ModalManager.prompt( + 'Enter folder name (lowercase, underscore only):', + 'new_folder', + 'New Folder' + ); + + if (!foldername) return; + + let finalFoldername = foldername; + const validation = this.validateFileName(foldername, true); + + if (!validation.valid) { + showNotification(validation.message, 'warning'); + + if (validation.sanitized) { + const useSanitized = await window.ModalManager.confirm( + `${foldername} → ${validation.sanitized}`, + 'Use sanitized name?', + false + ); + if (useSanitized) { + finalFoldername = validation.sanitized; } else { return; } + } else { + return; } - - const fullPath = `${path}/${foldername}`.replace(/\/+/g, '/'); - await this.webdavClient.mkcol(fullPath); - await this.fileTree.load(); - showNotification(`Created folder ${foldername}`, 'success'); - }); + } + + const fullPath = `${path}/${finalFoldername}`.replace(/\/+/g, '/'); + await this.webdavClient.mkcol(fullPath); + + // Clear undo history since new folder was created + if (this.fileTree.lastMoveOperation) { + this.fileTree.lastMoveOperation = null; + } + + await this.fileTree.load(); + showNotification(`Created folder ${finalFoldername}`, 'success'); }, - rename: async function(path, isDir) { + rename: async function (path, isDir) { const oldName = path.split('/').pop(); - const newName = await this.showInputDialog('Rename to:', oldName); + const newName = await window.ModalManager.prompt( + 'Rename to:', + oldName, + 'Rename' + ); + if (newName && newName !== oldName) { const parentPath = path.substring(0, path.lastIndexOf('/')); const newPath = parentPath ? `${parentPath}/${newName}` : newName; await this.webdavClient.move(path, newPath); + + // Clear undo history since manual rename occurred + if (this.fileTree.lastMoveOperation) { + this.fileTree.lastMoveOperation = null; + } + await this.fileTree.load(); showNotification('Renamed', 'success'); } }, - copy: async function(path, isDir) { + copy: async function (path, isDir) { this.clipboard = { path, operation: 'copy', isDirectory: isDir }; - showNotification(`Copied: ${path.split('/').pop()}`, 'info'); + // No notification for copy - it's a quick operation this.updatePasteMenuItem(); }, - cut: async function(path, isDir) { + cut: async function (path, isDir) { this.clipboard = { path, operation: 'cut', isDirectory: isDir }; - showNotification(`Cut: ${path.split('/').pop()}`, 'warning'); + // No notification for cut - it's a quick operation this.updatePasteMenuItem(); }, - paste: async function(targetPath, isDir) { + paste: async function (targetPath, isDir) { if (!this.clipboard || !isDir) return; - + const itemName = this.clipboard.path.split('/').pop(); const destPath = `${targetPath}/${itemName}`.replace(/\/+/g, '/'); - + if (this.clipboard.operation === 'copy') { await this.webdavClient.copy(this.clipboard.path, destPath); - showNotification('Copied', 'success'); + // No notification for paste - file tree updates show the result } else { await this.webdavClient.move(this.clipboard.path, destPath); this.clipboard = null; this.updatePasteMenuItem(); - showNotification('Moved', 'success'); + // No notification for move - file tree updates show the result } - + await this.fileTree.load(); }, - delete: async function(path, isDir) { - 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'); } - - await this.webdavClient.delete(path); - await this.fileTree.load(); - showNotification(`Deleted ${name}`, 'success'); }, - download: async function(path, isDir) { - showNotification('Downloading...', 'info'); - // Implementation here + download: async function (path, isDir) { + Logger.info(`Downloading ${isDir ? 'folder' : 'file'}: ${path}`); + + if (isDir) { + await this.fileTree.downloadFolder(path); + } else { + await this.fileTree.downloadFile(path); + } }, - upload: async function(path, isDir) { + upload: async function (path, isDir) { if (!isDir) return; - + const input = document.createElement('input'); input.type = 'file'; input.multiple = true; - + input.onchange = async (e) => { const files = Array.from(e.target.files); for (const file of files) { @@ -202,156 +274,62 @@ class FileTreeActions { } await this.fileTree.load(); }; - + input.click(); + }, + + 'copy-to-collection': async function (path, isDir) { + // Get list of available collections + const collections = await this.webdavClient.getCollections(); + const currentCollection = this.webdavClient.currentCollection; + + // Filter out current collection + const otherCollections = collections.filter(c => c !== currentCollection); + + if (otherCollections.length === 0) { + showNotification('No other collections available', 'warning'); + return; + } + + // Show collection selection dialog + const targetCollection = await this.showCollectionSelectionDialog( + otherCollections, + `Copy ${PathUtils.getFileName(path)} to collection:` + ); + + if (!targetCollection) return; + + // Copy the file/folder + await this.copyToCollection(path, isDir, currentCollection, targetCollection); + }, + + 'move-to-collection': async function (path, isDir) { + // Get list of available collections + const collections = await this.webdavClient.getCollections(); + const currentCollection = this.webdavClient.currentCollection; + + // Filter out current collection + const otherCollections = collections.filter(c => c !== currentCollection); + + if (otherCollections.length === 0) { + showNotification('No other collections available', 'warning'); + return; + } + + // Show collection selection dialog + const targetCollection = await this.showCollectionSelectionDialog( + otherCollections, + `Move ${PathUtils.getFileName(path)} to collection:` + ); + + if (!targetCollection) return; + + // Move the file/folder + await this.moveToCollection(path, isDir, currentCollection, targetCollection); } }; - // Modern dialog implementations - async showInputDialog(title, placeholder = '', callback) { - return new Promise((resolve) => { - const dialog = this.createInputDialog(title, placeholder); - const input = dialog.querySelector('input'); - const confirmBtn = dialog.querySelector('.btn-primary'); - const cancelBtn = dialog.querySelector('.btn-secondary'); - - const cleanup = (value) => { - const modalInstance = bootstrap.Modal.getInstance(dialog); - if (modalInstance) { - modalInstance.hide(); - } - dialog.remove(); - const backdrop = document.querySelector('.modal-backdrop'); - if (backdrop) backdrop.remove(); - document.body.classList.remove('modal-open'); - resolve(value); - if (callback) callback(value); - }; - - confirmBtn.onclick = () => { - cleanup(input.value.trim()); - }; - - cancelBtn.onclick = () => { - cleanup(null); - }; - - dialog.addEventListener('hidden.bs.modal', () => { - cleanup(null); - }); - - input.onkeypress = (e) => { - if (e.key === 'Enter') confirmBtn.click(); - }; - - document.body.appendChild(dialog); - const modal = new bootstrap.Modal(dialog); - modal.show(); - input.focus(); - input.select(); - }); - } - - async showConfirmDialog(title, message = '', callback) { - return new Promise((resolve) => { - const dialog = this.createConfirmDialog(title, message); - const confirmBtn = dialog.querySelector('.btn-danger'); - const cancelBtn = dialog.querySelector('.btn-secondary'); - - const cleanup = (value) => { - const modalInstance = bootstrap.Modal.getInstance(dialog); - if (modalInstance) { - modalInstance.hide(); - } - dialog.remove(); - const backdrop = document.querySelector('.modal-backdrop'); - if (backdrop) backdrop.remove(); - document.body.classList.remove('modal-open'); - resolve(value); - if (callback) callback(value); - }; - - confirmBtn.onclick = () => { - cleanup(true); - }; - - cancelBtn.onclick = () => { - cleanup(false); - }; - - dialog.addEventListener('hidden.bs.modal', () => { - cleanup(false); - }); - - document.body.appendChild(dialog); - const modal = new bootstrap.Modal(dialog); - modal.show(); - confirmBtn.focus(); - }); - } - - createInputDialog(title, placeholder) { - const backdrop = document.createElement('div'); - backdrop.className = 'modal-backdrop fade show'; - - const dialog = document.createElement('div'); - dialog.className = 'modal fade show d-block'; - dialog.setAttribute('tabindex', '-1'); - dialog.style.display = 'block'; - - dialog.innerHTML = ` - - `; - - document.body.appendChild(backdrop); - return dialog; - } - - createConfirmDialog(title, message) { - const backdrop = document.createElement('div'); - backdrop.className = 'modal-backdrop fade show'; - - const dialog = document.createElement('div'); - dialog.className = 'modal fade show d-block'; - dialog.setAttribute('tabindex', '-1'); - dialog.style.display = 'block'; - - dialog.innerHTML = ` - - `; - - document.body.appendChild(backdrop); - return dialog; - } + // Old deprecated modal methods removed - all modals now use window.ModalManager updatePasteMenuItem() { const pasteItem = document.getElementById('pasteMenuItem'); @@ -359,4 +337,268 @@ class FileTreeActions { pasteItem.style.display = this.clipboard ? 'flex' : 'none'; } } + + /** + * Show a dialog to select a collection + * @param {Array} collections - List of collection names + * @param {string} message - Dialog message + * @returns {Promise} 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 = ` + + `; + + 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); + } } \ No newline at end of file diff --git a/static/js/file-tree.js b/static/js/file-tree.js index 29a3fd6..43e6c9e 100644 --- a/static/js/file-tree.js +++ b/static/js/file-tree.js @@ -4,30 +4,49 @@ */ 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) { this.selectFolder(path); @@ -35,9 +54,19 @@ class FileTree { this.selectFile(path); } }); - - // Context menu + + // Context menu (only in edit mode) this.container.addEventListener('contextmenu', (e) => { + // Check if we're in edit mode + const isEditMode = document.body.classList.contains('edit-mode'); + + // In view mode, disable custom context menu entirely + if (!isEditMode) { + e.preventDefault(); // Prevent default browser context menu + return; // Don't show custom context menu + } + + // Edit mode: show custom context menu const node = e.target.closest('.tree-node'); e.preventDefault(); @@ -51,8 +80,335 @@ class FileTree { window.showContextMenu(e.clientX, e.clientY, { path: '', isDir: true }); } }); + + // Drag and drop event listeners (only in edit mode) + this.setupDragAndDrop(); } - + + setupUndoListener() { + // Listen for Ctrl+Z (Windows/Linux) or Cmd+Z (Mac) + document.addEventListener('keydown', async (e) => { + // Check for Ctrl+Z or Cmd+Z + const isUndo = (e.ctrlKey || e.metaKey) && e.key === 'z'; + + if (isUndo && this.isEditMode() && this.lastMoveOperation) { + e.preventDefault(); + await this.undoLastMove(); + } + }); + } + + async undoLastMove() { + if (!this.lastMoveOperation) { + return; + } + + const { sourcePath, destPath, fileName, isDirectory } = this.lastMoveOperation; + + try { + // Move the item back to its original location + await this.webdavClient.move(destPath, sourcePath); + + // Get the parent folder name for the notification + const sourceParent = PathUtils.getParentPath(sourcePath); + const parentName = sourceParent ? sourceParent + '/' : 'root'; + + // Clear the undo history + this.lastMoveOperation = null; + + // Reload the tree + await this.load(); + + // Re-select the moved item + this.selectAndExpandPath(sourcePath); + + showNotification(`Undo: Moved ${fileName} back to ${parentName}`, 'success'); + } catch (error) { + console.error('Failed to undo move:', error); + showNotification('Failed to undo move: ' + error.message, 'danger'); + } + } + + setupDragAndDrop() { + // Dragover event on container to allow dropping on root level + this.container.addEventListener('dragover', (e) => { + if (!this.isEditMode() || !this.draggedPath) return; + + const node = e.target.closest('.tree-node'); + if (!node) { + // Hovering over empty space (root level) + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + + // Highlight the entire container as a drop target + this.container.classList.add('drag-over-root'); + } + }); + + // Dragleave event on container to remove root-level highlighting + this.container.addEventListener('dragleave', (e) => { + if (!this.isEditMode()) return; + + // Only remove if we're actually leaving the container + // Check if the related target is outside the container + if (!this.container.contains(e.relatedTarget)) { + this.container.classList.remove('drag-over-root'); + } + }); + + // Dragenter event to manage highlighting + this.container.addEventListener('dragenter', (e) => { + if (!this.isEditMode() || !this.draggedPath) return; + + const node = e.target.closest('.tree-node'); + if (!node) { + // Entering empty space + this.container.classList.add('drag-over-root'); + } else { + // Entering a node, remove root highlighting + this.container.classList.remove('drag-over-root'); + } + }); + + // Drop event on container for root level drops + this.container.addEventListener('drop', async (e) => { + if (!this.isEditMode()) return; + + const node = e.target.closest('.tree-node'); + if (!node && this.draggedPath) { + // Dropped on root level + e.preventDefault(); + this.container.classList.remove('drag-over-root'); + await this.handleDrop('', true); + } + }); + } + + isEditMode() { + return document.body.classList.contains('edit-mode'); + } + + setupNodeDragHandlers(nodeElement, node) { + // Dragstart - when user starts dragging + nodeElement.addEventListener('dragstart', (e) => { + this.draggedNode = nodeElement; + this.draggedPath = node.path; + this.draggedIsDir = node.isDirectory; + + nodeElement.classList.add('dragging'); + document.body.classList.add('dragging-active'); + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', node.path); + + // Create a custom drag image with fixed width + const dragImage = nodeElement.cloneNode(true); + dragImage.style.position = 'absolute'; + dragImage.style.top = '-9999px'; + dragImage.style.left = '-9999px'; + dragImage.style.width = `${Config.DRAG_PREVIEW_WIDTH}px`; + dragImage.style.maxWidth = `${Config.DRAG_PREVIEW_WIDTH}px`; + dragImage.style.opacity = Config.DRAG_PREVIEW_OPACITY; + dragImage.style.backgroundColor = 'var(--bg-secondary)'; + dragImage.style.border = '1px solid var(--border-color)'; + dragImage.style.borderRadius = '4px'; + dragImage.style.padding = '4px 8px'; + dragImage.style.whiteSpace = 'nowrap'; + dragImage.style.overflow = 'hidden'; + dragImage.style.textOverflow = 'ellipsis'; + + document.body.appendChild(dragImage); + e.dataTransfer.setDragImage(dragImage, 10, 10); + setTimeout(() => { + if (dragImage.parentNode) { + document.body.removeChild(dragImage); + } + }, 0); + }); + + // Dragend - when drag operation ends + nodeElement.addEventListener('dragend', () => { + nodeElement.classList.remove('dragging'); + nodeElement.classList.remove('drag-ready'); + document.body.classList.remove('dragging-active'); + this.container.classList.remove('drag-over-root'); + this.clearDragOverStates(); + + // Reset draggable state + nodeElement.draggable = false; + nodeElement.style.cursor = ''; + this.isDraggingEnabled = false; + + this.draggedNode = null; + this.draggedPath = null; + this.draggedIsDir = false; + }); + + // Dragover - when dragging over this node + nodeElement.addEventListener('dragover', (e) => { + if (!this.draggedPath) return; + + const targetPath = node.path; + const targetIsDir = node.isDirectory; + + // Only allow dropping on directories + if (!targetIsDir) { + e.dataTransfer.dropEffect = 'none'; + return; + } + + // Check if this is a valid drop target + if (this.isValidDropTarget(this.draggedPath, this.draggedIsDir, targetPath)) { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + nodeElement.classList.add('drag-over'); + } else { + e.dataTransfer.dropEffect = 'none'; + } + }); + + // Dragleave - when drag leaves this node + nodeElement.addEventListener('dragleave', (e) => { + // Only remove if we're actually leaving the node (not entering a child) + if (e.target === nodeElement) { + nodeElement.classList.remove('drag-over'); + + // If leaving a node and not entering another node, might be going to root + const relatedNode = e.relatedTarget?.closest('.tree-node'); + if (!relatedNode && this.container.contains(e.relatedTarget)) { + // Moving to empty space (root area) + this.container.classList.add('drag-over-root'); + } + } + }); + + // Drop - when item is dropped on this node + nodeElement.addEventListener('drop', async (e) => { + e.preventDefault(); + e.stopPropagation(); + + nodeElement.classList.remove('drag-over'); + + if (!this.draggedPath) return; + + const targetPath = node.path; + const targetIsDir = node.isDirectory; + + if (targetIsDir && this.isValidDropTarget(this.draggedPath, this.draggedIsDir, targetPath)) { + await this.handleDrop(targetPath, targetIsDir); + } + }); + } + + clearDragOverStates() { + this.container.querySelectorAll('.drag-over').forEach(node => { + node.classList.remove('drag-over'); + }); + } + + isValidDropTarget(sourcePath, sourceIsDir, targetPath) { + // Can't drop on itself + if (sourcePath === targetPath) { + return false; + } + + // If dragging a directory, can't drop into its own descendants + if (sourceIsDir) { + // Check if target is a descendant of source + if (targetPath.startsWith(sourcePath + '/')) { + return false; + } + } + + // Can't drop into the same parent directory + const sourceParent = PathUtils.getParentPath(sourcePath); + if (sourceParent === targetPath) { + return false; + } + + return true; + } + + async handleDrop(targetPath, targetIsDir) { + if (!this.draggedPath) return; + + try { + const sourcePath = this.draggedPath; + const fileName = PathUtils.getFileName(sourcePath); + const isDirectory = this.draggedIsDir; + + // Construct destination path + let destPath; + if (targetPath === '') { + // Dropping to root + destPath = fileName; + } else { + destPath = `${targetPath}/${fileName}`; + } + + // Check if destination already exists + const destNode = this.findNode(destPath); + if (destNode) { + const overwrite = await window.ModalManager.confirm( + `A ${destNode.isDirectory ? 'folder' : 'file'} named "${fileName}" already exists in the destination. Do you want to overwrite it?`, + 'Name Conflict', + true + ); + + if (!overwrite) { + return; + } + + // Delete existing item first + await this.webdavClient.delete(destPath); + + // Clear undo history since we're overwriting + this.lastMoveOperation = null; + } + + // Perform the move + await this.webdavClient.move(sourcePath, destPath); + + // Store undo information (only if not overwriting) + if (!destNode) { + this.lastMoveOperation = { + sourcePath: sourcePath, + destPath: destPath, + fileName: fileName, + isDirectory: isDirectory + }; + } + + // If the moved item was the currently selected file, update the selection + if (this.selectedPath === sourcePath) { + this.selectedPath = destPath; + + // Update editor's current file path if it's the file being moved + if (!this.draggedIsDir && window.editor && window.editor.currentFile === sourcePath) { + window.editor.currentFile = destPath; + if (window.editor.filenameInput) { + window.editor.filenameInput.value = destPath; + } + } + + // Notify file select callback if it's a file + if (!this.draggedIsDir && this.onFileSelect) { + this.onFileSelect({ path: destPath, isDirectory: false }); + } + } + + // Reload the tree + await this.load(); + + // Re-select the moved item + this.selectAndExpandPath(destPath); + + showNotification(`Moved ${fileName} successfully`, 'success'); + } catch (error) { + console.error('Failed to move item:', error); + showNotification('Failed to move item: ' + error.message, 'danger'); + } + } + async load() { try { const items = await this.webdavClient.propfind('', 'infinity'); @@ -63,14 +419,27 @@ class FileTree { showNotification('Failed to load files', 'error'); } } - + render() { this.container.innerHTML = ''; this.renderNodes(this.tree, this.container, 0); } - + renderNodes(nodes, parentElement, level) { nodes.forEach(node => { + // 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,40 +447,56 @@ class FileTree { const nodeElement = this.createNodeElement(node, level); nodeWrapper.appendChild(nodeElement); - // Create children container ONLY if has children - if (node.children && node.children.length > 0) { + // Create children container for directories + if (node.isDirectory) { const childContainer = document.createElement('div'); childContainer.className = 'tree-children'; childContainer.style.display = 'none'; childContainer.dataset.parent = node.path; childContainer.style.marginLeft = `${(level + 1) * 12}px`; - // Recursively render children - this.renderNodes(node.children, childContainer, level + 1); + // Only render children if they exist + if (node.children && node.children.length > 0) { + this.renderNodes(node.children, childContainer, level + 1); + } else { + // Empty directory - show empty state message + const emptyMessage = document.createElement('div'); + emptyMessage.className = 'tree-empty-message'; + emptyMessage.textContent = 'Empty folder'; + childContainer.appendChild(emptyMessage); + } + nodeWrapper.appendChild(childContainer); - // Make toggle functional + // Make toggle functional for ALL directories (including empty ones) const toggle = nodeElement.querySelector('.tree-node-toggle'); if (toggle) { - toggle.addEventListener('click', (e) => { - console.log('Toggle clicked', e.target); + const toggleHandler = (e) => { e.stopPropagation(); const isHidden = childContainer.style.display === 'none'; - console.log('Is hidden?', isHidden); childContainer.style.display = isHidden ? 'block' : 'none'; - toggle.innerHTML = isHidden ? '▼' : '▶'; + toggle.style.transform = isHidden ? 'rotate(90deg)' : 'rotate(0deg)'; toggle.classList.toggle('expanded'); - }); + }; + + // Add click listener to toggle icon + toggle.addEventListener('click', toggleHandler); + + // Also allow double-click on the node to toggle + nodeElement.addEventListener('dblclick', toggleHandler); + + // Make toggle cursor pointer for all directories + toggle.style.cursor = 'pointer'; } } parentElement.appendChild(nodeWrapper); }); } - - + + // toggleFolder is no longer needed as the event listener is added in renderNodes. - + selectFile(path) { this.selectedPath = path; this.updateSelection(); @@ -119,7 +504,7 @@ class FileTree { this.onFileSelect({ path, isDirectory: false }); } } - + selectFolder(path) { this.selectedPath = path; this.updateSelection(); @@ -127,18 +512,111 @@ class FileTree { this.onFolderSelect({ path, isDirectory: true }); } } - + + /** + * Find a node by path + * @param {string} path - The path to find + * @returns {Object|null} The node or null if not found + */ + findNode(path) { + const search = (nodes, targetPath) => { + for (const node of nodes) { + if (node.path === targetPath) { + return node; + } + if (node.children && node.children.length > 0) { + const found = search(node.children, targetPath); + if (found) return found; + } + } + return null; + }; + + return search(this.tree, path); + } + + /** + * Get all files in a directory (direct children only) + * @param {string} dirPath - The directory path + * @returns {Array} Array of file nodes + */ + getDirectoryFiles(dirPath) { + const dirNode = this.findNode(dirPath); + if (dirNode && dirNode.children) { + return dirNode.children.filter(child => !child.isDirectory); + } + return []; + } + updateSelection() { // Remove previous selection this.container.querySelectorAll('.tree-node').forEach(node => { - node.classList.remove('selected'); + node.classList.remove('active'); }); - - // Add selection to current + + // Add selection to current and all parent directories if (this.selectedPath) { + // Add active class to the selected file/folder const node = this.container.querySelector(`[data-path="${this.selectedPath}"]`); if (node) { - node.classList.add('selected'); + node.classList.add('active'); + } + + // Add active class to all parent directories + const parts = this.selectedPath.split('/'); + let currentPath = ''; + for (let i = 0; i < parts.length - 1; i++) { + currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i]; + const parentNode = this.container.querySelector(`[data-path="${currentPath}"]`); + if (parentNode) { + parentNode.classList.add('active'); + } + } + } + } + + /** + * Highlight a file as active and expand all parent directories + * @param {string} path - The file path to highlight + */ + selectAndExpandPath(path) { + this.selectedPath = path; + + // Expand all parent directories + this.expandParentDirectories(path); + + // Update selection + this.updateSelection(); + } + + /** + * Expand all parent directories of a given path + * @param {string} path - The file path + */ + expandParentDirectories(path) { + // Get all parent paths + const parts = path.split('/'); + let currentPath = ''; + + for (let i = 0; i < parts.length - 1; i++) { + currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i]; + + // Find the node with this path + const parentNode = this.container.querySelector(`[data-path="${currentPath}"]`); + if (parentNode && parentNode.dataset.isdir === 'true') { + // Find the children container + const wrapper = parentNode.closest('.tree-node-wrapper'); + if (wrapper) { + const childContainer = wrapper.querySelector('.tree-children'); + if (childContainer && childContainer.style.display === 'none') { + // Expand it + childContainer.style.display = 'block'; + const toggle = parentNode.querySelector('.tree-node-toggle'); + if (toggle) { + toggle.classList.add('expanded'); + } + } + } } } } @@ -150,25 +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; @@ -176,7 +740,7 @@ class FileTree { const i = Math.floor(Math.log(bytes) / Math.log(k)); return Math.round(bytes / Math.pow(k, i) * 10) / 10 + ' ' + sizes[i]; } - + newFile() { this.selectedPath = null; this.updateSelection(); @@ -200,7 +764,7 @@ class FileTree { throw error; } } - + async createFolder(parentPath, foldername) { try { const fullPath = parentPath ? `${parentPath}/${foldername}` : foldername; @@ -214,7 +778,7 @@ class FileTree { throw error; } } - + async uploadFile(parentPath, file) { try { const fullPath = parentPath ? `${parentPath}/${file.name}` : file.name; @@ -229,63 +793,76 @@ class FileTree { throw error; } } - + async downloadFile(path) { try { const content = await this.webdavClient.get(path); - const filename = path.split('/').pop(); - this.triggerDownload(content, filename); + const filename = PathUtils.getFileName(path); + DownloadUtils.triggerDownload(content, filename); showNotification('Downloaded', 'success'); } catch (error) { console.error('Failed to download file:', error); showNotification('Failed to download file', 'error'); } } - + async downloadFolder(path) { try { showNotification('Creating zip...', 'info'); // Get all files in folder const items = await this.webdavClient.propfind(path, 'infinity'); const files = items.filter(item => !item.isDirectory); - + // Use JSZip to create zip file const JSZip = window.JSZip; if (!JSZip) { throw new Error('JSZip not loaded'); } - + const zip = new JSZip(); - const folder = zip.folder(path.split('/').pop() || 'download'); - + const folder = zip.folder(PathUtils.getFileName(path) || 'download'); + // Add all files to zip for (const file of files) { const content = await this.webdavClient.get(file.path); const relativePath = file.path.replace(path + '/', ''); folder.file(relativePath, content); } - + // Generate zip const zipBlob = await zip.generateAsync({ type: 'blob' }); - const zipFilename = `${path.split('/').pop() || 'download'}.zip`; - this.triggerDownload(zipBlob, zipFilename); + const zipFilename = `${PathUtils.getFileName(path) || 'download'}.zip`; + DownloadUtils.triggerDownload(zipBlob, zipFilename); showNotification('Downloaded', 'success'); } catch (error) { console.error('Failed to download folder:', error); showNotification('Failed to download folder', 'error'); } } - - triggerDownload(content, filename) { - const blob = content instanceof Blob ? content : new Blob([content]); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = filename; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); + + // triggerDownload method moved to DownloadUtils in utils.js + + /** + * Get the first markdown file in the tree + * Returns the path of the first .md file found, or null if none exist + */ + getFirstMarkdownFile() { + const findFirstFile = (nodes) => { + for (const node of nodes) { + // If it's a file and ends with .md, return it + if (!node.isDirectory && node.path.endsWith('.md')) { + return node.path; + } + // If it's a directory with children, search recursively + if (node.isDirectory && node.children && node.children.length > 0) { + const found = findFirstFile(node.children); + if (found) return found; + } + } + return null; + }; + + return findFirstFile(this.tree); } } diff --git a/static/js/file-upload.js b/static/js/file-upload.js new file mode 100644 index 0000000..7c88021 --- /dev/null +++ b/static/js/file-upload.js @@ -0,0 +1,37 @@ +/** + * File Upload Module + * Handles file upload dialog for uploading files to the file tree + */ + +/** + * Show file upload dialog + * @param {string} targetPath - The target directory path + * @param {Function} onUpload - Callback function to handle file upload + */ +function showFileUploadDialog(targetPath, onUpload) { + const input = document.createElement('input'); + input.type = 'file'; + input.multiple = true; + + input.addEventListener('change', async (e) => { + const files = Array.from(e.target.files); + if (files.length === 0) return; + + for (const file of files) { + try { + await onUpload(targetPath, file); + } catch (error) { + Logger.error('Upload failed:', error); + if (window.showNotification) { + window.showNotification(`Failed to upload ${file.name}`, 'error'); + } + } + } + }); + + input.click(); +} + +// Make function globally available +window.showFileUploadDialog = showFileUploadDialog; + diff --git a/static/js/loading-spinner.js b/static/js/loading-spinner.js new file mode 100644 index 0000000..8538e6c --- /dev/null +++ b/static/js/loading-spinner.js @@ -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; + diff --git a/static/js/logger.js b/static/js/logger.js new file mode 100644 index 0000000..a9f904b --- /dev/null +++ b/static/js/logger.js @@ -0,0 +1,174 @@ +/** + * Logger Module + * Provides structured logging with different levels + * Can be configured to show/hide different log levels + */ + +class Logger { + /** + * Log levels + */ + static LEVELS = { + DEBUG: 0, + INFO: 1, + WARN: 2, + ERROR: 3, + NONE: 4 + }; + + /** + * Current log level + * Set to DEBUG by default, can be changed via setLevel() + */ + static currentLevel = Logger.LEVELS.DEBUG; + + /** + * Enable/disable logging + */ + static enabled = true; + + /** + * Set the minimum log level + * @param {number} level - One of Logger.LEVELS + */ + static setLevel(level) { + if (typeof level === 'number' && level >= 0 && level <= 4) { + Logger.currentLevel = level; + } + } + + /** + * Enable or disable logging + * @param {boolean} enabled - Whether to enable logging + */ + static setEnabled(enabled) { + Logger.enabled = enabled; + } + + /** + * Log a debug message + * @param {string} message - The message to log + * @param {...any} args - Additional arguments to log + */ + static debug(message, ...args) { + if (!Logger.enabled || Logger.currentLevel > Logger.LEVELS.DEBUG) { + return; + } + console.log(`[DEBUG] ${message}`, ...args); + } + + /** + * Log an info message + * @param {string} message - The message to log + * @param {...any} args - Additional arguments to log + */ + static info(message, ...args) { + if (!Logger.enabled || Logger.currentLevel > Logger.LEVELS.INFO) { + return; + } + console.info(`[INFO] ${message}`, ...args); + } + + /** + * Log a warning message + * @param {string} message - The message to log + * @param {...any} args - Additional arguments to log + */ + static warn(message, ...args) { + if (!Logger.enabled || Logger.currentLevel > Logger.LEVELS.WARN) { + return; + } + console.warn(`[WARN] ${message}`, ...args); + } + + /** + * Log an error message + * @param {string} message - The message to log + * @param {...any} args - Additional arguments to log + */ + static error(message, ...args) { + if (!Logger.enabled || Logger.currentLevel > Logger.LEVELS.ERROR) { + return; + } + console.error(`[ERROR] ${message}`, ...args); + } + + /** + * Log a message with a custom prefix + * @param {string} prefix - The prefix to use + * @param {string} message - The message to log + * @param {...any} args - Additional arguments to log + */ + static log(prefix, message, ...args) { + if (!Logger.enabled) { + return; + } + console.log(`[${prefix}] ${message}`, ...args); + } + + /** + * Group related log messages + * @param {string} label - The group label + */ + static group(label) { + if (!Logger.enabled) { + return; + } + console.group(label); + } + + /** + * End a log group + */ + static groupEnd() { + if (!Logger.enabled) { + return; + } + console.groupEnd(); + } + + /** + * Log a table (useful for arrays of objects) + * @param {any} data - The data to display as a table + */ + static table(data) { + if (!Logger.enabled) { + return; + } + console.table(data); + } + + /** + * Start a timer + * @param {string} label - The timer label + */ + static time(label) { + if (!Logger.enabled) { + return; + } + console.time(label); + } + + /** + * End a timer and log the elapsed time + * @param {string} label - The timer label + */ + static timeEnd(label) { + if (!Logger.enabled) { + return; + } + console.timeEnd(label); + } +} + +// Make Logger globally available +window.Logger = Logger; + +// Set default log level based on environment +// In production, you might want to set this to WARN or ERROR +if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') { + Logger.setLevel(Logger.LEVELS.DEBUG); +} else { + Logger.setLevel(Logger.LEVELS.INFO); +} + diff --git a/static/js/macro-processor.js b/static/js/macro-processor.js index 3a7174d..3d20662 100644 --- a/static/js/macro-processor.js +++ b/static/js/macro-processor.js @@ -10,7 +10,7 @@ class MacroProcessor { this.includeStack = []; // Track includes to detect cycles this.registerDefaultPlugins(); } - + /** * Register a macro plugin * Plugin must implement: { canHandle(actor, method), process(macro, webdavClient) } @@ -19,27 +19,23 @@ class MacroProcessor { const key = `${actor}.${method}`; this.plugins.set(key, plugin); } - + /** * Process all macros in content * Returns { success: boolean, content: string, errors: [] } */ async processMacros(content) { - console.log('MacroProcessor: Starting macro processing for content:', content); const macros = MacroParser.extractMacros(content); - console.log('MacroProcessor: Extracted macros:', macros); const errors = []; let processedContent = content; - + // Process macros in reverse order to preserve positions for (let i = macros.length - 1; i >= 0; i--) { const macro = macros[i]; - console.log('MacroProcessor: Processing macro:', macro); - + try { const result = await this.processMacro(macro); - console.log('MacroProcessor: Macro processing result:', result); - + if (result.success) { // Replace macro with result processedContent = @@ -51,7 +47,7 @@ class MacroProcessor { macro: macro.fullMatch, error: result.error }); - + // Replace with error message const errorMsg = `\n\n⚠️ **Macro Error**: ${result.error}\n\n`; processedContent = @@ -64,7 +60,7 @@ class MacroProcessor { macro: macro.fullMatch, error: error.message }); - + const errorMsg = `\n\n⚠️ **Macro Error**: ${error.message}\n\n`; processedContent = processedContent.substring(0, macro.start) + @@ -72,15 +68,14 @@ class MacroProcessor { processedContent.substring(macro.end); } } - - console.log('MacroProcessor: Final processed content:', processedContent); + return { success: errors.length === 0, content: processedContent, errors }; } - + /** * Process single macro */ @@ -98,20 +93,20 @@ class MacroProcessor { }; } } - + if (!plugin) { return { success: false, error: `Unknown macro: !!${key}` }; } - + // Validate macro const validation = MacroParser.validateMacro(macro); if (!validation.valid) { return { success: false, error: validation.error }; } - + // Execute plugin try { return await plugin.process(macro, this.webdavClient); @@ -122,7 +117,7 @@ class MacroProcessor { }; } } - + /** * Register default plugins */ @@ -131,14 +126,14 @@ class MacroProcessor { this.registerPlugin('core', 'include', { process: async (macro, webdavClient) => { const path = macro.params.path || macro.params['']; - + if (!path) { return { success: false, error: 'include macro requires "path" parameter' }; } - + try { // Add to include stack this.includeStack.push(path); diff --git a/static/js/notification-service.js b/static/js/notification-service.js new file mode 100644 index 0000000..bcbd820 --- /dev/null +++ b/static/js/notification-service.js @@ -0,0 +1,77 @@ +/** + * Notification Service + * Provides a standardized way to show toast notifications + * Wraps the showNotification function from ui-utils.js + */ + +class NotificationService { + /** + * Show a success notification + * @param {string} message - The message to display + */ + static success(message) { + if (window.showNotification) { + window.showNotification(message, Config.NOTIFICATION_TYPES.SUCCESS); + } else { + Logger.warn('showNotification not available, falling back to console'); + console.log(`✅ ${message}`); + } + } + + /** + * Show an error notification + * @param {string} message - The message to display + */ + static error(message) { + if (window.showNotification) { + window.showNotification(message, Config.NOTIFICATION_TYPES.ERROR); + } else { + Logger.warn('showNotification not available, falling back to console'); + console.error(`❌ ${message}`); + } + } + + /** + * Show a warning notification + * @param {string} message - The message to display + */ + static warning(message) { + if (window.showNotification) { + window.showNotification(message, Config.NOTIFICATION_TYPES.WARNING); + } else { + Logger.warn('showNotification not available, falling back to console'); + console.warn(`⚠️ ${message}`); + } + } + + /** + * Show an info notification + * @param {string} message - The message to display + */ + static info(message) { + if (window.showNotification) { + window.showNotification(message, Config.NOTIFICATION_TYPES.INFO); + } else { + Logger.warn('showNotification not available, falling back to console'); + console.info(`ℹ️ ${message}`); + } + } + + /** + * Show a notification with a custom type + * @param {string} message - The message to display + * @param {string} type - The notification type (success, danger, warning, primary, etc.) + */ + static show(message, type = 'primary') { + if (window.showNotification) { + window.showNotification(message, type); + } else { + Logger.warn('showNotification not available, falling back to console'); + console.log(`[${type.toUpperCase()}] ${message}`); + } + } +} + +// Make NotificationService globally available +window.NotificationService = NotificationService; + diff --git a/static/js/sidebar-toggle.js b/static/js/sidebar-toggle.js new file mode 100644 index 0000000..876e753 --- /dev/null +++ b/static/js/sidebar-toggle.js @@ -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; + diff --git a/static/js/ui-utils.js b/static/js/ui-utils.js index afc5057..2ba0838 100644 --- a/static/js/ui-utils.js +++ b/static/js/ui-utils.js @@ -1,270 +1,60 @@ /** * UI Utilities Module - * Toast notifications, context menu, dark mode, file upload dialog + * Toast notifications (kept for backward compatibility) + * + * Other utilities have been moved to separate modules: + * - Context menu: context-menu.js + * - File upload: file-upload.js + * - Dark mode: dark-mode.js + * - Collection selector: collection-selector.js + * - Editor drop handler: editor-drop-handler.js */ /** * Show toast notification + * @param {string} message - The message to display + * @param {string} type - The notification type (info, success, error, warning, danger, primary) */ function showNotification(message, type = 'info') { const container = document.getElementById('toastContainer') || createToastContainer(); - + const toast = document.createElement('div'); const bgClass = type === 'error' ? 'danger' : type === 'success' ? 'success' : type === 'warning' ? 'warning' : 'primary'; toast.className = `toast align-items-center text-white bg-${bgClass} border-0`; toast.setAttribute('role', 'alert'); - + toast.innerHTML = `
${message}
`; - + container.appendChild(toast); - - const bsToast = new bootstrap.Toast(toast, { delay: 3000 }); + + const bsToast = new bootstrap.Toast(toast, { delay: Config.TOAST_DURATION }); bsToast.show(); - + toast.addEventListener('hidden.bs.toast', () => { toast.remove(); }); } +/** + * Create the toast container if it doesn't exist + * @returns {HTMLElement} The toast container element + */ function createToastContainer() { const container = document.createElement('div'); container.id = 'toastContainer'; container.className = 'toast-container position-fixed top-0 end-0 p-3'; - container.style.zIndex = '9999'; + container.style.zIndex = Config.TOAST_Z_INDEX; document.body.appendChild(container); return container; } -/** - * Enhanced Context Menu - */ -function showContextMenu(x, y, target) { - const menu = document.getElementById('contextMenu'); - if (!menu) return; - - // Store target data - menu.dataset.targetPath = target.path; - menu.dataset.targetIsDir = target.isDir; - - // Show/hide menu items based on target type - const items = { - 'new-file': target.isDir, - 'new-folder': target.isDir, - 'upload': target.isDir, - 'download': true, - 'paste': target.isDir && window.fileTreeActions?.clipboard, - 'open': !target.isDir - }; - - Object.entries(items).forEach(([action, show]) => { - const item = menu.querySelector(`[data-action="${action}"]`); - if (item) { - item.style.display = show ? 'flex' : 'none'; - } - }); - - // Position menu - menu.style.display = 'block'; - menu.style.left = x + 'px'; - menu.style.top = y + 'px'; - - // Adjust if off-screen - setTimeout(() => { - const rect = menu.getBoundingClientRect(); - if (rect.right > window.innerWidth) { - menu.style.left = (window.innerWidth - rect.width - 10) + 'px'; - } - if (rect.bottom > window.innerHeight) { - menu.style.top = (window.innerHeight - rect.height - 10) + 'px'; - } - }, 0); -} - -function hideContextMenu() { - const menu = document.getElementById('contextMenu'); - if (menu) { - menu.style.display = 'none'; - } -} - -// Combined click handler for context menu and outside clicks -document.addEventListener('click', async (e) => { - const menuItem = e.target.closest('.context-menu-item'); - - if (menuItem) { - // Handle context menu item click - const action = menuItem.dataset.action; - const menu = document.getElementById('contextMenu'); - const targetPath = menu.dataset.targetPath; - const isDir = menu.dataset.targetIsDir === 'true'; - - hideContextMenu(); - - if (window.fileTreeActions) { - await window.fileTreeActions.execute(action, targetPath, isDir); - } - } else if (!e.target.closest('#contextMenu') && !e.target.closest('.tree-node')) { - // Hide on outside click - hideContextMenu(); - } -}); - -/** - * File Upload Dialog - */ -function showFileUploadDialog(targetPath, onUpload) { - const input = document.createElement('input'); - input.type = 'file'; - input.multiple = true; - - input.addEventListener('change', async (e) => { - const files = Array.from(e.target.files); - if (files.length === 0) return; - - for (const file of files) { - try { - await onUpload(targetPath, file); - } catch (error) { - console.error('Upload failed:', error); - } - } - }); - - input.click(); -} - -/** - * Dark Mode Manager - */ -class DarkMode { - constructor() { - this.isDark = localStorage.getItem('darkMode') === 'true'; - this.apply(); - } - - toggle() { - this.isDark = !this.isDark; - localStorage.setItem('darkMode', this.isDark); - this.apply(); - } - - apply() { - if (this.isDark) { - document.body.classList.add('dark-mode'); - const btn = document.getElementById('darkModeBtn'); - if (btn) btn.textContent = '☀️'; - - // Update mermaid theme - if (window.mermaid) { - mermaid.initialize({ theme: 'dark' }); - } - } else { - document.body.classList.remove('dark-mode'); - const btn = document.getElementById('darkModeBtn'); - if (btn) btn.textContent = '🌙'; - - // Update mermaid theme - if (window.mermaid) { - mermaid.initialize({ theme: 'default' }); - } - } - } -} - -/** - * Collection Selector - */ -class CollectionSelector { - constructor(selectId, webdavClient) { - this.select = document.getElementById(selectId); - this.webdavClient = webdavClient; - this.onChange = null; - } - - async load() { - try { - const collections = await this.webdavClient.getCollections(); - this.select.innerHTML = ''; - - collections.forEach(collection => { - const option = document.createElement('option'); - option.value = collection; - option.textContent = collection; - this.select.appendChild(option); - }); - - // Select first collection - if (collections.length > 0) { - this.select.value = collections[0]; - this.webdavClient.setCollection(collections[0]); - if (this.onChange) { - this.onChange(collections[0]); - } - } - - // Add change listener - this.select.addEventListener('change', () => { - const collection = this.select.value; - this.webdavClient.setCollection(collection); - if (this.onChange) { - this.onChange(collection); - } - }); - } catch (error) { - console.error('Failed to load collections:', error); - showNotification('Failed to load collections', 'error'); - } - } -} - -/** - * Editor Drop Handler - * Handles file drops into the editor - */ -class EditorDropHandler { - constructor(editorElement, onFileDrop) { - this.editorElement = editorElement; - this.onFileDrop = onFileDrop; - this.setupHandlers(); - } - - setupHandlers() { - this.editorElement.addEventListener('dragover', (e) => { - e.preventDefault(); - e.stopPropagation(); - this.editorElement.classList.add('drag-over'); - }); - - this.editorElement.addEventListener('dragleave', (e) => { - e.preventDefault(); - e.stopPropagation(); - this.editorElement.classList.remove('drag-over'); - }); - - this.editorElement.addEventListener('drop', async (e) => { - e.preventDefault(); - e.stopPropagation(); - this.editorElement.classList.remove('drag-over'); - - const files = Array.from(e.dataTransfer.files); - if (files.length === 0) return; - - for (const file of files) { - try { - if (this.onFileDrop) { - await this.onFileDrop(file); - } - } catch (error) { - console.error('Drop failed:', error); - showNotification(`Failed to upload ${file.name}`, 'error'); - } - } - }); - } -} +// All other UI utilities have been moved to separate modules +// See the module list at the top of this file +// Make showNotification globally available +window.showNotification = showNotification; diff --git a/static/js/utils.js b/static/js/utils.js new file mode 100644 index 0000000..909ebf1 --- /dev/null +++ b/static/js/utils.js @@ -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; + diff --git a/static/js/webdav-client.js b/static/js/webdav-client.js index c3aa858..c2577d0 100644 --- a/static/js/webdav-client.js +++ b/static/js/webdav-client.js @@ -8,11 +8,11 @@ class WebDAVClient { this.baseUrl = baseUrl; this.currentCollection = null; } - + setCollection(collection) { this.currentCollection = collection; } - + getFullUrl(path) { if (!this.currentCollection) { throw new Error('No collection selected'); @@ -20,7 +20,7 @@ class WebDAVClient { const cleanPath = path.startsWith('/') ? path.slice(1) : path; return `${this.baseUrl}${this.currentCollection}/${cleanPath}`; } - + async getCollections() { const response = await fetch(this.baseUrl); if (!response.ok) { @@ -28,7 +28,25 @@ 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, { @@ -38,37 +56,64 @@ class WebDAVClient { 'Content-Type': 'application/xml' } }); - + if (!response.ok) { throw new Error(`PROPFIND failed: ${response.statusText}`); } - + const xml = await response.text(); 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 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); - + if (!response.ok) { throw new Error(`GET failed: ${response.statusText}`); } - + return await response.text(); } - + async getBinary(path) { const url = this.getFullUrl(path); const response = await fetch(url); - + if (!response.ok) { throw new Error(`GET failed: ${response.statusText}`); } - + return await response.blob(); } - + async put(path, content) { const url = this.getFullUrl(path); const response = await fetch(url, { @@ -78,109 +123,144 @@ class WebDAVClient { }, body: content }); - + if (!response.ok) { throw new Error(`PUT failed: ${response.statusText}`); } - + return true; } - + async putBinary(path, content) { const url = this.getFullUrl(path); const response = await fetch(url, { method: 'PUT', body: content }); - + if (!response.ok) { throw new Error(`PUT failed: ${response.statusText}`); } - + return true; } - + async delete(path) { const url = this.getFullUrl(path); const response = await fetch(url, { method: 'DELETE' }); - + if (!response.ok) { throw new Error(`DELETE failed: ${response.statusText}`); } - + return true; } - + async copy(sourcePath, destPath) { const sourceUrl = this.getFullUrl(sourcePath); const destUrl = this.getFullUrl(destPath); - + const response = await fetch(sourceUrl, { method: 'COPY', headers: { 'Destination': destUrl } }); - + if (!response.ok) { throw new Error(`COPY failed: ${response.statusText}`); } - + return true; } - + async move(sourcePath, destPath) { const sourceUrl = this.getFullUrl(sourcePath); const destUrl = this.getFullUrl(destPath); - + const response = await fetch(sourceUrl, { method: 'MOVE', headers: { 'Destination': destUrl } }); - + if (!response.ok) { throw new Error(`MOVE failed: ${response.statusText}`); } - + return true; } - + async mkcol(path) { const url = this.getFullUrl(path); const response = await fetch(url, { method: 'MKCOL' }); - + if (!response.ok && response.status !== 405) { // 405 means already exists throw new Error(`MKCOL failed: ${response.statusText}`); } - + return true; } - + + // Alias for mkcol + async createFolder(path) { + return await this.mkcol(path); + } + + /** + * Ensure all parent directories exist for a given path + * Creates missing parent directories recursively + */ + async ensureParentDirectories(filePath) { + const parts = filePath.split('/'); + + // Remove the filename (last part) + parts.pop(); + + // If no parent directories, nothing to do + if (parts.length === 0) { + return; + } + + // Create each parent directory level + let currentPath = ''; + for (const part of parts) { + currentPath = currentPath ? `${currentPath}/${part}` : part; + + try { + await this.mkcol(currentPath); + } catch (error) { + // Ignore errors - directory might already exist + // Only log for debugging + console.debug(`Directory ${currentPath} might already exist:`, error.message); + } + } + } + async includeFile(path) { try { // Parse path: "collection:path/to/file" or "path/to/file" let targetCollection = this.currentCollection; let targetPath = path; - + if (path.includes(':')) { [targetCollection, targetPath] = path.split(':'); } - + // Temporarily switch collection const originalCollection = this.currentCollection; this.currentCollection = targetCollection; - + const content = await this.get(targetPath); - + // Restore collection this.currentCollection = originalCollection; - + return content; } catch (error) { throw new Error(`Cannot include file "${path}": ${error.message}`); @@ -191,32 +271,32 @@ class WebDAVClient { const parser = new DOMParser(); const doc = parser.parseFromString(xml, 'text/xml'); const responses = doc.getElementsByTagNameNS('DAV:', 'response'); - + const items = []; for (let i = 0; i < responses.length; i++) { const response = responses[i]; const href = response.getElementsByTagNameNS('DAV:', 'href')[0].textContent; const propstat = response.getElementsByTagNameNS('DAV:', 'propstat')[0]; const prop = propstat.getElementsByTagNameNS('DAV:', 'prop')[0]; - + // Check if it's a collection (directory) const resourcetype = prop.getElementsByTagNameNS('DAV:', 'resourcetype')[0]; const isDirectory = resourcetype.getElementsByTagNameNS('DAV:', 'collection').length > 0; - + // Get size const contentlengthEl = prop.getElementsByTagNameNS('DAV:', 'getcontentlength')[0]; const size = contentlengthEl ? parseInt(contentlengthEl.textContent) : 0; - + // Extract path relative to collection const pathParts = href.split(`/${this.currentCollection}/`); const relativePath = pathParts.length > 1 ? pathParts[1] : ''; - + // Skip the collection root itself if (!relativePath) continue; - + // Remove trailing slash from directories const cleanPath = relativePath.endsWith('/') ? relativePath.slice(0, -1) : relativePath; - + items.push({ path: cleanPath, name: cleanPath.split('/').pop(), @@ -224,14 +304,14 @@ class WebDAVClient { size }); } - + return items; } - + buildTree(items) { const root = []; const map = {}; - + // Sort items by path depth and name items.sort((a, b) => { const depthA = a.path.split('/').length; @@ -239,26 +319,26 @@ class WebDAVClient { if (depthA !== depthB) return depthA - depthB; return a.path.localeCompare(b.path); }); - + items.forEach(item => { const parts = item.path.split('/'); const parentPath = parts.slice(0, -1).join('/'); - + const node = { ...item, children: [] }; - + map[item.path] = node; - + if (parentPath && map[parentPath]) { map[parentPath].children.push(node); } else { root.push(node); } - + }); - + return root; } } diff --git a/static/style.css b/static/style.css index ed66b87..42a5e4d 100644 --- a/static/style.css +++ b/static/style.css @@ -33,7 +33,8 @@ body.dark-mode { } /* Global styles */ -html, body { +html, +body { height: 100%; margin: 0; padding: 0; @@ -48,12 +49,6 @@ body { transition: background-color 0.3s ease, color 0.3s ease; } -.container-fluid { - flex: 1; - padding: 0; - overflow: hidden; -} - .row { margin: 0; } @@ -206,7 +201,12 @@ body.dark-mode .CodeMirror-linenumber { } /* Markdown preview styles */ -#preview h1, #preview h2, #preview h3, #preview h4, #preview h5, #preview h6 { +#preview h1, +#preview h2, +#preview h3, +#preview h4, +#preview h5, +#preview h6 { margin-top: 24px; margin-bottom: 16px; font-weight: 600; @@ -286,7 +286,8 @@ body.dark-mode .CodeMirror-linenumber { margin-bottom: 16px; } -#preview ul, #preview ol { +#preview ul, +#preview ol { margin-bottom: 16px; padding-left: 2em; } @@ -378,7 +379,7 @@ body.dark-mode .mermaid svg { .sidebar { display: none; } - + .editor-pane, .preview-pane { height: 50vh; @@ -590,5 +591,4 @@ body.dark-mode .sidebar h6 { body.dark-mode .tree-children { border-left-color: var(--border-color); -} - +} \ No newline at end of file diff --git a/templates/index.html b/templates/index.html index b59e6f1..b9bd543 100644 --- a/templates/index.html +++ b/templates/index.html @@ -30,20 +30,47 @@ @@ -56,7 +83,13 @@
- +
+ + +
@@ -120,13 +153,21 @@ Paste
+
+ Copy to Collection... +
+
+ Move to Collection... +
+
Delete
-