Compare commits

...

6 Commits

Author SHA1 Message Date
16aef59298 Merge pull request 'development_new_ui_version' (#1) from development_new_ui_version into master
Reviewed-on: #1
2025-10-27 17:54:52 +00:00
Mahmoud-Emad
3961628b3d feat: Implement collection deletion and loading spinners
- Add API endpoint and handler to delete collections
- Introduce LoadingSpinner component for async operations
- Show loading spinners during file loading and preview rendering
- Enhance modal accessibility by removing aria-hidden attribute
- Refactor delete functionality to distinguish between collections and files/folders
- Remove unused collection definitions from config
2025-10-27 11:32:20 +03:00
Mahmoud-Emad
afcd074913 feat: Implement sidebar collapse and expand functionality
- Add CSS for collapsed sidebar state and transitions
- Introduce SidebarToggle class for managing collapse/expand logic
- Integrate SidebarToggle initialization in main script
- Add toggle button to navbar and make mini sidebar clickable
- Store sidebar collapsed state in localStorage
- Filter image files and directories in view mode via FileTree
- Make navbar brand clickable to navigate to collection root or home
2025-10-26 18:48:31 +03:00
Mahmoud-Emad
7a9efd3542 style: Improve markdown editor styling and functionality
- Update dark mode button icon and styling
- Add styling for new collection button
- Apply default iframe styles in preview pane
- Adjust vertical divider height in header buttons
- Improve handling of JSX-like attributes in markdown
- Add support for new collection functionality
- Refine file loading logic in view mode
- Improve dark mode toggle icon and integration
- Update UI for edit/view mode toggle button
2025-10-26 17:59:48 +03:00
Mahmoud-Emad
f319f29d4c feat: Enhance WebDAV file management and UI
- Add functionality to create new collections via API
- Implement copy and move operations between collections
- Improve image rendering in markdown preview with relative path resolution
- Add support for previewing binary files (images, PDFs)
- Refactor modal styling to use flat buttons and improve accessibility
2025-10-26 17:29:45 +03:00
Mahmoud-Emad
0ed6bcf1f2 refactor: Modularize UI components and utilities
- Extract UI components into separate JS files
- Centralize configuration values in config.js
- Introduce a dedicated logger module
- Improve file tree drag-and-drop and undo functionality
- Refactor modal handling to a single manager
- Add URL routing support for SPA navigation
- Implement view mode for read-only access
2025-10-26 15:42:15 +03:00
70 changed files with 7114 additions and 1104 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
.venv

View File

@@ -0,0 +1,22 @@
# Start to end file
### Graph
---
This is just for testing
---
**See what i did?**
---
```mermaid
graph TD
A[Start] --> B{Process};
B --> C{Decision};
C -- Yes --> D[End Yes];
C -- No --> E[End No];
```

View File

@@ -0,0 +1,426 @@
# UI Code Refactoring Plan
**Project:** Markdown Editor
**Date:** 2025-10-26
**Status:** In Progress
---
## Executive Summary
This document outlines a comprehensive refactoring plan for the UI codebase to improve maintainability, remove dead code, extract utilities, and standardize patterns. The refactoring is organized into 6 phases with 14 tasks, prioritized by risk and impact.
**Key Metrics:**
- Total Lines of Code: ~3,587
- Dead Code to Remove: 213 lines (6%)
- Estimated Effort: 5-8 days
- Risk Level: Mostly LOW to MEDIUM
---
## Phase 1: Analysis Summary
### Files Reviewed
**JavaScript Files (10):**
- `/static/js/app.js` (484 lines)
- `/static/js/column-resizer.js` (100 lines)
- `/static/js/confirmation.js` (170 lines)
- `/static/js/editor.js` (420 lines)
- `/static/js/file-tree-actions.js` (482 lines)
- `/static/js/file-tree.js` (865 lines)
- `/static/js/macro-parser.js` (103 lines)
- `/static/js/macro-processor.js` (157 lines)
- `/static/js/ui-utils.js` (305 lines)
- `/static/js/webdav-client.js` (266 lines)
**CSS Files (6):**
- `/static/css/variables.css` (32 lines)
- `/static/css/layout.css`
- `/static/css/file-tree.css`
- `/static/css/editor.css`
- `/static/css/components.css`
- `/static/css/modal.css`
**HTML Templates (1):**
- `/templates/index.html` (203 lines)
---
## Issues Found
### 🔴 HIGH PRIORITY
1. **Deprecated Modal Code (Dead Code)**
- Location: `/static/js/file-tree-actions.js` lines 262-474
- Impact: 213 lines of unused code (44% of file)
- Risk: LOW to remove
2. **Duplicated Event Bus Implementation**
- Location: `/static/js/app.js` lines 16-30
- Should be extracted to reusable module
3. **Duplicated Debounce Function**
- Location: `/static/js/editor.js` lines 404-414
- Should be shared utility
4. **Inconsistent Notification Usage**
- Mixed usage of `window.showNotification` vs `showNotification`
5. **Duplicated File Download Logic**
- Location: `/static/js/file-tree.js` lines 829-839
- Should be shared utility
6. **Hard-coded Values**
- Long-press threshold: 400ms
- Debounce delay: 300ms
- Drag preview width: 200px
- Toast delay: 3000ms
### 🟡 MEDIUM PRIORITY
7. **Global State Management**
- Location: `/static/js/app.js` lines 6-13
- Makes testing difficult
8. **Duplicated Path Manipulation**
- `path.split('/').pop()` appears 10+ times
- `path.substring(0, path.lastIndexOf('/'))` appears 5+ times
9. **Mixed Responsibility in ui-utils.js**
- Contains 6 different classes/utilities
- Should be split into separate modules
10. **Deprecated Event Handler**
- Location: `/static/js/file-tree-actions.js` line 329
- Uses deprecated `onkeypress`
### 🟢 LOW PRIORITY
11. **Unused Function Parameters**
12. **Magic Numbers in Styling**
13. **Inconsistent Comment Styles**
14. **Console.log Statements**
---
## Phase 2: Proposed Reusable Components
### 1. Config Module (`/static/js/config.js`)
Centralize all configuration values:
```javascript
export const Config = {
// Timing
LONG_PRESS_THRESHOLD: 400,
DEBOUNCE_DELAY: 300,
TOAST_DURATION: 3000,
// UI
DRAG_PREVIEW_WIDTH: 200,
TREE_INDENT_PX: 12,
MOUSE_MOVE_THRESHOLD: 5,
// Validation
FILENAME_PATTERN: /^[a-z0-9_]+(\.[a-z0-9_]+)*$/,
// Storage Keys
STORAGE_KEYS: {
DARK_MODE: 'darkMode',
SELECTED_COLLECTION: 'selectedCollection',
LAST_VIEWED_PAGE: 'lastViewedPage',
COLUMN_DIMENSIONS: 'columnDimensions'
}
};
```
### 2. Logger Module (`/static/js/logger.js`)
Structured logging with levels:
```javascript
export class Logger {
static debug(message, ...args)
static info(message, ...args)
static warn(message, ...args)
static error(message, ...args)
static setLevel(level)
}
```
### 3. Event Bus Module (`/static/js/event-bus.js`)
Centralized event system:
```javascript
export class EventBus {
on(event, callback)
off(event, callback)
once(event, callback)
dispatch(event, data)
clear(event)
}
```
### 4. Utilities Module (`/static/js/utils.js`)
Common utility functions:
```javascript
export const PathUtils = {
getFileName(path),
getParentPath(path),
normalizePath(path),
joinPaths(...paths),
getExtension(path)
};
export const TimingUtils = {
debounce(func, wait),
throttle(func, wait)
};
export const DownloadUtils = {
triggerDownload(content, filename),
downloadAsBlob(blob, filename)
};
export const ValidationUtils = {
validateFileName(name, isFolder),
sanitizeFileName(name)
};
```
### 5. Notification Service (`/static/js/notification-service.js`)
Standardized notifications:
```javascript
export class NotificationService {
static success(message)
static error(message)
static warning(message)
static info(message)
}
```
---
## Phase 3: Refactoring Tasks
### 🔴 HIGH PRIORITY
**Task 1: Remove Dead Code**
- Files: `/static/js/file-tree-actions.js`
- Lines: 262-474 (213 lines)
- Risk: LOW
- Dependencies: None
**Task 2: Extract Event Bus**
- Files: NEW `/static/js/event-bus.js`, MODIFY `app.js`, `editor.js`
- Risk: MEDIUM
- Dependencies: None
**Task 3: Create Utilities Module**
- Files: NEW `/static/js/utils.js`, MODIFY multiple files
- Risk: MEDIUM
- Dependencies: None
**Task 4: Create Config Module**
- Files: NEW `/static/js/config.js`, MODIFY multiple files
- Risk: LOW
- Dependencies: None
**Task 5: Standardize Notification Usage**
- Files: NEW `/static/js/notification-service.js`, MODIFY multiple files
- Risk: LOW
- Dependencies: None
### 🟡 MEDIUM PRIORITY
**Task 6: Fix Deprecated Event Handler**
- Files: `/static/js/file-tree-actions.js` line 329
- Risk: LOW
- Dependencies: None
**Task 7: Refactor ui-utils.js**
- Files: DELETE `ui-utils.js`, CREATE 5 new modules
- Risk: HIGH
- Dependencies: Task 5
**Task 8: Standardize Class Export Pattern**
- Files: All class files
- Risk: MEDIUM
- Dependencies: None
**Task 9: Create Logger Module**
- Files: NEW `/static/js/logger.js`, MODIFY multiple files
- Risk: LOW
- Dependencies: None
**Task 10: Implement Download Action**
- Files: `/static/js/file-tree-actions.js`
- Risk: LOW
- Dependencies: Task 3
### 🟢 LOW PRIORITY
**Task 11: Standardize JSDoc Comments**
**Task 12: Extract Magic Numbers to CSS**
**Task 13: Add Error Boundaries**
**Task 14: Cache DOM Elements**
---
## Phase 4: Implementation Order
### Step 1: Foundation (Do First)
1. Create Config Module (Task 4)
2. Create Logger Module (Task 9)
3. Create Event Bus Module (Task 2)
### Step 2: Utilities (Do Second)
4. Create Utilities Module (Task 3)
5. Create Notification Service (Task 5)
### Step 3: Cleanup (Do Third)
6. Remove Dead Code (Task 1)
7. Fix Deprecated Event Handler (Task 6)
### Step 4: Restructuring (Do Fourth)
8. Refactor ui-utils.js (Task 7)
9. Standardize Class Export Pattern (Task 8)
### Step 5: Enhancements (Do Fifth)
10. Implement Download Action (Task 10)
11. Add Error Boundaries (Task 13)
### Step 6: Polish (Do Last)
12. Standardize JSDoc Comments (Task 11)
13. Extract Magic Numbers to CSS (Task 12)
14. Cache DOM Elements (Task 14)
---
## Phase 5: Testing Checklist
### Core Functionality
- [ ] File tree loads and displays correctly
- [ ] Files can be selected and opened
- [ ] Folders can be expanded/collapsed
- [ ] Editor loads file content
- [ ] Preview renders markdown correctly
- [ ] Save button saves files
- [ ] Delete button deletes files
- [ ] New button creates new files
### Context Menu Actions
- [ ] Right-click shows context menu
- [ ] New file action works
- [ ] New folder action works
- [ ] Rename action works
- [ ] Delete action works
- [ ] Copy/Cut/Paste actions work
- [ ] Upload action works
### Drag and Drop
- [ ] Long-press detection works
- [ ] Drag preview appears correctly
- [ ] Drop targets highlight properly
- [ ] Files can be moved
- [ ] Undo (Ctrl+Z) works
### Modals
- [ ] Confirmation modals appear
- [ ] Prompt modals appear
- [ ] Modals don't double-open
- [ ] Enter/Escape keys work
### UI Features
- [ ] Dark mode toggle works
- [ ] Collection selector works
- [ ] Column resizers work
- [ ] Notifications appear
- [ ] URL routing works
- [ ] View/Edit modes work
---
## Recommendations
### Immediate Actions (Before Production)
1. Remove dead code (Task 1)
2. Fix deprecated event handler (Task 6)
3. Create config module (Task 4)
### Short-term Actions (Next Sprint)
4. Extract utilities (Task 3)
5. Standardize notifications (Task 5)
6. Create event bus (Task 2)
### Medium-term Actions (Future Sprints)
7. Refactor ui-utils.js (Task 7)
8. Add logger (Task 9)
9. Standardize exports (Task 8)
---
## Success Metrics
**Before Refactoring:**
- Total Lines: ~3,587
- Dead Code: 213 lines (6%)
- Duplicated Code: ~50 lines
- Hard-coded Values: 15+
**After Refactoring:**
- Total Lines: ~3,400 (-5%)
- Dead Code: 0 lines
- Duplicated Code: 0 lines
- Hard-coded Values: 0
**Estimated Effort:** 5-8 days
---
## Conclusion
The UI codebase is generally well-structured. Main improvements needed:
1. Remove dead code
2. Extract duplicated utilities
3. Centralize configuration
4. Standardize patterns
Start with high-impact, low-risk changes first to ensure production readiness.

View File

@@ -0,0 +1,44 @@
## Using Docusaurus
Once you've set up Hero, you can use it to develop, manage and publish Docusaurus websites.
## Launch the Hero Website
To start a Hero Docusaurus website in development mode:
- Build the book then close the prompt with `Ctrl+C`
```bash
hero docs -d
```
- See the book on the local browser
```
bash /root/hero/var/docusaurus/develop.sh
```
You can then view the website in your browser at `https://localhost:3100`.
## Publish a Website
- To build and publish a Hero website:
- Development
```bash
hero docs -bpd
```
- Production
```bash
hero docs -bp
```
If you want to specify a different SSH key, use `-dk`:
```bash
hero docs -bpd -dk ~/.ssh/id_ed25519
```
> Note: The container handles the SSH agent and key management automatically on startup, so in most cases, you won't need to manually specify keys.

View File

@@ -0,0 +1,67 @@
You can build Hero as a Docker container.
The code is availabe at this [open-source repository](https://github.com/mik-tf/hero-container).
## Prerequisites
- Docker installed on your system (More info [here](https://manual.grid.tf/documentation/system_administrators/computer_it_basics/docker_basics.html#install-docker-desktop-and-docker-engine))
- SSH keys for deploying Hero websites (if publishing)
## Build the Image
- Clone the repository
```
git clone https://github.com/mik-tf/hero-container
cd hero-container
```
- Build the Docker image:
```bash
docker build -t heroc .
```
## Pull the Image from Docker Hub
If you don't want to build the image, you can pull it from Docker Hub.
```
docker pull logismosis/heroc
```
In this case, use `logismosi/heroc` instead of `heroc` to use the container.
## Run the Hero Container
You can run the container with an interactive shell:
```bash
docker run -it heroc
```
You can run the container with an interactive shell, while setting the host as your local network, mounting your current directory as the workspace and adding your SSH keys:
```bash
docker run --network=host \
-v $(pwd):/workspace \
-v ~/.ssh:/root/ssh \
-it heroc
```
By default, the container will:
- Start Redis server in the background
- Copy your SSH keys to the proper location
- Initialize the SSH agent
- Add your default SSH key (`id_ed25519`)
To use a different SSH key, specify it with the KEY environment variable (e.g. `KEY=id_ed25519`):
```bash
docker run --network=host \
-v $(pwd):/workspace \
-v ~/.ssh:/root/ssh \
-e KEY=your_custom_key_name \
-it heroc
```

View File

@@ -0,0 +1,22 @@
## Basic Hero
You can build Hero natively with the following lines:
```
curl https://raw.githubusercontent.com/freeflowuniverse/herolib/refs/heads/development/install_hero.sh > /tmp/install_hero.sh
bash /tmp/install_hero.sh
```
## Hero for Developers
For developers, use the following commands:
```
curl 'https://raw.githubusercontent.com/freeflowuniverse/herolib/refs/heads/development/install_v.sh' > /tmp/install_v.sh
bash /tmp/install_v.sh --analyzer --herolib
#DONT FORGET TO START A NEW SHELL (otherwise the paths will not be set)
```
## Hero with Docker
If you have issues running Hero natively, you can use the [Docker version of Hero](hero_docker.md).

View File

@@ -0,0 +1,5 @@
This ebook contains the basic information to get you started with the Hero tool.
## What is Hero?
Hero is an open-source toolset to work with Git, AI, mdBook, Docusaurus, Starlight and more.

View File

@@ -0,0 +1 @@
If you need help with Hero, reach out to the ThreeFold Support team [here](https://threefoldfaq.crisp.help/en/).

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

View File

@@ -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")
---

View File

@@ -0,0 +1,2 @@
# New File

View File

@@ -0,0 +1,40 @@
## Mycelium Product Presentation
This document provides an overview of the Mycelium technology stack (as commercially sold my our company GeoMind).
<div style={{
position: 'relative',
width: '100%',
height: 0,
paddingTop: '56.25%',
marginTop: '1.6em',
marginBottom: '0.9em',
overflow: 'hidden',
borderRadius: '8px',
willChange: 'transform'
}}>
<iframe
src="https://www.canva.com/design/DAG0UtzICsk/rqXpn5f3ibo2OpX-yDWmPQ/view?embed"
style={{
position: 'absolute',
width: '100%',
height: '100%',
top: 0,
left: 0,
border: 'none',
padding: 0,
margin: 0
}}
allowFullScreen={true}
allow="fullscreen">
</iframe>
</div>
<div style={{ marginTop: '10px' }}>
<a href="https://www.canva.com/design/DAG0UtzICsk/rqXpn5f3ibo2OpX-yDWmPQ/view"
target="_blank"
rel="noopener"
style={{ textDecoration: 'none' }}>
Geomind Product Intro 2025 (based on mycelium technology)
</a>
</div>

View File

@@ -1,10 +0,0 @@
# test
- 1
- 2
[2025 SeaweedFS Intro Slides.pdf](/notes/2025 SeaweedFS Intro Slides.pdf)

View File

@@ -0,0 +1,9 @@
# test
- 1
- 2
!!include path:test2.md

View File

@@ -0,0 +1,12 @@
## test2
- something
- another thing

View File

@@ -0,0 +1,426 @@
# UI Code Refactoring Plan
**Project:** Markdown Editor
**Date:** 2025-10-26
**Status:** In Progress
---
## Executive Summary
This document outlines a comprehensive refactoring plan for the UI codebase to improve maintainability, remove dead code, extract utilities, and standardize patterns. The refactoring is organized into 6 phases with 14 tasks, prioritized by risk and impact.
**Key Metrics:**
- Total Lines of Code: ~3,587
- Dead Code to Remove: 213 lines (6%)
- Estimated Effort: 5-8 days
- Risk Level: Mostly LOW to MEDIUM
---
## Phase 1: Analysis Summary
### Files Reviewed
**JavaScript Files (10):**
- `/static/js/app.js` (484 lines)
- `/static/js/column-resizer.js` (100 lines)
- `/static/js/confirmation.js` (170 lines)
- `/static/js/editor.js` (420 lines)
- `/static/js/file-tree-actions.js` (482 lines)
- `/static/js/file-tree.js` (865 lines)
- `/static/js/macro-parser.js` (103 lines)
- `/static/js/macro-processor.js` (157 lines)
- `/static/js/ui-utils.js` (305 lines)
- `/static/js/webdav-client.js` (266 lines)
**CSS Files (6):**
- `/static/css/variables.css` (32 lines)
- `/static/css/layout.css`
- `/static/css/file-tree.css`
- `/static/css/editor.css`
- `/static/css/components.css`
- `/static/css/modal.css`
**HTML Templates (1):**
- `/templates/index.html` (203 lines)
---
## Issues Found
### 🔴 HIGH PRIORITY
1. **Deprecated Modal Code (Dead Code)**
- Location: `/static/js/file-tree-actions.js` lines 262-474
- Impact: 213 lines of unused code (44% of file)
- Risk: LOW to remove
2. **Duplicated Event Bus Implementation**
- Location: `/static/js/app.js` lines 16-30
- Should be extracted to reusable module
3. **Duplicated Debounce Function**
- Location: `/static/js/editor.js` lines 404-414
- Should be shared utility
4. **Inconsistent Notification Usage**
- Mixed usage of `window.showNotification` vs `showNotification`
5. **Duplicated File Download Logic**
- Location: `/static/js/file-tree.js` lines 829-839
- Should be shared utility
6. **Hard-coded Values**
- Long-press threshold: 400ms
- Debounce delay: 300ms
- Drag preview width: 200px
- Toast delay: 3000ms
### 🟡 MEDIUM PRIORITY
7. **Global State Management**
- Location: `/static/js/app.js` lines 6-13
- Makes testing difficult
8. **Duplicated Path Manipulation**
- `path.split('/').pop()` appears 10+ times
- `path.substring(0, path.lastIndexOf('/'))` appears 5+ times
9. **Mixed Responsibility in ui-utils.js**
- Contains 6 different classes/utilities
- Should be split into separate modules
10. **Deprecated Event Handler**
- Location: `/static/js/file-tree-actions.js` line 329
- Uses deprecated `onkeypress`
### 🟢 LOW PRIORITY
11. **Unused Function Parameters**
12. **Magic Numbers in Styling**
13. **Inconsistent Comment Styles**
14. **Console.log Statements**
---
## Phase 2: Proposed Reusable Components
### 1. Config Module (`/static/js/config.js`)
Centralize all configuration values:
```javascript
export const Config = {
// Timing
LONG_PRESS_THRESHOLD: 400,
DEBOUNCE_DELAY: 300,
TOAST_DURATION: 3000,
// UI
DRAG_PREVIEW_WIDTH: 200,
TREE_INDENT_PX: 12,
MOUSE_MOVE_THRESHOLD: 5,
// Validation
FILENAME_PATTERN: /^[a-z0-9_]+(\.[a-z0-9_]+)*$/,
// Storage Keys
STORAGE_KEYS: {
DARK_MODE: 'darkMode',
SELECTED_COLLECTION: 'selectedCollection',
LAST_VIEWED_PAGE: 'lastViewedPage',
COLUMN_DIMENSIONS: 'columnDimensions'
}
};
```
### 2. Logger Module (`/static/js/logger.js`)
Structured logging with levels:
```javascript
export class Logger {
static debug(message, ...args)
static info(message, ...args)
static warn(message, ...args)
static error(message, ...args)
static setLevel(level)
}
```
### 3. Event Bus Module (`/static/js/event-bus.js`)
Centralized event system:
```javascript
export class EventBus {
on(event, callback)
off(event, callback)
once(event, callback)
dispatch(event, data)
clear(event)
}
```
### 4. Utilities Module (`/static/js/utils.js`)
Common utility functions:
```javascript
export const PathUtils = {
getFileName(path),
getParentPath(path),
normalizePath(path),
joinPaths(...paths),
getExtension(path)
};
export const TimingUtils = {
debounce(func, wait),
throttle(func, wait)
};
export const DownloadUtils = {
triggerDownload(content, filename),
downloadAsBlob(blob, filename)
};
export const ValidationUtils = {
validateFileName(name, isFolder),
sanitizeFileName(name)
};
```
### 5. Notification Service (`/static/js/notification-service.js`)
Standardized notifications:
```javascript
export class NotificationService {
static success(message)
static error(message)
static warning(message)
static info(message)
}
```
---
## Phase 3: Refactoring Tasks
### 🔴 HIGH PRIORITY
**Task 1: Remove Dead Code**
- Files: `/static/js/file-tree-actions.js`
- Lines: 262-474 (213 lines)
- Risk: LOW
- Dependencies: None
**Task 2: Extract Event Bus**
- Files: NEW `/static/js/event-bus.js`, MODIFY `app.js`, `editor.js`
- Risk: MEDIUM
- Dependencies: None
**Task 3: Create Utilities Module**
- Files: NEW `/static/js/utils.js`, MODIFY multiple files
- Risk: MEDIUM
- Dependencies: None
**Task 4: Create Config Module**
- Files: NEW `/static/js/config.js`, MODIFY multiple files
- Risk: LOW
- Dependencies: None
**Task 5: Standardize Notification Usage**
- Files: NEW `/static/js/notification-service.js`, MODIFY multiple files
- Risk: LOW
- Dependencies: None
### 🟡 MEDIUM PRIORITY
**Task 6: Fix Deprecated Event Handler**
- Files: `/static/js/file-tree-actions.js` line 329
- Risk: LOW
- Dependencies: None
**Task 7: Refactor ui-utils.js**
- Files: DELETE `ui-utils.js`, CREATE 5 new modules
- Risk: HIGH
- Dependencies: Task 5
**Task 8: Standardize Class Export Pattern**
- Files: All class files
- Risk: MEDIUM
- Dependencies: None
**Task 9: Create Logger Module**
- Files: NEW `/static/js/logger.js`, MODIFY multiple files
- Risk: LOW
- Dependencies: None
**Task 10: Implement Download Action**
- Files: `/static/js/file-tree-actions.js`
- Risk: LOW
- Dependencies: Task 3
### 🟢 LOW PRIORITY
**Task 11: Standardize JSDoc Comments**
**Task 12: Extract Magic Numbers to CSS**
**Task 13: Add Error Boundaries**
**Task 14: Cache DOM Elements**
---
## Phase 4: Implementation Order
### Step 1: Foundation (Do First)
1. Create Config Module (Task 4)
2. Create Logger Module (Task 9)
3. Create Event Bus Module (Task 2)
### Step 2: Utilities (Do Second)
4. Create Utilities Module (Task 3)
5. Create Notification Service (Task 5)
### Step 3: Cleanup (Do Third)
6. Remove Dead Code (Task 1)
7. Fix Deprecated Event Handler (Task 6)
### Step 4: Restructuring (Do Fourth)
8. Refactor ui-utils.js (Task 7)
9. Standardize Class Export Pattern (Task 8)
### Step 5: Enhancements (Do Fifth)
10. Implement Download Action (Task 10)
11. Add Error Boundaries (Task 13)
### Step 6: Polish (Do Last)
12. Standardize JSDoc Comments (Task 11)
13. Extract Magic Numbers to CSS (Task 12)
14. Cache DOM Elements (Task 14)
---
## Phase 5: Testing Checklist
### Core Functionality
- [ ] File tree loads and displays correctly
- [ ] Files can be selected and opened
- [ ] Folders can be expanded/collapsed
- [ ] Editor loads file content
- [ ] Preview renders markdown correctly
- [ ] Save button saves files
- [ ] Delete button deletes files
- [ ] New button creates new files
### Context Menu Actions
- [ ] Right-click shows context menu
- [ ] New file action works
- [ ] New folder action works
- [ ] Rename action works
- [ ] Delete action works
- [ ] Copy/Cut/Paste actions work
- [ ] Upload action works
### Drag and Drop
- [ ] Long-press detection works
- [ ] Drag preview appears correctly
- [ ] Drop targets highlight properly
- [ ] Files can be moved
- [ ] Undo (Ctrl+Z) works
### Modals
- [ ] Confirmation modals appear
- [ ] Prompt modals appear
- [ ] Modals don't double-open
- [ ] Enter/Escape keys work
### UI Features
- [ ] Dark mode toggle works
- [ ] Collection selector works
- [ ] Column resizers work
- [ ] Notifications appear
- [ ] URL routing works
- [ ] View/Edit modes work
---
## Recommendations
### Immediate Actions (Before Production)
1. Remove dead code (Task 1)
2. Fix deprecated event handler (Task 6)
3. Create config module (Task 4)
### Short-term Actions (Next Sprint)
4. Extract utilities (Task 3)
5. Standardize notifications (Task 5)
6. Create event bus (Task 2)
### Medium-term Actions (Future Sprints)
7. Refactor ui-utils.js (Task 7)
8. Add logger (Task 9)
9. Standardize exports (Task 8)
---
## Success Metrics
**Before Refactoring:**
- Total Lines: ~3,587
- Dead Code: 213 lines (6%)
- Duplicated Code: ~50 lines
- Hard-coded Values: 15+
**After Refactoring:**
- Total Lines: ~3,400 (-5%)
- Dead Code: 0 lines
- Duplicated Code: 0 lines
- Hard-coded Values: 0
**Estimated Effort:** 5-8 days
---
## Conclusion
The UI codebase is generally well-structured. Main improvements needed:
1. Remove dead code
2. Extract duplicated utilities
3. Centralize configuration
4. Standardize patterns
Start with high-impact, low-risk changes first to ensure production readiness.

78
collections/notes/why.md Normal file
View File

@@ -0,0 +1,78 @@
**Decentralized Infrastructure Technology for Everyone, Everywhere**
Mycelium is a comprehensive DePIN (Decentralized Physical Infrastructure) system designed to scale to planetary level, capable of providing resilient services with end-to-end encryption, and enabling any machine and human to communicate efficiently over optimal paths.
Mycelium is Compatible with Kubernetes, Docker, VMs, Web2, Web3 and building towards Web4.
## Terminology Clarification
- **Mycelium Tech**: The core technology stack (ZOS, QSS, Mycelium Network)
- **ThreeFold Grid**: The decentralized infrastructure offering built on Mycelium Tech
- **GeoMind**: The commercial tech company operating tier-S/H datacenters with Mycelium
## Why Decentralized Infrastructure Matters
Traditional internet infrastructure is burdened with inefficiencies, risks, and growing dependency on centralization.
### **The Challenges We Face**
- **Centralization Risks**: Digital infrastructure is controlled by a few corporations, compromising autonomy and creating single points of failure.
- **Economic Inefficiency**: Current infrastructure models extract value from local economies, funneling resources to centralized providers.
- **Outdated Protocols**: TCP/IP, the internet's core protocol, was never designed for modern needs like dynamic networks, security, and session management.
- **Geographic Inefficiency**: Over 70% of the world relies on distant infrastructure, making access expensive, unreliable, and dependent on fragile global systems.
- **Limited Access**: Over 50% of the world lacks decent internet access, widening opportunity gaps.
Mycelium addresses these challenges through a complete, integrated technology stack designed from first principles.
## What Mycelium Provides
Mycelium is unique in its ability to deliver an integrated platform covering all three fundamental layers of internet infrastructure:
### **Compute Layer** - ZOS
- Autonomous, stateless operating system
- MyImage architecture (up to 100x faster deployment)
- Deterministic, cryptographically verified deployment
- Supports Kubernetes, containers, VMs, and Linux workloads
- Self-healing with no manual maintenance required
### **Storage Layer** - Quantum Safe Storage (QSS)
- Mathematical encoding with forward error correction
- 20% overhead vs 400% for traditional replication
- Zero-knowledge design: storage nodes can't access data
- Petabyte-to-zetabyte scalability
- Self-healing bitrot protection
### **Network Layer** - Mycelium Network
- End-to-end encrypted IPv6 overlay
- Shortest-path optimization
- Multi-protocol support (TCP, QUIC, UDP, satellite, wireless)
- Peer-to-peer architecture with no central points of failure
- Distributed secure name services
## Key Differentiators
| Feature | Mycelium | Traditional Cloud |
| ------------------------ | -------------------------------------------- | ------------------------------------------ |
| **Architecture** | Distributed peer-to-peer, no central control | Centralized control planes |
| **Deployment** | Stateless network boot, zero-install | Local image installation |
| **Storage Efficiency** | 20% overhead | 300-400% overhead |
| **Security** | End-to-end encrypted, zero-knowledge design | Perimeter-based, trust intermediaries |
| **Energy** | Up to 10x more efficient | Higher consumption |
| **Autonomy** | Self-healing, autonomous agents | Requires active management |
| **Geographic Awareness** | Shortest path routing, location-aware | Static routing, no geographic optimization |
## Current Status
- **Deployed**: 20+ countries, 30,000+ vCPU
- **Proof of Concept**: Technology validated in production
- **Commercialization**: Beginning phase with enterprise roadmap
## Technology Maturity
- **All our core cloud technology**: Production
- **Quantum Safe Storage**: Production (6+ years)
- **Mycelium Network**: Beta
- **Deterministic Deployment**: OEM only
- **FungiStor**: H1 2026
Mycelium represents not just an upgrade to existing infrastructure, but a fundamental rethinking of how internet infrastructure should be built—distributed, autonomous, secure, and efficient.%

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 403 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 409 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 388 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

View File

@@ -0,0 +1,79 @@
**Decentralized Infrastructure Technology for Everyone, Everywhere**
Mycelium is a comprehensive DePIN (Decentralized Physical Infrastructure) system designed to scale to planetary level, capable of providing resilient services with end-to-end encryption, and enabling any machine and human to communicate efficiently over optimal paths.
Mycelium is Compatible with Kubernetes, Docker, VMs, Web2, Web3 and building towards Web4.
## Terminology Clarification
- **Mycelium Tech**: The core technology stack (ZOS, QSS, Mycelium Network)
- **ThreeFold Grid**: The decentralized infrastructure offering built on Mycelium Tech
- **GeoMind**: The commercial tech company operating tier-S/H datacenters with Mycelium
## Why Decentralized Infrastructure Matters
Traditional internet infrastructure is burdened with inefficiencies, risks, and growing dependency on centralization.
### **The Challenges We Face**
- **Centralization Risks**: Digital infrastructure is controlled by a few corporations, compromising autonomy and creating single points of failure.
- **Economic Inefficiency**: Current infrastructure models extract value from local economies, funneling resources to centralized providers.
- **Outdated Protocols**: TCP/IP, the internet's core protocol, was never designed for modern needs like dynamic networks, security, and session management.
- **Geographic Inefficiency**: Over 70% of the world relies on distant infrastructure, making access expensive, unreliable, and dependent on fragile global systems.
- **Limited Access**: Over 50% of the world lacks decent internet access, widening opportunity gaps.
Mycelium addresses these challenges through a complete, integrated technology stack designed from first principles.
## What Mycelium Provides
Mycelium is unique in its ability to deliver an integrated platform covering all three fundamental layers of internet infrastructure:
### **Compute Layer** - ZOS
- Autonomous, stateless operating system
- MyImage architecture (up to 100x faster deployment)
- Deterministic, cryptographically verified deployment
- Supports Kubernetes, containers, VMs, and Linux workloads
- Self-healing with no manual maintenance required
### **Storage Layer** - Quantum Safe Storage (QSS)
- Mathematical encoding with forward error correction
- 20% overhead vs 400% for traditional replication
- Zero-knowledge design: storage nodes can't access data
- Petabyte-to-zetabyte scalability
- Self-healing bitrot protection
### **Network Layer** - Mycelium Network
- End-to-end encrypted IPv6 overlay
- Shortest-path optimization
- Multi-protocol support (TCP, QUIC, UDP, satellite, wireless)
- Peer-to-peer architecture with no central points of failure
- Distributed secure name services
## Key Differentiators
| Feature | Mycelium | Traditional Cloud |
| ------------------------ | -------------------------------------------- | ------------------------------------------ |
| **Architecture** | Distributed peer-to-peer, no central control | Centralized control planes |
| **Deployment** | Stateless network boot, zero-install | Local image installation |
| **Storage Efficiency** | 20% overhead | 300-400% overhead |
| **Security** | End-to-end encrypted, zero-knowledge design | Perimeter-based, trust intermediaries |
| **Energy** | Up to 10x more efficient | Higher consumption |
| **Autonomy** | Self-healing, autonomous agents | Requires active management |
| **Geographic Awareness** | Shortest path routing, location-aware | Static routing, no geographic optimization |
## Current Status
- **Deployed**: 20+ countries, 30,000+ vCPU
- **Proof of Concept**: Technology validated in production
- **Commercialization**: Beginning phase with enterprise roadmap
## Technology Maturity
- **All our core cloud technology**: Production
- **Quantum Safe Storage**: Production (6+ years)
- **Mycelium Network**: Beta
- **Deterministic Deployment**: OEM only
- **FungiStor**: H1 2026
Mycelium represents not just an upgrade to existing infrastructure, but a fundamental rethinking of how internet infrastructure should be built—distributed, autonomous, secure, and efficient.

View File

@@ -0,0 +1,42 @@
## Mycelium Product Presentation
This document provides an overview of the Mycelium technology stack (as commercially sold my our company GeoMind).
<div style={{
position: 'relative',
width: '100%',
height: 0,
paddingTop: '56.25%',
marginTop: '1.6em',
marginBottom: '0.9em',
overflow: 'hidden',
borderRadius: '8px',
willChange: 'transform'
}}>
<iframe
src="https://www.canva.com/design/DAG0UtzICsk/rqXpn5f3ibo2OpX-yDWmPQ/view?embed"
style={{
position: 'absolute',
width: '100%',
height: '100%',
top: 0,
left: 0,
border: 'none',
padding: 0,
margin: 0
}}
allowFullScreen={true}
allow="fullscreen">
</iframe>
</div>
<div style={{ marginTop: '10px' }}>
<a href="https://www.canva.com/design/DAG0UtzICsk/rqXpn5f3ibo2OpX-yDWmPQ/view"
target="_blank"
rel="noopener"
style={{ textDecoration: 'none' }}>
Geomind Product Intro 2025 (based on mycelium technology)
</a>
</div>

View File

@@ -0,0 +1,50 @@
# Government, Commercial Hosters, Telco and Enterprise Roadmap
We are working on the government, commercial hosters, telco and enterprise releases of our technology.
> 90% of the work has been done as part of our base offering but we need additional features for enterprises.
## Enterprise User Interface
The current user interface is designed for an open-source tech audience. For enterprise use, we need a different approach to meet the unique needs of enterprise environments:
- **Private or Hybrid Context**: All operations should be conducted within a private or hybrid cloud context to ensure security and compliance.
- **Enhanced Monitoring**: We need more comprehensive monitoring dashboard screens to provide real-time insights and analytics.
- **Identity Management Integration**: Integration with enterprise-grade Identity Management solutions, such as LDAP, Active Directory, and SSO (Single Sign-On), is essential.
- **Enterprise-Friendly UI**: The user interface needs to be redesigned to be more intuitive and tailored to enterprise users, focusing on usability and efficiency.
- **Token Irrelevance**: Tokens are not a priority in this context and should be de-emphasized in the solution.
## Windows Support
The virtual Machine technology we use does support Windows, but we need to do some further integration.
## High Performance Network Integration
- **Local Network Integration**: ZOS is designed to support a wide range of technologies, though additional integration work is required to optimize performance.
- **High-Speed Backbones**: We aim to support high-speed Ethernet and RDMA (Infiniband) based backbones.
- **Instrumentation Enhancements**: Additional instrumentation needs to be incorporated into ZOS to achieve optimal performance.
- **Target Performance**: Our goal is to achieve network speeds exceeding 100 Gbps.
- **Custom Integration**: We offer integration with selected network equipment from our customers, accommodating custom integration requirements.
## High Performance Storage Block Device Integration
Next to the existing already integrated storage backends we want to support a high performance redundant storage block device.
- High performance redundant storage network
- Supports high-speed backbones as defined above
- Scalable to thousands of machines per cluster.
- Replication capability between zones.
- Custom Integration
- We offer integration with selected storage equipment from our customers, accommodating custom integration requirements.
## Service Level Management
- The system will have hooks and visualization for achievement of Service levels.
- This will allow a commercial service provider to get to higher revenue and better uptime management.
## Support for Liquid Cooling Tanks
- Do a test setup in liquid cooling rack or node.
- We can use our self-healing capabilities to manage in a better way.
- This is an integration effort, and not much code changes are needed.

View File

@@ -0,0 +1,24 @@
## AI Agent High Level Roadmap
MyAgent is our private AI agent.
The first version of our MyAgent enables the management of core services such as an innovative database backend, a autonomous decentralized git system, and the automatic integration and deployment of our workloads.
This stack allows everyone to deploy scalable Web 2,3 and 4 apps on top of the TFGrid in a fully automated way.
| | Roadmap | Timing |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------ | ------ |
| MyAgent Publisher | Publish websites, e-books, and more on top of the ThreeFold Grid | H2 25 |
| MyAgent CI = Continuous Integration | Easier to use Continuous Integration/Development, very powerfull, with multinode support | H2 25 |
| MyAgent Play | Integrate declarative automation and configuration management as part of wiki approach in MyAgent Publisher | H2 25 |
| MyAgent Git | Alternative to centralized Github (based on Gitea), fully integrated on top of TFGrid | H2 25 |
| MyAgent DB | Flexible ultra redundant database stor with indexing, queries, stored procedures, super scalable replication | H2 25 |
| MyAgent OSIS | Object Storage and Index system | H2 25 |
| MyAgent WEB | Web framework, deployable globally on TFGrid, integrated with Mycelium Net and Names | H2 25 |
| MyAgent Monitor | Monitor all your different components on redundant monitoring stack | H2 25 |
| MyAgent Happs | MyAgent natively supports Holochain HAPPS | Q4 25 |
| MyAgent Actors | MyAgent can serve actors which respond and act on OpenRPC calls ideal as backend for web or other apps | Q4 25 |
| MyAgent Web 3 Gateway | MyAgent aims to have native support for chosen Web3 partner solutions (Bitcoin, Ethereum, and more) | Q4 25 |
All of the specs above are fully integrated with the Mycelium Network and the ThreeFold Grid.

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

View File

@@ -0,0 +1,50 @@
## High Level Roadmap
### Status Today
The core offering is functioning effectively, maintained through a community-driven, best-effort approach. Currently,
there are no Service Level Agreements (SLAs) in place, and there should be increased visibility for users regarding their expectations for uptime, performance, and other service related requirements.
The uptime and stability of ZOS are very good.
Additionally, hardware compatibility is excellent, with most machines now supported out of the box.
| | Status today | SDK/API | Web UI |
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------- | ------- | ------ |
| ZOS | Used for management of +30,000 logical CPU cores | yes | yes |
| MyImage (flists) | Basis for ZOS modules as well as replaces images for VM's ... | yes | yes |
| MyImage from Docker | convert docker through our Hub | yes | yes |
| MyImage Hub | Mycelium is hosting some as well as everyone can install their own Hub | yes | yes |
| Mycelium Core | Integrated in ZOS for VM's as well s ZDB and monitoring | yes | yes |
| Mycelium Message Bus | Can be used by any developer for their own usecases | NA | NA |
| Quantum Safe Storage | Usable for experts only, is reliably working for +6 years, +100 MB/sec per stream | yes | no |
| Unbreakable Filesystem | Quantum Safe FS= usable for experts, is a fuse based filesystem on top of the QSS Core | yes | no |
| ZOS Kubernetes | Working very well, Integrated in ZOS, uses our overlay networks based on Wireguard, can use Quantum Safe FS underneith. | yes | yes |
| ZOS VM's | The base of our service portfolio, missing is better service level management | yes | yes |
| ZOS Monitoring | Working well | yes | yes |
| ZOS VM Monitoring | Working well, can be retrieved through SDK | yes | yes |
| ZOS Web Gateway | Working well, but documentation not good enough, and not enough of them deployed | yes | yes |
| Zero-Boot | There are multiple ways active on how to deploy ZOS all are stateless and capable for full secure boot | yes | yes |
### Planned new features
Considerable effort is being made to enable our partners to go into production;
however, for this initiative to truly succeed on planetary level, we need many more nodes deployed in the field.
Below you can find some of the planned features of Mycelium Network 4.0 mainly to achieve ability to scale to hundred of thousand of nodes.
| | Roadmap | Timing |
| ------------------------------- | ------------------------------------------------------------------- | ------- |
| ZOS v4 (our next major release) | V4, without Mycelium Chain, mutual credit, marketplace | Q2/3 25 |
| MyImage from Docker | CI/CD integration (See MyAgent CI/CD) | Q1 25 |
| MyImage Hub Integration | CI/CD integration (See MyAgent CI/CD) no more need for separate Hub | Q1 25 |
| Mycelium Core | Just more hardening and testing | Q1 25 |
| Mycelium Message Bus | Replace our current RMB, all our own RPC over Mycelium | Q1 25 |
| ZOS VM's Cloud Slices | Integration MyAgent CI, use cloud slices to manage | Q2 25 |
| ZOS Monitoring Docu | More docu and easier API | Q2 25 |
| ZOS Web Gateway Expansion | Need more deployed, better integration with new Mycelium | Q2 25 |
| Mycelium Names | In V4, name services | Q2 25 |
| ZOS Cloud,Storage,AI Slices | As part of marketplace for V4, flexible billing mutual credit | Q3 25 |
| FungiStor | A revolutionary different way how to deliver content | Q3 25 |
| MyImage on FungiStor | Can be stored on FungiStor | Q3 25 |

View File

@@ -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.

View File

@@ -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

43
collections/tech/what.md Normal file
View File

@@ -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 |

View File

@@ -1,25 +1,22 @@
# WsgiDAV Configuration
# Collections define WebDAV-accessible directories
collections:
documents:
path: "./collections/documents"
description: "General documents and notes"
path: ./collections/documents
description: General documents and notes
notes:
path: "./collections/notes"
description: "Personal notes and drafts"
path: ./collections/notes
description: Personal notes and drafts
projects:
path: "./collections/projects"
description: "Project documentation"
# Server settings
path: ./collections/projects
description: Project documentation
7madah:
path: collections/7madah
description: 'User-created collection: 7madah'
tech:
path: collections/tech
description: 'User-created collection: tech'
server:
host: "localhost"
host: localhost
port: 8004
# WebDAV settings
webdav:
verbose: 1
enable_loggers: []

426
refactor-plan.md Normal file
View File

@@ -0,0 +1,426 @@
# UI Code Refactoring Plan
**Project:** Markdown Editor
**Date:** 2025-10-26
**Status:** In Progress
---
## Executive Summary
This document outlines a comprehensive refactoring plan for the UI codebase to improve maintainability, remove dead code, extract utilities, and standardize patterns. The refactoring is organized into 6 phases with 14 tasks, prioritized by risk and impact.
**Key Metrics:**
- Total Lines of Code: ~3,587
- Dead Code to Remove: 213 lines (6%)
- Estimated Effort: 5-8 days
- Risk Level: Mostly LOW to MEDIUM
---
## Phase 1: Analysis Summary
### Files Reviewed
**JavaScript Files (10):**
- `/static/js/app.js` (484 lines)
- `/static/js/column-resizer.js` (100 lines)
- `/static/js/confirmation.js` (170 lines)
- `/static/js/editor.js` (420 lines)
- `/static/js/file-tree-actions.js` (482 lines)
- `/static/js/file-tree.js` (865 lines)
- `/static/js/macro-parser.js` (103 lines)
- `/static/js/macro-processor.js` (157 lines)
- `/static/js/ui-utils.js` (305 lines)
- `/static/js/webdav-client.js` (266 lines)
**CSS Files (6):**
- `/static/css/variables.css` (32 lines)
- `/static/css/layout.css`
- `/static/css/file-tree.css`
- `/static/css/editor.css`
- `/static/css/components.css`
- `/static/css/modal.css`
**HTML Templates (1):**
- `/templates/index.html` (203 lines)
---
## Issues Found
### 🔴 HIGH PRIORITY
1. **Deprecated Modal Code (Dead Code)**
- Location: `/static/js/file-tree-actions.js` lines 262-474
- Impact: 213 lines of unused code (44% of file)
- Risk: LOW to remove
2. **Duplicated Event Bus Implementation**
- Location: `/static/js/app.js` lines 16-30
- Should be extracted to reusable module
3. **Duplicated Debounce Function**
- Location: `/static/js/editor.js` lines 404-414
- Should be shared utility
4. **Inconsistent Notification Usage**
- Mixed usage of `window.showNotification` vs `showNotification`
5. **Duplicated File Download Logic**
- Location: `/static/js/file-tree.js` lines 829-839
- Should be shared utility
6. **Hard-coded Values**
- Long-press threshold: 400ms
- Debounce delay: 300ms
- Drag preview width: 200px
- Toast delay: 3000ms
### 🟡 MEDIUM PRIORITY
7. **Global State Management**
- Location: `/static/js/app.js` lines 6-13
- Makes testing difficult
8. **Duplicated Path Manipulation**
- `path.split('/').pop()` appears 10+ times
- `path.substring(0, path.lastIndexOf('/'))` appears 5+ times
9. **Mixed Responsibility in ui-utils.js**
- Contains 6 different classes/utilities
- Should be split into separate modules
10. **Deprecated Event Handler**
- Location: `/static/js/file-tree-actions.js` line 329
- Uses deprecated `onkeypress`
### 🟢 LOW PRIORITY
11. **Unused Function Parameters**
12. **Magic Numbers in Styling**
13. **Inconsistent Comment Styles**
14. **Console.log Statements**
---
## Phase 2: Proposed Reusable Components
### 1. Config Module (`/static/js/config.js`)
Centralize all configuration values:
```javascript
export const Config = {
// Timing
LONG_PRESS_THRESHOLD: 400,
DEBOUNCE_DELAY: 300,
TOAST_DURATION: 3000,
// UI
DRAG_PREVIEW_WIDTH: 200,
TREE_INDENT_PX: 12,
MOUSE_MOVE_THRESHOLD: 5,
// Validation
FILENAME_PATTERN: /^[a-z0-9_]+(\.[a-z0-9_]+)*$/,
// Storage Keys
STORAGE_KEYS: {
DARK_MODE: 'darkMode',
SELECTED_COLLECTION: 'selectedCollection',
LAST_VIEWED_PAGE: 'lastViewedPage',
COLUMN_DIMENSIONS: 'columnDimensions'
}
};
```
### 2. Logger Module (`/static/js/logger.js`)
Structured logging with levels:
```javascript
export class Logger {
static debug(message, ...args)
static info(message, ...args)
static warn(message, ...args)
static error(message, ...args)
static setLevel(level)
}
```
### 3. Event Bus Module (`/static/js/event-bus.js`)
Centralized event system:
```javascript
export class EventBus {
on(event, callback)
off(event, callback)
once(event, callback)
dispatch(event, data)
clear(event)
}
```
### 4. Utilities Module (`/static/js/utils.js`)
Common utility functions:
```javascript
export const PathUtils = {
getFileName(path),
getParentPath(path),
normalizePath(path),
joinPaths(...paths),
getExtension(path)
};
export const TimingUtils = {
debounce(func, wait),
throttle(func, wait)
};
export const DownloadUtils = {
triggerDownload(content, filename),
downloadAsBlob(blob, filename)
};
export const ValidationUtils = {
validateFileName(name, isFolder),
sanitizeFileName(name)
};
```
### 5. Notification Service (`/static/js/notification-service.js`)
Standardized notifications:
```javascript
export class NotificationService {
static success(message)
static error(message)
static warning(message)
static info(message)
}
```
---
## Phase 3: Refactoring Tasks
### 🔴 HIGH PRIORITY
**Task 1: Remove Dead Code**
- Files: `/static/js/file-tree-actions.js`
- Lines: 262-474 (213 lines)
- Risk: LOW
- Dependencies: None
**Task 2: Extract Event Bus**
- Files: NEW `/static/js/event-bus.js`, MODIFY `app.js`, `editor.js`
- Risk: MEDIUM
- Dependencies: None
**Task 3: Create Utilities Module**
- Files: NEW `/static/js/utils.js`, MODIFY multiple files
- Risk: MEDIUM
- Dependencies: None
**Task 4: Create Config Module**
- Files: NEW `/static/js/config.js`, MODIFY multiple files
- Risk: LOW
- Dependencies: None
**Task 5: Standardize Notification Usage**
- Files: NEW `/static/js/notification-service.js`, MODIFY multiple files
- Risk: LOW
- Dependencies: None
### 🟡 MEDIUM PRIORITY
**Task 6: Fix Deprecated Event Handler**
- Files: `/static/js/file-tree-actions.js` line 329
- Risk: LOW
- Dependencies: None
**Task 7: Refactor ui-utils.js**
- Files: DELETE `ui-utils.js`, CREATE 5 new modules
- Risk: HIGH
- Dependencies: Task 5
**Task 8: Standardize Class Export Pattern**
- Files: All class files
- Risk: MEDIUM
- Dependencies: None
**Task 9: Create Logger Module**
- Files: NEW `/static/js/logger.js`, MODIFY multiple files
- Risk: LOW
- Dependencies: None
**Task 10: Implement Download Action**
- Files: `/static/js/file-tree-actions.js`
- Risk: LOW
- Dependencies: Task 3
### 🟢 LOW PRIORITY
**Task 11: Standardize JSDoc Comments**
**Task 12: Extract Magic Numbers to CSS**
**Task 13: Add Error Boundaries**
**Task 14: Cache DOM Elements**
---
## Phase 4: Implementation Order
### Step 1: Foundation (Do First)
1. Create Config Module (Task 4)
2. Create Logger Module (Task 9)
3. Create Event Bus Module (Task 2)
### Step 2: Utilities (Do Second)
4. Create Utilities Module (Task 3)
5. Create Notification Service (Task 5)
### Step 3: Cleanup (Do Third)
6. Remove Dead Code (Task 1)
7. Fix Deprecated Event Handler (Task 6)
### Step 4: Restructuring (Do Fourth)
8. Refactor ui-utils.js (Task 7)
9. Standardize Class Export Pattern (Task 8)
### Step 5: Enhancements (Do Fifth)
10. Implement Download Action (Task 10)
11. Add Error Boundaries (Task 13)
### Step 6: Polish (Do Last)
12. Standardize JSDoc Comments (Task 11)
13. Extract Magic Numbers to CSS (Task 12)
14. Cache DOM Elements (Task 14)
---
## Phase 5: Testing Checklist
### Core Functionality
- [ ] File tree loads and displays correctly
- [ ] Files can be selected and opened
- [ ] Folders can be expanded/collapsed
- [ ] Editor loads file content
- [ ] Preview renders markdown correctly
- [ ] Save button saves files
- [ ] Delete button deletes files
- [ ] New button creates new files
### Context Menu Actions
- [ ] Right-click shows context menu
- [ ] New file action works
- [ ] New folder action works
- [ ] Rename action works
- [ ] Delete action works
- [ ] Copy/Cut/Paste actions work
- [ ] Upload action works
### Drag and Drop
- [ ] Long-press detection works
- [ ] Drag preview appears correctly
- [ ] Drop targets highlight properly
- [ ] Files can be moved
- [ ] Undo (Ctrl+Z) works
### Modals
- [ ] Confirmation modals appear
- [ ] Prompt modals appear
- [ ] Modals don't double-open
- [ ] Enter/Escape keys work
### UI Features
- [ ] Dark mode toggle works
- [ ] Collection selector works
- [ ] Column resizers work
- [ ] Notifications appear
- [ ] URL routing works
- [ ] View/Edit modes work
---
## Recommendations
### Immediate Actions (Before Production)
1. Remove dead code (Task 1)
2. Fix deprecated event handler (Task 6)
3. Create config module (Task 4)
### Short-term Actions (Next Sprint)
4. Extract utilities (Task 3)
5. Standardize notifications (Task 5)
6. Create event bus (Task 2)
### Medium-term Actions (Future Sprints)
7. Refactor ui-utils.js (Task 7)
8. Add logger (Task 9)
9. Standardize exports (Task 8)
---
## Success Metrics
**Before Refactoring:**
- Total Lines: ~3,587
- Dead Code: 213 lines (6%)
- Duplicated Code: ~50 lines
- Hard-coded Values: 15+
**After Refactoring:**
- Total Lines: ~3,400 (-5%)
- Dead Code: 0 lines
- Duplicated Code: 0 lines
- Hard-coded Values: 0
**Estimated Effort:** 5-8 days
---
## Conclusion
The UI codebase is generally well-structured. Main improvements needed:
1. Remove dead code
2. Extract duplicated utilities
3. Centralize configuration
4. Standardize patterns
Start with high-impact, low-risk changes first to ensure production readiness.

8
server_debug.log Normal file
View File

@@ -0,0 +1,8 @@
==============================================
Markdown Editor v3.0 - WebDAV Server
==============================================
Activating virtual environment...
Installing dependencies...
Audited 3 packages in 29ms
Checking for process on port 8004...
Starting WebDAV server...

View File

@@ -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 /

View File

@@ -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 = '<i class="bi bi-sun-fill"></i>';
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 = `
<div class="text-muted text-center mt-5">
@@ -243,17 +243,17 @@
`;
return;
}
try {
let html = marked.parse(markdown);
html = html.replace(
/<pre><code class="language-mermaid">([\s\S]*?)<\/code><\/pre>/g,
'<div class="mermaid">$1</div>'
);
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 = '<div class="text-muted text-center p-3">No files yet</div>';
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 @@
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
`;
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');
}

View File

@@ -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 = '<i class="bi bi-sun-fill"></i>';
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 = `
<div class="text-muted text-center mt-5">
@@ -244,15 +244,15 @@
try {
// Parse markdown to HTML
let html = marked.parse(content);
// Replace mermaid code blocks with div containers
html = html.replace(
/<pre><code class="language-mermaid">([\s\S]*?)<\/code><\/pre>/g,
'<div class="mermaid">$1</div>'
);
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 = '<div class="text-muted p-2 small">No files yet</div>';
return;
}
fileListDiv.innerHTML = files.map(file => `
<a href="#" class="list-group-item list-group-item-action file-item" data-filename="${file.filename}">
<span class="file-name">${file.filename}</span>
<span class="file-size">${formatFileSize(file.size)}</span>
</a>
`).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 = `
<div class="d-flex">
<div class="toast-body">${message}</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
`;
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');
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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%;
}

View File

@@ -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 = `
<div class="text-muted text-center mt-5">
<p>No content available</p>
</div>
`;
}
} catch (error) {
console.error('Failed to auto-load page in view mode:', error);
editor.previewElement.innerHTML = `
<div class="alert alert-danger">
<p>Failed to load content</p>
</div>
`;
}
};
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 = `<div class="directory-preview">`;
html += `<h2>${dirName}</h2>`;
if (files.length === 0) {
html += `<p>This directory is empty</p>`;
} else {
html += `<div class="directory-files">`;
// Create cards for each file
for (const file of files) {
const fileName = file.name;
let fileDescription = '';
// Try to get file description from markdown files
if (file.name.endsWith('.md')) {
try {
const content = await webdavClient.get(file.path);
// Extract first heading or first line as description
const lines = content.split('\n');
for (const line of lines) {
if (line.trim().startsWith('#')) {
fileDescription = line.replace(/^#+\s*/, '').trim();
break;
} else if (line.trim() && !line.startsWith('---')) {
fileDescription = line.trim().substring(0, 100);
break;
}
}
} catch (error) {
console.error('Failed to read file description:', error);
}
}
html += `
<div class="file-card" data-path="${file.path}">
<div class="file-card-header">
<i class="bi bi-file-earmark-text"></i>
<span class="file-card-name">${fileName}</span>
</div>
${fileDescription ? `<div class="file-card-description">${fileDescription}</div>` : ''}
</div>
`;
}
html += `</div>`;
}
html += `</div>`;
// Set the preview content
editor.previewElement.innerHTML = html;
// Add click handlers to file cards
editor.previewElement.querySelectorAll('.file-card').forEach(card => {
card.addEventListener('click', async () => {
const filePath = card.dataset.path;
await editor.loadFile(filePath);
fileTree.selectAndExpandPath(filePath);
});
});
} catch (error) {
console.error('Failed to show directory preview:', error);
editor.previewElement.innerHTML = `
<div class="alert alert-danger">
<p>Failed to load directory preview</p>
</div>
`;
}
}
/**
* Parse URL to extract collection and file path
* URL format: /<collection>/<file_path> or /<collection>/<dir>/<file>
* @returns {Object} {collection, filePath} or {collection, null} if only collection
*/
function parseURLPath() {
const pathname = window.location.pathname;
const parts = pathname.split('/').filter(p => p); // Remove empty parts
if (parts.length === 0) {
return { collection: null, filePath: null };
}
const collection = parts[0];
const filePath = parts.length > 1 ? parts.slice(1).join('/') : null;
return { collection, filePath };
}
/**
* Update URL based on current collection and file
* @param {string} collection - The collection name
* @param {string} filePath - The file path (optional)
* @param {boolean} isEditMode - Whether in edit mode
*/
function updateURL(collection, filePath, isEditMode) {
let url = `/${collection}`;
if (filePath) {
url += `/${filePath}`;
}
if (isEditMode) {
url += '?edit=true';
}
// Use pushState to update URL without reloading
window.history.pushState({ collection, filePath }, '', url);
}
/**
* Load file from URL path
* Assumes the collection is already set and file tree is loaded
* @param {string} collection - The collection name (for validation)
* @param {string} filePath - The file path
*/
async function loadFileFromURL(collection, filePath) {
console.log('[loadFileFromURL] Called with:', { collection, filePath });
if (!fileTree || !editor || !collectionSelector) {
console.error('[loadFileFromURL] Missing dependencies:', { fileTree: !!fileTree, editor: !!editor, collectionSelector: !!collectionSelector });
return;
}
try {
// Verify we're on the right collection
const currentCollection = collectionSelector.getCurrentCollection();
if (currentCollection !== collection) {
console.error(`[loadFileFromURL] Collection mismatch: expected ${collection}, got ${currentCollection}`);
return;
}
// Load the file or directory
if (filePath) {
// Check if the path is a directory or a file
const node = fileTree.findNode(filePath);
console.log('[loadFileFromURL] Found node:', node);
if (node && node.isDirectory) {
// It's a directory, show directory preview
console.log('[loadFileFromURL] Loading directory preview');
await showDirectoryPreview(filePath);
fileTree.selectAndExpandPath(filePath);
} else if (node) {
// It's a file, check if it's binary
console.log('[loadFileFromURL] Loading file');
// Use the fileTree.onFileSelect callback to handle both text and binary files
if (fileTree.onFileSelect) {
fileTree.onFileSelect({ path: filePath, isDirectory: false });
} else {
// Fallback to direct loading
await editor.loadFile(filePath);
fileTree.selectAndExpandPath(filePath);
}
} else {
console.error(`[loadFileFromURL] Path not found in file tree: ${filePath}`);
}
}
} catch (error) {
console.error('[loadFileFromURL] Failed to load file from URL:', error);
}
}
/**
* Handle browser back/forward navigation
*/
function setupPopStateListener() {
window.addEventListener('popstate', async (event) => {
const { collection, filePath } = parseURLPath();
if (collection) {
// Ensure the collection is set
const currentCollection = collectionSelector.getCurrentCollection();
if (currentCollection !== collection) {
await collectionSelector.setCollection(collection);
await fileTree.load();
}
// Load the file/directory
await loadFileFromURL(collection, filePath);
}
});
}
// Initialize application
document.addEventListener('DOMContentLoaded', async () => {
// Determine view mode from URL parameter
const urlParams = new URLSearchParams(window.location.search);
const isEditMode = urlParams.get('edit') === 'true';
// Set view mode class on body
if (isEditMode) {
document.body.classList.add('edit-mode');
document.body.classList.remove('view-mode');
} else {
document.body.classList.add('view-mode');
document.body.classList.remove('edit-mode');
}
// Initialize WebDAV client
webdavClient = new WebDAVClient('/fs/');
// 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 = `
<div style="padding: 20px; text-align: center;">
<h3>${fileName}</h3>
<p style="color: var(--text-secondary); margin-bottom: 20px;">Image Preview (Read-only)</p>
<img src="${fileUrl}" alt="${fileName}" style="max-width: 100%; height: auto; border: 1px solid var(--border-color); border-radius: 4px;">
</div>
`;
} else if (fileType === 'PDF') {
// Preview PDFs
previewHtml = `
<div style="padding: 20px;">
<h3>${fileName}</h3>
<p style="color: var(--text-secondary); margin-bottom: 20px;">PDF Preview (Read-only)</p>
<iframe src="${fileUrl}" style="width: 100%; height: 80vh; border: 1px solid var(--border-color); border-radius: 4px;"></iframe>
</div>
`;
} else {
// For other binary files, show download link
previewHtml = `
<div style="padding: 20px;">
<h3>${fileName}</h3>
<p style="color: var(--text-secondary); margin-bottom: 20px;">${fileType} File (Read-only)</p>
<p>This file cannot be previewed in the browser.</p>
<a href="${fileUrl}" download="${fileName}" class="btn btn-primary">Download ${fileName}</a>
</div>
`;
}
// Display in preview pane
editor.previewElement.innerHTML = previewHtml;
// Hide loading spinner after content is set
// Add small delay for images to start loading
setTimeout(() => {
if (editor.previewSpinner) {
editor.previewSpinner.hide();
}
}, fileType === 'Image' ? 300 : 100);
// Highlight the file in the tree
fileTree.selectAndExpandPath(item.path);
// Save as last viewed page (for binary files too)
editor.saveLastViewedPage(item.path);
// Update URL to reflect current file
updateURL(currentCollection, item.path, isEditMode);
return;
}
// For text files, restore the editor pane if it was hidden
if (isEditMode) {
const editorPane = document.getElementById('editorPane');
const resizer1 = document.getElementById('resizer1');
if (editorPane) editorPane.style.display = '';
if (resizer1) resizer1.style.display = '';
}
await editor.loadFile(item.path);
// Highlight the file in the tree and expand parent directories
fileTree.selectAndExpandPath(item.path);
// Update URL to reflect current file
updateURL(currentCollection, item.path, isEditMode);
} catch (error) {
Logger.error('Failed to select file:', error);
if (window.showNotification) {
window.showNotification('Failed to load file', 'error');
}
}
};
fileTree.onFolderSelect = async (item) => {
try {
// Show directory preview
await showDirectoryPreview(item.path);
// Highlight the directory in the tree and expand parent directories
fileTree.selectAndExpandPath(item.path);
// Update URL to reflect current directory
const currentCollection = collectionSelector.getCurrentCollection();
updateURL(currentCollection, item.path, isEditMode);
} catch (error) {
Logger.error('Failed to select folder:', error);
if (window.showNotification) {
window.showNotification('Failed to load folder', 'error');
}
}
};
collectionSelector.onChange = async (collection) => {
try {
await fileTree.load();
// In view mode, auto-load last viewed page when collection changes
if (!isEditMode) {
await autoLoadPageInViewMode();
}
} catch (error) {
Logger.error('Failed to change collection:', error);
if (window.showNotification) {
window.showNotification('Failed to change collection', 'error');
}
}
};
await fileTree.load();
// Parse URL to load file if specified
const { collection: urlCollection, filePath: urlFilePath } = parseURLPath();
console.log('[URL PARSE]', { urlCollection, urlFilePath });
if (urlCollection) {
// First ensure the collection is set
const currentCollection = collectionSelector.getCurrentCollection();
if (currentCollection !== urlCollection) {
console.log('[URL LOAD] Switching collection from', currentCollection, 'to', urlCollection);
await collectionSelector.setCollection(urlCollection);
await fileTree.load();
}
// If there's a file path in the URL, load it
if (urlFilePath) {
console.log('[URL LOAD] Loading file from URL:', urlCollection, urlFilePath);
await loadFileFromURL(urlCollection, urlFilePath);
} else if (!isEditMode) {
// Collection-only URL in view mode: auto-load last viewed page
console.log('[URL LOAD] Collection-only URL, auto-loading page');
await autoLoadPageInViewMode();
}
} else if (!isEditMode) {
// No URL collection specified, in view mode: auto-load last viewed page
await autoLoadPageInViewMode();
}
// Initialize file tree and editor-specific features only in edit mode
if (isEditMode) {
// Add test content to verify preview works
setTimeout(() => {
if (!editor.editor.getValue()) {
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) {

View File

@@ -0,0 +1,152 @@
/**
* Collection Selector Module
* Manages the collection dropdown selector and persistence
*/
class CollectionSelector {
constructor(selectId, webdavClient) {
this.select = document.getElementById(selectId);
this.webdavClient = webdavClient;
this.onChange = null;
this.storageKey = Config.STORAGE_KEYS.SELECTED_COLLECTION;
}
/**
* Load collections from WebDAV and populate the selector
*/
async load() {
try {
const collections = await this.webdavClient.getCollections();
this.select.innerHTML = '';
collections.forEach(collection => {
const option = document.createElement('option');
option.value = collection;
option.textContent = collection;
this.select.appendChild(option);
});
// Determine which collection to select (priority: URL > localStorage > first)
let collectionToSelect = collections[0]; // Default to first
// Check URL first (highest priority)
const urlCollection = this.getCollectionFromURL();
if (urlCollection && collections.includes(urlCollection)) {
collectionToSelect = urlCollection;
Logger.info(`Using collection from URL: ${urlCollection}`);
} else {
// Fall back to localStorage
const savedCollection = localStorage.getItem(this.storageKey);
if (savedCollection && collections.includes(savedCollection)) {
collectionToSelect = savedCollection;
Logger.info(`Using collection from localStorage: ${savedCollection}`);
}
}
if (collections.length > 0) {
this.select.value = collectionToSelect;
this.webdavClient.setCollection(collectionToSelect);
if (this.onChange) {
this.onChange(collectionToSelect);
}
}
// Add change listener
this.select.addEventListener('change', () => {
const collection = this.select.value;
// Save to localStorage
localStorage.setItem(this.storageKey, collection);
this.webdavClient.setCollection(collection);
Logger.info(`Collection changed to: ${collection}`);
// Update URL to reflect collection change
this.updateURLForCollection(collection);
if (this.onChange) {
this.onChange(collection);
}
});
Logger.debug(`Loaded ${collections.length} collections`);
} catch (error) {
Logger.error('Failed to load collections:', error);
if (window.showNotification) {
window.showNotification('Failed to load collections', 'error');
}
}
}
/**
* Get the currently selected collection
* @returns {string} The collection name
*/
getCurrentCollection() {
return this.select.value;
}
/**
* Set the collection to a specific value
* @param {string} collection - The collection name to set
*/
async setCollection(collection) {
const collections = Array.from(this.select.options).map(opt => opt.value);
if (collections.includes(collection)) {
this.select.value = collection;
localStorage.setItem(this.storageKey, collection);
this.webdavClient.setCollection(collection);
Logger.info(`Collection set to: ${collection}`);
// Update URL to reflect collection change
this.updateURLForCollection(collection);
if (this.onChange) {
this.onChange(collection);
}
} else {
Logger.warn(`Collection "${collection}" not found in available collections`);
}
}
/**
* Update the browser URL to reflect the current collection
* @param {string} collection - The collection name
*/
updateURLForCollection(collection) {
// Get current URL parameters
const urlParams = new URLSearchParams(window.location.search);
const isEditMode = urlParams.get('edit') === 'true';
// Build new URL with collection
let url = `/${collection}/`;
if (isEditMode) {
url += '?edit=true';
}
// Use pushState to update URL without reloading
window.history.pushState({ collection, filePath: null }, '', url);
Logger.debug(`Updated URL to: ${url}`);
}
/**
* Extract collection name from current URL
* URL format: /<collection>/ or /<collection>/<file_path>
* @returns {string|null} The collection name or null if not found
*/
getCollectionFromURL() {
const pathname = window.location.pathname;
const parts = pathname.split('/').filter(p => p); // Remove empty parts
if (parts.length === 0) {
return null;
}
// First part is the collection
return parts[0];
}
}
// Make CollectionSelector globally available
window.CollectionSelector = CollectionSelector;

View File

@@ -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);
}

207
static/js/config.js Normal file
View File

@@ -0,0 +1,207 @@
/**
* Application Configuration
* Centralized configuration values for the markdown editor
*/
const Config = {
// ===== TIMING CONFIGURATION =====
/**
* Long-press threshold in milliseconds
* Used for drag-and-drop detection in file tree
*/
LONG_PRESS_THRESHOLD: 400,
/**
* Debounce delay in milliseconds
* Used for editor preview updates
*/
DEBOUNCE_DELAY: 300,
/**
* Toast notification duration in milliseconds
*/
TOAST_DURATION: 3000,
/**
* Mouse move threshold in pixels
* Used to detect if user is dragging vs clicking
*/
MOUSE_MOVE_THRESHOLD: 5,
// ===== UI CONFIGURATION =====
/**
* Drag preview width in pixels
* Width of the drag ghost image during drag-and-drop
*/
DRAG_PREVIEW_WIDTH: 200,
/**
* Tree indentation in pixels
* Indentation per level in the file tree
*/
TREE_INDENT_PX: 12,
/**
* Toast container z-index
* Ensures toasts appear above other elements
*/
TOAST_Z_INDEX: 9999,
/**
* Minimum sidebar width in pixels
*/
MIN_SIDEBAR_WIDTH: 150,
/**
* Maximum sidebar width as percentage of container
*/
MAX_SIDEBAR_WIDTH_PERCENT: 40,
/**
* Minimum editor width in pixels
*/
MIN_EDITOR_WIDTH: 250,
/**
* Maximum editor width as percentage of container
*/
MAX_EDITOR_WIDTH_PERCENT: 70,
// ===== VALIDATION CONFIGURATION =====
/**
* Valid filename pattern
* Only lowercase letters, numbers, underscores, and dots allowed
*/
FILENAME_PATTERN: /^[a-z0-9_]+(\.[a-z0-9_]+)*$/,
/**
* Characters to replace in filenames
* All invalid characters will be replaced with underscore
*/
FILENAME_INVALID_CHARS: /[^a-z0-9_.]/g,
// ===== STORAGE KEYS =====
/**
* LocalStorage keys used throughout the application
*/
STORAGE_KEYS: {
/**
* Dark mode preference
*/
DARK_MODE: 'darkMode',
/**
* Currently selected collection
*/
SELECTED_COLLECTION: 'selectedCollection',
/**
* Last viewed page (per collection)
* Actual key will be: lastViewedPage:{collection}
*/
LAST_VIEWED_PAGE: 'lastViewedPage',
/**
* Column dimensions (sidebar, editor, preview widths)
*/
COLUMN_DIMENSIONS: 'columnDimensions',
/**
* Sidebar collapsed state
*/
SIDEBAR_COLLAPSED: 'sidebarCollapsed'
},
// ===== EDITOR CONFIGURATION =====
/**
* CodeMirror theme for light mode
*/
EDITOR_THEME_LIGHT: 'default',
/**
* CodeMirror theme for dark mode
*/
EDITOR_THEME_DARK: 'monokai',
/**
* Mermaid theme for light mode
*/
MERMAID_THEME_LIGHT: 'default',
/**
* Mermaid theme for dark mode
*/
MERMAID_THEME_DARK: 'dark',
// ===== FILE TREE CONFIGURATION =====
/**
* Default content for new files
*/
DEFAULT_FILE_CONTENT: '# New File\n\n',
/**
* Default filename for new files
*/
DEFAULT_NEW_FILENAME: 'new_file.md',
/**
* Default folder name for new folders
*/
DEFAULT_NEW_FOLDERNAME: 'new_folder',
// ===== WEBDAV CONFIGURATION =====
/**
* WebDAV base URL
*/
WEBDAV_BASE_URL: '/fs/',
/**
* PROPFIND depth for file tree loading
*/
PROPFIND_DEPTH: 'infinity',
// ===== DRAG AND DROP CONFIGURATION =====
/**
* Drag preview opacity
*/
DRAG_PREVIEW_OPACITY: 0.8,
/**
* Dragging item opacity
*/
DRAGGING_OPACITY: 0.4,
/**
* Drag preview offset X in pixels
*/
DRAG_PREVIEW_OFFSET_X: 10,
/**
* Drag preview offset Y in pixels
*/
DRAG_PREVIEW_OFFSET_Y: 10,
// ===== NOTIFICATION TYPES =====
/**
* Bootstrap notification type mappings
*/
NOTIFICATION_TYPES: {
SUCCESS: 'success',
ERROR: 'danger',
WARNING: 'warning',
INFO: 'primary'
}
};
// Make Config globally available
window.Config = Config;

View File

@@ -1,68 +1,180 @@
/**
* Confirmation Modal Manager
* Unified Modal Manager
* Handles showing and hiding a Bootstrap modal for confirmations and prompts.
* Uses a single reusable modal element to prevent double-opening issues.
*/
class Confirmation {
class ModalManager {
constructor(modalId) {
this.modalElement = document.getElementById(modalId);
this.modal = new bootstrap.Modal(this.modalElement);
if (!this.modalElement) {
console.error(`Modal element with id "${modalId}" not found`);
return;
}
this.modal = new bootstrap.Modal(this.modalElement, {
backdrop: 'static',
keyboard: true
});
this.messageElement = this.modalElement.querySelector('#confirmationMessage');
this.inputElement = this.modalElement.querySelector('#confirmationInput');
this.confirmButton = this.modalElement.querySelector('#confirmButton');
this.cancelButton = this.modalElement.querySelector('[data-bs-dismiss="modal"]');
this.titleElement = this.modalElement.querySelector('.modal-title');
this.currentResolver = null;
this.isShowing = false;
}
_show(message, title, showInput = false, defaultValue = '') {
/**
* Show a confirmation dialog
* @param {string} message - The message to display
* @param {string} title - The dialog title
* @param {boolean} isDangerous - Whether this is a dangerous action (shows red button)
* @returns {Promise<boolean>} - Resolves to true if confirmed, false/null if cancelled
*/
confirm(message, title = 'Confirmation', isDangerous = false) {
return new Promise((resolve) => {
// Prevent double-opening
if (this.isShowing) {
console.warn('Modal is already showing, ignoring duplicate request');
resolve(null);
return;
}
this.isShowing = true;
this.currentResolver = resolve;
this.titleElement.textContent = title;
this.messageElement.textContent = message;
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 = '<i class="bi bi-trash"></i> Delete';
} else {
this.inputElement.style.display = 'none';
this.confirmButton.className = 'btn-flat btn-flat-primary';
this.confirmButton.innerHTML = '<i class="bi bi-check-circle"></i> OK';
}
this.confirmButton.onclick = () => this._handleConfirm(showInput);
this.modalElement.addEventListener('hidden.bs.modal', () => this._handleCancel(), { once: true });
// Set up event handlers
this.confirmButton.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
this._handleConfirm(false);
};
// Handle modal hidden event for cleanup
this.modalElement.addEventListener('hidden.bs.modal', () => {
if (this.currentResolver) {
this._handleCancel();
}
}, { once: true });
// Remove aria-hidden before showing to prevent accessibility warning
this.modalElement.removeAttribute('aria-hidden');
this.modal.show();
// Focus confirm button after modal is shown
this.modalElement.addEventListener('shown.bs.modal', () => {
this.confirmButton.focus();
}, { once: true });
});
}
/**
* Show a prompt dialog (input dialog)
* @param {string} message - The message/label to display
* @param {string} defaultValue - The default input value
* @param {string} title - The dialog title
* @returns {Promise<string|null>} - Resolves to input value if confirmed, null if cancelled
*/
prompt(message, defaultValue = '', title = 'Input') {
return new Promise((resolve) => {
// Prevent double-opening
if (this.isShowing) {
console.warn('Modal is already showing, ignoring duplicate request');
resolve(null);
return;
}
this.isShowing = true;
this.currentResolver = resolve;
this.titleElement.textContent = title;
this.messageElement.textContent = message;
this.inputElement.style.display = 'block';
this.inputElement.value = defaultValue;
// Reset button to primary style for prompts
this.confirmButton.className = 'btn-flat btn-flat-primary';
this.confirmButton.innerHTML = '<i class="bi bi-check-circle"></i> OK';
// Set up event handlers
this.confirmButton.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
this._handleConfirm(true);
};
// Handle Enter key in input
this.inputElement.onkeydown = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
this._handleConfirm(true);
}
};
// Handle modal hidden event for cleanup
this.modalElement.addEventListener('hidden.bs.modal', () => {
if (this.currentResolver) {
this._handleCancel();
}
}, { once: true });
// Remove aria-hidden before showing to prevent accessibility warning
this.modalElement.removeAttribute('aria-hidden');
this.modal.show();
// Focus and select input after modal is shown
this.modalElement.addEventListener('shown.bs.modal', () => {
this.inputElement.focus();
this.inputElement.select();
}, { once: true });
});
}
_handleConfirm(isPrompt) {
if (this.currentResolver) {
const value = isPrompt ? this.inputElement.value : true;
this.currentResolver(value);
const value = isPrompt ? this.inputElement.value.trim() : true;
const resolver = this.currentResolver;
this._cleanup();
resolver(value);
}
}
_handleCancel() {
if (this.currentResolver) {
this.currentResolver(null); // Resolve with null for cancellation
const resolver = this.currentResolver;
this._cleanup();
resolver(null);
}
}
_cleanup() {
this.confirmButton.onclick = null;
this.modal.hide();
this.inputElement.onkeydown = null;
this.currentResolver = null;
}
this.isShowing = false;
this.modal.hide();
confirm(message, title = 'Confirmation') {
return this._show(message, title, false);
}
prompt(message, defaultValue = '', title = 'Prompt') {
return this._show(message, title, true, defaultValue);
// Restore aria-hidden after modal is hidden
this.modalElement.addEventListener('hidden.bs.modal', () => {
this.modalElement.setAttribute('aria-hidden', 'true');
}, { once: true });
}
}
// Make it globally available
window.ConfirmationManager = new Confirmation('confirmationModal');
window.ConfirmationManager = new ModalManager('confirmationModal');
window.ModalManager = window.ConfirmationManager; // Alias for clarity

89
static/js/context-menu.js Normal file
View File

@@ -0,0 +1,89 @@
/**
* Context Menu Module
* Handles the right-click context menu for file tree items
*/
/**
* Show context menu at specified position
* @param {number} x - X coordinate
* @param {number} y - Y coordinate
* @param {Object} target - Target object with path and isDir properties
*/
function showContextMenu(x, y, target) {
const menu = document.getElementById('contextMenu');
if (!menu) return;
// Store target data
menu.dataset.targetPath = target.path;
menu.dataset.targetIsDir = target.isDir;
// Show/hide menu items based on target type
const items = {
'new-file': target.isDir,
'new-folder': target.isDir,
'upload': target.isDir,
'download': true,
'paste': target.isDir && window.fileTreeActions?.clipboard,
'open': !target.isDir
};
Object.entries(items).forEach(([action, show]) => {
const item = menu.querySelector(`[data-action="${action}"]`);
if (item) {
item.style.display = show ? 'flex' : 'none';
}
});
// Position menu
menu.style.display = 'block';
menu.style.left = x + 'px';
menu.style.top = y + 'px';
// Adjust if off-screen
setTimeout(() => {
const rect = menu.getBoundingClientRect();
if (rect.right > window.innerWidth) {
menu.style.left = (window.innerWidth - rect.width - 10) + 'px';
}
if (rect.bottom > window.innerHeight) {
menu.style.top = (window.innerHeight - rect.height - 10) + 'px';
}
}, 0);
}
/**
* Hide the context menu
*/
function hideContextMenu() {
const menu = document.getElementById('contextMenu');
if (menu) {
menu.style.display = 'none';
}
}
// Combined click handler for context menu and outside clicks
document.addEventListener('click', async (e) => {
const menuItem = e.target.closest('.context-menu-item');
if (menuItem) {
// Handle context menu item click
const action = menuItem.dataset.action;
const menu = document.getElementById('contextMenu');
const targetPath = menu.dataset.targetPath;
const isDir = menu.dataset.targetIsDir === 'true';
hideContextMenu();
if (window.fileTreeActions) {
await window.fileTreeActions.execute(action, targetPath, isDir);
}
} else if (!e.target.closest('#contextMenu') && !e.target.closest('.tree-node')) {
// Hide on outside click
hideContextMenu();
}
});
// Make functions globally available
window.showContextMenu = showContextMenu;
window.hideContextMenu = hideContextMenu;

77
static/js/dark-mode.js Normal file
View File

@@ -0,0 +1,77 @@
/**
* Dark Mode Module
* Manages dark mode theme switching and persistence
*/
class DarkMode {
constructor() {
this.isDark = localStorage.getItem(Config.STORAGE_KEYS.DARK_MODE) === 'true';
this.apply();
}
/**
* Toggle dark mode on/off
*/
toggle() {
this.isDark = !this.isDark;
localStorage.setItem(Config.STORAGE_KEYS.DARK_MODE, this.isDark);
this.apply();
Logger.debug(`Dark mode ${this.isDark ? 'enabled' : 'disabled'}`);
}
/**
* Apply the current dark mode state
*/
apply() {
if (this.isDark) {
document.body.classList.add('dark-mode');
const btn = document.getElementById('darkModeBtn');
if (btn) btn.innerHTML = '<i class="bi bi-sun-fill"></i>';
// Update mermaid theme
if (window.mermaid) {
mermaid.initialize({ theme: Config.MERMAID_THEME_DARK });
}
} else {
document.body.classList.remove('dark-mode');
const btn = document.getElementById('darkModeBtn');
if (btn) btn.innerHTML = '<i class="bi bi-moon-fill"></i>';
// Update mermaid theme
if (window.mermaid) {
mermaid.initialize({ theme: Config.MERMAID_THEME_LIGHT });
}
}
}
/**
* Check if dark mode is currently enabled
* @returns {boolean} True if dark mode is enabled
*/
isEnabled() {
return this.isDark;
}
/**
* Enable dark mode
*/
enable() {
if (!this.isDark) {
this.toggle();
}
}
/**
* Disable dark mode
*/
disable() {
if (this.isDark) {
this.toggle();
}
}
}
// Make DarkMode globally available
window.DarkMode = DarkMode;

View File

@@ -0,0 +1,67 @@
/**
* Editor Drop Handler Module
* Handles file drops into the editor for uploading
*/
class EditorDropHandler {
constructor(editorElement, onFileDrop) {
this.editorElement = editorElement;
this.onFileDrop = onFileDrop;
this.setupHandlers();
}
/**
* Setup drag and drop event handlers
*/
setupHandlers() {
this.editorElement.addEventListener('dragover', (e) => {
e.preventDefault();
e.stopPropagation();
this.editorElement.classList.add('drag-over');
});
this.editorElement.addEventListener('dragleave', (e) => {
e.preventDefault();
e.stopPropagation();
this.editorElement.classList.remove('drag-over');
});
this.editorElement.addEventListener('drop', async (e) => {
e.preventDefault();
e.stopPropagation();
this.editorElement.classList.remove('drag-over');
const files = Array.from(e.dataTransfer.files);
if (files.length === 0) return;
Logger.debug(`Dropped ${files.length} file(s) into editor`);
for (const file of files) {
try {
if (this.onFileDrop) {
await this.onFileDrop(file);
}
} catch (error) {
Logger.error('Drop failed:', error);
if (window.showNotification) {
window.showNotification(`Failed to upload ${file.name}`, 'error');
}
}
}
});
}
/**
* Remove event handlers
*/
destroy() {
// Note: We can't easily remove the event listeners without keeping references
// This is a limitation of the current implementation
// In a future refactor, we could store the bound handlers
Logger.debug('EditorDropHandler destroyed');
}
}
// Make EditorDropHandler globally available
window.EditorDropHandler = EditorDropHandler;

View File

@@ -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 `<div class="alert alert-warning">⚠️ Invalid image data detected. Please re-upload the image.</div>`;
}
// Fix relative image paths to use WebDAV base URL
if (href && !href.startsWith('http://') && !href.startsWith('https://') && !href.startsWith('data:')) {
// Get the directory of the current file
const currentDir = this.currentFile ? PathUtils.getParentPath(this.currentFile) : '';
// Resolve relative path
let imagePath = href;
if (href.startsWith('./')) {
// Relative to current directory
imagePath = PathUtils.joinPaths(currentDir, href.substring(2));
} else if (href.startsWith('../')) {
// Relative to parent directory
imagePath = PathUtils.joinPaths(currentDir, href);
} else if (!href.startsWith('/')) {
// Relative to current directory (no ./)
imagePath = PathUtils.joinPaths(currentDir, href);
} else {
// Absolute path from collection root
imagePath = href.substring(1); // Remove leading /
}
// Build WebDAV URL - ensure no double slashes
if (this.webdavClient && this.webdavClient.currentCollection) {
// Remove trailing slash from baseUrl if present
const baseUrl = this.webdavClient.baseUrl.endsWith('/')
? this.webdavClient.baseUrl.slice(0, -1)
: this.webdavClient.baseUrl;
// Ensure imagePath doesn't start with /
const cleanImagePath = imagePath.startsWith('/') ? imagePath.substring(1) : imagePath;
href = `${baseUrl}/${this.webdavClient.currentCollection}/${cleanImagePath}`;
Logger.debug(`Resolved image URL: ${href}`);
}
}
// Generate HTML directly
const titleAttr = title ? ` title="${title}"` : '';
const altAttr = text ? ` alt="${text}"` : '';
return `<img src="${href}"${altAttr}${titleAttr}>`;
};
this.marked.setOptions({
breaks: true,
gfm: true,
renderer: renderer,
highlight: (code, lang) => {
if (lang && window.Prism.languages[lang]) {
return window.Prism.highlight(code, window.Prism.languages[lang], lang);
@@ -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 = `<div class="alert alert-danger">Preview engine not loaded.</div>`;
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}
</div>
`;
// Hide loading spinner on error
if (this.previewSpinner) {
this.previewSpinner.hide();
}
}
}
/**
* Update preview (backward compatibility wrapper)
* Calls renderPreview with content from editor
*/
async updatePreview() {
// Skip auto-update if showing custom preview (e.g., binary files)
if (this.isShowingCustomPreview) {
Logger.debug('Skipping auto-update: showing custom preview');
return;
}
if (this.editor) {
await this.renderPreview();
}
}
@@ -274,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

126
static/js/event-bus.js Normal file
View File

@@ -0,0 +1,126 @@
/**
* Event Bus Module
* Provides a centralized event system for application-wide communication
* Allows components to communicate without tight coupling
*/
class EventBus {
constructor() {
/**
* Map of event names to arrays of listener functions
* @type {Object.<string, Function[]>}
*/
this.listeners = {};
}
/**
* Register an event listener
* @param {string} event - The event name to listen for
* @param {Function} callback - The function to call when the event is dispatched
* @returns {Function} A function to unregister this listener
*/
on(event, callback) {
if (!this.listeners[event]) {
this.listeners[event] = [];
}
this.listeners[event].push(callback);
// Return unsubscribe function
return () => this.off(event, callback);
}
/**
* Register a one-time event listener
* The listener will be automatically removed after being called once
* @param {string} event - The event name to listen for
* @param {Function} callback - The function to call when the event is dispatched
* @returns {Function} A function to unregister this listener
*/
once(event, callback) {
const onceWrapper = (data) => {
callback(data);
this.off(event, onceWrapper);
};
return this.on(event, onceWrapper);
}
/**
* Unregister an event listener
* @param {string} event - The event name
* @param {Function} callback - The callback function to remove
*/
off(event, callback) {
if (!this.listeners[event]) {
return;
}
this.listeners[event] = this.listeners[event].filter(
listener => listener !== callback
);
// Clean up empty listener arrays
if (this.listeners[event].length === 0) {
delete this.listeners[event];
}
}
/**
* Dispatch an event to all registered listeners
* @param {string} event - The event name to dispatch
* @param {any} data - The data to pass to the listeners
*/
dispatch(event, data) {
if (!this.listeners[event]) {
return;
}
// Create a copy of the listeners array to avoid issues if listeners are added/removed during dispatch
const listeners = [...this.listeners[event]];
listeners.forEach(callback => {
try {
callback(data);
} catch (error) {
Logger.error(`Error in event listener for "${event}":`, error);
}
});
}
/**
* Remove all listeners for a specific event
* If no event is specified, removes all listeners for all events
* @param {string} [event] - The event name (optional)
*/
clear(event) {
if (event) {
delete this.listeners[event];
} else {
this.listeners = {};
}
}
/**
* Get the number of listeners for an event
* @param {string} event - The event name
* @returns {number} The number of listeners
*/
listenerCount(event) {
return this.listeners[event] ? this.listeners[event].length : 0;
}
/**
* Get all event names that have listeners
* @returns {string[]} Array of event names
*/
eventNames() {
return Object.keys(this.listeners);
}
}
// Create and export the global event bus instance
const eventBus = new EventBus();
// Make it globally available
window.eventBus = eventBus;
window.EventBus = EventBus;

View File

@@ -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 = `
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">${title}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="text" class="form-control" value="${placeholder}" autofocus>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary">OK</button>
</div>
</div>
</div>
`;
document.body.appendChild(backdrop);
return dialog;
}
createConfirmDialog(title, message) {
const backdrop = document.createElement('div');
backdrop.className = 'modal-backdrop fade show';
const dialog = document.createElement('div');
dialog.className = 'modal fade show d-block';
dialog.setAttribute('tabindex', '-1');
dialog.style.display = 'block';
dialog.innerHTML = `
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header border-danger">
<h5 class="modal-title text-danger">${title}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>${message}</p>
<p class="text-danger small">This action cannot be undone.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger">Delete</button>
</div>
</div>
</div>
`;
document.body.appendChild(backdrop);
return dialog;
}
// Old deprecated modal methods removed - all modals now use window.ModalManager
updatePasteMenuItem() {
const pasteItem = document.getElementById('pasteMenuItem');
@@ -359,4 +337,268 @@ class FileTreeActions {
pasteItem.style.display = this.clipboard ? 'flex' : 'none';
}
}
/**
* Show a dialog to select a collection
* @param {Array<string>} collections - List of collection names
* @param {string} message - Dialog message
* @returns {Promise<string|null>} Selected collection or null if cancelled
*/
async showCollectionSelectionDialog(collections, message) {
// Prevent duplicate modals
if (this._collectionModalShowing) {
Logger.warn('Collection selection modal is already showing');
return null;
}
this._collectionModalShowing = true;
// Create a custom modal with radio buttons for collection selection
const modal = document.createElement('div');
modal.className = 'modal fade';
modal.innerHTML = `
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-folder-symlink"></i> Select Collection</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p class="mb-3">${message}</p>
<div class="collection-list" style="max-height: 300px; overflow-y: auto;">
${collections.map((c, i) => `
<div class="form-check p-2 mb-2 rounded border collection-option" style="cursor: pointer; transition: all 0.2s;">
<input class="form-check-input" type="radio" name="collection" id="collection-${i}" value="${c}" ${i === 0 ? 'checked' : ''}>
<label class="form-check-label w-100" for="collection-${i}" style="cursor: pointer;">
<i class="bi bi-folder"></i> <strong>${c}</strong>
</label>
</div>
`).join('')}
</div>
<div id="confirmationPreview" class="alert alert-info mt-3" style="display: none;">
<i class="bi bi-info-circle"></i> <span id="confirmationText"></span>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn-flat btn-flat-secondary" data-bs-dismiss="modal">
<i class="bi bi-x-circle"></i> Cancel
</button>
<button type="button" class="btn-flat btn-flat-primary" id="confirmCollectionBtn">
<i class="bi bi-check-circle"></i> OK
</button>
</div>
</div>
</div>
`;
document.body.appendChild(modal);
const bsModal = new bootstrap.Modal(modal);
// Extract file name and action from message
// Message format: "Copy filename to collection:" or "Move filename to collection:"
const messageMatch = message.match(/(Copy|Move)\s+(.+?)\s+to collection:/);
const action = messageMatch ? messageMatch[1].toLowerCase() : 'copy';
const fileName = messageMatch ? messageMatch[2] : 'item';
// Get confirmation preview elements
const confirmationPreview = modal.querySelector('#confirmationPreview');
const confirmationText = modal.querySelector('#confirmationText');
// Function to update confirmation message
const updateConfirmation = (collectionName) => {
confirmationText.textContent = `"${fileName}" will be ${action}d to "${collectionName}"`;
confirmationPreview.style.display = 'block';
};
// Add hover effects and click handlers for collection options
const collectionOptions = modal.querySelectorAll('.collection-option');
collectionOptions.forEach(option => {
// Hover effect
option.addEventListener('mouseenter', () => {
option.style.backgroundColor = 'var(--bs-light)';
option.style.borderColor = 'var(--bs-primary)';
});
option.addEventListener('mouseleave', () => {
const radio = option.querySelector('input[type="radio"]');
if (!radio.checked) {
option.style.backgroundColor = '';
option.style.borderColor = '';
}
});
// Click on the whole div to select
option.addEventListener('click', () => {
const radio = option.querySelector('input[type="radio"]');
radio.checked = true;
// Update confirmation message
updateConfirmation(radio.value);
// Update all options styling
collectionOptions.forEach(opt => {
const r = opt.querySelector('input[type="radio"]');
if (r.checked) {
opt.style.backgroundColor = 'var(--bs-primary-bg-subtle)';
opt.style.borderColor = 'var(--bs-primary)';
} else {
opt.style.backgroundColor = '';
opt.style.borderColor = '';
}
});
});
// Set initial styling for checked option
const radio = option.querySelector('input[type="radio"]');
if (radio.checked) {
option.style.backgroundColor = 'var(--bs-primary-bg-subtle)';
option.style.borderColor = 'var(--bs-primary)';
// Show initial confirmation
updateConfirmation(radio.value);
}
});
return new Promise((resolve) => {
const confirmBtn = modal.querySelector('#confirmCollectionBtn');
confirmBtn.addEventListener('click', () => {
const selected = modal.querySelector('input[name="collection"]:checked');
this._collectionModalShowing = false;
bsModal.hide();
resolve(selected ? selected.value : null);
});
modal.addEventListener('hidden.bs.modal', () => {
modal.remove();
this._collectionModalShowing = false;
resolve(null);
});
bsModal.show();
});
}
/**
* Copy a file or folder to another collection
*/
async copyToCollection(path, isDir, sourceCollection, targetCollection) {
try {
Logger.info(`Copying ${path} from ${sourceCollection} to ${targetCollection}`);
if (isDir) {
// Copy folder recursively
await this.copyFolderToCollection(path, sourceCollection, targetCollection);
} else {
// Copy single file
await this.copyFileToCollection(path, sourceCollection, targetCollection);
}
showNotification(`Copied to ${targetCollection}`, 'success');
} catch (error) {
Logger.error('Failed to copy to collection:', error);
showNotification('Failed to copy to collection', 'error');
throw error;
}
}
/**
* Move a file or folder to another collection
*/
async moveToCollection(path, isDir, sourceCollection, targetCollection) {
try {
Logger.info(`Moving ${path} from ${sourceCollection} to ${targetCollection}`);
// First copy
await this.copyToCollection(path, isDir, sourceCollection, targetCollection);
// Then delete from source
await this.webdavClient.delete(path);
await this.fileTree.load();
showNotification(`Moved to ${targetCollection}`, 'success');
} catch (error) {
Logger.error('Failed to move to collection:', error);
showNotification('Failed to move to collection', 'error');
throw error;
}
}
/**
* Copy a single file to another collection
*/
async copyFileToCollection(path, sourceCollection, targetCollection) {
// Read file from source collection
const content = await this.webdavClient.get(path);
// Write to target collection
const originalCollection = this.webdavClient.currentCollection;
this.webdavClient.setCollection(targetCollection);
// Ensure parent directories exist in target collection
await this.webdavClient.ensureParentDirectories(path);
await this.webdavClient.put(path, content);
this.webdavClient.setCollection(originalCollection);
}
/**
* Copy a folder recursively to another collection
* @param {string} folderPath - Path of the folder to copy
* @param {string} sourceCollection - Source collection name
* @param {string} targetCollection - Target collection name
* @param {Set} visitedPaths - Set of already visited paths to prevent infinite loops
*/
async copyFolderToCollection(folderPath, sourceCollection, targetCollection, visitedPaths = new Set()) {
// Prevent infinite loops by tracking visited paths
if (visitedPaths.has(folderPath)) {
Logger.warn(`Skipping already visited path: ${folderPath}`);
return;
}
visitedPaths.add(folderPath);
Logger.info(`Copying folder: ${folderPath} from ${sourceCollection} to ${targetCollection}`);
// Set to source collection to list items
const originalCollection = this.webdavClient.currentCollection;
this.webdavClient.setCollection(sourceCollection);
// Get only direct children (not recursive to avoid infinite loop)
const items = await this.webdavClient.list(folderPath, false);
Logger.debug(`Found ${items.length} items in ${folderPath}:`, items.map(i => i.path));
// Create the folder in target collection
this.webdavClient.setCollection(targetCollection);
try {
// Ensure parent directories exist first
await this.webdavClient.ensureParentDirectories(folderPath + '/dummy.txt');
// Then create the folder itself
await this.webdavClient.createFolder(folderPath);
Logger.debug(`Created folder: ${folderPath}`);
} catch (error) {
// Folder might already exist (405 Method Not Allowed), ignore error
if (error.message && error.message.includes('405')) {
Logger.debug(`Folder ${folderPath} already exists (405)`);
} else {
Logger.debug('Folder might already exist:', error);
}
}
// Copy all items
for (const item of items) {
if (item.isDirectory) {
// Recursively copy subdirectory
await this.copyFolderToCollection(item.path, sourceCollection, targetCollection, visitedPaths);
} else {
// Copy file
this.webdavClient.setCollection(sourceCollection);
const content = await this.webdavClient.get(item.path);
this.webdavClient.setCollection(targetCollection);
// Ensure parent directories exist before copying file
await this.webdavClient.ensureParentDirectories(item.path);
await this.webdavClient.put(item.path, content);
Logger.debug(`Copied file: ${item.path}`);
}
}
this.webdavClient.setCollection(originalCollection);
}
}

View File

@@ -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);
}
}

37
static/js/file-upload.js Normal file
View File

@@ -0,0 +1,37 @@
/**
* File Upload Module
* Handles file upload dialog for uploading files to the file tree
*/
/**
* Show file upload dialog
* @param {string} targetPath - The target directory path
* @param {Function} onUpload - Callback function to handle file upload
*/
function showFileUploadDialog(targetPath, onUpload) {
const input = document.createElement('input');
input.type = 'file';
input.multiple = true;
input.addEventListener('change', async (e) => {
const files = Array.from(e.target.files);
if (files.length === 0) return;
for (const file of files) {
try {
await onUpload(targetPath, file);
} catch (error) {
Logger.error('Upload failed:', error);
if (window.showNotification) {
window.showNotification(`Failed to upload ${file.name}`, 'error');
}
}
}
});
input.click();
}
// Make function globally available
window.showFileUploadDialog = showFileUploadDialog;

View File

@@ -0,0 +1,151 @@
/**
* Loading Spinner Component
* Displays a loading overlay with spinner for async operations
*/
class LoadingSpinner {
/**
* Create a loading spinner for a container
* @param {string|HTMLElement} container - Container element or ID
* @param {string} message - Optional loading message
*/
constructor(container, message = 'Loading...') {
this.container = typeof container === 'string'
? document.getElementById(container)
: container;
if (!this.container) {
Logger.error('LoadingSpinner: Container not found');
return;
}
this.message = message;
this.overlay = null;
this.isShowing = false;
this.showTime = null; // Track when spinner was shown
this.minDisplayTime = 300; // Minimum time to show spinner (ms)
// Ensure container has position relative for absolute positioning
const position = window.getComputedStyle(this.container).position;
if (position === 'static') {
this.container.style.position = 'relative';
}
}
/**
* Show the loading spinner
* @param {string} message - Optional custom message
*/
show(message = null) {
if (this.isShowing) return;
// Record when spinner was shown
this.showTime = Date.now();
// Create overlay if it doesn't exist
if (!this.overlay) {
this.overlay = this.createOverlay(message || this.message);
this.container.appendChild(this.overlay);
} else {
// Update message if provided
if (message) {
const textElement = this.overlay.querySelector('.loading-text');
if (textElement) {
textElement.textContent = message;
}
}
this.overlay.classList.remove('hidden');
}
this.isShowing = true;
Logger.debug(`Loading spinner shown: ${message || this.message}`);
}
/**
* Hide the loading spinner
* Ensures minimum display time for better UX
*/
hide() {
if (!this.isShowing || !this.overlay) return;
// Calculate how long the spinner has been showing
const elapsed = Date.now() - this.showTime;
const remaining = Math.max(0, this.minDisplayTime - elapsed);
// If minimum time hasn't elapsed, delay hiding
if (remaining > 0) {
setTimeout(() => {
this.overlay.classList.add('hidden');
this.isShowing = false;
Logger.debug('Loading spinner hidden');
}, remaining);
} else {
this.overlay.classList.add('hidden');
this.isShowing = false;
Logger.debug('Loading spinner hidden');
}
}
/**
* Remove the loading spinner from DOM
*/
destroy() {
if (this.overlay && this.overlay.parentNode) {
this.overlay.parentNode.removeChild(this.overlay);
this.overlay = null;
}
this.isShowing = false;
}
/**
* Create the overlay element
* @param {string} message - Loading message
* @returns {HTMLElement} The overlay element
*/
createOverlay(message) {
const overlay = document.createElement('div');
overlay.className = 'loading-overlay';
const content = document.createElement('div');
content.className = 'loading-content';
const spinner = document.createElement('div');
spinner.className = 'loading-spinner';
const text = document.createElement('div');
text.className = 'loading-text';
text.textContent = message;
content.appendChild(spinner);
content.appendChild(text);
overlay.appendChild(content);
return overlay;
}
/**
* Update the loading message
* @param {string} message - New message
*/
updateMessage(message) {
this.message = message;
if (this.overlay && this.isShowing) {
const textElement = this.overlay.querySelector('.loading-text');
if (textElement) {
textElement.textContent = message;
}
}
}
/**
* Check if spinner is currently showing
* @returns {boolean} True if showing
*/
isVisible() {
return this.isShowing;
}
}
// Make LoadingSpinner globally available
window.LoadingSpinner = LoadingSpinner;

174
static/js/logger.js Normal file
View File

@@ -0,0 +1,174 @@
/**
* Logger Module
* Provides structured logging with different levels
* Can be configured to show/hide different log levels
*/
class Logger {
/**
* Log levels
*/
static LEVELS = {
DEBUG: 0,
INFO: 1,
WARN: 2,
ERROR: 3,
NONE: 4
};
/**
* Current log level
* Set to DEBUG by default, can be changed via setLevel()
*/
static currentLevel = Logger.LEVELS.DEBUG;
/**
* Enable/disable logging
*/
static enabled = true;
/**
* Set the minimum log level
* @param {number} level - One of Logger.LEVELS
*/
static setLevel(level) {
if (typeof level === 'number' && level >= 0 && level <= 4) {
Logger.currentLevel = level;
}
}
/**
* Enable or disable logging
* @param {boolean} enabled - Whether to enable logging
*/
static setEnabled(enabled) {
Logger.enabled = enabled;
}
/**
* Log a debug message
* @param {string} message - The message to log
* @param {...any} args - Additional arguments to log
*/
static debug(message, ...args) {
if (!Logger.enabled || Logger.currentLevel > Logger.LEVELS.DEBUG) {
return;
}
console.log(`[DEBUG] ${message}`, ...args);
}
/**
* Log an info message
* @param {string} message - The message to log
* @param {...any} args - Additional arguments to log
*/
static info(message, ...args) {
if (!Logger.enabled || Logger.currentLevel > Logger.LEVELS.INFO) {
return;
}
console.info(`[INFO] ${message}`, ...args);
}
/**
* Log a warning message
* @param {string} message - The message to log
* @param {...any} args - Additional arguments to log
*/
static warn(message, ...args) {
if (!Logger.enabled || Logger.currentLevel > Logger.LEVELS.WARN) {
return;
}
console.warn(`[WARN] ${message}`, ...args);
}
/**
* Log an error message
* @param {string} message - The message to log
* @param {...any} args - Additional arguments to log
*/
static error(message, ...args) {
if (!Logger.enabled || Logger.currentLevel > Logger.LEVELS.ERROR) {
return;
}
console.error(`[ERROR] ${message}`, ...args);
}
/**
* Log a message with a custom prefix
* @param {string} prefix - The prefix to use
* @param {string} message - The message to log
* @param {...any} args - Additional arguments to log
*/
static log(prefix, message, ...args) {
if (!Logger.enabled) {
return;
}
console.log(`[${prefix}] ${message}`, ...args);
}
/**
* Group related log messages
* @param {string} label - The group label
*/
static group(label) {
if (!Logger.enabled) {
return;
}
console.group(label);
}
/**
* End a log group
*/
static groupEnd() {
if (!Logger.enabled) {
return;
}
console.groupEnd();
}
/**
* Log a table (useful for arrays of objects)
* @param {any} data - The data to display as a table
*/
static table(data) {
if (!Logger.enabled) {
return;
}
console.table(data);
}
/**
* Start a timer
* @param {string} label - The timer label
*/
static time(label) {
if (!Logger.enabled) {
return;
}
console.time(label);
}
/**
* End a timer and log the elapsed time
* @param {string} label - The timer label
*/
static timeEnd(label) {
if (!Logger.enabled) {
return;
}
console.timeEnd(label);
}
}
// Make Logger globally available
window.Logger = Logger;
// Set default log level based on environment
// In production, you might want to set this to WARN or ERROR
if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
Logger.setLevel(Logger.LEVELS.DEBUG);
} else {
Logger.setLevel(Logger.LEVELS.INFO);
}

View File

@@ -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);

View File

@@ -0,0 +1,77 @@
/**
* Notification Service
* Provides a standardized way to show toast notifications
* Wraps the showNotification function from ui-utils.js
*/
class NotificationService {
/**
* Show a success notification
* @param {string} message - The message to display
*/
static success(message) {
if (window.showNotification) {
window.showNotification(message, Config.NOTIFICATION_TYPES.SUCCESS);
} else {
Logger.warn('showNotification not available, falling back to console');
console.log(`${message}`);
}
}
/**
* Show an error notification
* @param {string} message - The message to display
*/
static error(message) {
if (window.showNotification) {
window.showNotification(message, Config.NOTIFICATION_TYPES.ERROR);
} else {
Logger.warn('showNotification not available, falling back to console');
console.error(`${message}`);
}
}
/**
* Show a warning notification
* @param {string} message - The message to display
*/
static warning(message) {
if (window.showNotification) {
window.showNotification(message, Config.NOTIFICATION_TYPES.WARNING);
} else {
Logger.warn('showNotification not available, falling back to console');
console.warn(`⚠️ ${message}`);
}
}
/**
* Show an info notification
* @param {string} message - The message to display
*/
static info(message) {
if (window.showNotification) {
window.showNotification(message, Config.NOTIFICATION_TYPES.INFO);
} else {
Logger.warn('showNotification not available, falling back to console');
console.info(` ${message}`);
}
}
/**
* Show a notification with a custom type
* @param {string} message - The message to display
* @param {string} type - The notification type (success, danger, warning, primary, etc.)
*/
static show(message, type = 'primary') {
if (window.showNotification) {
window.showNotification(message, type);
} else {
Logger.warn('showNotification not available, falling back to console');
console.log(`[${type.toUpperCase()}] ${message}`);
}
}
}
// Make NotificationService globally available
window.NotificationService = NotificationService;

114
static/js/sidebar-toggle.js Normal file
View File

@@ -0,0 +1,114 @@
/**
* Sidebar Toggle Module
* Manages sidebar collapse/expand functionality with localStorage persistence
*/
class SidebarToggle {
constructor(sidebarId, toggleButtonId) {
this.sidebar = document.getElementById(sidebarId);
this.toggleButton = document.getElementById(toggleButtonId);
this.storageKey = Config.STORAGE_KEYS.SIDEBAR_COLLAPSED || 'sidebarCollapsed';
this.isCollapsed = localStorage.getItem(this.storageKey) === 'true';
this.init();
}
/**
* Initialize the sidebar toggle
*/
init() {
// Apply initial state
this.apply();
// Setup toggle button click handler
if (this.toggleButton) {
this.toggleButton.addEventListener('click', () => {
this.toggle();
});
}
// Make mini sidebar clickable to expand
if (this.sidebar) {
this.sidebar.addEventListener('click', (e) => {
// Only expand if sidebar is collapsed and click is on the mini sidebar itself
// (not on the file tree content when expanded)
if (this.isCollapsed) {
this.expand();
}
});
// Add cursor pointer when collapsed
this.sidebar.style.cursor = 'default';
}
Logger.debug(`Sidebar initialized: ${this.isCollapsed ? 'collapsed' : 'expanded'}`);
}
/**
* Toggle sidebar state
*/
toggle() {
this.isCollapsed = !this.isCollapsed;
localStorage.setItem(this.storageKey, this.isCollapsed);
this.apply();
Logger.debug(`Sidebar ${this.isCollapsed ? 'collapsed' : 'expanded'}`);
}
/**
* Apply the current sidebar state
*/
apply() {
if (this.sidebar) {
if (this.isCollapsed) {
this.sidebar.classList.add('collapsed');
this.sidebar.style.cursor = 'pointer'; // Make mini sidebar clickable
} else {
this.sidebar.classList.remove('collapsed');
this.sidebar.style.cursor = 'default'; // Normal cursor when expanded
}
}
// Update toggle button icon
if (this.toggleButton) {
const icon = this.toggleButton.querySelector('i');
if (icon) {
if (this.isCollapsed) {
icon.className = 'bi bi-layout-sidebar-inset-reverse';
} else {
icon.className = 'bi bi-layout-sidebar';
}
}
}
}
/**
* Collapse the sidebar
*/
collapse() {
if (!this.isCollapsed) {
this.toggle();
}
}
/**
* Expand the sidebar
*/
expand() {
if (this.isCollapsed) {
this.toggle();
}
}
/**
* Check if sidebar is currently collapsed
* @returns {boolean} True if sidebar is collapsed
*/
isCollapsedState() {
return this.isCollapsed;
}
}
// Make SidebarToggle globally available
window.SidebarToggle = SidebarToggle;

View File

@@ -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 = `
<div class="d-flex">
<div class="toast-body">${message}</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
`;
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;

429
static/js/utils.js Normal file
View File

@@ -0,0 +1,429 @@
/**
* Utilities Module
* Common utility functions used throughout the application
*/
/**
* Path Utilities
* Helper functions for path manipulation
*/
const PathUtils = {
/**
* Get the filename from a path
* @param {string} path - The file path
* @returns {string} The filename
* @example PathUtils.getFileName('folder/subfolder/file.md') // 'file.md'
*/
getFileName(path) {
if (!path) return '';
return path.split('/').pop();
},
/**
* Get the parent directory path
* @param {string} path - The file path
* @returns {string} The parent directory path
* @example PathUtils.getParentPath('folder/subfolder/file.md') // 'folder/subfolder'
*/
getParentPath(path) {
if (!path) return '';
const lastSlash = path.lastIndexOf('/');
return lastSlash === -1 ? '' : path.substring(0, lastSlash);
},
/**
* Normalize a path by removing duplicate slashes
* @param {string} path - The path to normalize
* @returns {string} The normalized path
* @example PathUtils.normalizePath('folder//subfolder///file.md') // 'folder/subfolder/file.md'
*/
normalizePath(path) {
if (!path) return '';
return path.replace(/\/+/g, '/');
},
/**
* Join multiple path segments
* @param {...string} paths - Path segments to join
* @returns {string} The joined path
* @example PathUtils.joinPaths('folder', 'subfolder', 'file.md') // 'folder/subfolder/file.md'
*/
joinPaths(...paths) {
return PathUtils.normalizePath(paths.filter(p => p).join('/'));
},
/**
* Get the file extension
* @param {string} path - The file path
* @returns {string} The file extension (without dot)
* @example PathUtils.getExtension('file.md') // 'md'
*/
getExtension(path) {
if (!path) return '';
const fileName = PathUtils.getFileName(path);
const lastDot = fileName.lastIndexOf('.');
return lastDot === -1 ? '' : fileName.substring(lastDot + 1);
},
/**
* Check if a path is a descendant of another path
* @param {string} path - The path to check
* @param {string} ancestorPath - The potential ancestor path
* @returns {boolean} True if path is a descendant of ancestorPath
* @example PathUtils.isDescendant('folder/subfolder/file.md', 'folder') // true
*/
isDescendant(path, ancestorPath) {
if (!path || !ancestorPath) return false;
return path.startsWith(ancestorPath + '/');
},
/**
* Check if a file is a binary/non-editable file based on extension
* @param {string} path - The file path
* @returns {boolean} True if the file is binary/non-editable
* @example PathUtils.isBinaryFile('image.png') // true
* @example PathUtils.isBinaryFile('document.md') // false
*/
isBinaryFile(path) {
const extension = PathUtils.getExtension(path).toLowerCase();
const binaryExtensions = [
// Images
'png', 'jpg', 'jpeg', 'gif', 'bmp', 'ico', 'svg', 'webp', 'tiff', 'tif',
// Documents
'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx',
// Archives
'zip', 'rar', '7z', 'tar', 'gz', 'bz2',
// Executables
'exe', 'dll', 'so', 'dylib', 'app',
// Media
'mp3', 'mp4', 'avi', 'mov', 'wmv', 'flv', 'wav', 'ogg',
// Other binary formats
'bin', 'dat', 'db', 'sqlite'
];
return binaryExtensions.includes(extension);
},
/**
* Check if a directory is an image directory based on its name
* @param {string} path - The directory path
* @returns {boolean} True if the directory is for images
* @example PathUtils.isImageDirectory('images') // true
* @example PathUtils.isImageDirectory('assets/images') // true
* @example PathUtils.isImageDirectory('docs') // false
*/
isImageDirectory(path) {
const dirName = PathUtils.getFileName(path).toLowerCase();
const imageDirectoryNames = [
'images',
'image',
'img',
'imgs',
'pictures',
'pics',
'photos',
'assets',
'media',
'static'
];
return imageDirectoryNames.includes(dirName);
},
/**
* Get a human-readable file type description
* @param {string} path - The file path
* @returns {string} The file type description
* @example PathUtils.getFileType('image.png') // 'Image'
*/
getFileType(path) {
const extension = PathUtils.getExtension(path).toLowerCase();
const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'ico', 'svg', 'webp', 'tiff', 'tif'];
const documentExtensions = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'];
const archiveExtensions = ['zip', 'rar', '7z', 'tar', 'gz', 'bz2'];
const mediaExtensions = ['mp3', 'mp4', 'avi', 'mov', 'wmv', 'flv', 'wav', 'ogg'];
if (imageExtensions.includes(extension)) return 'Image';
if (documentExtensions.includes(extension)) return 'Document';
if (archiveExtensions.includes(extension)) return 'Archive';
if (mediaExtensions.includes(extension)) return 'Media';
if (extension === 'pdf') return 'PDF';
return 'File';
}
};
/**
* DOM Utilities
* Helper functions for DOM manipulation
*/
const DOMUtils = {
/**
* Create an element with optional class and attributes
* @param {string} tag - The HTML tag name
* @param {string} [className] - Optional class name(s)
* @param {Object} [attributes] - Optional attributes object
* @returns {HTMLElement} The created element
*/
createElement(tag, className = '', attributes = {}) {
const element = document.createElement(tag);
if (className) {
element.className = className;
}
Object.entries(attributes).forEach(([key, value]) => {
element.setAttribute(key, value);
});
return element;
},
/**
* Remove all children from an element
* @param {HTMLElement} element - The element to clear
*/
removeAllChildren(element) {
while (element.firstChild) {
element.removeChild(element.firstChild);
}
},
/**
* Toggle a class on an element
* @param {HTMLElement} element - The element
* @param {string} className - The class name
* @param {boolean} [force] - Optional force add/remove
*/
toggleClass(element, className, force) {
if (force !== undefined) {
element.classList.toggle(className, force);
} else {
element.classList.toggle(className);
}
},
/**
* Query selector with error handling
* @param {string} selector - The CSS selector
* @param {HTMLElement} [parent] - Optional parent element
* @returns {HTMLElement|null} The found element or null
*/
querySelector(selector, parent = document) {
try {
return parent.querySelector(selector);
} catch (error) {
Logger.error(`Invalid selector: ${selector}`, error);
return null;
}
},
/**
* Query selector all with error handling
* @param {string} selector - The CSS selector
* @param {HTMLElement} [parent] - Optional parent element
* @returns {NodeList|Array} The found elements or empty array
*/
querySelectorAll(selector, parent = document) {
try {
return parent.querySelectorAll(selector);
} catch (error) {
Logger.error(`Invalid selector: ${selector}`, error);
return [];
}
}
};
/**
* Timing Utilities
* Helper functions for timing and throttling
*/
const TimingUtils = {
/**
* Debounce a function
* @param {Function} func - The function to debounce
* @param {number} wait - The wait time in milliseconds
* @returns {Function} The debounced function
*/
debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
},
/**
* Throttle a function
* @param {Function} func - The function to throttle
* @param {number} wait - The wait time in milliseconds
* @returns {Function} The throttled function
*/
throttle(func, wait) {
let inThrottle;
return function executedFunction(...args) {
if (!inThrottle) {
func(...args);
inThrottle = true;
setTimeout(() => inThrottle = false, wait);
}
};
},
/**
* Delay execution
* @param {number} ms - Milliseconds to delay
* @returns {Promise} Promise that resolves after delay
*/
delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
};
/**
* Download Utilities
* Helper functions for file downloads
*/
const DownloadUtils = {
/**
* Trigger a download in the browser
* @param {string|Blob} content - The content to download
* @param {string} filename - The filename for the download
*/
triggerDownload(content, filename) {
const blob = content instanceof Blob ? content : new Blob([content]);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
},
/**
* Download content as a blob
* @param {Blob} blob - The blob to download
* @param {string} filename - The filename for the download
*/
downloadAsBlob(blob, filename) {
DownloadUtils.triggerDownload(blob, filename);
}
};
/**
* Validation Utilities
* Helper functions for input validation
*/
const ValidationUtils = {
/**
* Validate and sanitize a filename
* @param {string} name - The filename to validate
* @param {boolean} [isFolder=false] - Whether this is a folder name
* @returns {Object} Validation result with {valid, sanitized, message}
*/
validateFileName(name, isFolder = false) {
const type = isFolder ? 'folder' : 'file';
if (!name || name.trim().length === 0) {
return { valid: false, sanitized: '', message: `${type} name cannot be empty` };
}
// Check for invalid characters using pattern from Config
const validPattern = Config.FILENAME_PATTERN;
if (!validPattern.test(name)) {
const sanitized = ValidationUtils.sanitizeFileName(name);
return {
valid: false,
sanitized,
message: `Invalid characters in ${type} name. Only lowercase letters, numbers, and underscores allowed.\n\nSuggestion: "${sanitized}"`
};
}
return { valid: true, sanitized: name, message: '' };
},
/**
* Sanitize a filename by removing/replacing invalid characters
* @param {string} name - The filename to sanitize
* @returns {string} The sanitized filename
*/
sanitizeFileName(name) {
return name
.toLowerCase()
.replace(Config.FILENAME_INVALID_CHARS, '_')
.replace(/_+/g, '_')
.replace(/^_+|_+$/g, '');
},
/**
* Check if a string is empty or whitespace
* @param {string} str - The string to check
* @returns {boolean} True if empty or whitespace
*/
isEmpty(str) {
return !str || str.trim().length === 0;
},
/**
* Check if a value is a valid email
* @param {string} email - The email to validate
* @returns {boolean} True if valid email
*/
isValidEmail(email) {
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailPattern.test(email);
}
};
/**
* String Utilities
* Helper functions for string manipulation
*/
const StringUtils = {
/**
* Truncate a string to a maximum length
* @param {string} str - The string to truncate
* @param {number} maxLength - Maximum length
* @param {string} [suffix='...'] - Suffix to add if truncated
* @returns {string} The truncated string
*/
truncate(str, maxLength, suffix = '...') {
if (!str || str.length <= maxLength) return str;
return str.substring(0, maxLength - suffix.length) + suffix;
},
/**
* Capitalize the first letter of a string
* @param {string} str - The string to capitalize
* @returns {string} The capitalized string
*/
capitalize(str) {
if (!str) return '';
return str.charAt(0).toUpperCase() + str.slice(1);
},
/**
* Convert a string to kebab-case
* @param {string} str - The string to convert
* @returns {string} The kebab-case string
*/
toKebabCase(str) {
return str
.replace(/([a-z])([A-Z])/g, '$1-$2')
.replace(/[\s_]+/g, '-')
.toLowerCase();
}
};
// Make utilities globally available
window.PathUtils = PathUtils;
window.DOMUtils = DOMUtils;
window.TimingUtils = TimingUtils;
window.DownloadUtils = DownloadUtils;
window.ValidationUtils = ValidationUtils;
window.StringUtils = StringUtils;

View File

@@ -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>} 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;
}
}

View File

@@ -33,7 +33,8 @@ body.dark-mode {
}
/* Global styles */
html, body {
html,
body {
height: 100%;
margin: 0;
padding: 0;
@@ -48,12 +49,6 @@ body {
transition: background-color 0.3s ease, color 0.3s ease;
}
.container-fluid {
flex: 1;
padding: 0;
overflow: hidden;
}
.row {
margin: 0;
}
@@ -206,7 +201,12 @@ body.dark-mode .CodeMirror-linenumber {
}
/* Markdown preview styles */
#preview h1, #preview h2, #preview h3, #preview h4, #preview h5, #preview h6 {
#preview h1,
#preview h2,
#preview h3,
#preview h4,
#preview h5,
#preview h6 {
margin-top: 24px;
margin-bottom: 16px;
font-weight: 600;
@@ -286,7 +286,8 @@ body.dark-mode .CodeMirror-linenumber {
margin-bottom: 16px;
}
#preview ul, #preview ol {
#preview ul,
#preview ol {
margin-bottom: 16px;
padding-left: 2em;
}
@@ -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);
}
}

View File

@@ -30,20 +30,47 @@
<!-- Navbar -->
<nav class="navbar navbar-expand-lg">
<div class="container-fluid">
<span class="navbar-brand">
<i class="bi bi-markdown"></i> Markdown Editor
</span>
<div class="d-flex gap-2">
<button id="newBtn" class="btn btn-success btn-sm">
<!-- Left: Sidebar Toggle + Logo and Title -->
<div class="d-flex align-items-center gap-2">
<!-- Sidebar Toggle Button -->
<button id="sidebarToggleBtn" class="btn-flat btn-flat-secondary" title="Toggle Sidebar">
<i class="bi bi-layout-sidebar"></i>
</button>
<!-- Logo and Title (Clickable) -->
<a href="/" class="navbar-brand mb-0" id="navbarBrand" style="cursor: pointer; text-decoration: none;">
<i class="bi bi-markdown"></i> Markdown Editor
</a>
</div>
<!-- Right: All Buttons -->
<div class="ms-auto d-flex gap-2 align-items-center">
<!-- View Mode Button -->
<button id="editModeBtn" class="btn-flat btn-flat" style="display: none;">
<i class="bi bi-pencil-square"></i> Edit this file
</button>
<!-- Edit Mode Buttons -->
<button id="newBtn" class="btn-flat btn-flat-success">
<i class="bi bi-file-plus"></i> New
</button>
<button id="saveBtn" class="btn btn-primary btn-sm">
<button id="saveBtn" class="btn-flat btn-flat-primary">
<i class="bi bi-save"></i> Save
</button>
<button id="deleteBtn" class="btn btn-danger btn-sm">
<button id="deleteBtn" class="btn-flat btn-flat-danger">
<i class="bi bi-trash"></i> Delete
</button>
<button id="darkModeBtn" class="btn btn-secondary btn-sm">🌙</button>
<button id="exitEditModeBtn" class="btn-flat btn-flat-secondary">
<i class="bi bi-eye"></i> Exit Edit Mode
</button>
<!-- Divider -->
<div class="vr" style="height: 40px;"></div>
<!-- Dark Mode Toggle -->
<button id="darkModeBtn" class="btn-flat btn-flat-secondary">
<i class="bi bi-moon-fill"></i>
</button>
</div>
</div>
</nav>
@@ -56,7 +83,13 @@
<!-- Collection Selector -->
<div class="collection-selector">
<label class="form-label small">Collection:</label>
<select id="collectionSelect" class="form-select form-select-sm"></select>
<div class="d-flex gap-1">
<select id="collectionSelect" class="form-select form-select-sm flex-grow-1"></select>
<button id="newCollectionBtn" class="btn btn-sm new-collection-btn"
title="Create New Collection">
<i class="bi bi-plus-lg"></i>
</button>
</div>
</div>
<!-- File Tree -->
<div id="fileTree" class="file-tree"></div>
@@ -120,13 +153,21 @@
<i class="bi bi-clipboard"></i> Paste
</div>
<div class="context-menu-divider"></div>
<div class="context-menu-item" data-action="copy-to-collection">
<i class="bi bi-box-arrow-right"></i> Copy to Collection...
</div>
<div class="context-menu-item" data-action="move-to-collection">
<i class="bi bi-arrow-right-square"></i> Move to Collection...
</div>
<div class="context-menu-divider"></div>
<div class="context-menu-item text-danger" data-action="delete">
<i class="bi bi-trash"></i> Delete
</div>
</div>
<!-- Confirmation Modal -->
<div class="modal fade" id="confirmationModal" tabindex="-1" aria-labelledby="confirmationModalLabel" aria-hidden="true">
<div class="modal fade" id="confirmationModal" tabindex="-1" aria-labelledby="confirmationModalLabel"
aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
@@ -138,8 +179,12 @@
<input type="text" id="confirmationInput" class="form-control" style="display: none;">
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="confirmButton">OK</button>
<button type="button" class="btn-flat btn-flat-secondary" data-bs-dismiss="modal">
<i class="bi bi-x-circle"></i> Cancel
</button>
<button type="button" class="btn-flat btn-flat-primary" id="confirmButton">
<i class="bi bi-check-circle"></i> OK
</button>
</div>
</div>
</div>
@@ -178,16 +223,33 @@
<!-- Mermaid for diagrams -->
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
<!-- Application Configuration (must load first) -->
<script src="/static/js/config.js"></script>
<script src="/static/js/logger.js"></script>
<script src="/static/js/event-bus.js"></script>
<script src="/static/js/utils.js"></script>
<script src="/static/js/notification-service.js"></script>
<!-- UI Components -->
<script src="/static/js/ui-utils.js" defer></script>
<script src="/static/js/context-menu.js" defer></script>
<script src="/static/js/file-upload.js" defer></script>
<script src="/static/js/dark-mode.js" defer></script>
<script src="/static/js/sidebar-toggle.js" defer></script>
<script src="/static/js/collection-selector.js" defer></script>
<script src="/static/js/editor-drop-handler.js" defer></script>
<script src="/static/js/loading-spinner.js" defer></script>
<!-- Core Application Modules -->
<script src="/static/js/webdav-client.js" defer></script>
<script src="/static/js/file-tree.js" defer></script>
<script src="/static/js/editor.js" defer></script>
<script src="/static/js/ui-utils.js" defer></script>
<script src="/static/js/confirmation.js" defer></script>
<script src="/static/js/file-tree-actions.js" defer></script>
<script src="/static/js/column-resizer.js" defer></script>
<script src="/static/js/app.js" defer></script>
<script src="/static/js/macro-parser.js" defer></script>
<script src="/static/js/macro-processor.js" defer></script>
<script src="/static/js/macro-parser.js" defer></script>
<script src="/static/js/macro-processor.js" defer></script>
</body>
</html>