Compare commits
6 Commits
23a24d42e2
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 16aef59298 | |||
|
|
3961628b3d | ||
|
|
afcd074913 | ||
|
|
7a9efd3542 | ||
|
|
f319f29d4c | ||
|
|
0ed6bcf1f2 |
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.venv
|
||||||
22
collections/7madah/tests/sub_tests/file1.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Start to end file
|
||||||
|
|
||||||
|
### Graph
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
This is just for testing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**See what i did?**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
A[Start] --> B{Process};
|
||||||
|
B --> C{Decision};
|
||||||
|
C -- Yes --> D[End Yes];
|
||||||
|
C -- No --> E[End No];
|
||||||
|
```
|
||||||
|
|
||||||
426
collections/7madah/tests/test3.md
Normal file
@@ -0,0 +1,426 @@
|
|||||||
|
# UI Code Refactoring Plan
|
||||||
|
|
||||||
|
**Project:** Markdown Editor
|
||||||
|
**Date:** 2025-10-26
|
||||||
|
**Status:** In Progress
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
This document outlines a comprehensive refactoring plan for the UI codebase to improve maintainability, remove dead code, extract utilities, and standardize patterns. The refactoring is organized into 6 phases with 14 tasks, prioritized by risk and impact.
|
||||||
|
|
||||||
|
**Key Metrics:**
|
||||||
|
|
||||||
|
- Total Lines of Code: ~3,587
|
||||||
|
- Dead Code to Remove: 213 lines (6%)
|
||||||
|
- Estimated Effort: 5-8 days
|
||||||
|
- Risk Level: Mostly LOW to MEDIUM
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Analysis Summary
|
||||||
|
|
||||||
|
### Files Reviewed
|
||||||
|
|
||||||
|
**JavaScript Files (10):**
|
||||||
|
|
||||||
|
- `/static/js/app.js` (484 lines)
|
||||||
|
- `/static/js/column-resizer.js` (100 lines)
|
||||||
|
- `/static/js/confirmation.js` (170 lines)
|
||||||
|
- `/static/js/editor.js` (420 lines)
|
||||||
|
- `/static/js/file-tree-actions.js` (482 lines)
|
||||||
|
- `/static/js/file-tree.js` (865 lines)
|
||||||
|
- `/static/js/macro-parser.js` (103 lines)
|
||||||
|
- `/static/js/macro-processor.js` (157 lines)
|
||||||
|
- `/static/js/ui-utils.js` (305 lines)
|
||||||
|
- `/static/js/webdav-client.js` (266 lines)
|
||||||
|
|
||||||
|
**CSS Files (6):**
|
||||||
|
|
||||||
|
- `/static/css/variables.css` (32 lines)
|
||||||
|
- `/static/css/layout.css`
|
||||||
|
- `/static/css/file-tree.css`
|
||||||
|
- `/static/css/editor.css`
|
||||||
|
- `/static/css/components.css`
|
||||||
|
- `/static/css/modal.css`
|
||||||
|
|
||||||
|
**HTML Templates (1):**
|
||||||
|
|
||||||
|
- `/templates/index.html` (203 lines)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Issues Found
|
||||||
|
|
||||||
|
### 🔴 HIGH PRIORITY
|
||||||
|
|
||||||
|
1. **Deprecated Modal Code (Dead Code)**
|
||||||
|
- Location: `/static/js/file-tree-actions.js` lines 262-474
|
||||||
|
- Impact: 213 lines of unused code (44% of file)
|
||||||
|
- Risk: LOW to remove
|
||||||
|
|
||||||
|
2. **Duplicated Event Bus Implementation**
|
||||||
|
- Location: `/static/js/app.js` lines 16-30
|
||||||
|
- Should be extracted to reusable module
|
||||||
|
|
||||||
|
3. **Duplicated Debounce Function**
|
||||||
|
- Location: `/static/js/editor.js` lines 404-414
|
||||||
|
- Should be shared utility
|
||||||
|
|
||||||
|
4. **Inconsistent Notification Usage**
|
||||||
|
- Mixed usage of `window.showNotification` vs `showNotification`
|
||||||
|
|
||||||
|
5. **Duplicated File Download Logic**
|
||||||
|
- Location: `/static/js/file-tree.js` lines 829-839
|
||||||
|
- Should be shared utility
|
||||||
|
|
||||||
|
6. **Hard-coded Values**
|
||||||
|
- Long-press threshold: 400ms
|
||||||
|
- Debounce delay: 300ms
|
||||||
|
- Drag preview width: 200px
|
||||||
|
- Toast delay: 3000ms
|
||||||
|
|
||||||
|
### 🟡 MEDIUM PRIORITY
|
||||||
|
|
||||||
|
7. **Global State Management**
|
||||||
|
- Location: `/static/js/app.js` lines 6-13
|
||||||
|
- Makes testing difficult
|
||||||
|
|
||||||
|
8. **Duplicated Path Manipulation**
|
||||||
|
- `path.split('/').pop()` appears 10+ times
|
||||||
|
- `path.substring(0, path.lastIndexOf('/'))` appears 5+ times
|
||||||
|
|
||||||
|
9. **Mixed Responsibility in ui-utils.js**
|
||||||
|
- Contains 6 different classes/utilities
|
||||||
|
- Should be split into separate modules
|
||||||
|
|
||||||
|
10. **Deprecated Event Handler**
|
||||||
|
- Location: `/static/js/file-tree-actions.js` line 329
|
||||||
|
- Uses deprecated `onkeypress`
|
||||||
|
|
||||||
|
### 🟢 LOW PRIORITY
|
||||||
|
|
||||||
|
11. **Unused Function Parameters**
|
||||||
|
12. **Magic Numbers in Styling**
|
||||||
|
13. **Inconsistent Comment Styles**
|
||||||
|
14. **Console.log Statements**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Proposed Reusable Components
|
||||||
|
|
||||||
|
### 1. Config Module (`/static/js/config.js`)
|
||||||
|
|
||||||
|
Centralize all configuration values:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
export const Config = {
|
||||||
|
// Timing
|
||||||
|
LONG_PRESS_THRESHOLD: 400,
|
||||||
|
DEBOUNCE_DELAY: 300,
|
||||||
|
TOAST_DURATION: 3000,
|
||||||
|
|
||||||
|
// UI
|
||||||
|
DRAG_PREVIEW_WIDTH: 200,
|
||||||
|
TREE_INDENT_PX: 12,
|
||||||
|
MOUSE_MOVE_THRESHOLD: 5,
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
FILENAME_PATTERN: /^[a-z0-9_]+(\.[a-z0-9_]+)*$/,
|
||||||
|
|
||||||
|
// Storage Keys
|
||||||
|
STORAGE_KEYS: {
|
||||||
|
DARK_MODE: 'darkMode',
|
||||||
|
SELECTED_COLLECTION: 'selectedCollection',
|
||||||
|
LAST_VIEWED_PAGE: 'lastViewedPage',
|
||||||
|
COLUMN_DIMENSIONS: 'columnDimensions'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Logger Module (`/static/js/logger.js`)
|
||||||
|
|
||||||
|
Structured logging with levels:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
export class Logger {
|
||||||
|
static debug(message, ...args)
|
||||||
|
static info(message, ...args)
|
||||||
|
static warn(message, ...args)
|
||||||
|
static error(message, ...args)
|
||||||
|
static setLevel(level)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Event Bus Module (`/static/js/event-bus.js`)
|
||||||
|
|
||||||
|
Centralized event system:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
export class EventBus {
|
||||||
|
on(event, callback)
|
||||||
|
off(event, callback)
|
||||||
|
once(event, callback)
|
||||||
|
dispatch(event, data)
|
||||||
|
clear(event)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Utilities Module (`/static/js/utils.js`)
|
||||||
|
|
||||||
|
Common utility functions:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
export const PathUtils = {
|
||||||
|
getFileName(path),
|
||||||
|
getParentPath(path),
|
||||||
|
normalizePath(path),
|
||||||
|
joinPaths(...paths),
|
||||||
|
getExtension(path)
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TimingUtils = {
|
||||||
|
debounce(func, wait),
|
||||||
|
throttle(func, wait)
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DownloadUtils = {
|
||||||
|
triggerDownload(content, filename),
|
||||||
|
downloadAsBlob(blob, filename)
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ValidationUtils = {
|
||||||
|
validateFileName(name, isFolder),
|
||||||
|
sanitizeFileName(name)
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Notification Service (`/static/js/notification-service.js`)
|
||||||
|
|
||||||
|
Standardized notifications:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
export class NotificationService {
|
||||||
|
static success(message)
|
||||||
|
static error(message)
|
||||||
|
static warning(message)
|
||||||
|
static info(message)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: Refactoring Tasks
|
||||||
|
|
||||||
|
### 🔴 HIGH PRIORITY
|
||||||
|
|
||||||
|
**Task 1: Remove Dead Code**
|
||||||
|
|
||||||
|
- Files: `/static/js/file-tree-actions.js`
|
||||||
|
- Lines: 262-474 (213 lines)
|
||||||
|
- Risk: LOW
|
||||||
|
- Dependencies: None
|
||||||
|
|
||||||
|
**Task 2: Extract Event Bus**
|
||||||
|
|
||||||
|
- Files: NEW `/static/js/event-bus.js`, MODIFY `app.js`, `editor.js`
|
||||||
|
- Risk: MEDIUM
|
||||||
|
- Dependencies: None
|
||||||
|
|
||||||
|
**Task 3: Create Utilities Module**
|
||||||
|
|
||||||
|
- Files: NEW `/static/js/utils.js`, MODIFY multiple files
|
||||||
|
- Risk: MEDIUM
|
||||||
|
- Dependencies: None
|
||||||
|
|
||||||
|
**Task 4: Create Config Module**
|
||||||
|
|
||||||
|
- Files: NEW `/static/js/config.js`, MODIFY multiple files
|
||||||
|
- Risk: LOW
|
||||||
|
- Dependencies: None
|
||||||
|
|
||||||
|
**Task 5: Standardize Notification Usage**
|
||||||
|
|
||||||
|
- Files: NEW `/static/js/notification-service.js`, MODIFY multiple files
|
||||||
|
- Risk: LOW
|
||||||
|
- Dependencies: None
|
||||||
|
|
||||||
|
### 🟡 MEDIUM PRIORITY
|
||||||
|
|
||||||
|
**Task 6: Fix Deprecated Event Handler**
|
||||||
|
|
||||||
|
- Files: `/static/js/file-tree-actions.js` line 329
|
||||||
|
- Risk: LOW
|
||||||
|
- Dependencies: None
|
||||||
|
|
||||||
|
**Task 7: Refactor ui-utils.js**
|
||||||
|
|
||||||
|
- Files: DELETE `ui-utils.js`, CREATE 5 new modules
|
||||||
|
- Risk: HIGH
|
||||||
|
- Dependencies: Task 5
|
||||||
|
|
||||||
|
**Task 8: Standardize Class Export Pattern**
|
||||||
|
|
||||||
|
- Files: All class files
|
||||||
|
- Risk: MEDIUM
|
||||||
|
- Dependencies: None
|
||||||
|
|
||||||
|
**Task 9: Create Logger Module**
|
||||||
|
|
||||||
|
- Files: NEW `/static/js/logger.js`, MODIFY multiple files
|
||||||
|
- Risk: LOW
|
||||||
|
- Dependencies: None
|
||||||
|
|
||||||
|
**Task 10: Implement Download Action**
|
||||||
|
|
||||||
|
- Files: `/static/js/file-tree-actions.js`
|
||||||
|
- Risk: LOW
|
||||||
|
- Dependencies: Task 3
|
||||||
|
|
||||||
|
### 🟢 LOW PRIORITY
|
||||||
|
|
||||||
|
**Task 11: Standardize JSDoc Comments**
|
||||||
|
**Task 12: Extract Magic Numbers to CSS**
|
||||||
|
**Task 13: Add Error Boundaries**
|
||||||
|
**Task 14: Cache DOM Elements**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: Implementation Order
|
||||||
|
|
||||||
|
### Step 1: Foundation (Do First)
|
||||||
|
|
||||||
|
1. Create Config Module (Task 4)
|
||||||
|
2. Create Logger Module (Task 9)
|
||||||
|
3. Create Event Bus Module (Task 2)
|
||||||
|
|
||||||
|
### Step 2: Utilities (Do Second)
|
||||||
|
|
||||||
|
4. Create Utilities Module (Task 3)
|
||||||
|
5. Create Notification Service (Task 5)
|
||||||
|
|
||||||
|
### Step 3: Cleanup (Do Third)
|
||||||
|
|
||||||
|
6. Remove Dead Code (Task 1)
|
||||||
|
7. Fix Deprecated Event Handler (Task 6)
|
||||||
|
|
||||||
|
### Step 4: Restructuring (Do Fourth)
|
||||||
|
|
||||||
|
8. Refactor ui-utils.js (Task 7)
|
||||||
|
9. Standardize Class Export Pattern (Task 8)
|
||||||
|
|
||||||
|
### Step 5: Enhancements (Do Fifth)
|
||||||
|
|
||||||
|
10. Implement Download Action (Task 10)
|
||||||
|
11. Add Error Boundaries (Task 13)
|
||||||
|
|
||||||
|
### Step 6: Polish (Do Last)
|
||||||
|
|
||||||
|
12. Standardize JSDoc Comments (Task 11)
|
||||||
|
13. Extract Magic Numbers to CSS (Task 12)
|
||||||
|
14. Cache DOM Elements (Task 14)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: Testing Checklist
|
||||||
|
|
||||||
|
### Core Functionality
|
||||||
|
|
||||||
|
- [ ] File tree loads and displays correctly
|
||||||
|
- [ ] Files can be selected and opened
|
||||||
|
- [ ] Folders can be expanded/collapsed
|
||||||
|
- [ ] Editor loads file content
|
||||||
|
- [ ] Preview renders markdown correctly
|
||||||
|
- [ ] Save button saves files
|
||||||
|
- [ ] Delete button deletes files
|
||||||
|
- [ ] New button creates new files
|
||||||
|
|
||||||
|
### Context Menu Actions
|
||||||
|
|
||||||
|
- [ ] Right-click shows context menu
|
||||||
|
- [ ] New file action works
|
||||||
|
- [ ] New folder action works
|
||||||
|
- [ ] Rename action works
|
||||||
|
- [ ] Delete action works
|
||||||
|
- [ ] Copy/Cut/Paste actions work
|
||||||
|
- [ ] Upload action works
|
||||||
|
|
||||||
|
### Drag and Drop
|
||||||
|
|
||||||
|
- [ ] Long-press detection works
|
||||||
|
- [ ] Drag preview appears correctly
|
||||||
|
- [ ] Drop targets highlight properly
|
||||||
|
- [ ] Files can be moved
|
||||||
|
- [ ] Undo (Ctrl+Z) works
|
||||||
|
|
||||||
|
### Modals
|
||||||
|
|
||||||
|
- [ ] Confirmation modals appear
|
||||||
|
- [ ] Prompt modals appear
|
||||||
|
- [ ] Modals don't double-open
|
||||||
|
- [ ] Enter/Escape keys work
|
||||||
|
|
||||||
|
### UI Features
|
||||||
|
|
||||||
|
- [ ] Dark mode toggle works
|
||||||
|
- [ ] Collection selector works
|
||||||
|
- [ ] Column resizers work
|
||||||
|
- [ ] Notifications appear
|
||||||
|
- [ ] URL routing works
|
||||||
|
- [ ] View/Edit modes work
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommendations
|
||||||
|
|
||||||
|
### Immediate Actions (Before Production)
|
||||||
|
|
||||||
|
1. Remove dead code (Task 1)
|
||||||
|
2. Fix deprecated event handler (Task 6)
|
||||||
|
3. Create config module (Task 4)
|
||||||
|
|
||||||
|
### Short-term Actions (Next Sprint)
|
||||||
|
|
||||||
|
4. Extract utilities (Task 3)
|
||||||
|
5. Standardize notifications (Task 5)
|
||||||
|
6. Create event bus (Task 2)
|
||||||
|
|
||||||
|
### Medium-term Actions (Future Sprints)
|
||||||
|
|
||||||
|
7. Refactor ui-utils.js (Task 7)
|
||||||
|
8. Add logger (Task 9)
|
||||||
|
9. Standardize exports (Task 8)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Success Metrics
|
||||||
|
|
||||||
|
**Before Refactoring:**
|
||||||
|
|
||||||
|
- Total Lines: ~3,587
|
||||||
|
- Dead Code: 213 lines (6%)
|
||||||
|
- Duplicated Code: ~50 lines
|
||||||
|
- Hard-coded Values: 15+
|
||||||
|
|
||||||
|
**After Refactoring:**
|
||||||
|
|
||||||
|
- Total Lines: ~3,400 (-5%)
|
||||||
|
- Dead Code: 0 lines
|
||||||
|
- Duplicated Code: 0 lines
|
||||||
|
- Hard-coded Values: 0
|
||||||
|
|
||||||
|
**Estimated Effort:** 5-8 days
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
The UI codebase is generally well-structured. Main improvements needed:
|
||||||
|
|
||||||
|
1. Remove dead code
|
||||||
|
2. Extract duplicated utilities
|
||||||
|
3. Centralize configuration
|
||||||
|
4. Standardize patterns
|
||||||
|
|
||||||
|
Start with high-impact, low-risk changes first to ensure production readiness.
|
||||||
44
collections/documents/docusaurus.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
## Using Docusaurus
|
||||||
|
|
||||||
|
Once you've set up Hero, you can use it to develop, manage and publish Docusaurus websites.
|
||||||
|
|
||||||
|
## Launch the Hero Website
|
||||||
|
|
||||||
|
To start a Hero Docusaurus website in development mode:
|
||||||
|
|
||||||
|
- Build the book then close the prompt with `Ctrl+C`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hero docs -d
|
||||||
|
```
|
||||||
|
|
||||||
|
- See the book on the local browser
|
||||||
|
|
||||||
|
```
|
||||||
|
bash /root/hero/var/docusaurus/develop.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
You can then view the website in your browser at `https://localhost:3100`.
|
||||||
|
|
||||||
|
## Publish a Website
|
||||||
|
|
||||||
|
- To build and publish a Hero website:
|
||||||
|
- Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hero docs -bpd
|
||||||
|
```
|
||||||
|
|
||||||
|
- Production
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hero docs -bp
|
||||||
|
```
|
||||||
|
|
||||||
|
If you want to specify a different SSH key, use `-dk`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hero docs -bpd -dk ~/.ssh/id_ed25519
|
||||||
|
```
|
||||||
|
|
||||||
|
> Note: The container handles the SSH agent and key management automatically on startup, so in most cases, you won't need to manually specify keys.
|
||||||
67
collections/documents/getting_started/hero_docker.md
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
You can build Hero as a Docker container.
|
||||||
|
|
||||||
|
The code is availabe at this [open-source repository](https://github.com/mik-tf/hero-container).
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Docker installed on your system (More info [here](https://manual.grid.tf/documentation/system_administrators/computer_it_basics/docker_basics.html#install-docker-desktop-and-docker-engine))
|
||||||
|
- SSH keys for deploying Hero websites (if publishing)
|
||||||
|
|
||||||
|
## Build the Image
|
||||||
|
|
||||||
|
- Clone the repository
|
||||||
|
|
||||||
|
```
|
||||||
|
git clone https://github.com/mik-tf/hero-container
|
||||||
|
cd hero-container
|
||||||
|
```
|
||||||
|
|
||||||
|
- Build the Docker image:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t heroc .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pull the Image from Docker Hub
|
||||||
|
|
||||||
|
If you don't want to build the image, you can pull it from Docker Hub.
|
||||||
|
|
||||||
|
```
|
||||||
|
docker pull logismosis/heroc
|
||||||
|
```
|
||||||
|
|
||||||
|
In this case, use `logismosi/heroc` instead of `heroc` to use the container.
|
||||||
|
|
||||||
|
## Run the Hero Container
|
||||||
|
|
||||||
|
You can run the container with an interactive shell:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -it heroc
|
||||||
|
```
|
||||||
|
|
||||||
|
You can run the container with an interactive shell, while setting the host as your local network, mounting your current directory as the workspace and adding your SSH keys:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --network=host \
|
||||||
|
-v $(pwd):/workspace \
|
||||||
|
-v ~/.ssh:/root/ssh \
|
||||||
|
-it heroc
|
||||||
|
```
|
||||||
|
|
||||||
|
By default, the container will:
|
||||||
|
|
||||||
|
- Start Redis server in the background
|
||||||
|
- Copy your SSH keys to the proper location
|
||||||
|
- Initialize the SSH agent
|
||||||
|
- Add your default SSH key (`id_ed25519`)
|
||||||
|
|
||||||
|
To use a different SSH key, specify it with the KEY environment variable (e.g. `KEY=id_ed25519`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --network=host \
|
||||||
|
-v $(pwd):/workspace \
|
||||||
|
-v ~/.ssh:/root/ssh \
|
||||||
|
-e KEY=your_custom_key_name \
|
||||||
|
-it heroc
|
||||||
|
```
|
||||||
22
collections/documents/getting_started/hero_native.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
## Basic Hero
|
||||||
|
|
||||||
|
You can build Hero natively with the following lines:
|
||||||
|
|
||||||
|
```
|
||||||
|
curl https://raw.githubusercontent.com/freeflowuniverse/herolib/refs/heads/development/install_hero.sh > /tmp/install_hero.sh
|
||||||
|
bash /tmp/install_hero.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hero for Developers
|
||||||
|
|
||||||
|
For developers, use the following commands:
|
||||||
|
|
||||||
|
```
|
||||||
|
curl 'https://raw.githubusercontent.com/freeflowuniverse/herolib/refs/heads/development/install_v.sh' > /tmp/install_v.sh
|
||||||
|
bash /tmp/install_v.sh --analyzer --herolib
|
||||||
|
#DONT FORGET TO START A NEW SHELL (otherwise the paths will not be set)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hero with Docker
|
||||||
|
|
||||||
|
If you have issues running Hero natively, you can use the [Docker version of Hero](hero_docker.md).
|
||||||
5
collections/documents/intro.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
This ebook contains the basic information to get you started with the Hero tool.
|
||||||
|
|
||||||
|
## What is Hero?
|
||||||
|
|
||||||
|
Hero is an open-source toolset to work with Git, AI, mdBook, Docusaurus, Starlight and more.
|
||||||
1
collections/documents/support.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
If you need help with Hero, reach out to the ThreeFold Support team [here](https://threefoldfaq.crisp.help/en/).
|
||||||
BIN
collections/notes/images/logo-blue.png
Normal file
|
After Width: | Height: | Size: 98 KiB |
18
collections/notes/introduction.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Introduction
|
||||||
|
|
||||||
|
### This is an introduction
|
||||||
|
|
||||||
|
|
||||||
|
* **This is an internal image**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
* **This is an external image**
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
---
|
||||||
2
collections/notes/new_folder/zeko.md
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# New File
|
||||||
|
|
||||||
40
collections/notes/presentation.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
## Mycelium Product Presentation
|
||||||
|
|
||||||
|
This document provides an overview of the Mycelium technology stack (as commercially sold my our company GeoMind).
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
position: 'relative',
|
||||||
|
width: '100%',
|
||||||
|
height: 0,
|
||||||
|
paddingTop: '56.25%',
|
||||||
|
marginTop: '1.6em',
|
||||||
|
marginBottom: '0.9em',
|
||||||
|
overflow: 'hidden',
|
||||||
|
borderRadius: '8px',
|
||||||
|
willChange: 'transform'
|
||||||
|
}}>
|
||||||
|
<iframe
|
||||||
|
src="https://www.canva.com/design/DAG0UtzICsk/rqXpn5f3ibo2OpX-yDWmPQ/view?embed"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
border: 'none',
|
||||||
|
padding: 0,
|
||||||
|
margin: 0
|
||||||
|
}}
|
||||||
|
allowFullScreen={true}
|
||||||
|
allow="fullscreen">
|
||||||
|
</iframe>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: '10px' }}>
|
||||||
|
<a href="https://www.canva.com/design/DAG0UtzICsk/rqXpn5f3ibo2OpX-yDWmPQ/view"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
style={{ textDecoration: 'none' }}>
|
||||||
|
Geomind Product Intro 2025 (based on mycelium technology)
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
|
|
||||||
# test
|
|
||||||
|
|
||||||
- 1
|
|
||||||
- 2
|
|
||||||
|
|
||||||
[2025 SeaweedFS Intro Slides.pdf](/notes/2025 SeaweedFS Intro Slides.pdf)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
9
collections/notes/tests/test.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
|
||||||
|
# test
|
||||||
|
|
||||||
|
- 1
|
||||||
|
- 2
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
!!include path:test2.md
|
||||||
12
collections/notes/tests/test2.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
|
||||||
|
## test2
|
||||||
|
|
||||||
|
- something
|
||||||
|
- another thing
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
426
collections/notes/tests/test3.md
Normal file
@@ -0,0 +1,426 @@
|
|||||||
|
# UI Code Refactoring Plan
|
||||||
|
|
||||||
|
**Project:** Markdown Editor
|
||||||
|
**Date:** 2025-10-26
|
||||||
|
**Status:** In Progress
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
This document outlines a comprehensive refactoring plan for the UI codebase to improve maintainability, remove dead code, extract utilities, and standardize patterns. The refactoring is organized into 6 phases with 14 tasks, prioritized by risk and impact.
|
||||||
|
|
||||||
|
**Key Metrics:**
|
||||||
|
|
||||||
|
- Total Lines of Code: ~3,587
|
||||||
|
- Dead Code to Remove: 213 lines (6%)
|
||||||
|
- Estimated Effort: 5-8 days
|
||||||
|
- Risk Level: Mostly LOW to MEDIUM
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Analysis Summary
|
||||||
|
|
||||||
|
### Files Reviewed
|
||||||
|
|
||||||
|
**JavaScript Files (10):**
|
||||||
|
|
||||||
|
- `/static/js/app.js` (484 lines)
|
||||||
|
- `/static/js/column-resizer.js` (100 lines)
|
||||||
|
- `/static/js/confirmation.js` (170 lines)
|
||||||
|
- `/static/js/editor.js` (420 lines)
|
||||||
|
- `/static/js/file-tree-actions.js` (482 lines)
|
||||||
|
- `/static/js/file-tree.js` (865 lines)
|
||||||
|
- `/static/js/macro-parser.js` (103 lines)
|
||||||
|
- `/static/js/macro-processor.js` (157 lines)
|
||||||
|
- `/static/js/ui-utils.js` (305 lines)
|
||||||
|
- `/static/js/webdav-client.js` (266 lines)
|
||||||
|
|
||||||
|
**CSS Files (6):**
|
||||||
|
|
||||||
|
- `/static/css/variables.css` (32 lines)
|
||||||
|
- `/static/css/layout.css`
|
||||||
|
- `/static/css/file-tree.css`
|
||||||
|
- `/static/css/editor.css`
|
||||||
|
- `/static/css/components.css`
|
||||||
|
- `/static/css/modal.css`
|
||||||
|
|
||||||
|
**HTML Templates (1):**
|
||||||
|
|
||||||
|
- `/templates/index.html` (203 lines)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Issues Found
|
||||||
|
|
||||||
|
### 🔴 HIGH PRIORITY
|
||||||
|
|
||||||
|
1. **Deprecated Modal Code (Dead Code)**
|
||||||
|
- Location: `/static/js/file-tree-actions.js` lines 262-474
|
||||||
|
- Impact: 213 lines of unused code (44% of file)
|
||||||
|
- Risk: LOW to remove
|
||||||
|
|
||||||
|
2. **Duplicated Event Bus Implementation**
|
||||||
|
- Location: `/static/js/app.js` lines 16-30
|
||||||
|
- Should be extracted to reusable module
|
||||||
|
|
||||||
|
3. **Duplicated Debounce Function**
|
||||||
|
- Location: `/static/js/editor.js` lines 404-414
|
||||||
|
- Should be shared utility
|
||||||
|
|
||||||
|
4. **Inconsistent Notification Usage**
|
||||||
|
- Mixed usage of `window.showNotification` vs `showNotification`
|
||||||
|
|
||||||
|
5. **Duplicated File Download Logic**
|
||||||
|
- Location: `/static/js/file-tree.js` lines 829-839
|
||||||
|
- Should be shared utility
|
||||||
|
|
||||||
|
6. **Hard-coded Values**
|
||||||
|
- Long-press threshold: 400ms
|
||||||
|
- Debounce delay: 300ms
|
||||||
|
- Drag preview width: 200px
|
||||||
|
- Toast delay: 3000ms
|
||||||
|
|
||||||
|
### 🟡 MEDIUM PRIORITY
|
||||||
|
|
||||||
|
7. **Global State Management**
|
||||||
|
- Location: `/static/js/app.js` lines 6-13
|
||||||
|
- Makes testing difficult
|
||||||
|
|
||||||
|
8. **Duplicated Path Manipulation**
|
||||||
|
- `path.split('/').pop()` appears 10+ times
|
||||||
|
- `path.substring(0, path.lastIndexOf('/'))` appears 5+ times
|
||||||
|
|
||||||
|
9. **Mixed Responsibility in ui-utils.js**
|
||||||
|
- Contains 6 different classes/utilities
|
||||||
|
- Should be split into separate modules
|
||||||
|
|
||||||
|
10. **Deprecated Event Handler**
|
||||||
|
- Location: `/static/js/file-tree-actions.js` line 329
|
||||||
|
- Uses deprecated `onkeypress`
|
||||||
|
|
||||||
|
### 🟢 LOW PRIORITY
|
||||||
|
|
||||||
|
11. **Unused Function Parameters**
|
||||||
|
12. **Magic Numbers in Styling**
|
||||||
|
13. **Inconsistent Comment Styles**
|
||||||
|
14. **Console.log Statements**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Proposed Reusable Components
|
||||||
|
|
||||||
|
### 1. Config Module (`/static/js/config.js`)
|
||||||
|
|
||||||
|
Centralize all configuration values:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
export const Config = {
|
||||||
|
// Timing
|
||||||
|
LONG_PRESS_THRESHOLD: 400,
|
||||||
|
DEBOUNCE_DELAY: 300,
|
||||||
|
TOAST_DURATION: 3000,
|
||||||
|
|
||||||
|
// UI
|
||||||
|
DRAG_PREVIEW_WIDTH: 200,
|
||||||
|
TREE_INDENT_PX: 12,
|
||||||
|
MOUSE_MOVE_THRESHOLD: 5,
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
FILENAME_PATTERN: /^[a-z0-9_]+(\.[a-z0-9_]+)*$/,
|
||||||
|
|
||||||
|
// Storage Keys
|
||||||
|
STORAGE_KEYS: {
|
||||||
|
DARK_MODE: 'darkMode',
|
||||||
|
SELECTED_COLLECTION: 'selectedCollection',
|
||||||
|
LAST_VIEWED_PAGE: 'lastViewedPage',
|
||||||
|
COLUMN_DIMENSIONS: 'columnDimensions'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Logger Module (`/static/js/logger.js`)
|
||||||
|
|
||||||
|
Structured logging with levels:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
export class Logger {
|
||||||
|
static debug(message, ...args)
|
||||||
|
static info(message, ...args)
|
||||||
|
static warn(message, ...args)
|
||||||
|
static error(message, ...args)
|
||||||
|
static setLevel(level)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Event Bus Module (`/static/js/event-bus.js`)
|
||||||
|
|
||||||
|
Centralized event system:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
export class EventBus {
|
||||||
|
on(event, callback)
|
||||||
|
off(event, callback)
|
||||||
|
once(event, callback)
|
||||||
|
dispatch(event, data)
|
||||||
|
clear(event)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Utilities Module (`/static/js/utils.js`)
|
||||||
|
|
||||||
|
Common utility functions:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
export const PathUtils = {
|
||||||
|
getFileName(path),
|
||||||
|
getParentPath(path),
|
||||||
|
normalizePath(path),
|
||||||
|
joinPaths(...paths),
|
||||||
|
getExtension(path)
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TimingUtils = {
|
||||||
|
debounce(func, wait),
|
||||||
|
throttle(func, wait)
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DownloadUtils = {
|
||||||
|
triggerDownload(content, filename),
|
||||||
|
downloadAsBlob(blob, filename)
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ValidationUtils = {
|
||||||
|
validateFileName(name, isFolder),
|
||||||
|
sanitizeFileName(name)
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Notification Service (`/static/js/notification-service.js`)
|
||||||
|
|
||||||
|
Standardized notifications:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
export class NotificationService {
|
||||||
|
static success(message)
|
||||||
|
static error(message)
|
||||||
|
static warning(message)
|
||||||
|
static info(message)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: Refactoring Tasks
|
||||||
|
|
||||||
|
### 🔴 HIGH PRIORITY
|
||||||
|
|
||||||
|
**Task 1: Remove Dead Code**
|
||||||
|
|
||||||
|
- Files: `/static/js/file-tree-actions.js`
|
||||||
|
- Lines: 262-474 (213 lines)
|
||||||
|
- Risk: LOW
|
||||||
|
- Dependencies: None
|
||||||
|
|
||||||
|
**Task 2: Extract Event Bus**
|
||||||
|
|
||||||
|
- Files: NEW `/static/js/event-bus.js`, MODIFY `app.js`, `editor.js`
|
||||||
|
- Risk: MEDIUM
|
||||||
|
- Dependencies: None
|
||||||
|
|
||||||
|
**Task 3: Create Utilities Module**
|
||||||
|
|
||||||
|
- Files: NEW `/static/js/utils.js`, MODIFY multiple files
|
||||||
|
- Risk: MEDIUM
|
||||||
|
- Dependencies: None
|
||||||
|
|
||||||
|
**Task 4: Create Config Module**
|
||||||
|
|
||||||
|
- Files: NEW `/static/js/config.js`, MODIFY multiple files
|
||||||
|
- Risk: LOW
|
||||||
|
- Dependencies: None
|
||||||
|
|
||||||
|
**Task 5: Standardize Notification Usage**
|
||||||
|
|
||||||
|
- Files: NEW `/static/js/notification-service.js`, MODIFY multiple files
|
||||||
|
- Risk: LOW
|
||||||
|
- Dependencies: None
|
||||||
|
|
||||||
|
### 🟡 MEDIUM PRIORITY
|
||||||
|
|
||||||
|
**Task 6: Fix Deprecated Event Handler**
|
||||||
|
|
||||||
|
- Files: `/static/js/file-tree-actions.js` line 329
|
||||||
|
- Risk: LOW
|
||||||
|
- Dependencies: None
|
||||||
|
|
||||||
|
**Task 7: Refactor ui-utils.js**
|
||||||
|
|
||||||
|
- Files: DELETE `ui-utils.js`, CREATE 5 new modules
|
||||||
|
- Risk: HIGH
|
||||||
|
- Dependencies: Task 5
|
||||||
|
|
||||||
|
**Task 8: Standardize Class Export Pattern**
|
||||||
|
|
||||||
|
- Files: All class files
|
||||||
|
- Risk: MEDIUM
|
||||||
|
- Dependencies: None
|
||||||
|
|
||||||
|
**Task 9: Create Logger Module**
|
||||||
|
|
||||||
|
- Files: NEW `/static/js/logger.js`, MODIFY multiple files
|
||||||
|
- Risk: LOW
|
||||||
|
- Dependencies: None
|
||||||
|
|
||||||
|
**Task 10: Implement Download Action**
|
||||||
|
|
||||||
|
- Files: `/static/js/file-tree-actions.js`
|
||||||
|
- Risk: LOW
|
||||||
|
- Dependencies: Task 3
|
||||||
|
|
||||||
|
### 🟢 LOW PRIORITY
|
||||||
|
|
||||||
|
**Task 11: Standardize JSDoc Comments**
|
||||||
|
**Task 12: Extract Magic Numbers to CSS**
|
||||||
|
**Task 13: Add Error Boundaries**
|
||||||
|
**Task 14: Cache DOM Elements**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: Implementation Order
|
||||||
|
|
||||||
|
### Step 1: Foundation (Do First)
|
||||||
|
|
||||||
|
1. Create Config Module (Task 4)
|
||||||
|
2. Create Logger Module (Task 9)
|
||||||
|
3. Create Event Bus Module (Task 2)
|
||||||
|
|
||||||
|
### Step 2: Utilities (Do Second)
|
||||||
|
|
||||||
|
4. Create Utilities Module (Task 3)
|
||||||
|
5. Create Notification Service (Task 5)
|
||||||
|
|
||||||
|
### Step 3: Cleanup (Do Third)
|
||||||
|
|
||||||
|
6. Remove Dead Code (Task 1)
|
||||||
|
7. Fix Deprecated Event Handler (Task 6)
|
||||||
|
|
||||||
|
### Step 4: Restructuring (Do Fourth)
|
||||||
|
|
||||||
|
8. Refactor ui-utils.js (Task 7)
|
||||||
|
9. Standardize Class Export Pattern (Task 8)
|
||||||
|
|
||||||
|
### Step 5: Enhancements (Do Fifth)
|
||||||
|
|
||||||
|
10. Implement Download Action (Task 10)
|
||||||
|
11. Add Error Boundaries (Task 13)
|
||||||
|
|
||||||
|
### Step 6: Polish (Do Last)
|
||||||
|
|
||||||
|
12. Standardize JSDoc Comments (Task 11)
|
||||||
|
13. Extract Magic Numbers to CSS (Task 12)
|
||||||
|
14. Cache DOM Elements (Task 14)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: Testing Checklist
|
||||||
|
|
||||||
|
### Core Functionality
|
||||||
|
|
||||||
|
- [ ] File tree loads and displays correctly
|
||||||
|
- [ ] Files can be selected and opened
|
||||||
|
- [ ] Folders can be expanded/collapsed
|
||||||
|
- [ ] Editor loads file content
|
||||||
|
- [ ] Preview renders markdown correctly
|
||||||
|
- [ ] Save button saves files
|
||||||
|
- [ ] Delete button deletes files
|
||||||
|
- [ ] New button creates new files
|
||||||
|
|
||||||
|
### Context Menu Actions
|
||||||
|
|
||||||
|
- [ ] Right-click shows context menu
|
||||||
|
- [ ] New file action works
|
||||||
|
- [ ] New folder action works
|
||||||
|
- [ ] Rename action works
|
||||||
|
- [ ] Delete action works
|
||||||
|
- [ ] Copy/Cut/Paste actions work
|
||||||
|
- [ ] Upload action works
|
||||||
|
|
||||||
|
### Drag and Drop
|
||||||
|
|
||||||
|
- [ ] Long-press detection works
|
||||||
|
- [ ] Drag preview appears correctly
|
||||||
|
- [ ] Drop targets highlight properly
|
||||||
|
- [ ] Files can be moved
|
||||||
|
- [ ] Undo (Ctrl+Z) works
|
||||||
|
|
||||||
|
### Modals
|
||||||
|
|
||||||
|
- [ ] Confirmation modals appear
|
||||||
|
- [ ] Prompt modals appear
|
||||||
|
- [ ] Modals don't double-open
|
||||||
|
- [ ] Enter/Escape keys work
|
||||||
|
|
||||||
|
### UI Features
|
||||||
|
|
||||||
|
- [ ] Dark mode toggle works
|
||||||
|
- [ ] Collection selector works
|
||||||
|
- [ ] Column resizers work
|
||||||
|
- [ ] Notifications appear
|
||||||
|
- [ ] URL routing works
|
||||||
|
- [ ] View/Edit modes work
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommendations
|
||||||
|
|
||||||
|
### Immediate Actions (Before Production)
|
||||||
|
|
||||||
|
1. Remove dead code (Task 1)
|
||||||
|
2. Fix deprecated event handler (Task 6)
|
||||||
|
3. Create config module (Task 4)
|
||||||
|
|
||||||
|
### Short-term Actions (Next Sprint)
|
||||||
|
|
||||||
|
4. Extract utilities (Task 3)
|
||||||
|
5. Standardize notifications (Task 5)
|
||||||
|
6. Create event bus (Task 2)
|
||||||
|
|
||||||
|
### Medium-term Actions (Future Sprints)
|
||||||
|
|
||||||
|
7. Refactor ui-utils.js (Task 7)
|
||||||
|
8. Add logger (Task 9)
|
||||||
|
9. Standardize exports (Task 8)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Success Metrics
|
||||||
|
|
||||||
|
**Before Refactoring:**
|
||||||
|
|
||||||
|
- Total Lines: ~3,587
|
||||||
|
- Dead Code: 213 lines (6%)
|
||||||
|
- Duplicated Code: ~50 lines
|
||||||
|
- Hard-coded Values: 15+
|
||||||
|
|
||||||
|
**After Refactoring:**
|
||||||
|
|
||||||
|
- Total Lines: ~3,400 (-5%)
|
||||||
|
- Dead Code: 0 lines
|
||||||
|
- Duplicated Code: 0 lines
|
||||||
|
- Hard-coded Values: 0
|
||||||
|
|
||||||
|
**Estimated Effort:** 5-8 days
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
The UI codebase is generally well-structured. Main improvements needed:
|
||||||
|
|
||||||
|
1. Remove dead code
|
||||||
|
2. Extract duplicated utilities
|
||||||
|
3. Centralize configuration
|
||||||
|
4. Standardize patterns
|
||||||
|
|
||||||
|
Start with high-impact, low-risk changes first to ensure production readiness.
|
||||||
78
collections/notes/why.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
**Decentralized Infrastructure Technology for Everyone, Everywhere**
|
||||||
|
|
||||||
|
Mycelium is a comprehensive DePIN (Decentralized Physical Infrastructure) system designed to scale to planetary level, capable of providing resilient services with end-to-end encryption, and enabling any machine and human to communicate efficiently over optimal paths.
|
||||||
|
|
||||||
|
Mycelium is Compatible with Kubernetes, Docker, VMs, Web2, Web3 – and building towards Web4.
|
||||||
|
|
||||||
|
## Terminology Clarification
|
||||||
|
|
||||||
|
- **Mycelium Tech**: The core technology stack (ZOS, QSS, Mycelium Network)
|
||||||
|
- **ThreeFold Grid**: The decentralized infrastructure offering built on Mycelium Tech
|
||||||
|
- **GeoMind**: The commercial tech company operating tier-S/H datacenters with Mycelium
|
||||||
|
|
||||||
|
## Why Decentralized Infrastructure Matters
|
||||||
|
|
||||||
|
Traditional internet infrastructure is burdened with inefficiencies, risks, and growing dependency on centralization.
|
||||||
|
|
||||||
|
### **The Challenges We Face**
|
||||||
|
|
||||||
|
- **Centralization Risks**: Digital infrastructure is controlled by a few corporations, compromising autonomy and creating single points of failure.
|
||||||
|
- **Economic Inefficiency**: Current infrastructure models extract value from local economies, funneling resources to centralized providers.
|
||||||
|
- **Outdated Protocols**: TCP/IP, the internet's core protocol, was never designed for modern needs like dynamic networks, security, and session management.
|
||||||
|
- **Geographic Inefficiency**: Over 70% of the world relies on distant infrastructure, making access expensive, unreliable, and dependent on fragile global systems.
|
||||||
|
- **Limited Access**: Over 50% of the world lacks decent internet access, widening opportunity gaps.
|
||||||
|
|
||||||
|
Mycelium addresses these challenges through a complete, integrated technology stack designed from first principles.
|
||||||
|
|
||||||
|
## What Mycelium Provides
|
||||||
|
|
||||||
|
Mycelium is unique in its ability to deliver an integrated platform covering all three fundamental layers of internet infrastructure:
|
||||||
|
|
||||||
|
### **Compute Layer** - ZOS
|
||||||
|
- Autonomous, stateless operating system
|
||||||
|
- MyImage architecture (up to 100x faster deployment)
|
||||||
|
- Deterministic, cryptographically verified deployment
|
||||||
|
- Supports Kubernetes, containers, VMs, and Linux workloads
|
||||||
|
- Self-healing with no manual maintenance required
|
||||||
|
|
||||||
|
### **Storage Layer** - Quantum Safe Storage (QSS)
|
||||||
|
- Mathematical encoding with forward error correction
|
||||||
|
- 20% overhead vs 400% for traditional replication
|
||||||
|
- Zero-knowledge design: storage nodes can't access data
|
||||||
|
- Petabyte-to-zetabyte scalability
|
||||||
|
- Self-healing bitrot protection
|
||||||
|
|
||||||
|
### **Network Layer** - Mycelium Network
|
||||||
|
- End-to-end encrypted IPv6 overlay
|
||||||
|
- Shortest-path optimization
|
||||||
|
- Multi-protocol support (TCP, QUIC, UDP, satellite, wireless)
|
||||||
|
- Peer-to-peer architecture with no central points of failure
|
||||||
|
- Distributed secure name services
|
||||||
|
|
||||||
|
## Key Differentiators
|
||||||
|
|
||||||
|
| Feature | Mycelium | Traditional Cloud |
|
||||||
|
| ------------------------ | -------------------------------------------- | ------------------------------------------ |
|
||||||
|
| **Architecture** | Distributed peer-to-peer, no central control | Centralized control planes |
|
||||||
|
| **Deployment** | Stateless network boot, zero-install | Local image installation |
|
||||||
|
| **Storage Efficiency** | 20% overhead | 300-400% overhead |
|
||||||
|
| **Security** | End-to-end encrypted, zero-knowledge design | Perimeter-based, trust intermediaries |
|
||||||
|
| **Energy** | Up to 10x more efficient | Higher consumption |
|
||||||
|
| **Autonomy** | Self-healing, autonomous agents | Requires active management |
|
||||||
|
| **Geographic Awareness** | Shortest path routing, location-aware | Static routing, no geographic optimization |
|
||||||
|
|
||||||
|
## Current Status
|
||||||
|
|
||||||
|
- **Deployed**: 20+ countries, 30,000+ vCPU
|
||||||
|
- **Proof of Concept**: Technology validated in production
|
||||||
|
- **Commercialization**: Beginning phase with enterprise roadmap
|
||||||
|
|
||||||
|
## Technology Maturity
|
||||||
|
|
||||||
|
- **All our core cloud technology**: Production
|
||||||
|
- **Quantum Safe Storage**: Production (6+ years)
|
||||||
|
- **Mycelium Network**: Beta
|
||||||
|
- **Deterministic Deployment**: OEM only
|
||||||
|
- **FungiStor**: H1 2026
|
||||||
|
|
||||||
|
Mycelium represents not just an upgrade to existing infrastructure, but a fundamental rethinking of how internet infrastructure should be built—distributed, autonomous, secure, and efficient.%
|
||||||
BIN
collections/tech/images/arch.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
collections/tech/images/dashboard.png
Normal file
|
After Width: | Height: | Size: 403 KiB |
BIN
collections/tech/images/letsfix.png
Normal file
|
After Width: | Height: | Size: 409 KiB |
BIN
collections/tech/images/opportunity.png
Normal file
|
After Width: | Height: | Size: 254 KiB |
BIN
collections/tech/images/status.png
Normal file
|
After Width: | Height: | Size: 388 KiB |
BIN
collections/tech/images/unique.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
collections/tech/images/usable_by_all.png
Normal file
|
After Width: | Height: | Size: 248 KiB |
BIN
collections/tech/images/web4.png
Normal file
|
After Width: | Height: | Size: 61 KiB |
79
collections/tech/introduction.md
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
|
||||||
|
**Decentralized Infrastructure Technology for Everyone, Everywhere**
|
||||||
|
|
||||||
|
Mycelium is a comprehensive DePIN (Decentralized Physical Infrastructure) system designed to scale to planetary level, capable of providing resilient services with end-to-end encryption, and enabling any machine and human to communicate efficiently over optimal paths.
|
||||||
|
|
||||||
|
Mycelium is Compatible with Kubernetes, Docker, VMs, Web2, Web3 – and building towards Web4.
|
||||||
|
|
||||||
|
## Terminology Clarification
|
||||||
|
|
||||||
|
- **Mycelium Tech**: The core technology stack (ZOS, QSS, Mycelium Network)
|
||||||
|
- **ThreeFold Grid**: The decentralized infrastructure offering built on Mycelium Tech
|
||||||
|
- **GeoMind**: The commercial tech company operating tier-S/H datacenters with Mycelium
|
||||||
|
|
||||||
|
## Why Decentralized Infrastructure Matters
|
||||||
|
|
||||||
|
Traditional internet infrastructure is burdened with inefficiencies, risks, and growing dependency on centralization.
|
||||||
|
|
||||||
|
### **The Challenges We Face**
|
||||||
|
|
||||||
|
- **Centralization Risks**: Digital infrastructure is controlled by a few corporations, compromising autonomy and creating single points of failure.
|
||||||
|
- **Economic Inefficiency**: Current infrastructure models extract value from local economies, funneling resources to centralized providers.
|
||||||
|
- **Outdated Protocols**: TCP/IP, the internet's core protocol, was never designed for modern needs like dynamic networks, security, and session management.
|
||||||
|
- **Geographic Inefficiency**: Over 70% of the world relies on distant infrastructure, making access expensive, unreliable, and dependent on fragile global systems.
|
||||||
|
- **Limited Access**: Over 50% of the world lacks decent internet access, widening opportunity gaps.
|
||||||
|
|
||||||
|
Mycelium addresses these challenges through a complete, integrated technology stack designed from first principles.
|
||||||
|
|
||||||
|
## What Mycelium Provides
|
||||||
|
|
||||||
|
Mycelium is unique in its ability to deliver an integrated platform covering all three fundamental layers of internet infrastructure:
|
||||||
|
|
||||||
|
### **Compute Layer** - ZOS
|
||||||
|
- Autonomous, stateless operating system
|
||||||
|
- MyImage architecture (up to 100x faster deployment)
|
||||||
|
- Deterministic, cryptographically verified deployment
|
||||||
|
- Supports Kubernetes, containers, VMs, and Linux workloads
|
||||||
|
- Self-healing with no manual maintenance required
|
||||||
|
|
||||||
|
### **Storage Layer** - Quantum Safe Storage (QSS)
|
||||||
|
- Mathematical encoding with forward error correction
|
||||||
|
- 20% overhead vs 400% for traditional replication
|
||||||
|
- Zero-knowledge design: storage nodes can't access data
|
||||||
|
- Petabyte-to-zetabyte scalability
|
||||||
|
- Self-healing bitrot protection
|
||||||
|
|
||||||
|
### **Network Layer** - Mycelium Network
|
||||||
|
- End-to-end encrypted IPv6 overlay
|
||||||
|
- Shortest-path optimization
|
||||||
|
- Multi-protocol support (TCP, QUIC, UDP, satellite, wireless)
|
||||||
|
- Peer-to-peer architecture with no central points of failure
|
||||||
|
- Distributed secure name services
|
||||||
|
|
||||||
|
## Key Differentiators
|
||||||
|
|
||||||
|
| Feature | Mycelium | Traditional Cloud |
|
||||||
|
| ------------------------ | -------------------------------------------- | ------------------------------------------ |
|
||||||
|
| **Architecture** | Distributed peer-to-peer, no central control | Centralized control planes |
|
||||||
|
| **Deployment** | Stateless network boot, zero-install | Local image installation |
|
||||||
|
| **Storage Efficiency** | 20% overhead | 300-400% overhead |
|
||||||
|
| **Security** | End-to-end encrypted, zero-knowledge design | Perimeter-based, trust intermediaries |
|
||||||
|
| **Energy** | Up to 10x more efficient | Higher consumption |
|
||||||
|
| **Autonomy** | Self-healing, autonomous agents | Requires active management |
|
||||||
|
| **Geographic Awareness** | Shortest path routing, location-aware | Static routing, no geographic optimization |
|
||||||
|
|
||||||
|
## Current Status
|
||||||
|
|
||||||
|
- **Deployed**: 20+ countries, 30,000+ vCPU
|
||||||
|
- **Proof of Concept**: Technology validated in production
|
||||||
|
- **Commercialization**: Beginning phase with enterprise roadmap
|
||||||
|
|
||||||
|
## Technology Maturity
|
||||||
|
|
||||||
|
- **All our core cloud technology**: Production
|
||||||
|
- **Quantum Safe Storage**: Production (6+ years)
|
||||||
|
- **Mycelium Network**: Beta
|
||||||
|
- **Deterministic Deployment**: OEM only
|
||||||
|
- **FungiStor**: H1 2026
|
||||||
|
|
||||||
|
Mycelium represents not just an upgrade to existing infrastructure, but a fundamental rethinking of how internet infrastructure should be built—distributed, autonomous, secure, and efficient.
|
||||||
42
collections/tech/presentation.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
## Mycelium Product Presentation
|
||||||
|
|
||||||
|
This document provides an overview of the Mycelium technology stack (as commercially sold my our company GeoMind).
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
position: 'relative',
|
||||||
|
width: '100%',
|
||||||
|
height: 0,
|
||||||
|
paddingTop: '56.25%',
|
||||||
|
marginTop: '1.6em',
|
||||||
|
marginBottom: '0.9em',
|
||||||
|
overflow: 'hidden',
|
||||||
|
borderRadius: '8px',
|
||||||
|
willChange: 'transform'
|
||||||
|
}}>
|
||||||
|
<iframe
|
||||||
|
src="https://www.canva.com/design/DAG0UtzICsk/rqXpn5f3ibo2OpX-yDWmPQ/view?embed"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
border: 'none',
|
||||||
|
padding: 0,
|
||||||
|
margin: 0
|
||||||
|
}}
|
||||||
|
allowFullScreen={true}
|
||||||
|
allow="fullscreen">
|
||||||
|
</iframe>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: '10px' }}>
|
||||||
|
<a href="https://www.canva.com/design/DAG0UtzICsk/rqXpn5f3ibo2OpX-yDWmPQ/view"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
style={{ textDecoration: 'none' }}>
|
||||||
|
Geomind Product Intro 2025 (based on mycelium technology)
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
50
collections/tech/roadmap/enterprise_roadmap.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
|
||||||
|
# Government, Commercial Hosters, Telco and Enterprise Roadmap
|
||||||
|
|
||||||
|
We are working on the government, commercial hosters, telco and enterprise releases of our technology.
|
||||||
|
|
||||||
|
> 90% of the work has been done as part of our base offering but we need additional features for enterprises.
|
||||||
|
|
||||||
|
## Enterprise User Interface
|
||||||
|
|
||||||
|
The current user interface is designed for an open-source tech audience. For enterprise use, we need a different approach to meet the unique needs of enterprise environments:
|
||||||
|
|
||||||
|
- **Private or Hybrid Context**: All operations should be conducted within a private or hybrid cloud context to ensure security and compliance.
|
||||||
|
- **Enhanced Monitoring**: We need more comprehensive monitoring dashboard screens to provide real-time insights and analytics.
|
||||||
|
- **Identity Management Integration**: Integration with enterprise-grade Identity Management solutions, such as LDAP, Active Directory, and SSO (Single Sign-On), is essential.
|
||||||
|
- **Enterprise-Friendly UI**: The user interface needs to be redesigned to be more intuitive and tailored to enterprise users, focusing on usability and efficiency.
|
||||||
|
- **Token Irrelevance**: Tokens are not a priority in this context and should be de-emphasized in the solution.
|
||||||
|
|
||||||
|
## Windows Support
|
||||||
|
|
||||||
|
The virtual Machine technology we use does support Windows, but we need to do some further integration.
|
||||||
|
|
||||||
|
## High Performance Network Integration
|
||||||
|
|
||||||
|
- **Local Network Integration**: ZOS is designed to support a wide range of technologies, though additional integration work is required to optimize performance.
|
||||||
|
- **High-Speed Backbones**: We aim to support high-speed Ethernet and RDMA (Infiniband) based backbones.
|
||||||
|
- **Instrumentation Enhancements**: Additional instrumentation needs to be incorporated into ZOS to achieve optimal performance.
|
||||||
|
- **Target Performance**: Our goal is to achieve network speeds exceeding 100 Gbps.
|
||||||
|
- **Custom Integration**: We offer integration with selected network equipment from our customers, accommodating custom integration requirements.
|
||||||
|
|
||||||
|
## High Performance Storage Block Device Integration
|
||||||
|
|
||||||
|
Next to the existing already integrated storage backends we want to support a high performance redundant storage block device.
|
||||||
|
|
||||||
|
- High performance redundant storage network
|
||||||
|
- Supports high-speed backbones as defined above
|
||||||
|
- Scalable to thousands of machines per cluster.
|
||||||
|
- Replication capability between zones.
|
||||||
|
- Custom Integration
|
||||||
|
- We offer integration with selected storage equipment from our customers, accommodating custom integration requirements.
|
||||||
|
|
||||||
|
## Service Level Management
|
||||||
|
|
||||||
|
- The system will have hooks and visualization for achievement of Service levels.
|
||||||
|
- This will allow a commercial service provider to get to higher revenue and better uptime management.
|
||||||
|
|
||||||
|
## Support for Liquid Cooling Tanks
|
||||||
|
|
||||||
|
- Do a test setup in liquid cooling rack or node.
|
||||||
|
- We can use our self-healing capabilities to manage in a better way.
|
||||||
|
- This is an integration effort, and not much code changes are needed.
|
||||||
24
collections/tech/roadmap/hero_roadmap.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
|
||||||
|
## AI Agent High Level Roadmap
|
||||||
|
|
||||||
|
MyAgent is our private AI agent.
|
||||||
|
|
||||||
|
The first version of our MyAgent enables the management of core services such as an innovative database backend, a autonomous decentralized git system, and the automatic integration and deployment of our workloads.
|
||||||
|
|
||||||
|
This stack allows everyone to deploy scalable Web 2,3 and 4 apps on top of the TFGrid in a fully automated way.
|
||||||
|
|
||||||
|
| | Roadmap | Timing |
|
||||||
|
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------ | ------ |
|
||||||
|
| MyAgent Publisher | Publish websites, e-books, and more on top of the ThreeFold Grid | H2 25 |
|
||||||
|
| MyAgent CI = Continuous Integration | Easier to use Continuous Integration/Development, very powerfull, with multinode support | H2 25 |
|
||||||
|
| MyAgent Play | Integrate declarative automation and configuration management as part of wiki approach in MyAgent Publisher | H2 25 |
|
||||||
|
| MyAgent Git | Alternative to centralized Github (based on Gitea), fully integrated on top of TFGrid | H2 25 |
|
||||||
|
| MyAgent DB | Flexible ultra redundant database stor with indexing, queries, stored procedures, super scalable replication | H2 25 |
|
||||||
|
| MyAgent OSIS | Object Storage and Index system | H2 25 |
|
||||||
|
| MyAgent WEB | Web framework, deployable globally on TFGrid, integrated with Mycelium Net and Names | H2 25 |
|
||||||
|
| MyAgent Monitor | Monitor all your different components on redundant monitoring stack | H2 25 |
|
||||||
|
| MyAgent Happs | MyAgent natively supports Holochain HAPPS | Q4 25 |
|
||||||
|
| MyAgent Actors | MyAgent can serve actors which respond and act on OpenRPC calls ideal as backend for web or other apps | Q4 25 |
|
||||||
|
| MyAgent Web 3 Gateway | MyAgent aims to have native support for chosen Web3 partner solutions (Bitcoin, Ethereum, and more) | Q4 25 |
|
||||||
|
|
||||||
|
All of the specs above are fully integrated with the Mycelium Network and the ThreeFold Grid.
|
||||||
40
collections/tech/roadmap/high_level.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
# Roadmap in Phases
|
||||||
|
|
||||||
|
## Phase 1: Wave 1 of Companies, Leading to Our expertise (DONE)
|
||||||
|
|
||||||
|
- Technology creation
|
||||||
|
- This was result of 20 years of evolution
|
||||||
|
- 7 startups acquired as part of this process
|
||||||
|
- Technology used globally by big vendors
|
||||||
|
- +600m USD in exits
|
||||||
|
|
||||||
|
## Phase 2: Proof of Tech (DONE)
|
||||||
|
|
||||||
|
- Open source technology launched globally
|
||||||
|
- +60,000,000 active vCPU
|
||||||
|
- Large scale proof of core technology
|
||||||
|
- Focus on early adoptors in tech space (Cloud, Web2, Web3, etc.)
|
||||||
|
- 50m USD funded by founders, community and hosters (people providing capacity)
|
||||||
|
|
||||||
|
## Phase 3: Commercialization & Global Expansion (START)
|
||||||
|
|
||||||
|
### Phase 3.1: Commercial Partners
|
||||||
|
|
||||||
|
- Mycelium Launches with commercial strategic partners
|
||||||
|
- Telco Operatators
|
||||||
|
- IT Integrators
|
||||||
|
- Enterprise roadmap delivered within 6 months
|
||||||
|
- This is mainly about integration, documentation and UI work
|
||||||
|
- Together with partners we deliver on the many projects which are in our funnel today, e.g., East Africa, Brazil
|
||||||
|
|
||||||
|
### Phase 3.2: Large Scale Financancing for Infrastructure
|
||||||
|
|
||||||
|
**Large Scaling Financing Round**
|
||||||
|
|
||||||
|
- Financing for infrastructure projects (trillions available right now for infrastructures in emerging countries)
|
||||||
|
- Public STO (security token offering)
|
||||||
|
- This lets people around the world to co-own the infrastructure for their internet
|
||||||
|
- Large partnerships drive alternative to Tier 3 and 4 datacenters
|
||||||
BIN
collections/tech/roadmap/img/roadmap.jpg
Normal file
|
After Width: | Height: | Size: 97 KiB |
50
collections/tech/roadmap/tfgrid_roadmap.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
|
||||||
|
## High Level Roadmap
|
||||||
|
|
||||||
|
### Status Today
|
||||||
|
|
||||||
|
The core offering is functioning effectively, maintained through a community-driven, best-effort approach. Currently,
|
||||||
|
there are no Service Level Agreements (SLAs) in place, and there should be increased visibility for users regarding their expectations for uptime, performance, and other service related requirements.
|
||||||
|
|
||||||
|
The uptime and stability of ZOS are very good.
|
||||||
|
|
||||||
|
Additionally, hardware compatibility is excellent, with most machines now supported out of the box.
|
||||||
|
|
||||||
|
| | Status today | SDK/API | Web UI |
|
||||||
|
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------- | ------- | ------ |
|
||||||
|
| ZOS | Used for management of +30,000 logical CPU cores | yes | yes |
|
||||||
|
| MyImage (flists) | Basis for ZOS modules as well as replaces images for VM's ... | yes | yes |
|
||||||
|
| MyImage from Docker | convert docker through our Hub | yes | yes |
|
||||||
|
| MyImage Hub | Mycelium is hosting some as well as everyone can install their own Hub | yes | yes |
|
||||||
|
| Mycelium Core | Integrated in ZOS for VM's as well s ZDB and monitoring | yes | yes |
|
||||||
|
| Mycelium Message Bus | Can be used by any developer for their own usecases | NA | NA |
|
||||||
|
| Quantum Safe Storage | Usable for experts only, is reliably working for +6 years, +100 MB/sec per stream | yes | no |
|
||||||
|
| Unbreakable Filesystem | Quantum Safe FS= usable for experts, is a fuse based filesystem on top of the QSS Core | yes | no |
|
||||||
|
| ZOS Kubernetes | Working very well, Integrated in ZOS, uses our overlay networks based on Wireguard, can use Quantum Safe FS underneith. | yes | yes |
|
||||||
|
| ZOS VM's | The base of our service portfolio, missing is better service level management | yes | yes |
|
||||||
|
| ZOS Monitoring | Working well | yes | yes |
|
||||||
|
| ZOS VM Monitoring | Working well, can be retrieved through SDK | yes | yes |
|
||||||
|
| ZOS Web Gateway | Working well, but documentation not good enough, and not enough of them deployed | yes | yes |
|
||||||
|
| Zero-Boot | There are multiple ways active on how to deploy ZOS all are stateless and capable for full secure boot | yes | yes |
|
||||||
|
|
||||||
|
### Planned new features
|
||||||
|
|
||||||
|
Considerable effort is being made to enable our partners to go into production;
|
||||||
|
however, for this initiative to truly succeed on planetary level, we need many more nodes deployed in the field.
|
||||||
|
|
||||||
|
Below you can find some of the planned features of Mycelium Network 4.0 mainly to achieve ability to scale to hundred of thousand of nodes.
|
||||||
|
|
||||||
|
| | Roadmap | Timing |
|
||||||
|
| ------------------------------- | ------------------------------------------------------------------- | ------- |
|
||||||
|
| ZOS v4 (our next major release) | V4, without Mycelium Chain, mutual credit, marketplace | Q2/3 25 |
|
||||||
|
| MyImage from Docker | CI/CD integration (See MyAgent CI/CD) | Q1 25 |
|
||||||
|
| MyImage Hub Integration | CI/CD integration (See MyAgent CI/CD) no more need for separate Hub | Q1 25 |
|
||||||
|
| Mycelium Core | Just more hardening and testing | Q1 25 |
|
||||||
|
| Mycelium Message Bus | Replace our current RMB, all our own RPC over Mycelium | Q1 25 |
|
||||||
|
| ZOS VM's Cloud Slices | Integration MyAgent CI, use cloud slices to manage | Q2 25 |
|
||||||
|
| ZOS Monitoring Docu | More docu and easier API | Q2 25 |
|
||||||
|
| ZOS Web Gateway Expansion | Need more deployed, better integration with new Mycelium | Q2 25 |
|
||||||
|
| Mycelium Names | In V4, name services | Q2 25 |
|
||||||
|
| ZOS Cloud,Storage,AI Slices | As part of marketplace for V4, flexible billing mutual credit | Q3 25 |
|
||||||
|
| FungiStor | A revolutionary different way how to deliver content | Q3 25 |
|
||||||
|
| MyImage on FungiStor | Can be stored on FungiStor | Q3 25 |
|
||||||
21
collections/tech/status.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
## Technology Status
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
The Mycelium technology stack is proven and operational in production environments globally.
|
||||||
|
|
||||||
|
Ongoing deployment and enhancement activities continue across the platform, with expanding adoption and application scope.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Usable for Any Infrastructure Use Case
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Mycelium is designed to support any infrastructure workload - from traditional cloud applications to edge computing, AI services, and decentralized applications.
|
||||||
|
|
||||||
|
## Differentiated Architecture
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Mycelium's unique value lies in its integrated approach: autonomous infrastructure, deterministic deployment, zero-knowledge storage, and optimized networking - delivered as a cohesive platform rather than point solutions.
|
||||||
21
collections/tech/vision.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|

|
||||||
|
|
||||||
|
## Vision
|
||||||
|
|
||||||
|
Building the foundational internet infrastructure layer that is more reliable, safe, private, scalable, and sustainable.
|
||||||
|
|
||||||
|
Our technology enables anyone to become an infrastructure provider while maintaining autonomous, self-healing services covering all three fundamental layers of internet architecture.
|
||||||
|
|
||||||
|
Our system is unique in its ability to deliver integrated services across compute (ZOS), storage (Quantum Safe Storage), and networking (Mycelium Network) within a single, coherent platform.
|
||||||
|
|
||||||
|
## Lets Fix Our Internet
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**We are a grounded project:**
|
||||||
|
|
||||||
|
- Already deployed in 30+ countries with 30,000+ vCPUs live
|
||||||
|
- Proven technology in production for multiple years
|
||||||
|
- Complete stack: OS, storage, networking, AI agents
|
||||||
|
- Focused on building and proving technology
|
||||||
|
- Commercial phase launching with enterprise roadmap
|
||||||
43
collections/tech/what.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
|
||||||
|
## What do we do?
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
A truly reliable Internet requires fundamentally better systems for networking (communication), storage (data), and compute.
|
||||||
|
|
||||||
|
Mycelium has built these core technologies from the ground up, enabling anyone to become an infrastructure provider while maintaining autonomous, self-healing services covering all three fundamental layers of internet architecture.
|
||||||
|
|
||||||
|
### Authentic, Secure & Globally Scalable Network Technology
|
||||||
|
|
||||||
|
- Our Mycelium Network technology enables seamless, private communication between people and machines, anywhere in the world, using the most efficient path available.
|
||||||
|
- It integrates with a global edge network of ultra-connected, low-latency supernodes to deliver superior performance and resilience.
|
||||||
|
- Mycelium is designed to overcome the limitations of the traditional Internet, such as unreliability, poor performance, and security risks.
|
||||||
|
- It provides core services including Naming, Shortest Path Routing, End-to-End Encryption, Authentication, a Secure Message Bus, and Content Delivery.
|
||||||
|
|
||||||
|
### Data Storage & Distribution
|
||||||
|
|
||||||
|
- Our Quantum-Safe Storage system enables users to store unlimited amounts of data with full ownership and control.
|
||||||
|
- As soon as data leaves the application or compute layer, it is encoded in a way that is resistant even to quantum-level attacks.
|
||||||
|
- Users have full control over data availability, redundancy, and geographic placement.
|
||||||
|
- The system supports multiple interfaces, including IPFS, S3, WebDAV, HTTP, and standard file system access.
|
||||||
|
- Data can never be corrupted, and the storage system is self-healing by design.
|
||||||
|
|
||||||
|
### Secure Compute
|
||||||
|
|
||||||
|
- Self-Managing & Stateless: Requires no manual interactions, enabling fully autonomous operation across global infrastructure.
|
||||||
|
- Secure & Deterministic Deployments: Every workload is cryptographically verified and deployed with guaranteed consistency—no room for tampering or drift.
|
||||||
|
- Efficient Deployment Storage System (Zero-Image): Achieves up to 100x reduction in image size and transfer using a unique metadata-driven architecture.
|
||||||
|
- Compatible: Runs Docker containers, virtual machines, and Linux workloads seamlessly.
|
||||||
|
- Smart Contract-Based Deployment: Workloads are governed by cryptographically signed contracts, ensuring transparent, tamper-proof deployment and execution.
|
||||||
|
|
||||||
|
## Compare
|
||||||
|
|
||||||
|
| Feature | Others | Mycelium Tech |
|
||||||
|
| ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ------------- |
|
||||||
|
| Deterministic Deployments Possible, no one (hacker) can alter state. | NO | YES |
|
||||||
|
| Autonomous/Self Healing Infrastructure which can scale to the planet. | NO | YES |
|
||||||
|
| Usable for any web2, web3 workload, compatible with now & future. | NO | YES |
|
||||||
|
| Data is geo-aware, war & disaster proof. | NO | YES |
|
||||||
|
| Can work in hyperscale datacenters as well as at edge. | NO | YES |
|
||||||
|
| Cost effective, can be 3x less | NO | YES |
|
||||||
|
| Networks can always find the shortest path and work over multiple media e.g. satellite, std internet, meshed wireless, lorawan, etc all end to end encrypted. | NO | YES |
|
||||||
29
config.yaml
@@ -1,25 +1,22 @@
|
|||||||
# WsgiDAV Configuration
|
|
||||||
# Collections define WebDAV-accessible directories
|
|
||||||
|
|
||||||
collections:
|
collections:
|
||||||
documents:
|
documents:
|
||||||
path: "./collections/documents"
|
path: ./collections/documents
|
||||||
description: "General documents and notes"
|
description: General documents and notes
|
||||||
|
|
||||||
notes:
|
notes:
|
||||||
path: "./collections/notes"
|
path: ./collections/notes
|
||||||
description: "Personal notes and drafts"
|
description: Personal notes and drafts
|
||||||
|
|
||||||
projects:
|
projects:
|
||||||
path: "./collections/projects"
|
path: ./collections/projects
|
||||||
description: "Project documentation"
|
description: Project documentation
|
||||||
|
7madah:
|
||||||
# Server settings
|
path: collections/7madah
|
||||||
|
description: 'User-created collection: 7madah'
|
||||||
|
tech:
|
||||||
|
path: collections/tech
|
||||||
|
description: 'User-created collection: tech'
|
||||||
server:
|
server:
|
||||||
host: "localhost"
|
host: localhost
|
||||||
port: 8004
|
port: 8004
|
||||||
|
|
||||||
# WebDAV settings
|
|
||||||
webdav:
|
webdav:
|
||||||
verbose: 1
|
verbose: 1
|
||||||
enable_loggers: []
|
enable_loggers: []
|
||||||
|
|||||||
426
refactor-plan.md
Normal file
@@ -0,0 +1,426 @@
|
|||||||
|
# UI Code Refactoring Plan
|
||||||
|
|
||||||
|
**Project:** Markdown Editor
|
||||||
|
**Date:** 2025-10-26
|
||||||
|
**Status:** In Progress
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
This document outlines a comprehensive refactoring plan for the UI codebase to improve maintainability, remove dead code, extract utilities, and standardize patterns. The refactoring is organized into 6 phases with 14 tasks, prioritized by risk and impact.
|
||||||
|
|
||||||
|
**Key Metrics:**
|
||||||
|
|
||||||
|
- Total Lines of Code: ~3,587
|
||||||
|
- Dead Code to Remove: 213 lines (6%)
|
||||||
|
- Estimated Effort: 5-8 days
|
||||||
|
- Risk Level: Mostly LOW to MEDIUM
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Analysis Summary
|
||||||
|
|
||||||
|
### Files Reviewed
|
||||||
|
|
||||||
|
**JavaScript Files (10):**
|
||||||
|
|
||||||
|
- `/static/js/app.js` (484 lines)
|
||||||
|
- `/static/js/column-resizer.js` (100 lines)
|
||||||
|
- `/static/js/confirmation.js` (170 lines)
|
||||||
|
- `/static/js/editor.js` (420 lines)
|
||||||
|
- `/static/js/file-tree-actions.js` (482 lines)
|
||||||
|
- `/static/js/file-tree.js` (865 lines)
|
||||||
|
- `/static/js/macro-parser.js` (103 lines)
|
||||||
|
- `/static/js/macro-processor.js` (157 lines)
|
||||||
|
- `/static/js/ui-utils.js` (305 lines)
|
||||||
|
- `/static/js/webdav-client.js` (266 lines)
|
||||||
|
|
||||||
|
**CSS Files (6):**
|
||||||
|
|
||||||
|
- `/static/css/variables.css` (32 lines)
|
||||||
|
- `/static/css/layout.css`
|
||||||
|
- `/static/css/file-tree.css`
|
||||||
|
- `/static/css/editor.css`
|
||||||
|
- `/static/css/components.css`
|
||||||
|
- `/static/css/modal.css`
|
||||||
|
|
||||||
|
**HTML Templates (1):**
|
||||||
|
|
||||||
|
- `/templates/index.html` (203 lines)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Issues Found
|
||||||
|
|
||||||
|
### 🔴 HIGH PRIORITY
|
||||||
|
|
||||||
|
1. **Deprecated Modal Code (Dead Code)**
|
||||||
|
- Location: `/static/js/file-tree-actions.js` lines 262-474
|
||||||
|
- Impact: 213 lines of unused code (44% of file)
|
||||||
|
- Risk: LOW to remove
|
||||||
|
|
||||||
|
2. **Duplicated Event Bus Implementation**
|
||||||
|
- Location: `/static/js/app.js` lines 16-30
|
||||||
|
- Should be extracted to reusable module
|
||||||
|
|
||||||
|
3. **Duplicated Debounce Function**
|
||||||
|
- Location: `/static/js/editor.js` lines 404-414
|
||||||
|
- Should be shared utility
|
||||||
|
|
||||||
|
4. **Inconsistent Notification Usage**
|
||||||
|
- Mixed usage of `window.showNotification` vs `showNotification`
|
||||||
|
|
||||||
|
5. **Duplicated File Download Logic**
|
||||||
|
- Location: `/static/js/file-tree.js` lines 829-839
|
||||||
|
- Should be shared utility
|
||||||
|
|
||||||
|
6. **Hard-coded Values**
|
||||||
|
- Long-press threshold: 400ms
|
||||||
|
- Debounce delay: 300ms
|
||||||
|
- Drag preview width: 200px
|
||||||
|
- Toast delay: 3000ms
|
||||||
|
|
||||||
|
### 🟡 MEDIUM PRIORITY
|
||||||
|
|
||||||
|
7. **Global State Management**
|
||||||
|
- Location: `/static/js/app.js` lines 6-13
|
||||||
|
- Makes testing difficult
|
||||||
|
|
||||||
|
8. **Duplicated Path Manipulation**
|
||||||
|
- `path.split('/').pop()` appears 10+ times
|
||||||
|
- `path.substring(0, path.lastIndexOf('/'))` appears 5+ times
|
||||||
|
|
||||||
|
9. **Mixed Responsibility in ui-utils.js**
|
||||||
|
- Contains 6 different classes/utilities
|
||||||
|
- Should be split into separate modules
|
||||||
|
|
||||||
|
10. **Deprecated Event Handler**
|
||||||
|
- Location: `/static/js/file-tree-actions.js` line 329
|
||||||
|
- Uses deprecated `onkeypress`
|
||||||
|
|
||||||
|
### 🟢 LOW PRIORITY
|
||||||
|
|
||||||
|
11. **Unused Function Parameters**
|
||||||
|
12. **Magic Numbers in Styling**
|
||||||
|
13. **Inconsistent Comment Styles**
|
||||||
|
14. **Console.log Statements**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Proposed Reusable Components
|
||||||
|
|
||||||
|
### 1. Config Module (`/static/js/config.js`)
|
||||||
|
|
||||||
|
Centralize all configuration values:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
export const Config = {
|
||||||
|
// Timing
|
||||||
|
LONG_PRESS_THRESHOLD: 400,
|
||||||
|
DEBOUNCE_DELAY: 300,
|
||||||
|
TOAST_DURATION: 3000,
|
||||||
|
|
||||||
|
// UI
|
||||||
|
DRAG_PREVIEW_WIDTH: 200,
|
||||||
|
TREE_INDENT_PX: 12,
|
||||||
|
MOUSE_MOVE_THRESHOLD: 5,
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
FILENAME_PATTERN: /^[a-z0-9_]+(\.[a-z0-9_]+)*$/,
|
||||||
|
|
||||||
|
// Storage Keys
|
||||||
|
STORAGE_KEYS: {
|
||||||
|
DARK_MODE: 'darkMode',
|
||||||
|
SELECTED_COLLECTION: 'selectedCollection',
|
||||||
|
LAST_VIEWED_PAGE: 'lastViewedPage',
|
||||||
|
COLUMN_DIMENSIONS: 'columnDimensions'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Logger Module (`/static/js/logger.js`)
|
||||||
|
|
||||||
|
Structured logging with levels:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
export class Logger {
|
||||||
|
static debug(message, ...args)
|
||||||
|
static info(message, ...args)
|
||||||
|
static warn(message, ...args)
|
||||||
|
static error(message, ...args)
|
||||||
|
static setLevel(level)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Event Bus Module (`/static/js/event-bus.js`)
|
||||||
|
|
||||||
|
Centralized event system:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
export class EventBus {
|
||||||
|
on(event, callback)
|
||||||
|
off(event, callback)
|
||||||
|
once(event, callback)
|
||||||
|
dispatch(event, data)
|
||||||
|
clear(event)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Utilities Module (`/static/js/utils.js`)
|
||||||
|
|
||||||
|
Common utility functions:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
export const PathUtils = {
|
||||||
|
getFileName(path),
|
||||||
|
getParentPath(path),
|
||||||
|
normalizePath(path),
|
||||||
|
joinPaths(...paths),
|
||||||
|
getExtension(path)
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TimingUtils = {
|
||||||
|
debounce(func, wait),
|
||||||
|
throttle(func, wait)
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DownloadUtils = {
|
||||||
|
triggerDownload(content, filename),
|
||||||
|
downloadAsBlob(blob, filename)
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ValidationUtils = {
|
||||||
|
validateFileName(name, isFolder),
|
||||||
|
sanitizeFileName(name)
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Notification Service (`/static/js/notification-service.js`)
|
||||||
|
|
||||||
|
Standardized notifications:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
export class NotificationService {
|
||||||
|
static success(message)
|
||||||
|
static error(message)
|
||||||
|
static warning(message)
|
||||||
|
static info(message)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: Refactoring Tasks
|
||||||
|
|
||||||
|
### 🔴 HIGH PRIORITY
|
||||||
|
|
||||||
|
**Task 1: Remove Dead Code**
|
||||||
|
|
||||||
|
- Files: `/static/js/file-tree-actions.js`
|
||||||
|
- Lines: 262-474 (213 lines)
|
||||||
|
- Risk: LOW
|
||||||
|
- Dependencies: None
|
||||||
|
|
||||||
|
**Task 2: Extract Event Bus**
|
||||||
|
|
||||||
|
- Files: NEW `/static/js/event-bus.js`, MODIFY `app.js`, `editor.js`
|
||||||
|
- Risk: MEDIUM
|
||||||
|
- Dependencies: None
|
||||||
|
|
||||||
|
**Task 3: Create Utilities Module**
|
||||||
|
|
||||||
|
- Files: NEW `/static/js/utils.js`, MODIFY multiple files
|
||||||
|
- Risk: MEDIUM
|
||||||
|
- Dependencies: None
|
||||||
|
|
||||||
|
**Task 4: Create Config Module**
|
||||||
|
|
||||||
|
- Files: NEW `/static/js/config.js`, MODIFY multiple files
|
||||||
|
- Risk: LOW
|
||||||
|
- Dependencies: None
|
||||||
|
|
||||||
|
**Task 5: Standardize Notification Usage**
|
||||||
|
|
||||||
|
- Files: NEW `/static/js/notification-service.js`, MODIFY multiple files
|
||||||
|
- Risk: LOW
|
||||||
|
- Dependencies: None
|
||||||
|
|
||||||
|
### 🟡 MEDIUM PRIORITY
|
||||||
|
|
||||||
|
**Task 6: Fix Deprecated Event Handler**
|
||||||
|
|
||||||
|
- Files: `/static/js/file-tree-actions.js` line 329
|
||||||
|
- Risk: LOW
|
||||||
|
- Dependencies: None
|
||||||
|
|
||||||
|
**Task 7: Refactor ui-utils.js**
|
||||||
|
|
||||||
|
- Files: DELETE `ui-utils.js`, CREATE 5 new modules
|
||||||
|
- Risk: HIGH
|
||||||
|
- Dependencies: Task 5
|
||||||
|
|
||||||
|
**Task 8: Standardize Class Export Pattern**
|
||||||
|
|
||||||
|
- Files: All class files
|
||||||
|
- Risk: MEDIUM
|
||||||
|
- Dependencies: None
|
||||||
|
|
||||||
|
**Task 9: Create Logger Module**
|
||||||
|
|
||||||
|
- Files: NEW `/static/js/logger.js`, MODIFY multiple files
|
||||||
|
- Risk: LOW
|
||||||
|
- Dependencies: None
|
||||||
|
|
||||||
|
**Task 10: Implement Download Action**
|
||||||
|
|
||||||
|
- Files: `/static/js/file-tree-actions.js`
|
||||||
|
- Risk: LOW
|
||||||
|
- Dependencies: Task 3
|
||||||
|
|
||||||
|
### 🟢 LOW PRIORITY
|
||||||
|
|
||||||
|
**Task 11: Standardize JSDoc Comments**
|
||||||
|
**Task 12: Extract Magic Numbers to CSS**
|
||||||
|
**Task 13: Add Error Boundaries**
|
||||||
|
**Task 14: Cache DOM Elements**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: Implementation Order
|
||||||
|
|
||||||
|
### Step 1: Foundation (Do First)
|
||||||
|
|
||||||
|
1. Create Config Module (Task 4)
|
||||||
|
2. Create Logger Module (Task 9)
|
||||||
|
3. Create Event Bus Module (Task 2)
|
||||||
|
|
||||||
|
### Step 2: Utilities (Do Second)
|
||||||
|
|
||||||
|
4. Create Utilities Module (Task 3)
|
||||||
|
5. Create Notification Service (Task 5)
|
||||||
|
|
||||||
|
### Step 3: Cleanup (Do Third)
|
||||||
|
|
||||||
|
6. Remove Dead Code (Task 1)
|
||||||
|
7. Fix Deprecated Event Handler (Task 6)
|
||||||
|
|
||||||
|
### Step 4: Restructuring (Do Fourth)
|
||||||
|
|
||||||
|
8. Refactor ui-utils.js (Task 7)
|
||||||
|
9. Standardize Class Export Pattern (Task 8)
|
||||||
|
|
||||||
|
### Step 5: Enhancements (Do Fifth)
|
||||||
|
|
||||||
|
10. Implement Download Action (Task 10)
|
||||||
|
11. Add Error Boundaries (Task 13)
|
||||||
|
|
||||||
|
### Step 6: Polish (Do Last)
|
||||||
|
|
||||||
|
12. Standardize JSDoc Comments (Task 11)
|
||||||
|
13. Extract Magic Numbers to CSS (Task 12)
|
||||||
|
14. Cache DOM Elements (Task 14)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: Testing Checklist
|
||||||
|
|
||||||
|
### Core Functionality
|
||||||
|
|
||||||
|
- [ ] File tree loads and displays correctly
|
||||||
|
- [ ] Files can be selected and opened
|
||||||
|
- [ ] Folders can be expanded/collapsed
|
||||||
|
- [ ] Editor loads file content
|
||||||
|
- [ ] Preview renders markdown correctly
|
||||||
|
- [ ] Save button saves files
|
||||||
|
- [ ] Delete button deletes files
|
||||||
|
- [ ] New button creates new files
|
||||||
|
|
||||||
|
### Context Menu Actions
|
||||||
|
|
||||||
|
- [ ] Right-click shows context menu
|
||||||
|
- [ ] New file action works
|
||||||
|
- [ ] New folder action works
|
||||||
|
- [ ] Rename action works
|
||||||
|
- [ ] Delete action works
|
||||||
|
- [ ] Copy/Cut/Paste actions work
|
||||||
|
- [ ] Upload action works
|
||||||
|
|
||||||
|
### Drag and Drop
|
||||||
|
|
||||||
|
- [ ] Long-press detection works
|
||||||
|
- [ ] Drag preview appears correctly
|
||||||
|
- [ ] Drop targets highlight properly
|
||||||
|
- [ ] Files can be moved
|
||||||
|
- [ ] Undo (Ctrl+Z) works
|
||||||
|
|
||||||
|
### Modals
|
||||||
|
|
||||||
|
- [ ] Confirmation modals appear
|
||||||
|
- [ ] Prompt modals appear
|
||||||
|
- [ ] Modals don't double-open
|
||||||
|
- [ ] Enter/Escape keys work
|
||||||
|
|
||||||
|
### UI Features
|
||||||
|
|
||||||
|
- [ ] Dark mode toggle works
|
||||||
|
- [ ] Collection selector works
|
||||||
|
- [ ] Column resizers work
|
||||||
|
- [ ] Notifications appear
|
||||||
|
- [ ] URL routing works
|
||||||
|
- [ ] View/Edit modes work
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommendations
|
||||||
|
|
||||||
|
### Immediate Actions (Before Production)
|
||||||
|
|
||||||
|
1. Remove dead code (Task 1)
|
||||||
|
2. Fix deprecated event handler (Task 6)
|
||||||
|
3. Create config module (Task 4)
|
||||||
|
|
||||||
|
### Short-term Actions (Next Sprint)
|
||||||
|
|
||||||
|
4. Extract utilities (Task 3)
|
||||||
|
5. Standardize notifications (Task 5)
|
||||||
|
6. Create event bus (Task 2)
|
||||||
|
|
||||||
|
### Medium-term Actions (Future Sprints)
|
||||||
|
|
||||||
|
7. Refactor ui-utils.js (Task 7)
|
||||||
|
8. Add logger (Task 9)
|
||||||
|
9. Standardize exports (Task 8)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Success Metrics
|
||||||
|
|
||||||
|
**Before Refactoring:**
|
||||||
|
|
||||||
|
- Total Lines: ~3,587
|
||||||
|
- Dead Code: 213 lines (6%)
|
||||||
|
- Duplicated Code: ~50 lines
|
||||||
|
- Hard-coded Values: 15+
|
||||||
|
|
||||||
|
**After Refactoring:**
|
||||||
|
|
||||||
|
- Total Lines: ~3,400 (-5%)
|
||||||
|
- Dead Code: 0 lines
|
||||||
|
- Duplicated Code: 0 lines
|
||||||
|
- Hard-coded Values: 0
|
||||||
|
|
||||||
|
**Estimated Effort:** 5-8 days
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
The UI codebase is generally well-structured. Main improvements needed:
|
||||||
|
|
||||||
|
1. Remove dead code
|
||||||
|
2. Extract duplicated utilities
|
||||||
|
3. Centralize configuration
|
||||||
|
4. Standardize patterns
|
||||||
|
|
||||||
|
Start with high-impact, low-risk changes first to ensure production readiness.
|
||||||
8
server_debug.log
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
==============================================
|
||||||
|
Markdown Editor v3.0 - WebDAV Server
|
||||||
|
==============================================
|
||||||
|
Activating virtual environment...
|
||||||
|
Installing dependencies...
|
||||||
|
Audited 3 packages in 29ms
|
||||||
|
Checking for process on port 8004...
|
||||||
|
Starting WebDAV server...
|
||||||
164
server_webdav.py
@@ -28,9 +28,17 @@ class MarkdownEditorApp:
|
|||||||
|
|
||||||
def load_config(self, config_path):
|
def load_config(self, config_path):
|
||||||
"""Load configuration from YAML file"""
|
"""Load configuration from YAML file"""
|
||||||
|
self.config_path = config_path
|
||||||
with open(config_path, 'r') as f:
|
with open(config_path, 'r') as f:
|
||||||
return yaml.safe_load(f)
|
return yaml.safe_load(f)
|
||||||
|
|
||||||
|
def save_config(self):
|
||||||
|
"""Save configuration to YAML file"""
|
||||||
|
# Update config with current collections
|
||||||
|
self.config['collections'] = self.collections
|
||||||
|
with open(self.config_path, 'w') as f:
|
||||||
|
yaml.dump(self.config, f, default_flow_style=False, sort_keys=False)
|
||||||
|
|
||||||
def setup_collections(self):
|
def setup_collections(self):
|
||||||
"""Create collection directories if they don't exist"""
|
"""Create collection directories if they don't exist"""
|
||||||
for name, config in self.collections.items():
|
for name, config in self.collections.items():
|
||||||
@@ -92,13 +100,30 @@ class MarkdownEditorApp:
|
|||||||
if path == '/fs/' and method == 'GET':
|
if path == '/fs/' and method == 'GET':
|
||||||
return self.handle_collections_list(environ, start_response)
|
return self.handle_collections_list(environ, start_response)
|
||||||
|
|
||||||
|
# API to create new collection
|
||||||
|
if path == '/fs/' and method == 'POST':
|
||||||
|
return self.handle_create_collection(environ, start_response)
|
||||||
|
|
||||||
|
# API to delete a collection
|
||||||
|
if path.startswith('/api/collections/') and method == 'DELETE':
|
||||||
|
return self.handle_delete_collection(environ, start_response)
|
||||||
|
|
||||||
|
# Check if path starts with a collection name (for SPA routing)
|
||||||
|
# This handles URLs like /notes/ttt or /documents/file.md
|
||||||
|
# MUST be checked BEFORE WebDAV routing to prevent WebDAV from intercepting SPA routes
|
||||||
|
path_parts = path.strip('/').split('/')
|
||||||
|
if path_parts and path_parts[0] in self.collections:
|
||||||
|
# This is a SPA route for a collection, serve index.html
|
||||||
|
# The client-side router will handle the path
|
||||||
|
return self.handle_index(environ, start_response)
|
||||||
|
|
||||||
# All other /fs/ requests go to WebDAV
|
# All other /fs/ requests go to WebDAV
|
||||||
if path.startswith('/fs/'):
|
if path.startswith('/fs/'):
|
||||||
return self.webdav_app(environ, start_response)
|
return self.webdav_app(environ, start_response)
|
||||||
|
|
||||||
# Fallback for anything else (shouldn't happen with correct linking)
|
# Fallback: Serve index.html for all other routes (SPA routing)
|
||||||
start_response('404 Not Found', [('Content-Type', 'text/plain')])
|
# This allows client-side routing to handle any other paths
|
||||||
return [b'Not Found']
|
return self.handle_index(environ, start_response)
|
||||||
|
|
||||||
def handle_collections_list(self, environ, start_response):
|
def handle_collections_list(self, environ, start_response):
|
||||||
"""Return list of available collections"""
|
"""Return list of available collections"""
|
||||||
@@ -113,6 +138,139 @@ class MarkdownEditorApp:
|
|||||||
|
|
||||||
return [response_body]
|
return [response_body]
|
||||||
|
|
||||||
|
def handle_create_collection(self, environ, start_response):
|
||||||
|
"""Create a new collection"""
|
||||||
|
try:
|
||||||
|
# Read request body
|
||||||
|
content_length = int(environ.get('CONTENT_LENGTH', 0))
|
||||||
|
request_body = environ['wsgi.input'].read(content_length)
|
||||||
|
data = json.loads(request_body.decode('utf-8'))
|
||||||
|
|
||||||
|
collection_name = data.get('name')
|
||||||
|
if not collection_name:
|
||||||
|
start_response('400 Bad Request', [('Content-Type', 'application/json')])
|
||||||
|
return [json.dumps({'error': 'Collection name is required'}).encode('utf-8')]
|
||||||
|
|
||||||
|
# Check if collection already exists
|
||||||
|
if collection_name in self.collections:
|
||||||
|
start_response('409 Conflict', [('Content-Type', 'application/json')])
|
||||||
|
return [json.dumps({'error': f'Collection "{collection_name}" already exists'}).encode('utf-8')]
|
||||||
|
|
||||||
|
# Create collection directory
|
||||||
|
collection_path = Path(f'./collections/{collection_name}')
|
||||||
|
collection_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Create images subdirectory
|
||||||
|
images_path = collection_path / 'images'
|
||||||
|
images_path.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
# Add to collections dict
|
||||||
|
self.collections[collection_name] = {
|
||||||
|
'path': str(collection_path),
|
||||||
|
'description': f'User-created collection: {collection_name}'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Update config file
|
||||||
|
self.save_config()
|
||||||
|
|
||||||
|
# Add to WebDAV provider mapping
|
||||||
|
from wsgidav.fs_dav_provider import FilesystemProvider
|
||||||
|
provider_path = os.path.abspath(str(collection_path))
|
||||||
|
provider_key = f'/fs/{collection_name}'
|
||||||
|
|
||||||
|
# Use the add_provider method if available, otherwise add directly to provider_map
|
||||||
|
provider = FilesystemProvider(provider_path)
|
||||||
|
if hasattr(self.webdav_app, 'add_provider'):
|
||||||
|
self.webdav_app.add_provider(provider_key, provider)
|
||||||
|
print(f"Added provider using add_provider(): {provider_key}")
|
||||||
|
else:
|
||||||
|
self.webdav_app.provider_map[provider_key] = provider
|
||||||
|
print(f"Added provider to provider_map: {provider_key}")
|
||||||
|
|
||||||
|
# Also update sorted_share_list if it exists
|
||||||
|
if hasattr(self.webdav_app, 'sorted_share_list'):
|
||||||
|
if provider_key not in self.webdav_app.sorted_share_list:
|
||||||
|
self.webdav_app.sorted_share_list.append(provider_key)
|
||||||
|
self.webdav_app.sorted_share_list.sort(reverse=True)
|
||||||
|
print(f"Updated sorted_share_list")
|
||||||
|
|
||||||
|
print(f"Created collection '{collection_name}' at {provider_path}")
|
||||||
|
|
||||||
|
response_body = json.dumps({'success': True, 'name': collection_name}).encode('utf-8')
|
||||||
|
start_response('201 Created', [
|
||||||
|
('Content-Type', 'application/json'),
|
||||||
|
('Content-Length', str(len(response_body))),
|
||||||
|
('Access-Control-Allow-Origin', '*')
|
||||||
|
])
|
||||||
|
|
||||||
|
return [response_body]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error creating collection: {e}")
|
||||||
|
start_response('500 Internal Server Error', [('Content-Type', 'application/json')])
|
||||||
|
return [json.dumps({'error': str(e)}).encode('utf-8')]
|
||||||
|
|
||||||
|
def handle_delete_collection(self, environ, start_response):
|
||||||
|
"""Delete a collection"""
|
||||||
|
try:
|
||||||
|
# Extract collection name from path: /api/collections/{name}
|
||||||
|
path = environ.get('PATH_INFO', '')
|
||||||
|
collection_name = path.split('/')[-1]
|
||||||
|
|
||||||
|
if not collection_name:
|
||||||
|
start_response('400 Bad Request', [('Content-Type', 'application/json')])
|
||||||
|
return [json.dumps({'error': 'Collection name is required'}).encode('utf-8')]
|
||||||
|
|
||||||
|
# Check if collection exists
|
||||||
|
if collection_name not in self.collections:
|
||||||
|
start_response('404 Not Found', [('Content-Type', 'application/json')])
|
||||||
|
return [json.dumps({'error': f'Collection "{collection_name}" not found'}).encode('utf-8')]
|
||||||
|
|
||||||
|
# Get collection path
|
||||||
|
collection_config = self.collections[collection_name]
|
||||||
|
collection_path = Path(collection_config['path'])
|
||||||
|
|
||||||
|
# Delete the collection directory and all its contents
|
||||||
|
import shutil
|
||||||
|
if collection_path.exists():
|
||||||
|
shutil.rmtree(collection_path)
|
||||||
|
print(f"Deleted collection directory: {collection_path}")
|
||||||
|
|
||||||
|
# Remove from collections dict
|
||||||
|
del self.collections[collection_name]
|
||||||
|
|
||||||
|
# Update config file
|
||||||
|
self.save_config()
|
||||||
|
|
||||||
|
# Remove from WebDAV provider mapping
|
||||||
|
provider_key = f'/fs/{collection_name}'
|
||||||
|
if hasattr(self.webdav_app, 'provider_map') and provider_key in self.webdav_app.provider_map:
|
||||||
|
del self.webdav_app.provider_map[provider_key]
|
||||||
|
print(f"Removed provider from provider_map: {provider_key}")
|
||||||
|
|
||||||
|
# Remove from sorted_share_list if it exists
|
||||||
|
if hasattr(self.webdav_app, 'sorted_share_list') and provider_key in self.webdav_app.sorted_share_list:
|
||||||
|
self.webdav_app.sorted_share_list.remove(provider_key)
|
||||||
|
print(f"Removed from sorted_share_list: {provider_key}")
|
||||||
|
|
||||||
|
print(f"Deleted collection '{collection_name}'")
|
||||||
|
|
||||||
|
response_body = json.dumps({'success': True, 'name': collection_name}).encode('utf-8')
|
||||||
|
start_response('200 OK', [
|
||||||
|
('Content-Type', 'application/json'),
|
||||||
|
('Content-Length', str(len(response_body))),
|
||||||
|
('Access-Control-Allow-Origin', '*')
|
||||||
|
])
|
||||||
|
|
||||||
|
return [response_body]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error deleting collection: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
start_response('500 Internal Server Error', [('Content-Type', 'application/json')])
|
||||||
|
return [json.dumps({'error': str(e)}).encode('utf-8')]
|
||||||
|
|
||||||
def handle_static(self, environ, start_response):
|
def handle_static(self, environ, start_response):
|
||||||
"""Serve static files"""
|
"""Serve static files"""
|
||||||
path = environ.get('PATH_INFO', '')[1:] # Remove leading /
|
path = environ.get('PATH_INFO', '')[1:] # Remove leading /
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
function enableDarkMode() {
|
function enableDarkMode() {
|
||||||
isDarkMode = true;
|
isDarkMode = true;
|
||||||
document.body.classList.add('dark-mode');
|
document.body.classList.add('dark-mode');
|
||||||
document.getElementById('darkModeIcon').textContent = '☀️';
|
document.getElementById('darkModeIcon').innerHTML = '<i class="bi bi-sun-fill"></i>';
|
||||||
localStorage.setItem('darkMode', 'true');
|
localStorage.setItem('darkMode', 'true');
|
||||||
|
|
||||||
mermaid.initialize({
|
mermaid.initialize({
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
function disableDarkMode() {
|
function disableDarkMode() {
|
||||||
isDarkMode = false;
|
isDarkMode = false;
|
||||||
document.body.classList.remove('dark-mode');
|
document.body.classList.remove('dark-mode');
|
||||||
document.getElementById('darkModeIcon').textContent = '🌙';
|
// document.getElementById('darkModeIcon').textContent = '🌙';
|
||||||
localStorage.setItem('darkMode', 'false');
|
localStorage.setItem('darkMode', 'false');
|
||||||
|
|
||||||
mermaid.initialize({
|
mermaid.initialize({
|
||||||
@@ -338,7 +338,6 @@
|
|||||||
if (node.type === 'directory') {
|
if (node.type === 'directory') {
|
||||||
const toggle = document.createElement('span');
|
const toggle = document.createElement('span');
|
||||||
toggle.className = 'tree-node-toggle';
|
toggle.className = 'tree-node-toggle';
|
||||||
toggle.innerHTML = '▶';
|
|
||||||
toggle.addEventListener('click', (e) => {
|
toggle.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
toggleNode(nodeDiv);
|
toggleNode(nodeDiv);
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
function enableDarkMode() {
|
function enableDarkMode() {
|
||||||
isDarkMode = true;
|
isDarkMode = true;
|
||||||
document.body.classList.add('dark-mode');
|
document.body.classList.add('dark-mode');
|
||||||
document.getElementById('darkModeIcon').textContent = '☀️';
|
document.getElementById('darkModeIcon').innerHTML = '<i class="bi bi-sun-fill"></i>';
|
||||||
localStorage.setItem('darkMode', 'true');
|
localStorage.setItem('darkMode', 'true');
|
||||||
|
|
||||||
// Update mermaid theme
|
// Update mermaid theme
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
function disableDarkMode() {
|
function disableDarkMode() {
|
||||||
isDarkMode = false;
|
isDarkMode = false;
|
||||||
document.body.classList.remove('dark-mode');
|
document.body.classList.remove('dark-mode');
|
||||||
document.getElementById('darkModeIcon').textContent = '🌙';
|
// document.getElementById('darkModeIcon').textContent = '🌙';
|
||||||
localStorage.setItem('darkMode', 'false');
|
localStorage.setItem('darkMode', 'false');
|
||||||
|
|
||||||
// Update mermaid theme
|
// Update mermaid theme
|
||||||
|
|||||||
@@ -2,10 +2,21 @@
|
|||||||
.preview-pane {
|
.preview-pane {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
|
color: var(--text-primary);
|
||||||
|
background-color: var(--bg-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-pane h1, .preview-pane h2, .preview-pane h3,
|
#preview {
|
||||||
.preview-pane h4, .preview-pane h5, .preview-pane h6 {
|
color: var(--text-primary);
|
||||||
|
background-color: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-pane h1,
|
||||||
|
.preview-pane h2,
|
||||||
|
.preview-pane h3,
|
||||||
|
.preview-pane h4,
|
||||||
|
.preview-pane h5,
|
||||||
|
.preview-pane h6 {
|
||||||
margin-top: 24px;
|
margin-top: 24px;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -132,11 +143,21 @@ body.dark-mode .context-menu {
|
|||||||
animation: slideIn 0.3s ease;
|
animation: slideIn 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Override Bootstrap warning background to be darker for better text contrast */
|
||||||
|
.toast.bg-warning {
|
||||||
|
background-color: #cc9a06 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .toast.bg-warning {
|
||||||
|
background-color: #b8860b !important;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes slideIn {
|
@keyframes slideIn {
|
||||||
from {
|
from {
|
||||||
transform: translateX(400px);
|
transform: translateX(400px);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
to {
|
to {
|
||||||
transform: translateX(0);
|
transform: translateX(0);
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
@@ -152,6 +173,7 @@ body.dark-mode .context-menu {
|
|||||||
transform: translateX(0);
|
transform: translateX(0);
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
to {
|
to {
|
||||||
transform: translateX(400px);
|
transform: translateX(400px);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@@ -206,3 +228,226 @@ body.dark-mode .modal-footer {
|
|||||||
border-color: var(--link-color);
|
border-color: var(--link-color);
|
||||||
box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25);
|
box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Directory Preview Styles */
|
||||||
|
.directory-preview {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.directory-preview h2 {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
/* color: var(--text-primary); */
|
||||||
|
}
|
||||||
|
|
||||||
|
.directory-files {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-card {
|
||||||
|
background-color: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-card:hover {
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
border-color: var(--link-color);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-card-header i {
|
||||||
|
color: var(--link-color);
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-card-name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-card-description {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.4;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Flat Button Styles */
|
||||||
|
.btn-flat {
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
background-color: transparent;
|
||||||
|
color: var(--text-primary);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-flat:hover {
|
||||||
|
background-color: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-flat:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Flat button variants */
|
||||||
|
.btn-flat-primary {
|
||||||
|
color: #0d6efd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-flat-primary:hover {
|
||||||
|
background-color: rgba(13, 110, 253, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-flat-success {
|
||||||
|
color: #198754;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-flat-success:hover {
|
||||||
|
background-color: rgba(25, 135, 84, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-flat-danger {
|
||||||
|
color: #dc3545;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-flat-danger:hover {
|
||||||
|
background-color: rgba(220, 53, 69, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-flat-warning {
|
||||||
|
color: #ffc107;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-flat-warning:hover {
|
||||||
|
background-color: rgba(255, 193, 7, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-flat-secondary {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-flat-secondary:hover {
|
||||||
|
background-color: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode adjustments */
|
||||||
|
body.dark-mode .btn-flat-primary {
|
||||||
|
color: #6ea8fe;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .btn-flat-success {
|
||||||
|
color: #75b798;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .btn-flat-danger {
|
||||||
|
color: #ea868f;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .btn-flat-warning {
|
||||||
|
color: #ffda6a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark Mode Button Icon Styles */
|
||||||
|
#darkModeBtn i {
|
||||||
|
font-size: 16px;
|
||||||
|
color: inherit;
|
||||||
|
/* Inherit color from parent button */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Light mode: moon icon */
|
||||||
|
body:not(.dark-mode) #darkModeBtn i {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode: sun icon */
|
||||||
|
body.dark-mode #darkModeBtn i {
|
||||||
|
color: #ffc107;
|
||||||
|
/* Warm sun color */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hover effects */
|
||||||
|
#darkModeBtn:hover i {
|
||||||
|
color: inherit;
|
||||||
|
/* Inherit hover color from parent */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===================================
|
||||||
|
Loading Spinner Component
|
||||||
|
=================================== */
|
||||||
|
|
||||||
|
/* Loading overlay - covers the target container */
|
||||||
|
.loading-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
opacity: 0.95;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading spinner */
|
||||||
|
.loading-spinner {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border: 4px solid var(--border-color);
|
||||||
|
border-top-color: var(--primary-color);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Spinner animation */
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading text */
|
||||||
|
.loading-text {
|
||||||
|
margin-top: 16px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading container with spinner and text */
|
||||||
|
.loading-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide loading overlay by default */
|
||||||
|
.loading-overlay.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-bash {
|
||||||
|
color: var(--text-primary) !important;
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
/* Prevent header from shrinking */
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-header input {
|
.editor-header input {
|
||||||
@@ -19,18 +21,42 @@
|
|||||||
|
|
||||||
.editor-container {
|
.editor-container {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
/* Take remaining space */
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
/* Prevent container overflow, CodeMirror handles its own scrolling */
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
/* Important: allows flex child to shrink below content size */
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
#editor {
|
||||||
|
flex: 1;
|
||||||
|
/* Take all available space */
|
||||||
|
min-height: 0;
|
||||||
|
/* Allow shrinking */
|
||||||
|
overflow: hidden;
|
||||||
|
/* CodeMirror will handle scrolling */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* CodeMirror customization */
|
/* CodeMirror customization */
|
||||||
.CodeMirror {
|
.CodeMirror {
|
||||||
height: 100%;
|
height: 100% !important;
|
||||||
|
/* Force full height */
|
||||||
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
background-color: var(--bg-primary);
|
background-color: var(--bg-primary);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.CodeMirror-scroll {
|
||||||
|
overflow-y: auto !important;
|
||||||
|
/* Ensure vertical scrolling is enabled */
|
||||||
|
overflow-x: auto !important;
|
||||||
|
/* Ensure horizontal scrolling is enabled */
|
||||||
|
}
|
||||||
|
|
||||||
body.dark-mode .CodeMirror {
|
body.dark-mode .CodeMirror {
|
||||||
background-color: #1c2128;
|
background-color: #1c2128;
|
||||||
color: #e6edf3;
|
color: #e6edf3;
|
||||||
@@ -72,4 +98,3 @@ body.dark-mode .CodeMirror-gutters {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,8 +20,9 @@
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
transition: all 0.15s ease;
|
transition: all 0.15s ease;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: visible;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
min-height: 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-node:hover {
|
.tree-node:hover {
|
||||||
@@ -29,14 +30,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tree-node.active {
|
.tree-node.active {
|
||||||
background-color: var(--link-color);
|
color: var(--link-color);
|
||||||
color: white;
|
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-node.active:hover {
|
.tree-node.active:hover {
|
||||||
background-color: var(--link-color);
|
filter: brightness(1.2);
|
||||||
filter: brightness(1.1);
|
}
|
||||||
|
|
||||||
|
.tree-node.active .tree-node-icon {
|
||||||
|
color: var(--link-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Toggle arrow */
|
/* Toggle arrow */
|
||||||
@@ -46,16 +49,25 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 16px;
|
width: 16px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
font-size: 10px;
|
min-width: 16px;
|
||||||
|
min-height: 16px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
transition: transform 0.2s ease;
|
transition: transform 0.2s ease;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
overflow: visible;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-node-toggle.expanded {
|
.tree-node-toggle.expanded {
|
||||||
transform: rotate(90deg);
|
transform: rotate(90deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tree-node-toggle:hover {
|
||||||
|
color: var(--link-color);
|
||||||
|
}
|
||||||
|
|
||||||
/* Icon styling */
|
/* Icon styling */
|
||||||
.tree-node-icon {
|
.tree-node-icon {
|
||||||
width: 16px;
|
width: 16px;
|
||||||
@@ -67,10 +79,6 @@
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-node.active .tree-node-icon {
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Content wrapper */
|
/* Content wrapper */
|
||||||
.tree-node-content {
|
.tree-node-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -112,13 +120,54 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Drag and drop */
|
/* Drag and drop */
|
||||||
|
/* Default cursor is pointer, not grab (only show grab after long-press) */
|
||||||
|
.tree-node {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Show grab cursor only when drag is ready (after long-press) */
|
||||||
|
.tree-node.drag-ready {
|
||||||
|
cursor: grab !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-node.drag-ready:active {
|
||||||
|
cursor: grabbing !important;
|
||||||
|
}
|
||||||
|
|
||||||
.tree-node.dragging {
|
.tree-node.dragging {
|
||||||
opacity: 0.5;
|
opacity: 0.4;
|
||||||
|
background-color: var(--bg-tertiary);
|
||||||
|
cursor: grabbing !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-node.drag-over {
|
.tree-node.drag-over {
|
||||||
background-color: rgba(13, 110, 253, 0.2);
|
background-color: rgba(13, 110, 253, 0.15) !important;
|
||||||
border: 1px dashed var(--link-color);
|
border: 2px dashed var(--link-color) !important;
|
||||||
|
box-shadow: 0 0 8px rgba(13, 110, 253, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Root-level drop target highlighting */
|
||||||
|
.file-tree.drag-over-root {
|
||||||
|
background-color: rgba(13, 110, 253, 0.08);
|
||||||
|
border: 2px dashed var(--link-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: inset 0 0 12px rgba(13, 110, 253, 0.2);
|
||||||
|
margin: 4px;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Only show drag cursor on directories when dragging */
|
||||||
|
body.dragging-active .tree-node[data-isdir="true"] {
|
||||||
|
cursor: copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dragging-active .tree-node[data-isdir="false"] {
|
||||||
|
cursor: no-drop;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Show move cursor when hovering over root-level empty space */
|
||||||
|
body.dragging-active .file-tree.drag-over-root {
|
||||||
|
cursor: move;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Collection selector - Bootstrap styled */
|
/* Collection selector - Bootstrap styled */
|
||||||
@@ -156,13 +205,34 @@ body.dark-mode .tree-node:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
body.dark-mode .tree-node.active {
|
body.dark-mode .tree-node.active {
|
||||||
background-color: var(--link-color);
|
color: var(--link-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .tree-node.active .tree-node-icon {
|
||||||
|
color: var(--link-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .tree-node.active .tree-node-icon .tree-node-toggle {
|
||||||
|
color: var(--link-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
body.dark-mode .tree-children {
|
body.dark-mode .tree-children {
|
||||||
border-left-color: var(--border-color);
|
border-left-color: var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Empty directory message */
|
||||||
|
.tree-empty-message {
|
||||||
|
padding: 8px 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-style: italic;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .tree-empty-message {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
/* Scrollbar in sidebar */
|
/* Scrollbar in sidebar */
|
||||||
.sidebar::-webkit-scrollbar-thumb {
|
.sidebar::-webkit-scrollbar-thumb {
|
||||||
background-color: var(--border-color);
|
background-color: var(--border-color);
|
||||||
@@ -171,3 +241,13 @@ body.dark-mode .tree-children {
|
|||||||
.sidebar::-webkit-scrollbar-thumb:hover {
|
.sidebar::-webkit-scrollbar-thumb:hover {
|
||||||
background-color: var(--text-secondary);
|
background-color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.new-collection-btn {
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
@@ -1,14 +1,22 @@
|
|||||||
/* Base layout styles */
|
/* Base layout styles */
|
||||||
html, body {
|
html,
|
||||||
height: 100%;
|
body {
|
||||||
|
height: 100vh;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
/* Prevent page-level scrolling */
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif;
|
||||||
background-color: var(--bg-primary);
|
background-color: var(--bg-primary);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
transition: background-color 0.3s ease, color 0.3s ease;
|
transition: background-color 0.3s ease, color 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
/* Column Resizer */
|
/* Column Resizer */
|
||||||
.column-resizer {
|
.column-resizer {
|
||||||
width: 1px;
|
width: 1px;
|
||||||
@@ -17,14 +25,21 @@ html, body {
|
|||||||
transition: background-color 0.2s ease, width 0.2s ease, box-shadow 0.2s ease;
|
transition: background-color 0.2s ease, width 0.2s ease, box-shadow 0.2s ease;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding: 0 3px; /* Add invisible padding for easier grab */
|
padding: 0 3px;
|
||||||
margin: 0 -3px; /* Compensate for padding */
|
/* Add invisible padding for easier grab */
|
||||||
|
margin: 0 -3px;
|
||||||
|
/* Compensate for padding */
|
||||||
|
height: 100%;
|
||||||
|
/* Take full height of parent */
|
||||||
|
align-self: stretch;
|
||||||
|
/* Ensure it stretches to full height */
|
||||||
}
|
}
|
||||||
|
|
||||||
.column-resizer:hover {
|
.column-resizer:hover {
|
||||||
background-color: var(--link-color);
|
background-color: var(--link-color);
|
||||||
width: 1px;
|
width: 1px;
|
||||||
box-shadow: 0 0 6px rgba(13, 110, 253, 0.3); /* Visual feedback instead of width change */
|
box-shadow: 0 0 6px rgba(13, 110, 253, 0.3);
|
||||||
|
/* Visual feedback instead of width change */
|
||||||
}
|
}
|
||||||
|
|
||||||
.column-resizer.dragging {
|
.column-resizer.dragging {
|
||||||
@@ -36,12 +51,59 @@ html, body {
|
|||||||
background-color: var(--link-color);
|
background-color: var(--link-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Adjust container for flex layout */
|
/* Navbar */
|
||||||
.container-fluid {
|
.navbar {
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
transition: background-color 0.3s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
/* Prevent navbar from shrinking */
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar .container-fluid {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
height: calc(100% - 56px);
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
overflow: visible;
|
||||||
|
/* Override the hidden overflow for navbar */
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-brand {
|
||||||
|
color: var(--text-primary) !important;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-brand i {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-center {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-right {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Adjust container for flex layout */
|
||||||
|
.container-fluid {
|
||||||
|
flex: 1;
|
||||||
|
/* Take remaining space after navbar */
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
/* Prevent container scrolling */
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.row {
|
.row {
|
||||||
@@ -50,13 +112,75 @@ html, body {
|
|||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
/* Prevent row scrolling */
|
||||||
}
|
}
|
||||||
|
|
||||||
#sidebarPane {
|
#sidebarPane {
|
||||||
flex: 0 0 20%;
|
flex: 0 0 20%;
|
||||||
min-width: 150px;
|
min-width: 150px;
|
||||||
max-width: 40%;
|
max-width: 20%;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
/* Prevent pane scrolling */
|
||||||
|
transition: flex 0.3s ease, min-width 0.3s ease, max-width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Collapsed sidebar state - mini sidebar */
|
||||||
|
#sidebarPane.collapsed {
|
||||||
|
flex: 0 0 50px;
|
||||||
|
min-width: 50px;
|
||||||
|
max-width: 50px;
|
||||||
|
border-right: 1px solid var(--border-color);
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide file tree content when collapsed */
|
||||||
|
#sidebarPane.collapsed #fileTree {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide collection selector when collapsed */
|
||||||
|
#sidebarPane.collapsed .collection-selector {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Visual indicator in the mini sidebar */
|
||||||
|
#sidebarPane.collapsed::before {
|
||||||
|
content: '';
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hover effect on mini sidebar */
|
||||||
|
#sidebarPane.collapsed:hover::before {
|
||||||
|
background: var(--hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Right arrow icon in the center of mini sidebar */
|
||||||
|
#sidebarPane.collapsed::after {
|
||||||
|
content: '\F285';
|
||||||
|
/* Bootstrap icon chevron-right */
|
||||||
|
font-family: 'bootstrap-icons';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
font-size: 20px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.5;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#sidebarPane.collapsed:hover::after {
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
#editorPane {
|
#editorPane {
|
||||||
@@ -64,25 +188,23 @@ html, body {
|
|||||||
min-width: 250px;
|
min-width: 250px;
|
||||||
max-width: 70%;
|
max-width: 70%;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
#previewPane {
|
height: 100%;
|
||||||
flex: 1 1 40%;
|
overflow: hidden;
|
||||||
min-width: 250px;
|
/* Prevent pane scrolling */
|
||||||
max-width: 70%;
|
|
||||||
padding: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Sidebar - improved */
|
/* Sidebar - improved */
|
||||||
.sidebar {
|
.sidebar {
|
||||||
background-color: var(--bg-secondary);
|
background-color: var(--bg-secondary);
|
||||||
border-right: 1px solid var(--border-color);
|
border-right: 1px solid var(--border-color);
|
||||||
overflow-y: auto;
|
|
||||||
overflow-x: hidden;
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
transition: background-color 0.3s ease;
|
transition: background-color 0.3s ease;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
/* Prevent sidebar container scrolling */
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar h6 {
|
.sidebar h6 {
|
||||||
@@ -92,25 +214,27 @@ html, body {
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
/* Prevent header from shrinking */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Collection selector - fixed height */
|
||||||
|
.collection-selector {
|
||||||
|
flex-shrink: 0;
|
||||||
|
/* Prevent selector from shrinking */
|
||||||
|
padding: 12px 10px;
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
#fileTree {
|
#fileTree {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
/* Take remaining space */
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
/* Enable vertical scrolling */
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
padding: 4px 0;
|
padding: 4px 10px;
|
||||||
}
|
min-height: 0;
|
||||||
|
/* Important: allows flex child to shrink below content size */
|
||||||
/* Navbar */
|
|
||||||
.navbar {
|
|
||||||
background-color: var(--bg-secondary);
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
transition: background-color 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar-brand {
|
|
||||||
color: var(--text-primary) !important;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Scrollbar styling */
|
/* Scrollbar styling */
|
||||||
@@ -135,21 +259,25 @@ html, body {
|
|||||||
|
|
||||||
/* Preview Pane Styling */
|
/* Preview Pane Styling */
|
||||||
#previewPane {
|
#previewPane {
|
||||||
flex: 1 1 40%;
|
|
||||||
min-width: 250px;
|
min-width: 250px;
|
||||||
max-width: 70%;
|
max-width: 70%;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
overflow-y: auto;
|
|
||||||
overflow-x: hidden;
|
|
||||||
background-color: var(--bg-primary);
|
background-color: var(--bg-primary);
|
||||||
border-left: 1px solid var(--border-color);
|
border-left: 1px solid var(--border-color);
|
||||||
|
flex: 1;
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
/* Enable vertical scrolling for preview pane */
|
||||||
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
#preview {
|
#preview {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
min-height: 100%;
|
|
||||||
overflow-wrap: break-word;
|
overflow-wrap: break-word;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
|
color: var(--text-primary);
|
||||||
|
min-height: 100%;
|
||||||
|
/* Ensure content fills at least the full height */
|
||||||
}
|
}
|
||||||
|
|
||||||
#preview>p:first-child {
|
#preview>p:first-child {
|
||||||
@@ -160,3 +288,57 @@ html, body {
|
|||||||
#preview>h2:first-child {
|
#preview>h2:first-child {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Iframe styles in preview - minimal defaults that can be overridden */
|
||||||
|
#preview iframe {
|
||||||
|
border: none;
|
||||||
|
/* Default to no border, can be overridden by inline styles */
|
||||||
|
display: block;
|
||||||
|
/* Prevent inline spacing issues */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* View Mode Styles */
|
||||||
|
body.view-mode #editorPane {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.view-mode #resizer1 {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.view-mode #resizer2 {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.view-mode #previewPane {
|
||||||
|
max-width: 100%;
|
||||||
|
min-width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.view-mode #sidebarPane {
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0 20%;
|
||||||
|
height: 100%;
|
||||||
|
/* Keep sidebar at 20% width in view mode */
|
||||||
|
}
|
||||||
|
|
||||||
|
body.edit-mode #editorPane {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.edit-mode #resizer1 {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.edit-mode #resizer2 {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.edit-mode #previewPane {
|
||||||
|
max-width: 70%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.edit-mode #sidebarPane {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
589
static/js/app.js
@@ -12,25 +12,262 @@ let collectionSelector;
|
|||||||
let clipboard = null;
|
let clipboard = null;
|
||||||
let currentFilePath = null;
|
let currentFilePath = null;
|
||||||
|
|
||||||
// Simple event bus
|
// Event bus is now loaded from event-bus.js module
|
||||||
const eventBus = {
|
// No need to define it here - it's available as window.eventBus
|
||||||
listeners: {},
|
|
||||||
on(event, callback) {
|
/**
|
||||||
if (!this.listeners[event]) {
|
* Auto-load page in view mode
|
||||||
this.listeners[event] = [];
|
* Tries to load the last viewed page, falls back to first file if none saved
|
||||||
|
*/
|
||||||
|
async function autoLoadPageInViewMode() {
|
||||||
|
if (!editor || !fileTree) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try to get last viewed page
|
||||||
|
let pageToLoad = editor.getLastViewedPage();
|
||||||
|
|
||||||
|
// If no last viewed page, get the first markdown file
|
||||||
|
if (!pageToLoad) {
|
||||||
|
pageToLoad = fileTree.getFirstMarkdownFile();
|
||||||
}
|
}
|
||||||
this.listeners[event].push(callback);
|
|
||||||
},
|
// If we found a page to load, load it
|
||||||
dispatch(event, data) {
|
if (pageToLoad) {
|
||||||
if (this.listeners[event]) {
|
// Use fileTree.onFileSelect to handle both text and binary files
|
||||||
this.listeners[event].forEach(callback => callback(data));
|
if (fileTree.onFileSelect) {
|
||||||
|
fileTree.onFileSelect({ path: pageToLoad, isDirectory: false });
|
||||||
|
} else {
|
||||||
|
// Fallback to direct loading (for text files only)
|
||||||
|
await editor.loadFile(pageToLoad);
|
||||||
|
fileTree.selectAndExpandPath(pageToLoad);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No files found, show empty state message
|
||||||
|
editor.previewElement.innerHTML = `
|
||||||
|
<div class="text-muted text-center mt-5">
|
||||||
|
<p>No content available</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to auto-load page in view mode:', error);
|
||||||
|
editor.previewElement.innerHTML = `
|
||||||
|
<div class="alert alert-danger">
|
||||||
|
<p>Failed to load content</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
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
|
// Initialize application
|
||||||
document.addEventListener('DOMContentLoaded', async () => {
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
// Determine view mode from URL parameter
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const isEditMode = urlParams.get('edit') === 'true';
|
||||||
|
|
||||||
|
// Set view mode class on body
|
||||||
|
if (isEditMode) {
|
||||||
|
document.body.classList.add('edit-mode');
|
||||||
|
document.body.classList.remove('view-mode');
|
||||||
|
} else {
|
||||||
|
document.body.classList.add('view-mode');
|
||||||
|
document.body.classList.remove('edit-mode');
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize WebDAV client
|
// Initialize WebDAV client
|
||||||
webdavClient = new WebDAVClient('/fs/');
|
webdavClient = new WebDAVClient('/fs/');
|
||||||
|
|
||||||
@@ -40,24 +277,241 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
darkMode.toggle();
|
darkMode.toggle();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initialize file tree
|
// Initialize sidebar toggle
|
||||||
fileTree = new FileTree('fileTree', webdavClient);
|
const sidebarToggle = new SidebarToggle('sidebarPane', 'sidebarToggleBtn');
|
||||||
fileTree.onFileSelect = async (item) => {
|
|
||||||
await editor.loadFile(item.path);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Initialize collection selector
|
// Initialize collection selector (always needed)
|
||||||
collectionSelector = new CollectionSelector('collectionSelect', webdavClient);
|
collectionSelector = new CollectionSelector('collectionSelect', webdavClient);
|
||||||
collectionSelector.onChange = async (collection) => {
|
|
||||||
await fileTree.load();
|
|
||||||
};
|
|
||||||
await collectionSelector.load();
|
await collectionSelector.load();
|
||||||
await fileTree.load();
|
|
||||||
|
|
||||||
// Initialize editor
|
// Setup New Collection button
|
||||||
editor = new MarkdownEditor('editor', 'preview', 'filenameInput');
|
document.getElementById('newCollectionBtn').addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
const collectionName = await window.ModalManager.prompt(
|
||||||
|
'Enter new collection name (lowercase, underscore only):',
|
||||||
|
'new_collection'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!collectionName) return;
|
||||||
|
|
||||||
|
// Validate collection name
|
||||||
|
const validation = ValidationUtils.validateFileName(collectionName, true);
|
||||||
|
if (!validation.valid) {
|
||||||
|
window.showNotification(validation.message, 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the collection
|
||||||
|
await webdavClient.createCollection(validation.sanitized);
|
||||||
|
|
||||||
|
// Reload collections and switch to the new one
|
||||||
|
await collectionSelector.load();
|
||||||
|
await collectionSelector.setCollection(validation.sanitized);
|
||||||
|
|
||||||
|
window.showNotification(`Collection "${validation.sanitized}" created`, 'success');
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error('Failed to create collection:', error);
|
||||||
|
window.showNotification('Failed to create collection', 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Setup URL routing
|
||||||
|
setupPopStateListener();
|
||||||
|
|
||||||
|
// Initialize editor (always needed for preview)
|
||||||
|
// In view mode, editor is read-only
|
||||||
|
editor = new MarkdownEditor('editor', 'preview', 'filenameInput', !isEditMode);
|
||||||
editor.setWebDAVClient(webdavClient);
|
editor.setWebDAVClient(webdavClient);
|
||||||
|
|
||||||
|
// Initialize file tree (needed in both modes)
|
||||||
|
// Pass isEditMode to control image filtering (hide images only in view mode)
|
||||||
|
fileTree = new FileTree('fileTree', webdavClient, isEditMode);
|
||||||
|
fileTree.onFileSelect = async (item) => {
|
||||||
|
try {
|
||||||
|
const currentCollection = collectionSelector.getCurrentCollection();
|
||||||
|
|
||||||
|
// Check if the file is a binary/non-editable file
|
||||||
|
if (PathUtils.isBinaryFile(item.path)) {
|
||||||
|
const fileType = PathUtils.getFileType(item.path);
|
||||||
|
const fileName = PathUtils.getFileName(item.path);
|
||||||
|
|
||||||
|
Logger.info(`Previewing binary file: ${item.path}`);
|
||||||
|
|
||||||
|
// Initialize and show loading spinner for binary file preview
|
||||||
|
editor.initLoadingSpinners();
|
||||||
|
if (editor.previewSpinner) {
|
||||||
|
editor.previewSpinner.show(`Loading ${fileType.toLowerCase()}...`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set flag to prevent auto-update of preview
|
||||||
|
editor.isShowingCustomPreview = true;
|
||||||
|
|
||||||
|
// In edit mode, show a warning notification
|
||||||
|
if (isEditMode) {
|
||||||
|
if (window.showNotification) {
|
||||||
|
window.showNotification(
|
||||||
|
`"${fileName}" is read-only. Showing preview only.`,
|
||||||
|
'warning'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide the editor pane temporarily
|
||||||
|
const editorPane = document.getElementById('editorPane');
|
||||||
|
const resizer1 = document.getElementById('resizer1');
|
||||||
|
if (editorPane) editorPane.style.display = 'none';
|
||||||
|
if (resizer1) resizer1.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the editor (but don't trigger preview update due to flag)
|
||||||
|
if (editor.editor) {
|
||||||
|
editor.editor.setValue('');
|
||||||
|
}
|
||||||
|
editor.filenameInput.value = item.path;
|
||||||
|
editor.currentFile = item.path;
|
||||||
|
|
||||||
|
// Build the file URL using the WebDAV client's method
|
||||||
|
const fileUrl = webdavClient.getFullUrl(item.path);
|
||||||
|
Logger.debug(`Binary file URL: ${fileUrl}`);
|
||||||
|
|
||||||
|
// Generate preview HTML based on file type
|
||||||
|
let previewHtml = '';
|
||||||
|
|
||||||
|
if (fileType === 'Image') {
|
||||||
|
// Preview images
|
||||||
|
previewHtml = `
|
||||||
|
<div style="padding: 20px; text-align: center;">
|
||||||
|
<h3>${fileName}</h3>
|
||||||
|
<p style="color: var(--text-secondary); margin-bottom: 20px;">Image Preview (Read-only)</p>
|
||||||
|
<img src="${fileUrl}" alt="${fileName}" style="max-width: 100%; height: auto; border: 1px solid var(--border-color); border-radius: 4px;">
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else if (fileType === 'PDF') {
|
||||||
|
// Preview PDFs
|
||||||
|
previewHtml = `
|
||||||
|
<div style="padding: 20px;">
|
||||||
|
<h3>${fileName}</h3>
|
||||||
|
<p style="color: var(--text-secondary); margin-bottom: 20px;">PDF Preview (Read-only)</p>
|
||||||
|
<iframe src="${fileUrl}" style="width: 100%; height: 80vh; border: 1px solid var(--border-color); border-radius: 4px;"></iframe>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
// For other binary files, show download link
|
||||||
|
previewHtml = `
|
||||||
|
<div style="padding: 20px;">
|
||||||
|
<h3>${fileName}</h3>
|
||||||
|
<p style="color: var(--text-secondary); margin-bottom: 20px;">${fileType} File (Read-only)</p>
|
||||||
|
<p>This file cannot be previewed in the browser.</p>
|
||||||
|
<a href="${fileUrl}" download="${fileName}" class="btn btn-primary">Download ${fileName}</a>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display in preview pane
|
||||||
|
editor.previewElement.innerHTML = previewHtml;
|
||||||
|
|
||||||
|
// Hide loading spinner after content is set
|
||||||
|
// Add small delay for images to start loading
|
||||||
|
setTimeout(() => {
|
||||||
|
if (editor.previewSpinner) {
|
||||||
|
editor.previewSpinner.hide();
|
||||||
|
}
|
||||||
|
}, fileType === 'Image' ? 300 : 100);
|
||||||
|
|
||||||
|
// Highlight the file in the tree
|
||||||
|
fileTree.selectAndExpandPath(item.path);
|
||||||
|
|
||||||
|
// Save as last viewed page (for binary files too)
|
||||||
|
editor.saveLastViewedPage(item.path);
|
||||||
|
|
||||||
|
// Update URL to reflect current file
|
||||||
|
updateURL(currentCollection, item.path, isEditMode);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For text files, restore the editor pane if it was hidden
|
||||||
|
if (isEditMode) {
|
||||||
|
const editorPane = document.getElementById('editorPane');
|
||||||
|
const resizer1 = document.getElementById('resizer1');
|
||||||
|
if (editorPane) editorPane.style.display = '';
|
||||||
|
if (resizer1) resizer1.style.display = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
await editor.loadFile(item.path);
|
||||||
|
// Highlight the file in the tree and expand parent directories
|
||||||
|
fileTree.selectAndExpandPath(item.path);
|
||||||
|
// Update URL to reflect current file
|
||||||
|
updateURL(currentCollection, item.path, isEditMode);
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error('Failed to select file:', error);
|
||||||
|
if (window.showNotification) {
|
||||||
|
window.showNotification('Failed to load file', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fileTree.onFolderSelect = async (item) => {
|
||||||
|
try {
|
||||||
|
// Show directory preview
|
||||||
|
await showDirectoryPreview(item.path);
|
||||||
|
// Highlight the directory in the tree and expand parent directories
|
||||||
|
fileTree.selectAndExpandPath(item.path);
|
||||||
|
// Update URL to reflect current directory
|
||||||
|
const currentCollection = collectionSelector.getCurrentCollection();
|
||||||
|
updateURL(currentCollection, item.path, isEditMode);
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error('Failed to select folder:', error);
|
||||||
|
if (window.showNotification) {
|
||||||
|
window.showNotification('Failed to load folder', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
collectionSelector.onChange = async (collection) => {
|
||||||
|
try {
|
||||||
|
await fileTree.load();
|
||||||
|
// In view mode, auto-load last viewed page when collection changes
|
||||||
|
if (!isEditMode) {
|
||||||
|
await autoLoadPageInViewMode();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error('Failed to change collection:', error);
|
||||||
|
if (window.showNotification) {
|
||||||
|
window.showNotification('Failed to change collection', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await fileTree.load();
|
||||||
|
|
||||||
|
// Parse URL to load file if specified
|
||||||
|
const { collection: urlCollection, filePath: urlFilePath } = parseURLPath();
|
||||||
|
console.log('[URL PARSE]', { urlCollection, urlFilePath });
|
||||||
|
|
||||||
|
if (urlCollection) {
|
||||||
|
// First ensure the collection is set
|
||||||
|
const currentCollection = collectionSelector.getCurrentCollection();
|
||||||
|
if (currentCollection !== urlCollection) {
|
||||||
|
console.log('[URL LOAD] Switching collection from', currentCollection, 'to', urlCollection);
|
||||||
|
await collectionSelector.setCollection(urlCollection);
|
||||||
|
await fileTree.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
// If there's a file path in the URL, load it
|
||||||
|
if (urlFilePath) {
|
||||||
|
console.log('[URL LOAD] Loading file from URL:', urlCollection, urlFilePath);
|
||||||
|
await loadFileFromURL(urlCollection, urlFilePath);
|
||||||
|
} else if (!isEditMode) {
|
||||||
|
// Collection-only URL in view mode: auto-load last viewed page
|
||||||
|
console.log('[URL LOAD] Collection-only URL, auto-loading page');
|
||||||
|
await autoLoadPageInViewMode();
|
||||||
|
}
|
||||||
|
} else if (!isEditMode) {
|
||||||
|
// No URL collection specified, in view mode: auto-load last viewed page
|
||||||
|
await autoLoadPageInViewMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize file tree and editor-specific features only in edit mode
|
||||||
|
if (isEditMode) {
|
||||||
// Add test content to verify preview works
|
// Add test content to verify preview works
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (!editor.editor.getValue()) {
|
if (!editor.editor.getValue()) {
|
||||||
@@ -70,7 +524,11 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
const editorDropHandler = new EditorDropHandler(
|
const editorDropHandler = new EditorDropHandler(
|
||||||
document.querySelector('.editor-container'),
|
document.querySelector('.editor-container'),
|
||||||
async (file) => {
|
async (file) => {
|
||||||
|
try {
|
||||||
await handleEditorFileDrop(file);
|
await handleEditorFileDrop(file);
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error('Failed to handle file drop:', error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -80,33 +538,103 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('saveBtn').addEventListener('click', async () => {
|
document.getElementById('saveBtn').addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
await editor.save();
|
await editor.save();
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error('Failed to save file:', error);
|
||||||
|
if (window.showNotification) {
|
||||||
|
window.showNotification('Failed to save file', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('deleteBtn').addEventListener('click', async () => {
|
document.getElementById('deleteBtn').addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
await editor.deleteFile();
|
await editor.deleteFile();
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error('Failed to delete file:', error);
|
||||||
|
if (window.showNotification) {
|
||||||
|
window.showNotification('Failed to delete file', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Setup context menu handlers
|
// Setup context menu handlers
|
||||||
setupContextMenuHandlers();
|
setupContextMenuHandlers();
|
||||||
|
|
||||||
// Initialize mermaid
|
|
||||||
mermaid.initialize({ startOnLoad: true, theme: darkMode.isDark ? 'dark' : 'default' });
|
|
||||||
|
|
||||||
// Initialize file tree actions manager
|
// Initialize file tree actions manager
|
||||||
window.fileTreeActions = new FileTreeActions(webdavClient, fileTree, editor);
|
window.fileTreeActions = new FileTreeActions(webdavClient, fileTree, editor);
|
||||||
|
|
||||||
|
// Setup Exit Edit Mode button
|
||||||
|
document.getElementById('exitEditModeBtn').addEventListener('click', () => {
|
||||||
|
// Switch to view mode by removing edit=true from URL
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.delete('edit');
|
||||||
|
window.location.href = url.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hide Edit Mode button in edit mode
|
||||||
|
document.getElementById('editModeBtn').style.display = 'none';
|
||||||
|
} else {
|
||||||
|
// In view mode, hide editor buttons
|
||||||
|
document.getElementById('newBtn').style.display = 'none';
|
||||||
|
document.getElementById('saveBtn').style.display = 'none';
|
||||||
|
document.getElementById('deleteBtn').style.display = 'none';
|
||||||
|
document.getElementById('exitEditModeBtn').style.display = 'none';
|
||||||
|
|
||||||
|
// Show Edit Mode button in view mode
|
||||||
|
document.getElementById('editModeBtn').style.display = 'block';
|
||||||
|
|
||||||
|
// Setup Edit Mode button
|
||||||
|
document.getElementById('editModeBtn').addEventListener('click', () => {
|
||||||
|
// Switch to edit mode by adding edit=true to URL
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.set('edit', 'true');
|
||||||
|
window.location.href = url.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-load last viewed page or first file
|
||||||
|
await autoLoadPageInViewMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup clickable navbar brand (logo/title)
|
||||||
|
const navbarBrand = document.getElementById('navbarBrand');
|
||||||
|
if (navbarBrand) {
|
||||||
|
navbarBrand.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const currentCollection = collectionSelector ? collectionSelector.getCurrentCollection() : null;
|
||||||
|
if (currentCollection) {
|
||||||
|
// Navigate to collection root
|
||||||
|
window.location.href = `/${currentCollection}/`;
|
||||||
|
} else {
|
||||||
|
// Navigate to home page
|
||||||
|
window.location.href = '/';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize mermaid (always needed)
|
||||||
|
mermaid.initialize({ startOnLoad: true, theme: darkMode.isDark ? 'dark' : 'default' });
|
||||||
// Listen for file-saved event to reload file tree
|
// Listen for file-saved event to reload file tree
|
||||||
window.eventBus.on('file-saved', async (path) => {
|
window.eventBus.on('file-saved', async (path) => {
|
||||||
|
try {
|
||||||
if (fileTree) {
|
if (fileTree) {
|
||||||
await fileTree.load();
|
await fileTree.load();
|
||||||
fileTree.selectNode(path);
|
fileTree.selectNode(path);
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error('Failed to reload file tree after save:', error);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
window.eventBus.on('file-deleted', async () => {
|
window.eventBus.on('file-deleted', async () => {
|
||||||
|
try {
|
||||||
if (fileTree) {
|
if (fileTree) {
|
||||||
await fileTree.load();
|
await fileTree.load();
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error('Failed to reload file tree after delete:', error);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -168,10 +696,11 @@ async function handleEditorFileDrop(file) {
|
|||||||
const uploadedPath = await fileTree.uploadFile(targetDir, file);
|
const uploadedPath = await fileTree.uploadFile(targetDir, file);
|
||||||
|
|
||||||
// Insert markdown link at cursor
|
// Insert markdown link at cursor
|
||||||
|
// Use relative path (without collection name) so the image renderer can resolve it correctly
|
||||||
const isImage = file.type.startsWith('image/');
|
const isImage = file.type.startsWith('image/');
|
||||||
const link = isImage
|
const link = isImage
|
||||||
? ``
|
? ``
|
||||||
: `[${file.name}](/${webdavClient.currentCollection}/${uploadedPath})`;
|
: `[${file.name}](${uploadedPath})`;
|
||||||
|
|
||||||
editor.insertAtCursor(link);
|
editor.insertAtCursor(link);
|
||||||
showNotification(`Uploaded and inserted link`, 'success');
|
showNotification(`Uploaded and inserted link`, 'success');
|
||||||
|
|||||||
152
static/js/collection-selector.js
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
/**
|
||||||
|
* Collection Selector Module
|
||||||
|
* Manages the collection dropdown selector and persistence
|
||||||
|
*/
|
||||||
|
|
||||||
|
class CollectionSelector {
|
||||||
|
constructor(selectId, webdavClient) {
|
||||||
|
this.select = document.getElementById(selectId);
|
||||||
|
this.webdavClient = webdavClient;
|
||||||
|
this.onChange = null;
|
||||||
|
this.storageKey = Config.STORAGE_KEYS.SELECTED_COLLECTION;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load collections from WebDAV and populate the selector
|
||||||
|
*/
|
||||||
|
async load() {
|
||||||
|
try {
|
||||||
|
const collections = await this.webdavClient.getCollections();
|
||||||
|
this.select.innerHTML = '';
|
||||||
|
|
||||||
|
collections.forEach(collection => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = collection;
|
||||||
|
option.textContent = collection;
|
||||||
|
this.select.appendChild(option);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Determine which collection to select (priority: URL > localStorage > first)
|
||||||
|
let collectionToSelect = collections[0]; // Default to first
|
||||||
|
|
||||||
|
// Check URL first (highest priority)
|
||||||
|
const urlCollection = this.getCollectionFromURL();
|
||||||
|
if (urlCollection && collections.includes(urlCollection)) {
|
||||||
|
collectionToSelect = urlCollection;
|
||||||
|
Logger.info(`Using collection from URL: ${urlCollection}`);
|
||||||
|
} else {
|
||||||
|
// Fall back to localStorage
|
||||||
|
const savedCollection = localStorage.getItem(this.storageKey);
|
||||||
|
if (savedCollection && collections.includes(savedCollection)) {
|
||||||
|
collectionToSelect = savedCollection;
|
||||||
|
Logger.info(`Using collection from localStorage: ${savedCollection}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (collections.length > 0) {
|
||||||
|
this.select.value = collectionToSelect;
|
||||||
|
this.webdavClient.setCollection(collectionToSelect);
|
||||||
|
if (this.onChange) {
|
||||||
|
this.onChange(collectionToSelect);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add change listener
|
||||||
|
this.select.addEventListener('change', () => {
|
||||||
|
const collection = this.select.value;
|
||||||
|
// Save to localStorage
|
||||||
|
localStorage.setItem(this.storageKey, collection);
|
||||||
|
this.webdavClient.setCollection(collection);
|
||||||
|
|
||||||
|
Logger.info(`Collection changed to: ${collection}`);
|
||||||
|
|
||||||
|
// Update URL to reflect collection change
|
||||||
|
this.updateURLForCollection(collection);
|
||||||
|
|
||||||
|
if (this.onChange) {
|
||||||
|
this.onChange(collection);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Logger.debug(`Loaded ${collections.length} collections`);
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error('Failed to load collections:', error);
|
||||||
|
if (window.showNotification) {
|
||||||
|
window.showNotification('Failed to load collections', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the currently selected collection
|
||||||
|
* @returns {string} The collection name
|
||||||
|
*/
|
||||||
|
getCurrentCollection() {
|
||||||
|
return this.select.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the collection to a specific value
|
||||||
|
* @param {string} collection - The collection name to set
|
||||||
|
*/
|
||||||
|
async setCollection(collection) {
|
||||||
|
const collections = Array.from(this.select.options).map(opt => opt.value);
|
||||||
|
if (collections.includes(collection)) {
|
||||||
|
this.select.value = collection;
|
||||||
|
localStorage.setItem(this.storageKey, collection);
|
||||||
|
this.webdavClient.setCollection(collection);
|
||||||
|
|
||||||
|
Logger.info(`Collection set to: ${collection}`);
|
||||||
|
|
||||||
|
// Update URL to reflect collection change
|
||||||
|
this.updateURLForCollection(collection);
|
||||||
|
|
||||||
|
if (this.onChange) {
|
||||||
|
this.onChange(collection);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Logger.warn(`Collection "${collection}" not found in available collections`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the browser URL to reflect the current collection
|
||||||
|
* @param {string} collection - The collection name
|
||||||
|
*/
|
||||||
|
updateURLForCollection(collection) {
|
||||||
|
// Get current URL parameters
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const isEditMode = urlParams.get('edit') === 'true';
|
||||||
|
|
||||||
|
// Build new URL with collection
|
||||||
|
let url = `/${collection}/`;
|
||||||
|
if (isEditMode) {
|
||||||
|
url += '?edit=true';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use pushState to update URL without reloading
|
||||||
|
window.history.pushState({ collection, filePath: null }, '', url);
|
||||||
|
Logger.debug(`Updated URL to: ${url}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract collection name from current URL
|
||||||
|
* URL format: /<collection>/ or /<collection>/<file_path>
|
||||||
|
* @returns {string|null} The collection name or null if not found
|
||||||
|
*/
|
||||||
|
getCollectionFromURL() {
|
||||||
|
const pathname = window.location.pathname;
|
||||||
|
const parts = pathname.split('/').filter(p => p); // Remove empty parts
|
||||||
|
|
||||||
|
if (parts.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// First part is the collection
|
||||||
|
return parts[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make CollectionSelector globally available
|
||||||
|
window.CollectionSelector = CollectionSelector;
|
||||||
|
|
||||||
@@ -50,7 +50,6 @@ class ColumnResizer {
|
|||||||
const containerFlex = this.sidebarPane.offsetWidth;
|
const containerFlex = this.sidebarPane.offsetWidth;
|
||||||
|
|
||||||
this.editorPane.style.flex = `0 0 ${newWidth2}px`;
|
this.editorPane.style.flex = `0 0 ${newWidth2}px`;
|
||||||
this.previewPane.style.flex = `1 1 auto`;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -89,7 +88,6 @@ class ColumnResizer {
|
|||||||
const { sidebar, editor, preview } = JSON.parse(saved);
|
const { sidebar, editor, preview } = JSON.parse(saved);
|
||||||
this.sidebarPane.style.flex = `0 0 ${sidebar}px`;
|
this.sidebarPane.style.flex = `0 0 ${sidebar}px`;
|
||||||
this.editorPane.style.flex = `0 0 ${editor}px`;
|
this.editorPane.style.flex = `0 0 ${editor}px`;
|
||||||
this.previewPane.style.flex = `1 1 auto`;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load column dimensions:', error);
|
console.error('Failed to load column dimensions:', error);
|
||||||
}
|
}
|
||||||
|
|||||||
207
static/js/config.js
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
/**
|
||||||
|
* Application Configuration
|
||||||
|
* Centralized configuration values for the markdown editor
|
||||||
|
*/
|
||||||
|
|
||||||
|
const Config = {
|
||||||
|
// ===== TIMING CONFIGURATION =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Long-press threshold in milliseconds
|
||||||
|
* Used for drag-and-drop detection in file tree
|
||||||
|
*/
|
||||||
|
LONG_PRESS_THRESHOLD: 400,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debounce delay in milliseconds
|
||||||
|
* Used for editor preview updates
|
||||||
|
*/
|
||||||
|
DEBOUNCE_DELAY: 300,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toast notification duration in milliseconds
|
||||||
|
*/
|
||||||
|
TOAST_DURATION: 3000,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mouse move threshold in pixels
|
||||||
|
* Used to detect if user is dragging vs clicking
|
||||||
|
*/
|
||||||
|
MOUSE_MOVE_THRESHOLD: 5,
|
||||||
|
|
||||||
|
// ===== UI CONFIGURATION =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drag preview width in pixels
|
||||||
|
* Width of the drag ghost image during drag-and-drop
|
||||||
|
*/
|
||||||
|
DRAG_PREVIEW_WIDTH: 200,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tree indentation in pixels
|
||||||
|
* Indentation per level in the file tree
|
||||||
|
*/
|
||||||
|
TREE_INDENT_PX: 12,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toast container z-index
|
||||||
|
* Ensures toasts appear above other elements
|
||||||
|
*/
|
||||||
|
TOAST_Z_INDEX: 9999,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimum sidebar width in pixels
|
||||||
|
*/
|
||||||
|
MIN_SIDEBAR_WIDTH: 150,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum sidebar width as percentage of container
|
||||||
|
*/
|
||||||
|
MAX_SIDEBAR_WIDTH_PERCENT: 40,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimum editor width in pixels
|
||||||
|
*/
|
||||||
|
MIN_EDITOR_WIDTH: 250,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum editor width as percentage of container
|
||||||
|
*/
|
||||||
|
MAX_EDITOR_WIDTH_PERCENT: 70,
|
||||||
|
|
||||||
|
// ===== VALIDATION CONFIGURATION =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valid filename pattern
|
||||||
|
* Only lowercase letters, numbers, underscores, and dots allowed
|
||||||
|
*/
|
||||||
|
FILENAME_PATTERN: /^[a-z0-9_]+(\.[a-z0-9_]+)*$/,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Characters to replace in filenames
|
||||||
|
* All invalid characters will be replaced with underscore
|
||||||
|
*/
|
||||||
|
FILENAME_INVALID_CHARS: /[^a-z0-9_.]/g,
|
||||||
|
|
||||||
|
// ===== STORAGE KEYS =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LocalStorage keys used throughout the application
|
||||||
|
*/
|
||||||
|
STORAGE_KEYS: {
|
||||||
|
/**
|
||||||
|
* Dark mode preference
|
||||||
|
*/
|
||||||
|
DARK_MODE: 'darkMode',
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Currently selected collection
|
||||||
|
*/
|
||||||
|
SELECTED_COLLECTION: 'selectedCollection',
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Last viewed page (per collection)
|
||||||
|
* Actual key will be: lastViewedPage:{collection}
|
||||||
|
*/
|
||||||
|
LAST_VIEWED_PAGE: 'lastViewedPage',
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Column dimensions (sidebar, editor, preview widths)
|
||||||
|
*/
|
||||||
|
COLUMN_DIMENSIONS: 'columnDimensions',
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sidebar collapsed state
|
||||||
|
*/
|
||||||
|
SIDEBAR_COLLAPSED: 'sidebarCollapsed'
|
||||||
|
},
|
||||||
|
|
||||||
|
// ===== EDITOR CONFIGURATION =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CodeMirror theme for light mode
|
||||||
|
*/
|
||||||
|
EDITOR_THEME_LIGHT: 'default',
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CodeMirror theme for dark mode
|
||||||
|
*/
|
||||||
|
EDITOR_THEME_DARK: 'monokai',
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mermaid theme for light mode
|
||||||
|
*/
|
||||||
|
MERMAID_THEME_LIGHT: 'default',
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mermaid theme for dark mode
|
||||||
|
*/
|
||||||
|
MERMAID_THEME_DARK: 'dark',
|
||||||
|
|
||||||
|
// ===== FILE TREE CONFIGURATION =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default content for new files
|
||||||
|
*/
|
||||||
|
DEFAULT_FILE_CONTENT: '# New File\n\n',
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default filename for new files
|
||||||
|
*/
|
||||||
|
DEFAULT_NEW_FILENAME: 'new_file.md',
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default folder name for new folders
|
||||||
|
*/
|
||||||
|
DEFAULT_NEW_FOLDERNAME: 'new_folder',
|
||||||
|
|
||||||
|
// ===== WEBDAV CONFIGURATION =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebDAV base URL
|
||||||
|
*/
|
||||||
|
WEBDAV_BASE_URL: '/fs/',
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PROPFIND depth for file tree loading
|
||||||
|
*/
|
||||||
|
PROPFIND_DEPTH: 'infinity',
|
||||||
|
|
||||||
|
// ===== DRAG AND DROP CONFIGURATION =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drag preview opacity
|
||||||
|
*/
|
||||||
|
DRAG_PREVIEW_OPACITY: 0.8,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dragging item opacity
|
||||||
|
*/
|
||||||
|
DRAGGING_OPACITY: 0.4,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drag preview offset X in pixels
|
||||||
|
*/
|
||||||
|
DRAG_PREVIEW_OFFSET_X: 10,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drag preview offset Y in pixels
|
||||||
|
*/
|
||||||
|
DRAG_PREVIEW_OFFSET_Y: 10,
|
||||||
|
|
||||||
|
// ===== NOTIFICATION TYPES =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bootstrap notification type mappings
|
||||||
|
*/
|
||||||
|
NOTIFICATION_TYPES: {
|
||||||
|
SUCCESS: 'success',
|
||||||
|
ERROR: 'danger',
|
||||||
|
WARNING: 'warning',
|
||||||
|
INFO: 'primary'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Make Config globally available
|
||||||
|
window.Config = Config;
|
||||||
|
|
||||||
@@ -1,68 +1,180 @@
|
|||||||
/**
|
/**
|
||||||
* Confirmation Modal Manager
|
* Unified Modal Manager
|
||||||
* Handles showing and hiding a Bootstrap modal for confirmations and prompts.
|
* Handles showing and hiding a Bootstrap modal for confirmations and prompts.
|
||||||
|
* Uses a single reusable modal element to prevent double-opening issues.
|
||||||
*/
|
*/
|
||||||
class Confirmation {
|
class ModalManager {
|
||||||
constructor(modalId) {
|
constructor(modalId) {
|
||||||
this.modalElement = document.getElementById(modalId);
|
this.modalElement = document.getElementById(modalId);
|
||||||
this.modal = new bootstrap.Modal(this.modalElement);
|
if (!this.modalElement) {
|
||||||
|
console.error(`Modal element with id "${modalId}" not found`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.modal = new bootstrap.Modal(this.modalElement, {
|
||||||
|
backdrop: 'static',
|
||||||
|
keyboard: true
|
||||||
|
});
|
||||||
|
|
||||||
this.messageElement = this.modalElement.querySelector('#confirmationMessage');
|
this.messageElement = this.modalElement.querySelector('#confirmationMessage');
|
||||||
this.inputElement = this.modalElement.querySelector('#confirmationInput');
|
this.inputElement = this.modalElement.querySelector('#confirmationInput');
|
||||||
this.confirmButton = this.modalElement.querySelector('#confirmButton');
|
this.confirmButton = this.modalElement.querySelector('#confirmButton');
|
||||||
|
this.cancelButton = this.modalElement.querySelector('[data-bs-dismiss="modal"]');
|
||||||
this.titleElement = this.modalElement.querySelector('.modal-title');
|
this.titleElement = this.modalElement.querySelector('.modal-title');
|
||||||
this.currentResolver = null;
|
this.currentResolver = null;
|
||||||
|
this.isShowing = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
_show(message, title, showInput = false, defaultValue = '') {
|
/**
|
||||||
|
* Show a confirmation dialog
|
||||||
|
* @param {string} message - The message to display
|
||||||
|
* @param {string} title - The dialog title
|
||||||
|
* @param {boolean} isDangerous - Whether this is a dangerous action (shows red button)
|
||||||
|
* @returns {Promise<boolean>} - Resolves to true if confirmed, false/null if cancelled
|
||||||
|
*/
|
||||||
|
confirm(message, title = 'Confirmation', isDangerous = false) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
|
// Prevent double-opening
|
||||||
|
if (this.isShowing) {
|
||||||
|
console.warn('Modal is already showing, ignoring duplicate request');
|
||||||
|
resolve(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isShowing = true;
|
||||||
this.currentResolver = resolve;
|
this.currentResolver = resolve;
|
||||||
this.titleElement.textContent = title;
|
this.titleElement.textContent = title;
|
||||||
this.messageElement.textContent = message;
|
this.messageElement.textContent = message;
|
||||||
|
|
||||||
if (showInput) {
|
|
||||||
this.inputElement.style.display = 'block';
|
|
||||||
this.inputElement.value = defaultValue;
|
|
||||||
this.inputElement.focus();
|
|
||||||
} else {
|
|
||||||
this.inputElement.style.display = 'none';
|
this.inputElement.style.display = 'none';
|
||||||
|
|
||||||
|
// Update button styling based on danger level
|
||||||
|
if (isDangerous) {
|
||||||
|
this.confirmButton.className = 'btn-flat btn-flat-danger';
|
||||||
|
this.confirmButton.innerHTML = '<i class="bi bi-trash"></i> Delete';
|
||||||
|
} else {
|
||||||
|
this.confirmButton.className = 'btn-flat btn-flat-primary';
|
||||||
|
this.confirmButton.innerHTML = '<i class="bi bi-check-circle"></i> OK';
|
||||||
}
|
}
|
||||||
|
|
||||||
this.confirmButton.onclick = () => this._handleConfirm(showInput);
|
// Set up event handlers
|
||||||
this.modalElement.addEventListener('hidden.bs.modal', () => this._handleCancel(), { once: true });
|
this.confirmButton.onclick = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
this._handleConfirm(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle modal hidden event for cleanup
|
||||||
|
this.modalElement.addEventListener('hidden.bs.modal', () => {
|
||||||
|
if (this.currentResolver) {
|
||||||
|
this._handleCancel();
|
||||||
|
}
|
||||||
|
}, { once: true });
|
||||||
|
|
||||||
|
// Remove aria-hidden before showing to prevent accessibility warning
|
||||||
|
this.modalElement.removeAttribute('aria-hidden');
|
||||||
|
|
||||||
this.modal.show();
|
this.modal.show();
|
||||||
|
|
||||||
|
// Focus confirm button after modal is shown
|
||||||
|
this.modalElement.addEventListener('shown.bs.modal', () => {
|
||||||
|
this.confirmButton.focus();
|
||||||
|
}, { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show a prompt dialog (input dialog)
|
||||||
|
* @param {string} message - The message/label to display
|
||||||
|
* @param {string} defaultValue - The default input value
|
||||||
|
* @param {string} title - The dialog title
|
||||||
|
* @returns {Promise<string|null>} - Resolves to input value if confirmed, null if cancelled
|
||||||
|
*/
|
||||||
|
prompt(message, defaultValue = '', title = 'Input') {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
// Prevent double-opening
|
||||||
|
if (this.isShowing) {
|
||||||
|
console.warn('Modal is already showing, ignoring duplicate request');
|
||||||
|
resolve(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isShowing = true;
|
||||||
|
this.currentResolver = resolve;
|
||||||
|
this.titleElement.textContent = title;
|
||||||
|
this.messageElement.textContent = message;
|
||||||
|
this.inputElement.style.display = 'block';
|
||||||
|
this.inputElement.value = defaultValue;
|
||||||
|
|
||||||
|
// Reset button to primary style for prompts
|
||||||
|
this.confirmButton.className = 'btn-flat btn-flat-primary';
|
||||||
|
this.confirmButton.innerHTML = '<i class="bi bi-check-circle"></i> OK';
|
||||||
|
|
||||||
|
// Set up event handlers
|
||||||
|
this.confirmButton.onclick = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
this._handleConfirm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle Enter key in input
|
||||||
|
this.inputElement.onkeydown = (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
this._handleConfirm(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle modal hidden event for cleanup
|
||||||
|
this.modalElement.addEventListener('hidden.bs.modal', () => {
|
||||||
|
if (this.currentResolver) {
|
||||||
|
this._handleCancel();
|
||||||
|
}
|
||||||
|
}, { once: true });
|
||||||
|
|
||||||
|
// Remove aria-hidden before showing to prevent accessibility warning
|
||||||
|
this.modalElement.removeAttribute('aria-hidden');
|
||||||
|
|
||||||
|
this.modal.show();
|
||||||
|
|
||||||
|
// Focus and select input after modal is shown
|
||||||
|
this.modalElement.addEventListener('shown.bs.modal', () => {
|
||||||
|
this.inputElement.focus();
|
||||||
|
this.inputElement.select();
|
||||||
|
}, { once: true });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
_handleConfirm(isPrompt) {
|
_handleConfirm(isPrompt) {
|
||||||
if (this.currentResolver) {
|
if (this.currentResolver) {
|
||||||
const value = isPrompt ? this.inputElement.value : true;
|
const value = isPrompt ? this.inputElement.value.trim() : true;
|
||||||
this.currentResolver(value);
|
const resolver = this.currentResolver;
|
||||||
this._cleanup();
|
this._cleanup();
|
||||||
|
resolver(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_handleCancel() {
|
_handleCancel() {
|
||||||
if (this.currentResolver) {
|
if (this.currentResolver) {
|
||||||
this.currentResolver(null); // Resolve with null for cancellation
|
const resolver = this.currentResolver;
|
||||||
this._cleanup();
|
this._cleanup();
|
||||||
|
resolver(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_cleanup() {
|
_cleanup() {
|
||||||
this.confirmButton.onclick = null;
|
this.confirmButton.onclick = null;
|
||||||
this.modal.hide();
|
this.inputElement.onkeydown = null;
|
||||||
this.currentResolver = null;
|
this.currentResolver = null;
|
||||||
}
|
this.isShowing = false;
|
||||||
|
this.modal.hide();
|
||||||
|
|
||||||
confirm(message, title = 'Confirmation') {
|
// Restore aria-hidden after modal is hidden
|
||||||
return this._show(message, title, false);
|
this.modalElement.addEventListener('hidden.bs.modal', () => {
|
||||||
}
|
this.modalElement.setAttribute('aria-hidden', 'true');
|
||||||
|
}, { once: true });
|
||||||
prompt(message, defaultValue = '', title = 'Prompt') {
|
|
||||||
return this._show(message, title, true, defaultValue);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make it globally available
|
// Make it globally available
|
||||||
window.ConfirmationManager = new Confirmation('confirmationModal');
|
window.ConfirmationManager = new ModalManager('confirmationModal');
|
||||||
|
window.ModalManager = window.ConfirmationManager; // Alias for clarity
|
||||||
|
|||||||
89
static/js/context-menu.js
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* Context Menu Module
|
||||||
|
* Handles the right-click context menu for file tree items
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show context menu at specified position
|
||||||
|
* @param {number} x - X coordinate
|
||||||
|
* @param {number} y - Y coordinate
|
||||||
|
* @param {Object} target - Target object with path and isDir properties
|
||||||
|
*/
|
||||||
|
function showContextMenu(x, y, target) {
|
||||||
|
const menu = document.getElementById('contextMenu');
|
||||||
|
if (!menu) return;
|
||||||
|
|
||||||
|
// Store target data
|
||||||
|
menu.dataset.targetPath = target.path;
|
||||||
|
menu.dataset.targetIsDir = target.isDir;
|
||||||
|
|
||||||
|
// Show/hide menu items based on target type
|
||||||
|
const items = {
|
||||||
|
'new-file': target.isDir,
|
||||||
|
'new-folder': target.isDir,
|
||||||
|
'upload': target.isDir,
|
||||||
|
'download': true,
|
||||||
|
'paste': target.isDir && window.fileTreeActions?.clipboard,
|
||||||
|
'open': !target.isDir
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.entries(items).forEach(([action, show]) => {
|
||||||
|
const item = menu.querySelector(`[data-action="${action}"]`);
|
||||||
|
if (item) {
|
||||||
|
item.style.display = show ? 'flex' : 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Position menu
|
||||||
|
menu.style.display = 'block';
|
||||||
|
menu.style.left = x + 'px';
|
||||||
|
menu.style.top = y + 'px';
|
||||||
|
|
||||||
|
// Adjust if off-screen
|
||||||
|
setTimeout(() => {
|
||||||
|
const rect = menu.getBoundingClientRect();
|
||||||
|
if (rect.right > window.innerWidth) {
|
||||||
|
menu.style.left = (window.innerWidth - rect.width - 10) + 'px';
|
||||||
|
}
|
||||||
|
if (rect.bottom > window.innerHeight) {
|
||||||
|
menu.style.top = (window.innerHeight - rect.height - 10) + 'px';
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide the context menu
|
||||||
|
*/
|
||||||
|
function hideContextMenu() {
|
||||||
|
const menu = document.getElementById('contextMenu');
|
||||||
|
if (menu) {
|
||||||
|
menu.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combined click handler for context menu and outside clicks
|
||||||
|
document.addEventListener('click', async (e) => {
|
||||||
|
const menuItem = e.target.closest('.context-menu-item');
|
||||||
|
|
||||||
|
if (menuItem) {
|
||||||
|
// Handle context menu item click
|
||||||
|
const action = menuItem.dataset.action;
|
||||||
|
const menu = document.getElementById('contextMenu');
|
||||||
|
const targetPath = menu.dataset.targetPath;
|
||||||
|
const isDir = menu.dataset.targetIsDir === 'true';
|
||||||
|
|
||||||
|
hideContextMenu();
|
||||||
|
|
||||||
|
if (window.fileTreeActions) {
|
||||||
|
await window.fileTreeActions.execute(action, targetPath, isDir);
|
||||||
|
}
|
||||||
|
} else if (!e.target.closest('#contextMenu') && !e.target.closest('.tree-node')) {
|
||||||
|
// Hide on outside click
|
||||||
|
hideContextMenu();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Make functions globally available
|
||||||
|
window.showContextMenu = showContextMenu;
|
||||||
|
window.hideContextMenu = hideContextMenu;
|
||||||
|
|
||||||
77
static/js/dark-mode.js
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* Dark Mode Module
|
||||||
|
* Manages dark mode theme switching and persistence
|
||||||
|
*/
|
||||||
|
|
||||||
|
class DarkMode {
|
||||||
|
constructor() {
|
||||||
|
this.isDark = localStorage.getItem(Config.STORAGE_KEYS.DARK_MODE) === 'true';
|
||||||
|
this.apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle dark mode on/off
|
||||||
|
*/
|
||||||
|
toggle() {
|
||||||
|
this.isDark = !this.isDark;
|
||||||
|
localStorage.setItem(Config.STORAGE_KEYS.DARK_MODE, this.isDark);
|
||||||
|
this.apply();
|
||||||
|
|
||||||
|
Logger.debug(`Dark mode ${this.isDark ? 'enabled' : 'disabled'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply the current dark mode state
|
||||||
|
*/
|
||||||
|
apply() {
|
||||||
|
if (this.isDark) {
|
||||||
|
document.body.classList.add('dark-mode');
|
||||||
|
const btn = document.getElementById('darkModeBtn');
|
||||||
|
if (btn) btn.innerHTML = '<i class="bi bi-sun-fill"></i>';
|
||||||
|
|
||||||
|
// Update mermaid theme
|
||||||
|
if (window.mermaid) {
|
||||||
|
mermaid.initialize({ theme: Config.MERMAID_THEME_DARK });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
document.body.classList.remove('dark-mode');
|
||||||
|
const btn = document.getElementById('darkModeBtn');
|
||||||
|
if (btn) btn.innerHTML = '<i class="bi bi-moon-fill"></i>';
|
||||||
|
|
||||||
|
// Update mermaid theme
|
||||||
|
if (window.mermaid) {
|
||||||
|
mermaid.initialize({ theme: Config.MERMAID_THEME_LIGHT });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if dark mode is currently enabled
|
||||||
|
* @returns {boolean} True if dark mode is enabled
|
||||||
|
*/
|
||||||
|
isEnabled() {
|
||||||
|
return this.isDark;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable dark mode
|
||||||
|
*/
|
||||||
|
enable() {
|
||||||
|
if (!this.isDark) {
|
||||||
|
this.toggle();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disable dark mode
|
||||||
|
*/
|
||||||
|
disable() {
|
||||||
|
if (this.isDark) {
|
||||||
|
this.toggle();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make DarkMode globally available
|
||||||
|
window.DarkMode = DarkMode;
|
||||||
|
|
||||||
67
static/js/editor-drop-handler.js
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* Editor Drop Handler Module
|
||||||
|
* Handles file drops into the editor for uploading
|
||||||
|
*/
|
||||||
|
|
||||||
|
class EditorDropHandler {
|
||||||
|
constructor(editorElement, onFileDrop) {
|
||||||
|
this.editorElement = editorElement;
|
||||||
|
this.onFileDrop = onFileDrop;
|
||||||
|
this.setupHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup drag and drop event handlers
|
||||||
|
*/
|
||||||
|
setupHandlers() {
|
||||||
|
this.editorElement.addEventListener('dragover', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
this.editorElement.classList.add('drag-over');
|
||||||
|
});
|
||||||
|
|
||||||
|
this.editorElement.addEventListener('dragleave', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
this.editorElement.classList.remove('drag-over');
|
||||||
|
});
|
||||||
|
|
||||||
|
this.editorElement.addEventListener('drop', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
this.editorElement.classList.remove('drag-over');
|
||||||
|
|
||||||
|
const files = Array.from(e.dataTransfer.files);
|
||||||
|
if (files.length === 0) return;
|
||||||
|
|
||||||
|
Logger.debug(`Dropped ${files.length} file(s) into editor`);
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
try {
|
||||||
|
if (this.onFileDrop) {
|
||||||
|
await this.onFileDrop(file);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error('Drop failed:', error);
|
||||||
|
if (window.showNotification) {
|
||||||
|
window.showNotification(`Failed to upload ${file.name}`, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove event handlers
|
||||||
|
*/
|
||||||
|
destroy() {
|
||||||
|
// Note: We can't easily remove the event listeners without keeping references
|
||||||
|
// This is a limitation of the current implementation
|
||||||
|
// In a future refactor, we could store the bound handlers
|
||||||
|
Logger.debug('EditorDropHandler destroyed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make EditorDropHandler globally available
|
||||||
|
window.EditorDropHandler = EditorDropHandler;
|
||||||
|
|
||||||
@@ -4,15 +4,26 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
class MarkdownEditor {
|
class MarkdownEditor {
|
||||||
constructor(editorId, previewId, filenameInputId) {
|
constructor(editorId, previewId, filenameInputId, readOnly = false) {
|
||||||
this.editorElement = document.getElementById(editorId);
|
this.editorElement = document.getElementById(editorId);
|
||||||
this.previewElement = document.getElementById(previewId);
|
this.previewElement = document.getElementById(previewId);
|
||||||
this.filenameInput = document.getElementById(filenameInputId);
|
this.filenameInput = document.getElementById(filenameInputId);
|
||||||
this.currentFile = null;
|
this.currentFile = null;
|
||||||
this.webdavClient = null;
|
this.webdavClient = null;
|
||||||
this.macroProcessor = new MacroProcessor(null); // Will be set later
|
this.macroProcessor = new MacroProcessor(null); // Will be set later
|
||||||
|
this.lastViewedStorageKey = 'lastViewedPage'; // localStorage key for tracking last viewed page
|
||||||
|
this.readOnly = readOnly; // Whether editor is in read-only mode
|
||||||
|
this.editor = null; // Will be initialized later
|
||||||
|
this.isShowingCustomPreview = false; // Flag to prevent auto-update when showing binary files
|
||||||
|
|
||||||
|
// Initialize loading spinners (will be created lazily when needed)
|
||||||
|
this.editorSpinner = null;
|
||||||
|
this.previewSpinner = null;
|
||||||
|
|
||||||
|
// Only initialize CodeMirror if not in read-only mode (view mode)
|
||||||
|
if (!readOnly) {
|
||||||
this.initCodeMirror();
|
this.initCodeMirror();
|
||||||
|
}
|
||||||
this.initMarkdown();
|
this.initMarkdown();
|
||||||
this.initMermaid();
|
this.initMermaid();
|
||||||
}
|
}
|
||||||
@@ -21,22 +32,27 @@ class MarkdownEditor {
|
|||||||
* Initialize CodeMirror
|
* Initialize CodeMirror
|
||||||
*/
|
*/
|
||||||
initCodeMirror() {
|
initCodeMirror() {
|
||||||
|
// Determine theme based on dark mode
|
||||||
|
const isDarkMode = document.body.classList.contains('dark-mode');
|
||||||
|
const theme = isDarkMode ? 'monokai' : 'default';
|
||||||
|
|
||||||
this.editor = CodeMirror(this.editorElement, {
|
this.editor = CodeMirror(this.editorElement, {
|
||||||
mode: 'markdown',
|
mode: 'markdown',
|
||||||
theme: 'monokai',
|
theme: theme,
|
||||||
lineNumbers: true,
|
lineNumbers: true,
|
||||||
lineWrapping: true,
|
lineWrapping: true,
|
||||||
autofocus: true,
|
autofocus: !this.readOnly, // Don't autofocus in read-only mode
|
||||||
extraKeys: {
|
readOnly: this.readOnly, // Set read-only mode
|
||||||
|
extraKeys: this.readOnly ? {} : {
|
||||||
'Ctrl-S': () => this.save(),
|
'Ctrl-S': () => this.save(),
|
||||||
'Cmd-S': () => this.save()
|
'Cmd-S': () => this.save()
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update preview on change with debouncing
|
// Update preview on change with debouncing
|
||||||
this.editor.on('change', this.debounce(() => {
|
this.editor.on('change', TimingUtils.debounce(() => {
|
||||||
this.updatePreview();
|
this.updatePreview();
|
||||||
}, 300));
|
}, Config.DEBOUNCE_DELAY));
|
||||||
|
|
||||||
// Initial preview render
|
// Initial preview render
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -47,6 +63,27 @@ class MarkdownEditor {
|
|||||||
this.editor.on('scroll', () => {
|
this.editor.on('scroll', () => {
|
||||||
this.syncScroll();
|
this.syncScroll();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Listen for dark mode changes
|
||||||
|
this.setupThemeListener();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup listener for dark mode changes
|
||||||
|
*/
|
||||||
|
setupThemeListener() {
|
||||||
|
// Watch for dark mode class changes
|
||||||
|
const observer = new MutationObserver((mutations) => {
|
||||||
|
mutations.forEach((mutation) => {
|
||||||
|
if (mutation.attributeName === 'class') {
|
||||||
|
const isDarkMode = document.body.classList.contains('dark-mode');
|
||||||
|
const newTheme = isDarkMode ? 'monokai' : 'default';
|
||||||
|
this.editor.setOption('theme', newTheme);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(document.body, { attributes: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -55,9 +92,88 @@ class MarkdownEditor {
|
|||||||
initMarkdown() {
|
initMarkdown() {
|
||||||
if (window.marked) {
|
if (window.marked) {
|
||||||
this.marked = window.marked;
|
this.marked = window.marked;
|
||||||
|
|
||||||
|
// Create custom renderer for images
|
||||||
|
const renderer = new marked.Renderer();
|
||||||
|
|
||||||
|
renderer.image = (token) => {
|
||||||
|
// Handle both old API (string params) and new API (token object)
|
||||||
|
let href, title, text;
|
||||||
|
|
||||||
|
if (typeof token === 'object' && token !== null) {
|
||||||
|
// New API: token is an object
|
||||||
|
href = token.href || '';
|
||||||
|
title = token.title || '';
|
||||||
|
text = token.text || '';
|
||||||
|
} else {
|
||||||
|
// Old API: separate parameters (href, title, text)
|
||||||
|
href = arguments[0] || '';
|
||||||
|
title = arguments[1] || '';
|
||||||
|
text = arguments[2] || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure all are strings
|
||||||
|
href = String(href || '');
|
||||||
|
title = String(title || '');
|
||||||
|
text = String(text || '');
|
||||||
|
|
||||||
|
Logger.debug(`Image renderer called with href="${href}", title="${title}", text="${text}"`);
|
||||||
|
|
||||||
|
// Check if href contains binary data (starts with non-printable characters)
|
||||||
|
if (href && href.length > 100 && /^[\x00-\x1F\x7F-\xFF]/.test(href)) {
|
||||||
|
Logger.error('Image href contains binary data - this should not happen!');
|
||||||
|
Logger.error('First 50 chars:', href.substring(0, 50));
|
||||||
|
// Return a placeholder image
|
||||||
|
return `<div class="alert alert-warning">⚠️ Invalid image data detected. Please re-upload the image.</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fix relative image paths to use WebDAV base URL
|
||||||
|
if (href && !href.startsWith('http://') && !href.startsWith('https://') && !href.startsWith('data:')) {
|
||||||
|
// Get the directory of the current file
|
||||||
|
const currentDir = this.currentFile ? PathUtils.getParentPath(this.currentFile) : '';
|
||||||
|
|
||||||
|
// Resolve relative path
|
||||||
|
let imagePath = href;
|
||||||
|
if (href.startsWith('./')) {
|
||||||
|
// Relative to current directory
|
||||||
|
imagePath = PathUtils.joinPaths(currentDir, href.substring(2));
|
||||||
|
} else if (href.startsWith('../')) {
|
||||||
|
// Relative to parent directory
|
||||||
|
imagePath = PathUtils.joinPaths(currentDir, href);
|
||||||
|
} else if (!href.startsWith('/')) {
|
||||||
|
// Relative to current directory (no ./)
|
||||||
|
imagePath = PathUtils.joinPaths(currentDir, href);
|
||||||
|
} else {
|
||||||
|
// Absolute path from collection root
|
||||||
|
imagePath = href.substring(1); // Remove leading /
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build WebDAV URL - ensure no double slashes
|
||||||
|
if (this.webdavClient && this.webdavClient.currentCollection) {
|
||||||
|
// Remove trailing slash from baseUrl if present
|
||||||
|
const baseUrl = this.webdavClient.baseUrl.endsWith('/')
|
||||||
|
? this.webdavClient.baseUrl.slice(0, -1)
|
||||||
|
: this.webdavClient.baseUrl;
|
||||||
|
|
||||||
|
// Ensure imagePath doesn't start with /
|
||||||
|
const cleanImagePath = imagePath.startsWith('/') ? imagePath.substring(1) : imagePath;
|
||||||
|
|
||||||
|
href = `${baseUrl}/${this.webdavClient.currentCollection}/${cleanImagePath}`;
|
||||||
|
|
||||||
|
Logger.debug(`Resolved image URL: ${href}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate HTML directly
|
||||||
|
const titleAttr = title ? ` title="${title}"` : '';
|
||||||
|
const altAttr = text ? ` alt="${text}"` : '';
|
||||||
|
return `<img src="${href}"${altAttr}${titleAttr}>`;
|
||||||
|
};
|
||||||
|
|
||||||
this.marked.setOptions({
|
this.marked.setOptions({
|
||||||
breaks: true,
|
breaks: true,
|
||||||
gfm: true,
|
gfm: true,
|
||||||
|
renderer: renderer,
|
||||||
highlight: (code, lang) => {
|
highlight: (code, lang) => {
|
||||||
if (lang && window.Prism.languages[lang]) {
|
if (lang && window.Prism.languages[lang]) {
|
||||||
return window.Prism.highlight(code, window.Prism.languages[lang], lang);
|
return window.Prism.highlight(code, window.Prism.languages[lang], lang);
|
||||||
@@ -94,21 +210,74 @@ class MarkdownEditor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize loading spinners (lazy initialization)
|
||||||
|
*/
|
||||||
|
initLoadingSpinners() {
|
||||||
|
if (!this.editorSpinner && !this.readOnly && this.editorElement) {
|
||||||
|
this.editorSpinner = new LoadingSpinner(this.editorElement, 'Loading file...');
|
||||||
|
}
|
||||||
|
if (!this.previewSpinner && this.previewElement) {
|
||||||
|
this.previewSpinner = new LoadingSpinner(this.previewElement, 'Rendering preview...');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load file
|
* Load file
|
||||||
*/
|
*/
|
||||||
async loadFile(path) {
|
async loadFile(path) {
|
||||||
try {
|
try {
|
||||||
|
// Initialize loading spinners if not already done
|
||||||
|
this.initLoadingSpinners();
|
||||||
|
|
||||||
|
// Show loading spinners
|
||||||
|
if (this.editorSpinner) {
|
||||||
|
this.editorSpinner.show('Loading file...');
|
||||||
|
}
|
||||||
|
if (this.previewSpinner) {
|
||||||
|
this.previewSpinner.show('Loading preview...');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset custom preview flag when loading text files
|
||||||
|
this.isShowingCustomPreview = false;
|
||||||
|
|
||||||
const content = await this.webdavClient.get(path);
|
const content = await this.webdavClient.get(path);
|
||||||
this.currentFile = path;
|
this.currentFile = path;
|
||||||
this.filenameInput.value = path;
|
|
||||||
this.editor.setValue(content);
|
|
||||||
this.updatePreview();
|
|
||||||
|
|
||||||
if (window.showNotification) {
|
// Update filename input if it exists
|
||||||
window.showNotification(`Loaded ${path}`, 'info');
|
if (this.filenameInput) {
|
||||||
|
this.filenameInput.value = path;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update editor if it exists (edit mode)
|
||||||
|
if (this.editor) {
|
||||||
|
this.editor.setValue(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update preview with the loaded content
|
||||||
|
await this.renderPreview(content);
|
||||||
|
|
||||||
|
// Save as last viewed page
|
||||||
|
this.saveLastViewedPage(path);
|
||||||
|
|
||||||
|
// Hide loading spinners
|
||||||
|
if (this.editorSpinner) {
|
||||||
|
this.editorSpinner.hide();
|
||||||
|
}
|
||||||
|
if (this.previewSpinner) {
|
||||||
|
this.previewSpinner.hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
// No notification for successful file load - it's not critical
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// Hide loading spinners on error
|
||||||
|
if (this.editorSpinner) {
|
||||||
|
this.editorSpinner.hide();
|
||||||
|
}
|
||||||
|
if (this.previewSpinner) {
|
||||||
|
this.previewSpinner.hide();
|
||||||
|
}
|
||||||
|
|
||||||
console.error('Failed to load file:', error);
|
console.error('Failed to load file:', error);
|
||||||
if (window.showNotification) {
|
if (window.showNotification) {
|
||||||
window.showNotification('Failed to load file', 'danger');
|
window.showNotification('Failed to load file', 'danger');
|
||||||
@@ -116,6 +285,32 @@ class MarkdownEditor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save the last viewed page to localStorage
|
||||||
|
* Stores per collection so different collections can have different last viewed pages
|
||||||
|
*/
|
||||||
|
saveLastViewedPage(path) {
|
||||||
|
if (!this.webdavClient || !this.webdavClient.currentCollection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const collection = this.webdavClient.currentCollection;
|
||||||
|
const storageKey = `${this.lastViewedStorageKey}:${collection}`;
|
||||||
|
localStorage.setItem(storageKey, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the last viewed page from localStorage
|
||||||
|
* Returns null if no page was previously viewed
|
||||||
|
*/
|
||||||
|
getLastViewedPage() {
|
||||||
|
if (!this.webdavClient || !this.webdavClient.currentCollection) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const collection = this.webdavClient.currentCollection;
|
||||||
|
const storageKey = `${this.lastViewedStorageKey}:${collection}`;
|
||||||
|
return localStorage.getItem(storageKey);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save file
|
* Save file
|
||||||
*/
|
*/
|
||||||
@@ -159,10 +354,7 @@ class MarkdownEditor {
|
|||||||
this.filenameInput.focus();
|
this.filenameInput.focus();
|
||||||
this.editor.setValue('# New File\n\nStart typing...\n');
|
this.editor.setValue('# New File\n\nStart typing...\n');
|
||||||
this.updatePreview();
|
this.updatePreview();
|
||||||
|
// No notification needed - UI is self-explanatory
|
||||||
if (window.showNotification) {
|
|
||||||
window.showNotification('Enter filename and start typing', 'info');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -174,7 +366,7 @@ class MarkdownEditor {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const confirmed = await window.ConfirmationManager.confirm(`Are you sure you want to delete ${this.currentFile}?`, 'Delete File');
|
const confirmed = await window.ConfirmationManager.confirm(`Are you sure you want to delete ${this.currentFile}?`, 'Delete File', true);
|
||||||
if (confirmed) {
|
if (confirmed) {
|
||||||
try {
|
try {
|
||||||
await this.webdavClient.delete(this.currentFile);
|
await this.webdavClient.delete(this.currentFile);
|
||||||
@@ -189,10 +381,66 @@ class MarkdownEditor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update preview
|
* Convert JSX-style attributes to HTML attributes
|
||||||
|
* Handles style={{...}} and boolean attributes like allowFullScreen={true}
|
||||||
*/
|
*/
|
||||||
async updatePreview() {
|
convertJSXToHTML(content) {
|
||||||
const markdown = this.editor.getValue();
|
Logger.debug('Converting JSX to HTML...');
|
||||||
|
|
||||||
|
// Convert style={{...}} to style="..."
|
||||||
|
// This regex finds style={{...}} and converts the object notation to CSS string
|
||||||
|
content = content.replace(/style=\{\{([^}]+)\}\}/g, (match, styleContent) => {
|
||||||
|
Logger.debug(`Found JSX style: ${match}`);
|
||||||
|
|
||||||
|
// Parse the object-like syntax and convert to CSS
|
||||||
|
const cssRules = styleContent
|
||||||
|
.split(',')
|
||||||
|
.map(rule => {
|
||||||
|
const colonIndex = rule.indexOf(':');
|
||||||
|
if (colonIndex === -1) return '';
|
||||||
|
|
||||||
|
const key = rule.substring(0, colonIndex).trim();
|
||||||
|
const value = rule.substring(colonIndex + 1).trim();
|
||||||
|
|
||||||
|
if (!key || !value) return '';
|
||||||
|
|
||||||
|
// Convert camelCase to kebab-case (e.g., paddingTop -> padding-top)
|
||||||
|
const cssKey = key.replace(/([A-Z])/g, '-$1').toLowerCase();
|
||||||
|
|
||||||
|
// Remove quotes from value
|
||||||
|
let cssValue = value.replace(/^['"]|['"]$/g, '');
|
||||||
|
|
||||||
|
return `${cssKey}: ${cssValue}`;
|
||||||
|
})
|
||||||
|
.filter(rule => rule)
|
||||||
|
.join('; ');
|
||||||
|
|
||||||
|
Logger.debug(`Converted to CSS: style="${cssRules}"`);
|
||||||
|
return `style="${cssRules}"`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Convert boolean attributes like allowFullScreen={true} to allowfullscreen
|
||||||
|
content = content.replace(/(\w+)=\{true\}/g, (match, attrName) => {
|
||||||
|
Logger.debug(`Found boolean attribute: ${match}`);
|
||||||
|
// Convert camelCase to lowercase for HTML attributes
|
||||||
|
const htmlAttr = attrName.toLowerCase();
|
||||||
|
Logger.debug(`Converted to: ${htmlAttr}`);
|
||||||
|
return htmlAttr;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove attributes set to {false}
|
||||||
|
content = content.replace(/\s+\w+=\{false\}/g, '');
|
||||||
|
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render preview from markdown content
|
||||||
|
* Can be called with explicit content (for view mode) or from editor (for edit mode)
|
||||||
|
*/
|
||||||
|
async renderPreview(markdownContent = null) {
|
||||||
|
// Get markdown content from editor if not provided
|
||||||
|
const markdown = markdownContent !== null ? markdownContent : (this.editor ? this.editor.getValue() : '');
|
||||||
const previewDiv = this.previewElement;
|
const previewDiv = this.previewElement;
|
||||||
|
|
||||||
if (!markdown || !markdown.trim()) {
|
if (!markdown || !markdown.trim()) {
|
||||||
@@ -205,23 +453,30 @@ class MarkdownEditor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Step 1: Process macros
|
// Initialize loading spinners if not already done
|
||||||
let processedContent = markdown;
|
this.initLoadingSpinners();
|
||||||
|
|
||||||
if (this.macroProcessor) {
|
// Show preview loading spinner (only if not already shown by loadFile)
|
||||||
const processingResult = await this.macroProcessor.processMacros(markdown);
|
if (this.previewSpinner && !this.previewSpinner.isVisible()) {
|
||||||
processedContent = processingResult.content;
|
this.previewSpinner.show('Rendering preview...');
|
||||||
|
|
||||||
// Log errors if any
|
|
||||||
if (processingResult.errors.length > 0) {
|
|
||||||
console.warn('Macro processing errors:', processingResult.errors);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Step 0: Convert JSX-style syntax to HTML
|
||||||
|
let processedContent = this.convertJSXToHTML(markdown);
|
||||||
|
|
||||||
|
// Step 1: Process macros
|
||||||
|
if (this.macroProcessor) {
|
||||||
|
const processingResult = await this.macroProcessor.processMacros(processedContent);
|
||||||
|
processedContent = processingResult.content;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Parse markdown to HTML
|
// Step 2: Parse markdown to HTML
|
||||||
if (!this.marked) {
|
if (!this.marked) {
|
||||||
console.error("Markdown parser (marked) not initialized.");
|
console.error("Markdown parser (marked) not initialized.");
|
||||||
previewDiv.innerHTML = `<div class="alert alert-danger">Preview engine not loaded.</div>`;
|
previewDiv.innerHTML = `<div class="alert alert-danger">Preview engine not loaded.</div>`;
|
||||||
|
if (this.previewSpinner) {
|
||||||
|
this.previewSpinner.hide();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,6 +514,13 @@ class MarkdownEditor {
|
|||||||
console.warn('Mermaid rendering error:', error);
|
console.warn('Mermaid rendering error:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hide preview loading spinner after a small delay to ensure rendering is complete
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.previewSpinner) {
|
||||||
|
this.previewSpinner.hide();
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Preview rendering error:', error);
|
console.error('Preview rendering error:', error);
|
||||||
previewDiv.innerHTML = `
|
previewDiv.innerHTML = `
|
||||||
@@ -267,6 +529,27 @@ class MarkdownEditor {
|
|||||||
${error.message}
|
${error.message}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// Hide loading spinner on error
|
||||||
|
if (this.previewSpinner) {
|
||||||
|
this.previewSpinner.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update preview (backward compatibility wrapper)
|
||||||
|
* Calls renderPreview with content from editor
|
||||||
|
*/
|
||||||
|
async updatePreview() {
|
||||||
|
// Skip auto-update if showing custom preview (e.g., binary files)
|
||||||
|
if (this.isShowingCustomPreview) {
|
||||||
|
Logger.debug('Skipping auto-update: showing custom preview');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.editor) {
|
||||||
|
await this.renderPreview();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,6 +557,8 @@ class MarkdownEditor {
|
|||||||
* Sync scroll between editor and preview
|
* Sync scroll between editor and preview
|
||||||
*/
|
*/
|
||||||
syncScroll() {
|
syncScroll() {
|
||||||
|
if (!this.editor) return; // Skip if no editor (view mode)
|
||||||
|
|
||||||
const scrollInfo = this.editor.getScrollInfo();
|
const scrollInfo = this.editor.getScrollInfo();
|
||||||
const scrollPercent = scrollInfo.top / (scrollInfo.height - scrollInfo.clientHeight);
|
const scrollPercent = scrollInfo.top / (scrollInfo.height - scrollInfo.clientHeight);
|
||||||
|
|
||||||
@@ -324,20 +609,7 @@ class MarkdownEditor {
|
|||||||
this.editor.setValue(content);
|
this.editor.setValue(content);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Debounce function moved to TimingUtils in utils.js
|
||||||
* Debounce function
|
|
||||||
*/
|
|
||||||
debounce(func, wait) {
|
|
||||||
let timeout;
|
|
||||||
return function executedFunction(...args) {
|
|
||||||
const later = () => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
func(...args);
|
|
||||||
};
|
|
||||||
clearTimeout(timeout);
|
|
||||||
timeout = setTimeout(later, wait);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export for use in other modules
|
// Export for use in other modules
|
||||||
|
|||||||
126
static/js/event-bus.js
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
/**
|
||||||
|
* Event Bus Module
|
||||||
|
* Provides a centralized event system for application-wide communication
|
||||||
|
* Allows components to communicate without tight coupling
|
||||||
|
*/
|
||||||
|
|
||||||
|
class EventBus {
|
||||||
|
constructor() {
|
||||||
|
/**
|
||||||
|
* Map of event names to arrays of listener functions
|
||||||
|
* @type {Object.<string, Function[]>}
|
||||||
|
*/
|
||||||
|
this.listeners = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register an event listener
|
||||||
|
* @param {string} event - The event name to listen for
|
||||||
|
* @param {Function} callback - The function to call when the event is dispatched
|
||||||
|
* @returns {Function} A function to unregister this listener
|
||||||
|
*/
|
||||||
|
on(event, callback) {
|
||||||
|
if (!this.listeners[event]) {
|
||||||
|
this.listeners[event] = [];
|
||||||
|
}
|
||||||
|
this.listeners[event].push(callback);
|
||||||
|
|
||||||
|
// Return unsubscribe function
|
||||||
|
return () => this.off(event, callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a one-time event listener
|
||||||
|
* The listener will be automatically removed after being called once
|
||||||
|
* @param {string} event - The event name to listen for
|
||||||
|
* @param {Function} callback - The function to call when the event is dispatched
|
||||||
|
* @returns {Function} A function to unregister this listener
|
||||||
|
*/
|
||||||
|
once(event, callback) {
|
||||||
|
const onceWrapper = (data) => {
|
||||||
|
callback(data);
|
||||||
|
this.off(event, onceWrapper);
|
||||||
|
};
|
||||||
|
return this.on(event, onceWrapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unregister an event listener
|
||||||
|
* @param {string} event - The event name
|
||||||
|
* @param {Function} callback - The callback function to remove
|
||||||
|
*/
|
||||||
|
off(event, callback) {
|
||||||
|
if (!this.listeners[event]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.listeners[event] = this.listeners[event].filter(
|
||||||
|
listener => listener !== callback
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clean up empty listener arrays
|
||||||
|
if (this.listeners[event].length === 0) {
|
||||||
|
delete this.listeners[event];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatch an event to all registered listeners
|
||||||
|
* @param {string} event - The event name to dispatch
|
||||||
|
* @param {any} data - The data to pass to the listeners
|
||||||
|
*/
|
||||||
|
dispatch(event, data) {
|
||||||
|
if (!this.listeners[event]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a copy of the listeners array to avoid issues if listeners are added/removed during dispatch
|
||||||
|
const listeners = [...this.listeners[event]];
|
||||||
|
|
||||||
|
listeners.forEach(callback => {
|
||||||
|
try {
|
||||||
|
callback(data);
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(`Error in event listener for "${event}":`, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove all listeners for a specific event
|
||||||
|
* If no event is specified, removes all listeners for all events
|
||||||
|
* @param {string} [event] - The event name (optional)
|
||||||
|
*/
|
||||||
|
clear(event) {
|
||||||
|
if (event) {
|
||||||
|
delete this.listeners[event];
|
||||||
|
} else {
|
||||||
|
this.listeners = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the number of listeners for an event
|
||||||
|
* @param {string} event - The event name
|
||||||
|
* @returns {number} The number of listeners
|
||||||
|
*/
|
||||||
|
listenerCount(event) {
|
||||||
|
return this.listeners[event] ? this.listeners[event].length : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all event names that have listeners
|
||||||
|
* @returns {string[]} Array of event names
|
||||||
|
*/
|
||||||
|
eventNames() {
|
||||||
|
return Object.keys(this.listeners);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create and export the global event bus instance
|
||||||
|
const eventBus = new EventBus();
|
||||||
|
|
||||||
|
// Make it globally available
|
||||||
|
window.eventBus = eventBus;
|
||||||
|
window.EventBus = EventBus;
|
||||||
|
|
||||||
@@ -14,32 +14,10 @@ class FileTreeActions {
|
|||||||
/**
|
/**
|
||||||
* Validate and sanitize filename/folder name
|
* Validate and sanitize filename/folder name
|
||||||
* Returns { valid: boolean, sanitized: string, message: string }
|
* Returns { valid: boolean, sanitized: string, message: string }
|
||||||
|
* Now uses ValidationUtils from utils.js
|
||||||
*/
|
*/
|
||||||
validateFileName(name, isFolder = false) {
|
validateFileName(name, isFolder = false) {
|
||||||
const type = isFolder ? 'folder' : 'file';
|
return ValidationUtils.validateFileName(name, isFolder);
|
||||||
|
|
||||||
if (!name || name.trim().length === 0) {
|
|
||||||
return { valid: false, message: `${type} name cannot be empty` };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for invalid characters
|
|
||||||
const validPattern = /^[a-z0-9_]+(\.[a-z0-9_]+)*$/;
|
|
||||||
|
|
||||||
if (!validPattern.test(name)) {
|
|
||||||
const sanitized = name
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9_.]/g, '_')
|
|
||||||
.replace(/_+/g, '_')
|
|
||||||
.replace(/^_+|_+$/g, '');
|
|
||||||
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
sanitized,
|
|
||||||
message: `Invalid characters in ${type} name. Only lowercase letters, numbers, and underscores allowed.\n\nSuggestion: "${sanitized}"`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return { valid: true, sanitized: name, message: '' };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async execute(action, targetPath, isDirectory) {
|
async execute(action, targetPath, isDirectory) {
|
||||||
@@ -67,9 +45,15 @@ class FileTreeActions {
|
|||||||
'new-file': async function (path, isDir) {
|
'new-file': async function (path, isDir) {
|
||||||
if (!isDir) return;
|
if (!isDir) return;
|
||||||
|
|
||||||
await this.showInputDialog('Enter filename (lowercase, underscore only):', 'new_file.md', async (filename) => {
|
const filename = await window.ModalManager.prompt(
|
||||||
|
'Enter filename (lowercase, underscore only):',
|
||||||
|
'new_file.md',
|
||||||
|
'New File'
|
||||||
|
);
|
||||||
|
|
||||||
if (!filename) return;
|
if (!filename) return;
|
||||||
|
|
||||||
|
let finalFilename = filename;
|
||||||
const validation = this.validateFileName(filename, false);
|
const validation = this.validateFileName(filename, false);
|
||||||
|
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
@@ -77,8 +61,13 @@ class FileTreeActions {
|
|||||||
|
|
||||||
// Ask if user wants to use sanitized version
|
// Ask if user wants to use sanitized version
|
||||||
if (validation.sanitized) {
|
if (validation.sanitized) {
|
||||||
if (await this.showConfirmDialog('Use sanitized name?', `${filename} → ${validation.sanitized}`)) {
|
const useSanitized = await window.ModalManager.confirm(
|
||||||
filename = validation.sanitized;
|
`${filename} → ${validation.sanitized}`,
|
||||||
|
'Use sanitized name?',
|
||||||
|
false
|
||||||
|
);
|
||||||
|
if (useSanitized) {
|
||||||
|
finalFilename = validation.sanitized;
|
||||||
} else {
|
} else {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -87,28 +76,44 @@ class FileTreeActions {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fullPath = `${path}/${filename}`.replace(/\/+/g, '/');
|
const fullPath = `${path}/${finalFilename}`.replace(/\/+/g, '/');
|
||||||
await this.webdavClient.put(fullPath, '# New File\n\n');
|
await this.webdavClient.put(fullPath, '# New File\n\n');
|
||||||
|
|
||||||
|
// Clear undo history since new file was created
|
||||||
|
if (this.fileTree.lastMoveOperation) {
|
||||||
|
this.fileTree.lastMoveOperation = null;
|
||||||
|
}
|
||||||
|
|
||||||
await this.fileTree.load();
|
await this.fileTree.load();
|
||||||
showNotification(`Created ${filename}`, 'success');
|
showNotification(`Created ${finalFilename}`, 'success');
|
||||||
await this.editor.loadFile(fullPath);
|
await this.editor.loadFile(fullPath);
|
||||||
});
|
|
||||||
},
|
},
|
||||||
|
|
||||||
'new-folder': async function (path, isDir) {
|
'new-folder': async function (path, isDir) {
|
||||||
if (!isDir) return;
|
if (!isDir) return;
|
||||||
|
|
||||||
await this.showInputDialog('Enter folder name (lowercase, underscore only):', 'new_folder', async (foldername) => {
|
const foldername = await window.ModalManager.prompt(
|
||||||
|
'Enter folder name (lowercase, underscore only):',
|
||||||
|
'new_folder',
|
||||||
|
'New Folder'
|
||||||
|
);
|
||||||
|
|
||||||
if (!foldername) return;
|
if (!foldername) return;
|
||||||
|
|
||||||
|
let finalFoldername = foldername;
|
||||||
const validation = this.validateFileName(foldername, true);
|
const validation = this.validateFileName(foldername, true);
|
||||||
|
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
showNotification(validation.message, 'warning');
|
showNotification(validation.message, 'warning');
|
||||||
|
|
||||||
if (validation.sanitized) {
|
if (validation.sanitized) {
|
||||||
if (await this.showConfirmDialog('Use sanitized name?', `${foldername} → ${validation.sanitized}`)) {
|
const useSanitized = await window.ModalManager.confirm(
|
||||||
foldername = validation.sanitized;
|
`${foldername} → ${validation.sanitized}`,
|
||||||
|
'Use sanitized name?',
|
||||||
|
false
|
||||||
|
);
|
||||||
|
if (useSanitized) {
|
||||||
|
finalFoldername = validation.sanitized;
|
||||||
} else {
|
} else {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -117,20 +122,36 @@ class FileTreeActions {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fullPath = `${path}/${foldername}`.replace(/\/+/g, '/');
|
const fullPath = `${path}/${finalFoldername}`.replace(/\/+/g, '/');
|
||||||
await this.webdavClient.mkcol(fullPath);
|
await this.webdavClient.mkcol(fullPath);
|
||||||
|
|
||||||
|
// Clear undo history since new folder was created
|
||||||
|
if (this.fileTree.lastMoveOperation) {
|
||||||
|
this.fileTree.lastMoveOperation = null;
|
||||||
|
}
|
||||||
|
|
||||||
await this.fileTree.load();
|
await this.fileTree.load();
|
||||||
showNotification(`Created folder ${foldername}`, 'success');
|
showNotification(`Created folder ${finalFoldername}`, 'success');
|
||||||
});
|
|
||||||
},
|
},
|
||||||
|
|
||||||
rename: async function (path, isDir) {
|
rename: async function (path, isDir) {
|
||||||
const oldName = path.split('/').pop();
|
const oldName = path.split('/').pop();
|
||||||
const newName = await this.showInputDialog('Rename to:', oldName);
|
const newName = await window.ModalManager.prompt(
|
||||||
|
'Rename to:',
|
||||||
|
oldName,
|
||||||
|
'Rename'
|
||||||
|
);
|
||||||
|
|
||||||
if (newName && newName !== oldName) {
|
if (newName && newName !== oldName) {
|
||||||
const parentPath = path.substring(0, path.lastIndexOf('/'));
|
const parentPath = path.substring(0, path.lastIndexOf('/'));
|
||||||
const newPath = parentPath ? `${parentPath}/${newName}` : newName;
|
const newPath = parentPath ? `${parentPath}/${newName}` : newName;
|
||||||
await this.webdavClient.move(path, newPath);
|
await this.webdavClient.move(path, newPath);
|
||||||
|
|
||||||
|
// Clear undo history since manual rename occurred
|
||||||
|
if (this.fileTree.lastMoveOperation) {
|
||||||
|
this.fileTree.lastMoveOperation = null;
|
||||||
|
}
|
||||||
|
|
||||||
await this.fileTree.load();
|
await this.fileTree.load();
|
||||||
showNotification('Renamed', 'success');
|
showNotification('Renamed', 'success');
|
||||||
}
|
}
|
||||||
@@ -138,13 +159,13 @@ class FileTreeActions {
|
|||||||
|
|
||||||
copy: async function (path, isDir) {
|
copy: async function (path, isDir) {
|
||||||
this.clipboard = { path, operation: 'copy', isDirectory: isDir };
|
this.clipboard = { path, operation: 'copy', isDirectory: isDir };
|
||||||
showNotification(`Copied: ${path.split('/').pop()}`, 'info');
|
// No notification for copy - it's a quick operation
|
||||||
this.updatePasteMenuItem();
|
this.updatePasteMenuItem();
|
||||||
},
|
},
|
||||||
|
|
||||||
cut: async function (path, isDir) {
|
cut: async function (path, isDir) {
|
||||||
this.clipboard = { path, operation: 'cut', isDirectory: isDir };
|
this.clipboard = { path, operation: 'cut', isDirectory: isDir };
|
||||||
showNotification(`Cut: ${path.split('/').pop()}`, 'warning');
|
// No notification for cut - it's a quick operation
|
||||||
this.updatePasteMenuItem();
|
this.updatePasteMenuItem();
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -156,33 +177,84 @@ class FileTreeActions {
|
|||||||
|
|
||||||
if (this.clipboard.operation === 'copy') {
|
if (this.clipboard.operation === 'copy') {
|
||||||
await this.webdavClient.copy(this.clipboard.path, destPath);
|
await this.webdavClient.copy(this.clipboard.path, destPath);
|
||||||
showNotification('Copied', 'success');
|
// No notification for paste - file tree updates show the result
|
||||||
} else {
|
} else {
|
||||||
await this.webdavClient.move(this.clipboard.path, destPath);
|
await this.webdavClient.move(this.clipboard.path, destPath);
|
||||||
this.clipboard = null;
|
this.clipboard = null;
|
||||||
this.updatePasteMenuItem();
|
this.updatePasteMenuItem();
|
||||||
showNotification('Moved', 'success');
|
// No notification for move - file tree updates show the result
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.fileTree.load();
|
await this.fileTree.load();
|
||||||
},
|
},
|
||||||
|
|
||||||
delete: async function (path, isDir) {
|
delete: async function (path, isDir) {
|
||||||
const name = path.split('/').pop();
|
const name = path.split('/').pop() || this.webdavClient.currentCollection;
|
||||||
const type = isDir ? 'folder' : 'file';
|
const type = isDir ? 'folder' : 'file';
|
||||||
|
|
||||||
if (!await this.showConfirmDialog(`Delete this ${type}?`, `${name}`)) {
|
// Check if this is a root-level collection (empty path or single-level path)
|
||||||
return;
|
const pathParts = path.split('/').filter(p => p.length > 0);
|
||||||
|
const isCollection = pathParts.length === 0;
|
||||||
|
|
||||||
|
if (isCollection) {
|
||||||
|
// Deleting a collection - use backend API
|
||||||
|
const confirmed = await window.ModalManager.confirm(
|
||||||
|
`Are you sure you want to delete the collection "${name}"? This will delete all files and folders in it.`,
|
||||||
|
'Delete Collection?',
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Call backend API to delete collection
|
||||||
|
const response = await fetch(`/api/collections/${name}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.text();
|
||||||
|
throw new Error(error || 'Failed to delete collection');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
showNotification(`Collection "${name}" deleted successfully`, 'success');
|
||||||
|
|
||||||
|
// Reload the page to refresh collections list
|
||||||
|
window.location.href = '/';
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error('Failed to delete collection:', error);
|
||||||
|
showNotification(`Failed to delete collection: ${error.message}`, 'error');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Deleting a regular file/folder - use WebDAV
|
||||||
|
const confirmed = await window.ModalManager.confirm(
|
||||||
|
`Are you sure you want to delete ${name}?`,
|
||||||
|
`Delete this ${type}?`,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
await this.webdavClient.delete(path);
|
await this.webdavClient.delete(path);
|
||||||
|
|
||||||
|
// Clear undo history since manual delete occurred
|
||||||
|
if (this.fileTree.lastMoveOperation) {
|
||||||
|
this.fileTree.lastMoveOperation = null;
|
||||||
|
}
|
||||||
|
|
||||||
await this.fileTree.load();
|
await this.fileTree.load();
|
||||||
showNotification(`Deleted ${name}`, 'success');
|
showNotification(`Deleted ${name}`, 'success');
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
download: async function (path, isDir) {
|
download: async function (path, isDir) {
|
||||||
showNotification('Downloading...', 'info');
|
Logger.info(`Downloading ${isDir ? 'folder' : 'file'}: ${path}`);
|
||||||
// Implementation here
|
|
||||||
|
if (isDir) {
|
||||||
|
await this.fileTree.downloadFolder(path);
|
||||||
|
} else {
|
||||||
|
await this.fileTree.downloadFile(path);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
upload: async function (path, isDir) {
|
upload: async function (path, isDir) {
|
||||||
@@ -204,154 +276,60 @@ class FileTreeActions {
|
|||||||
};
|
};
|
||||||
|
|
||||||
input.click();
|
input.click();
|
||||||
|
},
|
||||||
|
|
||||||
|
'copy-to-collection': async function (path, isDir) {
|
||||||
|
// Get list of available collections
|
||||||
|
const collections = await this.webdavClient.getCollections();
|
||||||
|
const currentCollection = this.webdavClient.currentCollection;
|
||||||
|
|
||||||
|
// Filter out current collection
|
||||||
|
const otherCollections = collections.filter(c => c !== currentCollection);
|
||||||
|
|
||||||
|
if (otherCollections.length === 0) {
|
||||||
|
showNotification('No other collections available', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show collection selection dialog
|
||||||
|
const targetCollection = await this.showCollectionSelectionDialog(
|
||||||
|
otherCollections,
|
||||||
|
`Copy ${PathUtils.getFileName(path)} to collection:`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!targetCollection) return;
|
||||||
|
|
||||||
|
// Copy the file/folder
|
||||||
|
await this.copyToCollection(path, isDir, currentCollection, targetCollection);
|
||||||
|
},
|
||||||
|
|
||||||
|
'move-to-collection': async function (path, isDir) {
|
||||||
|
// Get list of available collections
|
||||||
|
const collections = await this.webdavClient.getCollections();
|
||||||
|
const currentCollection = this.webdavClient.currentCollection;
|
||||||
|
|
||||||
|
// Filter out current collection
|
||||||
|
const otherCollections = collections.filter(c => c !== currentCollection);
|
||||||
|
|
||||||
|
if (otherCollections.length === 0) {
|
||||||
|
showNotification('No other collections available', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show collection selection dialog
|
||||||
|
const targetCollection = await this.showCollectionSelectionDialog(
|
||||||
|
otherCollections,
|
||||||
|
`Move ${PathUtils.getFileName(path)} to collection:`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!targetCollection) return;
|
||||||
|
|
||||||
|
// Move the file/folder
|
||||||
|
await this.moveToCollection(path, isDir, currentCollection, targetCollection);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Modern dialog implementations
|
// Old deprecated modal methods removed - all modals now use window.ModalManager
|
||||||
async showInputDialog(title, placeholder = '', callback) {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const dialog = this.createInputDialog(title, placeholder);
|
|
||||||
const input = dialog.querySelector('input');
|
|
||||||
const confirmBtn = dialog.querySelector('.btn-primary');
|
|
||||||
const cancelBtn = dialog.querySelector('.btn-secondary');
|
|
||||||
|
|
||||||
const cleanup = (value) => {
|
|
||||||
const modalInstance = bootstrap.Modal.getInstance(dialog);
|
|
||||||
if (modalInstance) {
|
|
||||||
modalInstance.hide();
|
|
||||||
}
|
|
||||||
dialog.remove();
|
|
||||||
const backdrop = document.querySelector('.modal-backdrop');
|
|
||||||
if (backdrop) backdrop.remove();
|
|
||||||
document.body.classList.remove('modal-open');
|
|
||||||
resolve(value);
|
|
||||||
if (callback) callback(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
confirmBtn.onclick = () => {
|
|
||||||
cleanup(input.value.trim());
|
|
||||||
};
|
|
||||||
|
|
||||||
cancelBtn.onclick = () => {
|
|
||||||
cleanup(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
dialog.addEventListener('hidden.bs.modal', () => {
|
|
||||||
cleanup(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
input.onkeypress = (e) => {
|
|
||||||
if (e.key === 'Enter') confirmBtn.click();
|
|
||||||
};
|
|
||||||
|
|
||||||
document.body.appendChild(dialog);
|
|
||||||
const modal = new bootstrap.Modal(dialog);
|
|
||||||
modal.show();
|
|
||||||
input.focus();
|
|
||||||
input.select();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async showConfirmDialog(title, message = '', callback) {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const dialog = this.createConfirmDialog(title, message);
|
|
||||||
const confirmBtn = dialog.querySelector('.btn-danger');
|
|
||||||
const cancelBtn = dialog.querySelector('.btn-secondary');
|
|
||||||
|
|
||||||
const cleanup = (value) => {
|
|
||||||
const modalInstance = bootstrap.Modal.getInstance(dialog);
|
|
||||||
if (modalInstance) {
|
|
||||||
modalInstance.hide();
|
|
||||||
}
|
|
||||||
dialog.remove();
|
|
||||||
const backdrop = document.querySelector('.modal-backdrop');
|
|
||||||
if (backdrop) backdrop.remove();
|
|
||||||
document.body.classList.remove('modal-open');
|
|
||||||
resolve(value);
|
|
||||||
if (callback) callback(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
confirmBtn.onclick = () => {
|
|
||||||
cleanup(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
cancelBtn.onclick = () => {
|
|
||||||
cleanup(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
dialog.addEventListener('hidden.bs.modal', () => {
|
|
||||||
cleanup(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
document.body.appendChild(dialog);
|
|
||||||
const modal = new bootstrap.Modal(dialog);
|
|
||||||
modal.show();
|
|
||||||
confirmBtn.focus();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
createInputDialog(title, placeholder) {
|
|
||||||
const backdrop = document.createElement('div');
|
|
||||||
backdrop.className = 'modal-backdrop fade show';
|
|
||||||
|
|
||||||
const dialog = document.createElement('div');
|
|
||||||
dialog.className = 'modal fade show d-block';
|
|
||||||
dialog.setAttribute('tabindex', '-1');
|
|
||||||
dialog.style.display = 'block';
|
|
||||||
|
|
||||||
dialog.innerHTML = `
|
|
||||||
<div class="modal-dialog modal-dialog-centered">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title">${title}</h5>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<input type="text" class="form-control" value="${placeholder}" autofocus>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
||||||
<button type="button" class="btn btn-primary">OK</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
document.body.appendChild(backdrop);
|
|
||||||
return dialog;
|
|
||||||
}
|
|
||||||
|
|
||||||
createConfirmDialog(title, message) {
|
|
||||||
const backdrop = document.createElement('div');
|
|
||||||
backdrop.className = 'modal-backdrop fade show';
|
|
||||||
|
|
||||||
const dialog = document.createElement('div');
|
|
||||||
dialog.className = 'modal fade show d-block';
|
|
||||||
dialog.setAttribute('tabindex', '-1');
|
|
||||||
dialog.style.display = 'block';
|
|
||||||
|
|
||||||
dialog.innerHTML = `
|
|
||||||
<div class="modal-dialog modal-dialog-centered">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header border-danger">
|
|
||||||
<h5 class="modal-title text-danger">${title}</h5>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<p>${message}</p>
|
|
||||||
<p class="text-danger small">This action cannot be undone.</p>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
||||||
<button type="button" class="btn btn-danger">Delete</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
document.body.appendChild(backdrop);
|
|
||||||
return dialog;
|
|
||||||
}
|
|
||||||
|
|
||||||
updatePasteMenuItem() {
|
updatePasteMenuItem() {
|
||||||
const pasteItem = document.getElementById('pasteMenuItem');
|
const pasteItem = document.getElementById('pasteMenuItem');
|
||||||
@@ -359,4 +337,268 @@ class FileTreeActions {
|
|||||||
pasteItem.style.display = this.clipboard ? 'flex' : 'none';
|
pasteItem.style.display = this.clipboard ? 'flex' : 'none';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show a dialog to select a collection
|
||||||
|
* @param {Array<string>} collections - List of collection names
|
||||||
|
* @param {string} message - Dialog message
|
||||||
|
* @returns {Promise<string|null>} Selected collection or null if cancelled
|
||||||
|
*/
|
||||||
|
async showCollectionSelectionDialog(collections, message) {
|
||||||
|
// Prevent duplicate modals
|
||||||
|
if (this._collectionModalShowing) {
|
||||||
|
Logger.warn('Collection selection modal is already showing');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
this._collectionModalShowing = true;
|
||||||
|
|
||||||
|
// Create a custom modal with radio buttons for collection selection
|
||||||
|
const modal = document.createElement('div');
|
||||||
|
modal.className = 'modal fade';
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title"><i class="bi bi-folder-symlink"></i> Select Collection</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p class="mb-3">${message}</p>
|
||||||
|
<div class="collection-list" style="max-height: 300px; overflow-y: auto;">
|
||||||
|
${collections.map((c, i) => `
|
||||||
|
<div class="form-check p-2 mb-2 rounded border collection-option" style="cursor: pointer; transition: all 0.2s;">
|
||||||
|
<input class="form-check-input" type="radio" name="collection" id="collection-${i}" value="${c}" ${i === 0 ? 'checked' : ''}>
|
||||||
|
<label class="form-check-label w-100" for="collection-${i}" style="cursor: pointer;">
|
||||||
|
<i class="bi bi-folder"></i> <strong>${c}</strong>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
<div id="confirmationPreview" class="alert alert-info mt-3" style="display: none;">
|
||||||
|
<i class="bi bi-info-circle"></i> <span id="confirmationText"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn-flat btn-flat-secondary" data-bs-dismiss="modal">
|
||||||
|
<i class="bi bi-x-circle"></i> Cancel
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn-flat btn-flat-primary" id="confirmCollectionBtn">
|
||||||
|
<i class="bi bi-check-circle"></i> OK
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
const bsModal = new bootstrap.Modal(modal);
|
||||||
|
|
||||||
|
// Extract file name and action from message
|
||||||
|
// Message format: "Copy filename to collection:" or "Move filename to collection:"
|
||||||
|
const messageMatch = message.match(/(Copy|Move)\s+(.+?)\s+to collection:/);
|
||||||
|
const action = messageMatch ? messageMatch[1].toLowerCase() : 'copy';
|
||||||
|
const fileName = messageMatch ? messageMatch[2] : 'item';
|
||||||
|
|
||||||
|
// Get confirmation preview elements
|
||||||
|
const confirmationPreview = modal.querySelector('#confirmationPreview');
|
||||||
|
const confirmationText = modal.querySelector('#confirmationText');
|
||||||
|
|
||||||
|
// Function to update confirmation message
|
||||||
|
const updateConfirmation = (collectionName) => {
|
||||||
|
confirmationText.textContent = `"${fileName}" will be ${action}d to "${collectionName}"`;
|
||||||
|
confirmationPreview.style.display = 'block';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add hover effects and click handlers for collection options
|
||||||
|
const collectionOptions = modal.querySelectorAll('.collection-option');
|
||||||
|
collectionOptions.forEach(option => {
|
||||||
|
// Hover effect
|
||||||
|
option.addEventListener('mouseenter', () => {
|
||||||
|
option.style.backgroundColor = 'var(--bs-light)';
|
||||||
|
option.style.borderColor = 'var(--bs-primary)';
|
||||||
|
});
|
||||||
|
option.addEventListener('mouseleave', () => {
|
||||||
|
const radio = option.querySelector('input[type="radio"]');
|
||||||
|
if (!radio.checked) {
|
||||||
|
option.style.backgroundColor = '';
|
||||||
|
option.style.borderColor = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click on the whole div to select
|
||||||
|
option.addEventListener('click', () => {
|
||||||
|
const radio = option.querySelector('input[type="radio"]');
|
||||||
|
radio.checked = true;
|
||||||
|
|
||||||
|
// Update confirmation message
|
||||||
|
updateConfirmation(radio.value);
|
||||||
|
|
||||||
|
// Update all options styling
|
||||||
|
collectionOptions.forEach(opt => {
|
||||||
|
const r = opt.querySelector('input[type="radio"]');
|
||||||
|
if (r.checked) {
|
||||||
|
opt.style.backgroundColor = 'var(--bs-primary-bg-subtle)';
|
||||||
|
opt.style.borderColor = 'var(--bs-primary)';
|
||||||
|
} else {
|
||||||
|
opt.style.backgroundColor = '';
|
||||||
|
opt.style.borderColor = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set initial styling for checked option
|
||||||
|
const radio = option.querySelector('input[type="radio"]');
|
||||||
|
if (radio.checked) {
|
||||||
|
option.style.backgroundColor = 'var(--bs-primary-bg-subtle)';
|
||||||
|
option.style.borderColor = 'var(--bs-primary)';
|
||||||
|
// Show initial confirmation
|
||||||
|
updateConfirmation(radio.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const confirmBtn = modal.querySelector('#confirmCollectionBtn');
|
||||||
|
|
||||||
|
confirmBtn.addEventListener('click', () => {
|
||||||
|
const selected = modal.querySelector('input[name="collection"]:checked');
|
||||||
|
this._collectionModalShowing = false;
|
||||||
|
bsModal.hide();
|
||||||
|
resolve(selected ? selected.value : null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modal.addEventListener('hidden.bs.modal', () => {
|
||||||
|
modal.remove();
|
||||||
|
this._collectionModalShowing = false;
|
||||||
|
resolve(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
bsModal.show();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy a file or folder to another collection
|
||||||
|
*/
|
||||||
|
async copyToCollection(path, isDir, sourceCollection, targetCollection) {
|
||||||
|
try {
|
||||||
|
Logger.info(`Copying ${path} from ${sourceCollection} to ${targetCollection}`);
|
||||||
|
|
||||||
|
if (isDir) {
|
||||||
|
// Copy folder recursively
|
||||||
|
await this.copyFolderToCollection(path, sourceCollection, targetCollection);
|
||||||
|
} else {
|
||||||
|
// Copy single file
|
||||||
|
await this.copyFileToCollection(path, sourceCollection, targetCollection);
|
||||||
|
}
|
||||||
|
|
||||||
|
showNotification(`Copied to ${targetCollection}`, 'success');
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error('Failed to copy to collection:', error);
|
||||||
|
showNotification('Failed to copy to collection', 'error');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move a file or folder to another collection
|
||||||
|
*/
|
||||||
|
async moveToCollection(path, isDir, sourceCollection, targetCollection) {
|
||||||
|
try {
|
||||||
|
Logger.info(`Moving ${path} from ${sourceCollection} to ${targetCollection}`);
|
||||||
|
|
||||||
|
// First copy
|
||||||
|
await this.copyToCollection(path, isDir, sourceCollection, targetCollection);
|
||||||
|
|
||||||
|
// Then delete from source
|
||||||
|
await this.webdavClient.delete(path);
|
||||||
|
await this.fileTree.load();
|
||||||
|
|
||||||
|
showNotification(`Moved to ${targetCollection}`, 'success');
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error('Failed to move to collection:', error);
|
||||||
|
showNotification('Failed to move to collection', 'error');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy a single file to another collection
|
||||||
|
*/
|
||||||
|
async copyFileToCollection(path, sourceCollection, targetCollection) {
|
||||||
|
// Read file from source collection
|
||||||
|
const content = await this.webdavClient.get(path);
|
||||||
|
|
||||||
|
// Write to target collection
|
||||||
|
const originalCollection = this.webdavClient.currentCollection;
|
||||||
|
this.webdavClient.setCollection(targetCollection);
|
||||||
|
|
||||||
|
// Ensure parent directories exist in target collection
|
||||||
|
await this.webdavClient.ensureParentDirectories(path);
|
||||||
|
|
||||||
|
await this.webdavClient.put(path, content);
|
||||||
|
this.webdavClient.setCollection(originalCollection);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy a folder recursively to another collection
|
||||||
|
* @param {string} folderPath - Path of the folder to copy
|
||||||
|
* @param {string} sourceCollection - Source collection name
|
||||||
|
* @param {string} targetCollection - Target collection name
|
||||||
|
* @param {Set} visitedPaths - Set of already visited paths to prevent infinite loops
|
||||||
|
*/
|
||||||
|
async copyFolderToCollection(folderPath, sourceCollection, targetCollection, visitedPaths = new Set()) {
|
||||||
|
// Prevent infinite loops by tracking visited paths
|
||||||
|
if (visitedPaths.has(folderPath)) {
|
||||||
|
Logger.warn(`Skipping already visited path: ${folderPath}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
visitedPaths.add(folderPath);
|
||||||
|
|
||||||
|
Logger.info(`Copying folder: ${folderPath} from ${sourceCollection} to ${targetCollection}`);
|
||||||
|
|
||||||
|
// Set to source collection to list items
|
||||||
|
const originalCollection = this.webdavClient.currentCollection;
|
||||||
|
this.webdavClient.setCollection(sourceCollection);
|
||||||
|
|
||||||
|
// Get only direct children (not recursive to avoid infinite loop)
|
||||||
|
const items = await this.webdavClient.list(folderPath, false);
|
||||||
|
Logger.debug(`Found ${items.length} items in ${folderPath}:`, items.map(i => i.path));
|
||||||
|
|
||||||
|
// Create the folder in target collection
|
||||||
|
this.webdavClient.setCollection(targetCollection);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Ensure parent directories exist first
|
||||||
|
await this.webdavClient.ensureParentDirectories(folderPath + '/dummy.txt');
|
||||||
|
// Then create the folder itself
|
||||||
|
await this.webdavClient.createFolder(folderPath);
|
||||||
|
Logger.debug(`Created folder: ${folderPath}`);
|
||||||
|
} catch (error) {
|
||||||
|
// Folder might already exist (405 Method Not Allowed), ignore error
|
||||||
|
if (error.message && error.message.includes('405')) {
|
||||||
|
Logger.debug(`Folder ${folderPath} already exists (405)`);
|
||||||
|
} else {
|
||||||
|
Logger.debug('Folder might already exist:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy all items
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.isDirectory) {
|
||||||
|
// Recursively copy subdirectory
|
||||||
|
await this.copyFolderToCollection(item.path, sourceCollection, targetCollection, visitedPaths);
|
||||||
|
} else {
|
||||||
|
// Copy file
|
||||||
|
this.webdavClient.setCollection(sourceCollection);
|
||||||
|
const content = await this.webdavClient.get(item.path);
|
||||||
|
this.webdavClient.setCollection(targetCollection);
|
||||||
|
// Ensure parent directories exist before copying file
|
||||||
|
await this.webdavClient.ensureParentDirectories(item.path);
|
||||||
|
await this.webdavClient.put(item.path, content);
|
||||||
|
Logger.debug(`Copied file: ${item.path}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.webdavClient.setCollection(originalCollection);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -4,29 +4,48 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
class FileTree {
|
class FileTree {
|
||||||
constructor(containerId, webdavClient) {
|
constructor(containerId, webdavClient, isEditMode = false) {
|
||||||
this.container = document.getElementById(containerId);
|
this.container = document.getElementById(containerId);
|
||||||
this.webdavClient = webdavClient;
|
this.webdavClient = webdavClient;
|
||||||
this.tree = [];
|
this.tree = [];
|
||||||
this.selectedPath = null;
|
this.selectedPath = null;
|
||||||
this.onFileSelect = null;
|
this.onFileSelect = null;
|
||||||
this.onFolderSelect = null;
|
this.onFolderSelect = null;
|
||||||
|
this.filterImagesInViewMode = !isEditMode; // Track if we should filter images (true in view mode)
|
||||||
|
|
||||||
|
// Drag and drop state
|
||||||
|
this.draggedNode = null;
|
||||||
|
this.draggedPath = null;
|
||||||
|
this.draggedIsDir = false;
|
||||||
|
|
||||||
|
// Long-press detection
|
||||||
|
this.longPressTimer = null;
|
||||||
|
this.longPressThreshold = Config.LONG_PRESS_THRESHOLD;
|
||||||
|
this.isDraggingEnabled = false;
|
||||||
|
this.mouseDownNode = null;
|
||||||
|
|
||||||
|
// Undo functionality
|
||||||
|
this.lastMoveOperation = null;
|
||||||
|
|
||||||
this.setupEventListeners();
|
this.setupEventListeners();
|
||||||
|
this.setupUndoListener();
|
||||||
}
|
}
|
||||||
|
|
||||||
setupEventListeners() {
|
setupEventListeners() {
|
||||||
// Click handler for tree nodes
|
// Click handler for tree nodes
|
||||||
this.container.addEventListener('click', (e) => {
|
this.container.addEventListener('click', (e) => {
|
||||||
console.log('Container clicked', e.target);
|
|
||||||
const node = e.target.closest('.tree-node');
|
const node = e.target.closest('.tree-node');
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
|
|
||||||
console.log('Node found', node);
|
|
||||||
const path = node.dataset.path;
|
const path = node.dataset.path;
|
||||||
const isDir = node.dataset.isdir === 'true';
|
const isDir = node.dataset.isdir === 'true';
|
||||||
|
|
||||||
// The toggle is handled inside renderNodes now
|
// Check if toggle was clicked (icon or toggle button)
|
||||||
|
const toggle = e.target.closest('.tree-node-toggle');
|
||||||
|
if (toggle) {
|
||||||
|
// Toggle is handled by its own click listener in renderNodes
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Select node
|
// Select node
|
||||||
if (isDir) {
|
if (isDir) {
|
||||||
@@ -36,8 +55,18 @@ class FileTree {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Context menu
|
// Context menu (only in edit mode)
|
||||||
this.container.addEventListener('contextmenu', (e) => {
|
this.container.addEventListener('contextmenu', (e) => {
|
||||||
|
// Check if we're in edit mode
|
||||||
|
const isEditMode = document.body.classList.contains('edit-mode');
|
||||||
|
|
||||||
|
// In view mode, disable custom context menu entirely
|
||||||
|
if (!isEditMode) {
|
||||||
|
e.preventDefault(); // Prevent default browser context menu
|
||||||
|
return; // Don't show custom context menu
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edit mode: show custom context menu
|
||||||
const node = e.target.closest('.tree-node');
|
const node = e.target.closest('.tree-node');
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
@@ -51,6 +80,333 @@ class FileTree {
|
|||||||
window.showContextMenu(e.clientX, e.clientY, { path: '', isDir: true });
|
window.showContextMenu(e.clientX, e.clientY, { path: '', isDir: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Drag and drop event listeners (only in edit mode)
|
||||||
|
this.setupDragAndDrop();
|
||||||
|
}
|
||||||
|
|
||||||
|
setupUndoListener() {
|
||||||
|
// Listen for Ctrl+Z (Windows/Linux) or Cmd+Z (Mac)
|
||||||
|
document.addEventListener('keydown', async (e) => {
|
||||||
|
// Check for Ctrl+Z or Cmd+Z
|
||||||
|
const isUndo = (e.ctrlKey || e.metaKey) && e.key === 'z';
|
||||||
|
|
||||||
|
if (isUndo && this.isEditMode() && this.lastMoveOperation) {
|
||||||
|
e.preventDefault();
|
||||||
|
await this.undoLastMove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async undoLastMove() {
|
||||||
|
if (!this.lastMoveOperation) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { sourcePath, destPath, fileName, isDirectory } = this.lastMoveOperation;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Move the item back to its original location
|
||||||
|
await this.webdavClient.move(destPath, sourcePath);
|
||||||
|
|
||||||
|
// Get the parent folder name for the notification
|
||||||
|
const sourceParent = PathUtils.getParentPath(sourcePath);
|
||||||
|
const parentName = sourceParent ? sourceParent + '/' : 'root';
|
||||||
|
|
||||||
|
// Clear the undo history
|
||||||
|
this.lastMoveOperation = null;
|
||||||
|
|
||||||
|
// Reload the tree
|
||||||
|
await this.load();
|
||||||
|
|
||||||
|
// Re-select the moved item
|
||||||
|
this.selectAndExpandPath(sourcePath);
|
||||||
|
|
||||||
|
showNotification(`Undo: Moved ${fileName} back to ${parentName}`, 'success');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to undo move:', error);
|
||||||
|
showNotification('Failed to undo move: ' + error.message, 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setupDragAndDrop() {
|
||||||
|
// Dragover event on container to allow dropping on root level
|
||||||
|
this.container.addEventListener('dragover', (e) => {
|
||||||
|
if (!this.isEditMode() || !this.draggedPath) return;
|
||||||
|
|
||||||
|
const node = e.target.closest('.tree-node');
|
||||||
|
if (!node) {
|
||||||
|
// Hovering over empty space (root level)
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
|
||||||
|
// Highlight the entire container as a drop target
|
||||||
|
this.container.classList.add('drag-over-root');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dragleave event on container to remove root-level highlighting
|
||||||
|
this.container.addEventListener('dragleave', (e) => {
|
||||||
|
if (!this.isEditMode()) return;
|
||||||
|
|
||||||
|
// Only remove if we're actually leaving the container
|
||||||
|
// Check if the related target is outside the container
|
||||||
|
if (!this.container.contains(e.relatedTarget)) {
|
||||||
|
this.container.classList.remove('drag-over-root');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dragenter event to manage highlighting
|
||||||
|
this.container.addEventListener('dragenter', (e) => {
|
||||||
|
if (!this.isEditMode() || !this.draggedPath) return;
|
||||||
|
|
||||||
|
const node = e.target.closest('.tree-node');
|
||||||
|
if (!node) {
|
||||||
|
// Entering empty space
|
||||||
|
this.container.classList.add('drag-over-root');
|
||||||
|
} else {
|
||||||
|
// Entering a node, remove root highlighting
|
||||||
|
this.container.classList.remove('drag-over-root');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drop event on container for root level drops
|
||||||
|
this.container.addEventListener('drop', async (e) => {
|
||||||
|
if (!this.isEditMode()) return;
|
||||||
|
|
||||||
|
const node = e.target.closest('.tree-node');
|
||||||
|
if (!node && this.draggedPath) {
|
||||||
|
// Dropped on root level
|
||||||
|
e.preventDefault();
|
||||||
|
this.container.classList.remove('drag-over-root');
|
||||||
|
await this.handleDrop('', true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
isEditMode() {
|
||||||
|
return document.body.classList.contains('edit-mode');
|
||||||
|
}
|
||||||
|
|
||||||
|
setupNodeDragHandlers(nodeElement, node) {
|
||||||
|
// Dragstart - when user starts dragging
|
||||||
|
nodeElement.addEventListener('dragstart', (e) => {
|
||||||
|
this.draggedNode = nodeElement;
|
||||||
|
this.draggedPath = node.path;
|
||||||
|
this.draggedIsDir = node.isDirectory;
|
||||||
|
|
||||||
|
nodeElement.classList.add('dragging');
|
||||||
|
document.body.classList.add('dragging-active');
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
e.dataTransfer.setData('text/plain', node.path);
|
||||||
|
|
||||||
|
// Create a custom drag image with fixed width
|
||||||
|
const dragImage = nodeElement.cloneNode(true);
|
||||||
|
dragImage.style.position = 'absolute';
|
||||||
|
dragImage.style.top = '-9999px';
|
||||||
|
dragImage.style.left = '-9999px';
|
||||||
|
dragImage.style.width = `${Config.DRAG_PREVIEW_WIDTH}px`;
|
||||||
|
dragImage.style.maxWidth = `${Config.DRAG_PREVIEW_WIDTH}px`;
|
||||||
|
dragImage.style.opacity = Config.DRAG_PREVIEW_OPACITY;
|
||||||
|
dragImage.style.backgroundColor = 'var(--bg-secondary)';
|
||||||
|
dragImage.style.border = '1px solid var(--border-color)';
|
||||||
|
dragImage.style.borderRadius = '4px';
|
||||||
|
dragImage.style.padding = '4px 8px';
|
||||||
|
dragImage.style.whiteSpace = 'nowrap';
|
||||||
|
dragImage.style.overflow = 'hidden';
|
||||||
|
dragImage.style.textOverflow = 'ellipsis';
|
||||||
|
|
||||||
|
document.body.appendChild(dragImage);
|
||||||
|
e.dataTransfer.setDragImage(dragImage, 10, 10);
|
||||||
|
setTimeout(() => {
|
||||||
|
if (dragImage.parentNode) {
|
||||||
|
document.body.removeChild(dragImage);
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dragend - when drag operation ends
|
||||||
|
nodeElement.addEventListener('dragend', () => {
|
||||||
|
nodeElement.classList.remove('dragging');
|
||||||
|
nodeElement.classList.remove('drag-ready');
|
||||||
|
document.body.classList.remove('dragging-active');
|
||||||
|
this.container.classList.remove('drag-over-root');
|
||||||
|
this.clearDragOverStates();
|
||||||
|
|
||||||
|
// Reset draggable state
|
||||||
|
nodeElement.draggable = false;
|
||||||
|
nodeElement.style.cursor = '';
|
||||||
|
this.isDraggingEnabled = false;
|
||||||
|
|
||||||
|
this.draggedNode = null;
|
||||||
|
this.draggedPath = null;
|
||||||
|
this.draggedIsDir = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dragover - when dragging over this node
|
||||||
|
nodeElement.addEventListener('dragover', (e) => {
|
||||||
|
if (!this.draggedPath) return;
|
||||||
|
|
||||||
|
const targetPath = node.path;
|
||||||
|
const targetIsDir = node.isDirectory;
|
||||||
|
|
||||||
|
// Only allow dropping on directories
|
||||||
|
if (!targetIsDir) {
|
||||||
|
e.dataTransfer.dropEffect = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is a valid drop target
|
||||||
|
if (this.isValidDropTarget(this.draggedPath, this.draggedIsDir, targetPath)) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
nodeElement.classList.add('drag-over');
|
||||||
|
} else {
|
||||||
|
e.dataTransfer.dropEffect = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dragleave - when drag leaves this node
|
||||||
|
nodeElement.addEventListener('dragleave', (e) => {
|
||||||
|
// Only remove if we're actually leaving the node (not entering a child)
|
||||||
|
if (e.target === nodeElement) {
|
||||||
|
nodeElement.classList.remove('drag-over');
|
||||||
|
|
||||||
|
// If leaving a node and not entering another node, might be going to root
|
||||||
|
const relatedNode = e.relatedTarget?.closest('.tree-node');
|
||||||
|
if (!relatedNode && this.container.contains(e.relatedTarget)) {
|
||||||
|
// Moving to empty space (root area)
|
||||||
|
this.container.classList.add('drag-over-root');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drop - when item is dropped on this node
|
||||||
|
nodeElement.addEventListener('drop', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
nodeElement.classList.remove('drag-over');
|
||||||
|
|
||||||
|
if (!this.draggedPath) return;
|
||||||
|
|
||||||
|
const targetPath = node.path;
|
||||||
|
const targetIsDir = node.isDirectory;
|
||||||
|
|
||||||
|
if (targetIsDir && this.isValidDropTarget(this.draggedPath, this.draggedIsDir, targetPath)) {
|
||||||
|
await this.handleDrop(targetPath, targetIsDir);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
clearDragOverStates() {
|
||||||
|
this.container.querySelectorAll('.drag-over').forEach(node => {
|
||||||
|
node.classList.remove('drag-over');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
isValidDropTarget(sourcePath, sourceIsDir, targetPath) {
|
||||||
|
// Can't drop on itself
|
||||||
|
if (sourcePath === targetPath) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If dragging a directory, can't drop into its own descendants
|
||||||
|
if (sourceIsDir) {
|
||||||
|
// Check if target is a descendant of source
|
||||||
|
if (targetPath.startsWith(sourcePath + '/')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Can't drop into the same parent directory
|
||||||
|
const sourceParent = PathUtils.getParentPath(sourcePath);
|
||||||
|
if (sourceParent === targetPath) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleDrop(targetPath, targetIsDir) {
|
||||||
|
if (!this.draggedPath) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sourcePath = this.draggedPath;
|
||||||
|
const fileName = PathUtils.getFileName(sourcePath);
|
||||||
|
const isDirectory = this.draggedIsDir;
|
||||||
|
|
||||||
|
// Construct destination path
|
||||||
|
let destPath;
|
||||||
|
if (targetPath === '') {
|
||||||
|
// Dropping to root
|
||||||
|
destPath = fileName;
|
||||||
|
} else {
|
||||||
|
destPath = `${targetPath}/${fileName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if destination already exists
|
||||||
|
const destNode = this.findNode(destPath);
|
||||||
|
if (destNode) {
|
||||||
|
const overwrite = await window.ModalManager.confirm(
|
||||||
|
`A ${destNode.isDirectory ? 'folder' : 'file'} named "${fileName}" already exists in the destination. Do you want to overwrite it?`,
|
||||||
|
'Name Conflict',
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!overwrite) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete existing item first
|
||||||
|
await this.webdavClient.delete(destPath);
|
||||||
|
|
||||||
|
// Clear undo history since we're overwriting
|
||||||
|
this.lastMoveOperation = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform the move
|
||||||
|
await this.webdavClient.move(sourcePath, destPath);
|
||||||
|
|
||||||
|
// Store undo information (only if not overwriting)
|
||||||
|
if (!destNode) {
|
||||||
|
this.lastMoveOperation = {
|
||||||
|
sourcePath: sourcePath,
|
||||||
|
destPath: destPath,
|
||||||
|
fileName: fileName,
|
||||||
|
isDirectory: isDirectory
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the moved item was the currently selected file, update the selection
|
||||||
|
if (this.selectedPath === sourcePath) {
|
||||||
|
this.selectedPath = destPath;
|
||||||
|
|
||||||
|
// Update editor's current file path if it's the file being moved
|
||||||
|
if (!this.draggedIsDir && window.editor && window.editor.currentFile === sourcePath) {
|
||||||
|
window.editor.currentFile = destPath;
|
||||||
|
if (window.editor.filenameInput) {
|
||||||
|
window.editor.filenameInput.value = destPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify file select callback if it's a file
|
||||||
|
if (!this.draggedIsDir && this.onFileSelect) {
|
||||||
|
this.onFileSelect({ path: destPath, isDirectory: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload the tree
|
||||||
|
await this.load();
|
||||||
|
|
||||||
|
// Re-select the moved item
|
||||||
|
this.selectAndExpandPath(destPath);
|
||||||
|
|
||||||
|
showNotification(`Moved ${fileName} successfully`, 'success');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to move item:', error);
|
||||||
|
showNotification('Failed to move item: ' + error.message, 'danger');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async load() {
|
async load() {
|
||||||
@@ -71,6 +427,19 @@ class FileTree {
|
|||||||
|
|
||||||
renderNodes(nodes, parentElement, level) {
|
renderNodes(nodes, parentElement, level) {
|
||||||
nodes.forEach(node => {
|
nodes.forEach(node => {
|
||||||
|
// Filter out images and image directories in view mode
|
||||||
|
if (this.filterImagesInViewMode) {
|
||||||
|
// Skip image files
|
||||||
|
if (!node.isDirectory && PathUtils.isBinaryFile(node.path)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip image directories
|
||||||
|
if (node.isDirectory && PathUtils.isImageDirectory(node.path)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const nodeWrapper = document.createElement('div');
|
const nodeWrapper = document.createElement('div');
|
||||||
nodeWrapper.className = 'tree-node-wrapper';
|
nodeWrapper.className = 'tree-node-wrapper';
|
||||||
|
|
||||||
@@ -78,30 +447,46 @@ class FileTree {
|
|||||||
const nodeElement = this.createNodeElement(node, level);
|
const nodeElement = this.createNodeElement(node, level);
|
||||||
nodeWrapper.appendChild(nodeElement);
|
nodeWrapper.appendChild(nodeElement);
|
||||||
|
|
||||||
// Create children container ONLY if has children
|
// Create children container for directories
|
||||||
if (node.children && node.children.length > 0) {
|
if (node.isDirectory) {
|
||||||
const childContainer = document.createElement('div');
|
const childContainer = document.createElement('div');
|
||||||
childContainer.className = 'tree-children';
|
childContainer.className = 'tree-children';
|
||||||
childContainer.style.display = 'none';
|
childContainer.style.display = 'none';
|
||||||
childContainer.dataset.parent = node.path;
|
childContainer.dataset.parent = node.path;
|
||||||
childContainer.style.marginLeft = `${(level + 1) * 12}px`;
|
childContainer.style.marginLeft = `${(level + 1) * 12}px`;
|
||||||
|
|
||||||
// Recursively render children
|
// Only render children if they exist
|
||||||
|
if (node.children && node.children.length > 0) {
|
||||||
this.renderNodes(node.children, childContainer, level + 1);
|
this.renderNodes(node.children, childContainer, level + 1);
|
||||||
|
} else {
|
||||||
|
// Empty directory - show empty state message
|
||||||
|
const emptyMessage = document.createElement('div');
|
||||||
|
emptyMessage.className = 'tree-empty-message';
|
||||||
|
emptyMessage.textContent = 'Empty folder';
|
||||||
|
childContainer.appendChild(emptyMessage);
|
||||||
|
}
|
||||||
|
|
||||||
nodeWrapper.appendChild(childContainer);
|
nodeWrapper.appendChild(childContainer);
|
||||||
|
|
||||||
// Make toggle functional
|
// Make toggle functional for ALL directories (including empty ones)
|
||||||
const toggle = nodeElement.querySelector('.tree-node-toggle');
|
const toggle = nodeElement.querySelector('.tree-node-toggle');
|
||||||
if (toggle) {
|
if (toggle) {
|
||||||
toggle.addEventListener('click', (e) => {
|
const toggleHandler = (e) => {
|
||||||
console.log('Toggle clicked', e.target);
|
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const isHidden = childContainer.style.display === 'none';
|
const isHidden = childContainer.style.display === 'none';
|
||||||
console.log('Is hidden?', isHidden);
|
|
||||||
childContainer.style.display = isHidden ? 'block' : 'none';
|
childContainer.style.display = isHidden ? 'block' : 'none';
|
||||||
toggle.innerHTML = isHidden ? '▼' : '▶';
|
toggle.style.transform = isHidden ? 'rotate(90deg)' : 'rotate(0deg)';
|
||||||
toggle.classList.toggle('expanded');
|
toggle.classList.toggle('expanded');
|
||||||
});
|
};
|
||||||
|
|
||||||
|
// Add click listener to toggle icon
|
||||||
|
toggle.addEventListener('click', toggleHandler);
|
||||||
|
|
||||||
|
// Also allow double-click on the node to toggle
|
||||||
|
nodeElement.addEventListener('dblclick', toggleHandler);
|
||||||
|
|
||||||
|
// Make toggle cursor pointer for all directories
|
||||||
|
toggle.style.cursor = 'pointer';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,17 +513,110 @@ class FileTree {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a node by path
|
||||||
|
* @param {string} path - The path to find
|
||||||
|
* @returns {Object|null} The node or null if not found
|
||||||
|
*/
|
||||||
|
findNode(path) {
|
||||||
|
const search = (nodes, targetPath) => {
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.path === targetPath) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
if (node.children && node.children.length > 0) {
|
||||||
|
const found = search(node.children, targetPath);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return search(this.tree, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all files in a directory (direct children only)
|
||||||
|
* @param {string} dirPath - The directory path
|
||||||
|
* @returns {Array} Array of file nodes
|
||||||
|
*/
|
||||||
|
getDirectoryFiles(dirPath) {
|
||||||
|
const dirNode = this.findNode(dirPath);
|
||||||
|
if (dirNode && dirNode.children) {
|
||||||
|
return dirNode.children.filter(child => !child.isDirectory);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
updateSelection() {
|
updateSelection() {
|
||||||
// Remove previous selection
|
// Remove previous selection
|
||||||
this.container.querySelectorAll('.tree-node').forEach(node => {
|
this.container.querySelectorAll('.tree-node').forEach(node => {
|
||||||
node.classList.remove('selected');
|
node.classList.remove('active');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add selection to current
|
// Add selection to current and all parent directories
|
||||||
if (this.selectedPath) {
|
if (this.selectedPath) {
|
||||||
|
// Add active class to the selected file/folder
|
||||||
const node = this.container.querySelector(`[data-path="${this.selectedPath}"]`);
|
const node = this.container.querySelector(`[data-path="${this.selectedPath}"]`);
|
||||||
if (node) {
|
if (node) {
|
||||||
node.classList.add('selected');
|
node.classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add active class to all parent directories
|
||||||
|
const parts = this.selectedPath.split('/');
|
||||||
|
let currentPath = '';
|
||||||
|
for (let i = 0; i < parts.length - 1; i++) {
|
||||||
|
currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i];
|
||||||
|
const parentNode = this.container.querySelector(`[data-path="${currentPath}"]`);
|
||||||
|
if (parentNode) {
|
||||||
|
parentNode.classList.add('active');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Highlight a file as active and expand all parent directories
|
||||||
|
* @param {string} path - The file path to highlight
|
||||||
|
*/
|
||||||
|
selectAndExpandPath(path) {
|
||||||
|
this.selectedPath = path;
|
||||||
|
|
||||||
|
// Expand all parent directories
|
||||||
|
this.expandParentDirectories(path);
|
||||||
|
|
||||||
|
// Update selection
|
||||||
|
this.updateSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expand all parent directories of a given path
|
||||||
|
* @param {string} path - The file path
|
||||||
|
*/
|
||||||
|
expandParentDirectories(path) {
|
||||||
|
// Get all parent paths
|
||||||
|
const parts = path.split('/');
|
||||||
|
let currentPath = '';
|
||||||
|
|
||||||
|
for (let i = 0; i < parts.length - 1; i++) {
|
||||||
|
currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i];
|
||||||
|
|
||||||
|
// Find the node with this path
|
||||||
|
const parentNode = this.container.querySelector(`[data-path="${currentPath}"]`);
|
||||||
|
if (parentNode && parentNode.dataset.isdir === 'true') {
|
||||||
|
// Find the children container
|
||||||
|
const wrapper = parentNode.closest('.tree-node-wrapper');
|
||||||
|
if (wrapper) {
|
||||||
|
const childContainer = wrapper.querySelector('.tree-children');
|
||||||
|
if (childContainer && childContainer.style.display === 'none') {
|
||||||
|
// Expand it
|
||||||
|
childContainer.style.display = 'block';
|
||||||
|
const toggle = parentNode.querySelector('.tree-node-toggle');
|
||||||
|
if (toggle) {
|
||||||
|
toggle.classList.add('expanded');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -150,25 +628,111 @@ class FileTree {
|
|||||||
nodeElement.dataset.isdir = node.isDirectory;
|
nodeElement.dataset.isdir = node.isDirectory;
|
||||||
nodeElement.style.paddingLeft = `${level * 12}px`;
|
nodeElement.style.paddingLeft = `${level * 12}px`;
|
||||||
|
|
||||||
const icon = document.createElement('span');
|
// Enable drag and drop in edit mode with long-press detection
|
||||||
icon.className = 'tree-node-icon';
|
if (this.isEditMode()) {
|
||||||
|
// Start with draggable disabled
|
||||||
|
nodeElement.draggable = false;
|
||||||
|
this.setupNodeDragHandlers(nodeElement, node);
|
||||||
|
this.setupLongPressDetection(nodeElement, node);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create toggle/icon container
|
||||||
|
const iconContainer = document.createElement('span');
|
||||||
|
iconContainer.className = 'tree-node-icon';
|
||||||
|
|
||||||
if (node.isDirectory) {
|
if (node.isDirectory) {
|
||||||
icon.innerHTML = '▶'; // Collapsed by default
|
// Create toggle icon for folders
|
||||||
icon.classList.add('tree-node-toggle');
|
const toggle = document.createElement('i');
|
||||||
|
toggle.className = 'bi bi-chevron-right tree-node-toggle';
|
||||||
|
toggle.style.fontSize = '12px';
|
||||||
|
iconContainer.appendChild(toggle);
|
||||||
} else {
|
} else {
|
||||||
icon.innerHTML = '●'; // File icon
|
// Create file icon
|
||||||
|
const fileIcon = document.createElement('i');
|
||||||
|
fileIcon.className = 'bi bi-file-earmark-text';
|
||||||
|
fileIcon.style.fontSize = '14px';
|
||||||
|
iconContainer.appendChild(fileIcon);
|
||||||
}
|
}
|
||||||
|
|
||||||
const title = document.createElement('span');
|
const title = document.createElement('span');
|
||||||
title.className = 'tree-node-title';
|
title.className = 'tree-node-title';
|
||||||
title.textContent = node.name;
|
title.textContent = node.name;
|
||||||
|
|
||||||
nodeElement.appendChild(icon);
|
nodeElement.appendChild(iconContainer);
|
||||||
nodeElement.appendChild(title);
|
nodeElement.appendChild(title);
|
||||||
|
|
||||||
return nodeElement;
|
return nodeElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setupLongPressDetection(nodeElement, node) {
|
||||||
|
// Mouse down - start long-press timer
|
||||||
|
nodeElement.addEventListener('mousedown', (e) => {
|
||||||
|
// Ignore if clicking on toggle button
|
||||||
|
if (e.target.closest('.tree-node-toggle')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.mouseDownNode = nodeElement;
|
||||||
|
|
||||||
|
// Start timer for long-press
|
||||||
|
this.longPressTimer = setTimeout(() => {
|
||||||
|
// Long-press threshold met - enable dragging
|
||||||
|
this.isDraggingEnabled = true;
|
||||||
|
nodeElement.draggable = true;
|
||||||
|
nodeElement.classList.add('drag-ready');
|
||||||
|
|
||||||
|
// Change cursor to grab
|
||||||
|
nodeElement.style.cursor = 'grab';
|
||||||
|
}, this.longPressThreshold);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mouse up - cancel long-press timer
|
||||||
|
nodeElement.addEventListener('mouseup', () => {
|
||||||
|
this.clearLongPressTimer();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mouse leave - cancel long-press timer
|
||||||
|
nodeElement.addEventListener('mouseleave', () => {
|
||||||
|
this.clearLongPressTimer();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mouse move - cancel long-press if moved too much
|
||||||
|
let startX, startY;
|
||||||
|
nodeElement.addEventListener('mousedown', (e) => {
|
||||||
|
startX = e.clientX;
|
||||||
|
startY = e.clientY;
|
||||||
|
});
|
||||||
|
|
||||||
|
nodeElement.addEventListener('mousemove', (e) => {
|
||||||
|
if (this.longPressTimer && !this.isDraggingEnabled) {
|
||||||
|
const deltaX = Math.abs(e.clientX - startX);
|
||||||
|
const deltaY = Math.abs(e.clientY - startY);
|
||||||
|
|
||||||
|
// If mouse moved more than threshold, cancel long-press
|
||||||
|
if (deltaX > Config.MOUSE_MOVE_THRESHOLD || deltaY > Config.MOUSE_MOVE_THRESHOLD) {
|
||||||
|
this.clearLongPressTimer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
clearLongPressTimer() {
|
||||||
|
if (this.longPressTimer) {
|
||||||
|
clearTimeout(this.longPressTimer);
|
||||||
|
this.longPressTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset dragging state if not currently dragging
|
||||||
|
if (!this.draggedPath && this.mouseDownNode) {
|
||||||
|
this.mouseDownNode.draggable = false;
|
||||||
|
this.mouseDownNode.classList.remove('drag-ready');
|
||||||
|
this.mouseDownNode.style.cursor = '';
|
||||||
|
this.isDraggingEnabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.mouseDownNode = null;
|
||||||
|
}
|
||||||
|
|
||||||
formatSize(bytes) {
|
formatSize(bytes) {
|
||||||
if (bytes === 0) return '0 B';
|
if (bytes === 0) return '0 B';
|
||||||
const k = 1024;
|
const k = 1024;
|
||||||
@@ -233,8 +797,8 @@ class FileTree {
|
|||||||
async downloadFile(path) {
|
async downloadFile(path) {
|
||||||
try {
|
try {
|
||||||
const content = await this.webdavClient.get(path);
|
const content = await this.webdavClient.get(path);
|
||||||
const filename = path.split('/').pop();
|
const filename = PathUtils.getFileName(path);
|
||||||
this.triggerDownload(content, filename);
|
DownloadUtils.triggerDownload(content, filename);
|
||||||
showNotification('Downloaded', 'success');
|
showNotification('Downloaded', 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to download file:', error);
|
console.error('Failed to download file:', error);
|
||||||
@@ -256,7 +820,7 @@ class FileTree {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const zip = new JSZip();
|
const zip = new JSZip();
|
||||||
const folder = zip.folder(path.split('/').pop() || 'download');
|
const folder = zip.folder(PathUtils.getFileName(path) || 'download');
|
||||||
|
|
||||||
// Add all files to zip
|
// Add all files to zip
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
@@ -267,8 +831,8 @@ class FileTree {
|
|||||||
|
|
||||||
// Generate zip
|
// Generate zip
|
||||||
const zipBlob = await zip.generateAsync({ type: 'blob' });
|
const zipBlob = await zip.generateAsync({ type: 'blob' });
|
||||||
const zipFilename = `${path.split('/').pop() || 'download'}.zip`;
|
const zipFilename = `${PathUtils.getFileName(path) || 'download'}.zip`;
|
||||||
this.triggerDownload(zipBlob, zipFilename);
|
DownloadUtils.triggerDownload(zipBlob, zipFilename);
|
||||||
showNotification('Downloaded', 'success');
|
showNotification('Downloaded', 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to download folder:', error);
|
console.error('Failed to download folder:', error);
|
||||||
@@ -276,16 +840,29 @@ class FileTree {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
triggerDownload(content, filename) {
|
// triggerDownload method moved to DownloadUtils in utils.js
|
||||||
const blob = content instanceof Blob ? content : new Blob([content]);
|
|
||||||
const url = URL.createObjectURL(blob);
|
/**
|
||||||
const a = document.createElement('a');
|
* Get the first markdown file in the tree
|
||||||
a.href = url;
|
* Returns the path of the first .md file found, or null if none exist
|
||||||
a.download = filename;
|
*/
|
||||||
document.body.appendChild(a);
|
getFirstMarkdownFile() {
|
||||||
a.click();
|
const findFirstFile = (nodes) => {
|
||||||
document.body.removeChild(a);
|
for (const node of nodes) {
|
||||||
URL.revokeObjectURL(url);
|
// If it's a file and ends with .md, return it
|
||||||
|
if (!node.isDirectory && node.path.endsWith('.md')) {
|
||||||
|
return node.path;
|
||||||
|
}
|
||||||
|
// If it's a directory with children, search recursively
|
||||||
|
if (node.isDirectory && node.children && node.children.length > 0) {
|
||||||
|
const found = findFirstFile(node.children);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return findFirstFile(this.tree);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
37
static/js/file-upload.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* File Upload Module
|
||||||
|
* Handles file upload dialog for uploading files to the file tree
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show file upload dialog
|
||||||
|
* @param {string} targetPath - The target directory path
|
||||||
|
* @param {Function} onUpload - Callback function to handle file upload
|
||||||
|
*/
|
||||||
|
function showFileUploadDialog(targetPath, onUpload) {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'file';
|
||||||
|
input.multiple = true;
|
||||||
|
|
||||||
|
input.addEventListener('change', async (e) => {
|
||||||
|
const files = Array.from(e.target.files);
|
||||||
|
if (files.length === 0) return;
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
try {
|
||||||
|
await onUpload(targetPath, file);
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error('Upload failed:', error);
|
||||||
|
if (window.showNotification) {
|
||||||
|
window.showNotification(`Failed to upload ${file.name}`, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
input.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make function globally available
|
||||||
|
window.showFileUploadDialog = showFileUploadDialog;
|
||||||
|
|
||||||
151
static/js/loading-spinner.js
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
/**
|
||||||
|
* Loading Spinner Component
|
||||||
|
* Displays a loading overlay with spinner for async operations
|
||||||
|
*/
|
||||||
|
|
||||||
|
class LoadingSpinner {
|
||||||
|
/**
|
||||||
|
* Create a loading spinner for a container
|
||||||
|
* @param {string|HTMLElement} container - Container element or ID
|
||||||
|
* @param {string} message - Optional loading message
|
||||||
|
*/
|
||||||
|
constructor(container, message = 'Loading...') {
|
||||||
|
this.container = typeof container === 'string'
|
||||||
|
? document.getElementById(container)
|
||||||
|
: container;
|
||||||
|
|
||||||
|
if (!this.container) {
|
||||||
|
Logger.error('LoadingSpinner: Container not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.message = message;
|
||||||
|
this.overlay = null;
|
||||||
|
this.isShowing = false;
|
||||||
|
this.showTime = null; // Track when spinner was shown
|
||||||
|
this.minDisplayTime = 300; // Minimum time to show spinner (ms)
|
||||||
|
|
||||||
|
// Ensure container has position relative for absolute positioning
|
||||||
|
const position = window.getComputedStyle(this.container).position;
|
||||||
|
if (position === 'static') {
|
||||||
|
this.container.style.position = 'relative';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the loading spinner
|
||||||
|
* @param {string} message - Optional custom message
|
||||||
|
*/
|
||||||
|
show(message = null) {
|
||||||
|
if (this.isShowing) return;
|
||||||
|
|
||||||
|
// Record when spinner was shown
|
||||||
|
this.showTime = Date.now();
|
||||||
|
|
||||||
|
// Create overlay if it doesn't exist
|
||||||
|
if (!this.overlay) {
|
||||||
|
this.overlay = this.createOverlay(message || this.message);
|
||||||
|
this.container.appendChild(this.overlay);
|
||||||
|
} else {
|
||||||
|
// Update message if provided
|
||||||
|
if (message) {
|
||||||
|
const textElement = this.overlay.querySelector('.loading-text');
|
||||||
|
if (textElement) {
|
||||||
|
textElement.textContent = message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.overlay.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isShowing = true;
|
||||||
|
Logger.debug(`Loading spinner shown: ${message || this.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide the loading spinner
|
||||||
|
* Ensures minimum display time for better UX
|
||||||
|
*/
|
||||||
|
hide() {
|
||||||
|
if (!this.isShowing || !this.overlay) return;
|
||||||
|
|
||||||
|
// Calculate how long the spinner has been showing
|
||||||
|
const elapsed = Date.now() - this.showTime;
|
||||||
|
const remaining = Math.max(0, this.minDisplayTime - elapsed);
|
||||||
|
|
||||||
|
// If minimum time hasn't elapsed, delay hiding
|
||||||
|
if (remaining > 0) {
|
||||||
|
setTimeout(() => {
|
||||||
|
this.overlay.classList.add('hidden');
|
||||||
|
this.isShowing = false;
|
||||||
|
Logger.debug('Loading spinner hidden');
|
||||||
|
}, remaining);
|
||||||
|
} else {
|
||||||
|
this.overlay.classList.add('hidden');
|
||||||
|
this.isShowing = false;
|
||||||
|
Logger.debug('Loading spinner hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the loading spinner from DOM
|
||||||
|
*/
|
||||||
|
destroy() {
|
||||||
|
if (this.overlay && this.overlay.parentNode) {
|
||||||
|
this.overlay.parentNode.removeChild(this.overlay);
|
||||||
|
this.overlay = null;
|
||||||
|
}
|
||||||
|
this.isShowing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the overlay element
|
||||||
|
* @param {string} message - Loading message
|
||||||
|
* @returns {HTMLElement} The overlay element
|
||||||
|
*/
|
||||||
|
createOverlay(message) {
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'loading-overlay';
|
||||||
|
|
||||||
|
const content = document.createElement('div');
|
||||||
|
content.className = 'loading-content';
|
||||||
|
|
||||||
|
const spinner = document.createElement('div');
|
||||||
|
spinner.className = 'loading-spinner';
|
||||||
|
|
||||||
|
const text = document.createElement('div');
|
||||||
|
text.className = 'loading-text';
|
||||||
|
text.textContent = message;
|
||||||
|
|
||||||
|
content.appendChild(spinner);
|
||||||
|
content.appendChild(text);
|
||||||
|
overlay.appendChild(content);
|
||||||
|
|
||||||
|
return overlay;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the loading message
|
||||||
|
* @param {string} message - New message
|
||||||
|
*/
|
||||||
|
updateMessage(message) {
|
||||||
|
this.message = message;
|
||||||
|
if (this.overlay && this.isShowing) {
|
||||||
|
const textElement = this.overlay.querySelector('.loading-text');
|
||||||
|
if (textElement) {
|
||||||
|
textElement.textContent = message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if spinner is currently showing
|
||||||
|
* @returns {boolean} True if showing
|
||||||
|
*/
|
||||||
|
isVisible() {
|
||||||
|
return this.isShowing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make LoadingSpinner globally available
|
||||||
|
window.LoadingSpinner = LoadingSpinner;
|
||||||
|
|
||||||
174
static/js/logger.js
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
/**
|
||||||
|
* Logger Module
|
||||||
|
* Provides structured logging with different levels
|
||||||
|
* Can be configured to show/hide different log levels
|
||||||
|
*/
|
||||||
|
|
||||||
|
class Logger {
|
||||||
|
/**
|
||||||
|
* Log levels
|
||||||
|
*/
|
||||||
|
static LEVELS = {
|
||||||
|
DEBUG: 0,
|
||||||
|
INFO: 1,
|
||||||
|
WARN: 2,
|
||||||
|
ERROR: 3,
|
||||||
|
NONE: 4
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current log level
|
||||||
|
* Set to DEBUG by default, can be changed via setLevel()
|
||||||
|
*/
|
||||||
|
static currentLevel = Logger.LEVELS.DEBUG;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable/disable logging
|
||||||
|
*/
|
||||||
|
static enabled = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the minimum log level
|
||||||
|
* @param {number} level - One of Logger.LEVELS
|
||||||
|
*/
|
||||||
|
static setLevel(level) {
|
||||||
|
if (typeof level === 'number' && level >= 0 && level <= 4) {
|
||||||
|
Logger.currentLevel = level;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable or disable logging
|
||||||
|
* @param {boolean} enabled - Whether to enable logging
|
||||||
|
*/
|
||||||
|
static setEnabled(enabled) {
|
||||||
|
Logger.enabled = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log a debug message
|
||||||
|
* @param {string} message - The message to log
|
||||||
|
* @param {...any} args - Additional arguments to log
|
||||||
|
*/
|
||||||
|
static debug(message, ...args) {
|
||||||
|
if (!Logger.enabled || Logger.currentLevel > Logger.LEVELS.DEBUG) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(`[DEBUG] ${message}`, ...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log an info message
|
||||||
|
* @param {string} message - The message to log
|
||||||
|
* @param {...any} args - Additional arguments to log
|
||||||
|
*/
|
||||||
|
static info(message, ...args) {
|
||||||
|
if (!Logger.enabled || Logger.currentLevel > Logger.LEVELS.INFO) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.info(`[INFO] ${message}`, ...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log a warning message
|
||||||
|
* @param {string} message - The message to log
|
||||||
|
* @param {...any} args - Additional arguments to log
|
||||||
|
*/
|
||||||
|
static warn(message, ...args) {
|
||||||
|
if (!Logger.enabled || Logger.currentLevel > Logger.LEVELS.WARN) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.warn(`[WARN] ${message}`, ...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log an error message
|
||||||
|
* @param {string} message - The message to log
|
||||||
|
* @param {...any} args - Additional arguments to log
|
||||||
|
*/
|
||||||
|
static error(message, ...args) {
|
||||||
|
if (!Logger.enabled || Logger.currentLevel > Logger.LEVELS.ERROR) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error(`[ERROR] ${message}`, ...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log a message with a custom prefix
|
||||||
|
* @param {string} prefix - The prefix to use
|
||||||
|
* @param {string} message - The message to log
|
||||||
|
* @param {...any} args - Additional arguments to log
|
||||||
|
*/
|
||||||
|
static log(prefix, message, ...args) {
|
||||||
|
if (!Logger.enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(`[${prefix}] ${message}`, ...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Group related log messages
|
||||||
|
* @param {string} label - The group label
|
||||||
|
*/
|
||||||
|
static group(label) {
|
||||||
|
if (!Logger.enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.group(label);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End a log group
|
||||||
|
*/
|
||||||
|
static groupEnd() {
|
||||||
|
if (!Logger.enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.groupEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log a table (useful for arrays of objects)
|
||||||
|
* @param {any} data - The data to display as a table
|
||||||
|
*/
|
||||||
|
static table(data) {
|
||||||
|
if (!Logger.enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.table(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a timer
|
||||||
|
* @param {string} label - The timer label
|
||||||
|
*/
|
||||||
|
static time(label) {
|
||||||
|
if (!Logger.enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.time(label);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End a timer and log the elapsed time
|
||||||
|
* @param {string} label - The timer label
|
||||||
|
*/
|
||||||
|
static timeEnd(label) {
|
||||||
|
if (!Logger.enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.timeEnd(label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make Logger globally available
|
||||||
|
window.Logger = Logger;
|
||||||
|
|
||||||
|
// Set default log level based on environment
|
||||||
|
// In production, you might want to set this to WARN or ERROR
|
||||||
|
if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
|
||||||
|
Logger.setLevel(Logger.LEVELS.DEBUG);
|
||||||
|
} else {
|
||||||
|
Logger.setLevel(Logger.LEVELS.INFO);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -25,20 +25,16 @@ class MacroProcessor {
|
|||||||
* Returns { success: boolean, content: string, errors: [] }
|
* Returns { success: boolean, content: string, errors: [] }
|
||||||
*/
|
*/
|
||||||
async processMacros(content) {
|
async processMacros(content) {
|
||||||
console.log('MacroProcessor: Starting macro processing for content:', content);
|
|
||||||
const macros = MacroParser.extractMacros(content);
|
const macros = MacroParser.extractMacros(content);
|
||||||
console.log('MacroProcessor: Extracted macros:', macros);
|
|
||||||
const errors = [];
|
const errors = [];
|
||||||
let processedContent = content;
|
let processedContent = content;
|
||||||
|
|
||||||
// Process macros in reverse order to preserve positions
|
// Process macros in reverse order to preserve positions
|
||||||
for (let i = macros.length - 1; i >= 0; i--) {
|
for (let i = macros.length - 1; i >= 0; i--) {
|
||||||
const macro = macros[i];
|
const macro = macros[i];
|
||||||
console.log('MacroProcessor: Processing macro:', macro);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await this.processMacro(macro);
|
const result = await this.processMacro(macro);
|
||||||
console.log('MacroProcessor: Macro processing result:', result);
|
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
// Replace macro with result
|
// Replace macro with result
|
||||||
@@ -73,7 +69,6 @@ class MacroProcessor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('MacroProcessor: Final processed content:', processedContent);
|
|
||||||
return {
|
return {
|
||||||
success: errors.length === 0,
|
success: errors.length === 0,
|
||||||
content: processedContent,
|
content: processedContent,
|
||||||
|
|||||||
77
static/js/notification-service.js
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* Notification Service
|
||||||
|
* Provides a standardized way to show toast notifications
|
||||||
|
* Wraps the showNotification function from ui-utils.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
class NotificationService {
|
||||||
|
/**
|
||||||
|
* Show a success notification
|
||||||
|
* @param {string} message - The message to display
|
||||||
|
*/
|
||||||
|
static success(message) {
|
||||||
|
if (window.showNotification) {
|
||||||
|
window.showNotification(message, Config.NOTIFICATION_TYPES.SUCCESS);
|
||||||
|
} else {
|
||||||
|
Logger.warn('showNotification not available, falling back to console');
|
||||||
|
console.log(`✅ ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show an error notification
|
||||||
|
* @param {string} message - The message to display
|
||||||
|
*/
|
||||||
|
static error(message) {
|
||||||
|
if (window.showNotification) {
|
||||||
|
window.showNotification(message, Config.NOTIFICATION_TYPES.ERROR);
|
||||||
|
} else {
|
||||||
|
Logger.warn('showNotification not available, falling back to console');
|
||||||
|
console.error(`❌ ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show a warning notification
|
||||||
|
* @param {string} message - The message to display
|
||||||
|
*/
|
||||||
|
static warning(message) {
|
||||||
|
if (window.showNotification) {
|
||||||
|
window.showNotification(message, Config.NOTIFICATION_TYPES.WARNING);
|
||||||
|
} else {
|
||||||
|
Logger.warn('showNotification not available, falling back to console');
|
||||||
|
console.warn(`⚠️ ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show an info notification
|
||||||
|
* @param {string} message - The message to display
|
||||||
|
*/
|
||||||
|
static info(message) {
|
||||||
|
if (window.showNotification) {
|
||||||
|
window.showNotification(message, Config.NOTIFICATION_TYPES.INFO);
|
||||||
|
} else {
|
||||||
|
Logger.warn('showNotification not available, falling back to console');
|
||||||
|
console.info(`ℹ️ ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show a notification with a custom type
|
||||||
|
* @param {string} message - The message to display
|
||||||
|
* @param {string} type - The notification type (success, danger, warning, primary, etc.)
|
||||||
|
*/
|
||||||
|
static show(message, type = 'primary') {
|
||||||
|
if (window.showNotification) {
|
||||||
|
window.showNotification(message, type);
|
||||||
|
} else {
|
||||||
|
Logger.warn('showNotification not available, falling back to console');
|
||||||
|
console.log(`[${type.toUpperCase()}] ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make NotificationService globally available
|
||||||
|
window.NotificationService = NotificationService;
|
||||||
|
|
||||||
114
static/js/sidebar-toggle.js
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* Sidebar Toggle Module
|
||||||
|
* Manages sidebar collapse/expand functionality with localStorage persistence
|
||||||
|
*/
|
||||||
|
|
||||||
|
class SidebarToggle {
|
||||||
|
constructor(sidebarId, toggleButtonId) {
|
||||||
|
this.sidebar = document.getElementById(sidebarId);
|
||||||
|
this.toggleButton = document.getElementById(toggleButtonId);
|
||||||
|
this.storageKey = Config.STORAGE_KEYS.SIDEBAR_COLLAPSED || 'sidebarCollapsed';
|
||||||
|
this.isCollapsed = localStorage.getItem(this.storageKey) === 'true';
|
||||||
|
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the sidebar toggle
|
||||||
|
*/
|
||||||
|
init() {
|
||||||
|
// Apply initial state
|
||||||
|
this.apply();
|
||||||
|
|
||||||
|
// Setup toggle button click handler
|
||||||
|
if (this.toggleButton) {
|
||||||
|
this.toggleButton.addEventListener('click', () => {
|
||||||
|
this.toggle();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make mini sidebar clickable to expand
|
||||||
|
if (this.sidebar) {
|
||||||
|
this.sidebar.addEventListener('click', (e) => {
|
||||||
|
// Only expand if sidebar is collapsed and click is on the mini sidebar itself
|
||||||
|
// (not on the file tree content when expanded)
|
||||||
|
if (this.isCollapsed) {
|
||||||
|
this.expand();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add cursor pointer when collapsed
|
||||||
|
this.sidebar.style.cursor = 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.debug(`Sidebar initialized: ${this.isCollapsed ? 'collapsed' : 'expanded'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle sidebar state
|
||||||
|
*/
|
||||||
|
toggle() {
|
||||||
|
this.isCollapsed = !this.isCollapsed;
|
||||||
|
localStorage.setItem(this.storageKey, this.isCollapsed);
|
||||||
|
this.apply();
|
||||||
|
|
||||||
|
Logger.debug(`Sidebar ${this.isCollapsed ? 'collapsed' : 'expanded'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply the current sidebar state
|
||||||
|
*/
|
||||||
|
apply() {
|
||||||
|
if (this.sidebar) {
|
||||||
|
if (this.isCollapsed) {
|
||||||
|
this.sidebar.classList.add('collapsed');
|
||||||
|
this.sidebar.style.cursor = 'pointer'; // Make mini sidebar clickable
|
||||||
|
} else {
|
||||||
|
this.sidebar.classList.remove('collapsed');
|
||||||
|
this.sidebar.style.cursor = 'default'; // Normal cursor when expanded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update toggle button icon
|
||||||
|
if (this.toggleButton) {
|
||||||
|
const icon = this.toggleButton.querySelector('i');
|
||||||
|
if (icon) {
|
||||||
|
if (this.isCollapsed) {
|
||||||
|
icon.className = 'bi bi-layout-sidebar-inset-reverse';
|
||||||
|
} else {
|
||||||
|
icon.className = 'bi bi-layout-sidebar';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collapse the sidebar
|
||||||
|
*/
|
||||||
|
collapse() {
|
||||||
|
if (!this.isCollapsed) {
|
||||||
|
this.toggle();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expand the sidebar
|
||||||
|
*/
|
||||||
|
expand() {
|
||||||
|
if (this.isCollapsed) {
|
||||||
|
this.toggle();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if sidebar is currently collapsed
|
||||||
|
* @returns {boolean} True if sidebar is collapsed
|
||||||
|
*/
|
||||||
|
isCollapsedState() {
|
||||||
|
return this.isCollapsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make SidebarToggle globally available
|
||||||
|
window.SidebarToggle = SidebarToggle;
|
||||||
|
|
||||||
@@ -1,10 +1,19 @@
|
|||||||
/**
|
/**
|
||||||
* UI Utilities Module
|
* UI Utilities Module
|
||||||
* Toast notifications, context menu, dark mode, file upload dialog
|
* Toast notifications (kept for backward compatibility)
|
||||||
|
*
|
||||||
|
* Other utilities have been moved to separate modules:
|
||||||
|
* - Context menu: context-menu.js
|
||||||
|
* - File upload: file-upload.js
|
||||||
|
* - Dark mode: dark-mode.js
|
||||||
|
* - Collection selector: collection-selector.js
|
||||||
|
* - Editor drop handler: editor-drop-handler.js
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show toast notification
|
* Show toast notification
|
||||||
|
* @param {string} message - The message to display
|
||||||
|
* @param {string} type - The notification type (info, success, error, warning, danger, primary)
|
||||||
*/
|
*/
|
||||||
function showNotification(message, type = 'info') {
|
function showNotification(message, type = 'info') {
|
||||||
const container = document.getElementById('toastContainer') || createToastContainer();
|
const container = document.getElementById('toastContainer') || createToastContainer();
|
||||||
@@ -23,7 +32,7 @@ function showNotification(message, type = 'info') {
|
|||||||
|
|
||||||
container.appendChild(toast);
|
container.appendChild(toast);
|
||||||
|
|
||||||
const bsToast = new bootstrap.Toast(toast, { delay: 3000 });
|
const bsToast = new bootstrap.Toast(toast, { delay: Config.TOAST_DURATION });
|
||||||
bsToast.show();
|
bsToast.show();
|
||||||
|
|
||||||
toast.addEventListener('hidden.bs.toast', () => {
|
toast.addEventListener('hidden.bs.toast', () => {
|
||||||
@@ -31,240 +40,21 @@ function showNotification(message, type = 'info') {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the toast container if it doesn't exist
|
||||||
|
* @returns {HTMLElement} The toast container element
|
||||||
|
*/
|
||||||
function createToastContainer() {
|
function createToastContainer() {
|
||||||
const container = document.createElement('div');
|
const container = document.createElement('div');
|
||||||
container.id = 'toastContainer';
|
container.id = 'toastContainer';
|
||||||
container.className = 'toast-container position-fixed top-0 end-0 p-3';
|
container.className = 'toast-container position-fixed top-0 end-0 p-3';
|
||||||
container.style.zIndex = '9999';
|
container.style.zIndex = Config.TOAST_Z_INDEX;
|
||||||
document.body.appendChild(container);
|
document.body.appendChild(container);
|
||||||
return container;
|
return container;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// All other UI utilities have been moved to separate modules
|
||||||
* Enhanced Context Menu
|
// See the module list at the top of this file
|
||||||
*/
|
|
||||||
function showContextMenu(x, y, target) {
|
|
||||||
const menu = document.getElementById('contextMenu');
|
|
||||||
if (!menu) return;
|
|
||||||
|
|
||||||
// Store target data
|
|
||||||
menu.dataset.targetPath = target.path;
|
|
||||||
menu.dataset.targetIsDir = target.isDir;
|
|
||||||
|
|
||||||
// Show/hide menu items based on target type
|
|
||||||
const items = {
|
|
||||||
'new-file': target.isDir,
|
|
||||||
'new-folder': target.isDir,
|
|
||||||
'upload': target.isDir,
|
|
||||||
'download': true,
|
|
||||||
'paste': target.isDir && window.fileTreeActions?.clipboard,
|
|
||||||
'open': !target.isDir
|
|
||||||
};
|
|
||||||
|
|
||||||
Object.entries(items).forEach(([action, show]) => {
|
|
||||||
const item = menu.querySelector(`[data-action="${action}"]`);
|
|
||||||
if (item) {
|
|
||||||
item.style.display = show ? 'flex' : 'none';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Position menu
|
|
||||||
menu.style.display = 'block';
|
|
||||||
menu.style.left = x + 'px';
|
|
||||||
menu.style.top = y + 'px';
|
|
||||||
|
|
||||||
// Adjust if off-screen
|
|
||||||
setTimeout(() => {
|
|
||||||
const rect = menu.getBoundingClientRect();
|
|
||||||
if (rect.right > window.innerWidth) {
|
|
||||||
menu.style.left = (window.innerWidth - rect.width - 10) + 'px';
|
|
||||||
}
|
|
||||||
if (rect.bottom > window.innerHeight) {
|
|
||||||
menu.style.top = (window.innerHeight - rect.height - 10) + 'px';
|
|
||||||
}
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
function hideContextMenu() {
|
|
||||||
const menu = document.getElementById('contextMenu');
|
|
||||||
if (menu) {
|
|
||||||
menu.style.display = 'none';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Combined click handler for context menu and outside clicks
|
|
||||||
document.addEventListener('click', async (e) => {
|
|
||||||
const menuItem = e.target.closest('.context-menu-item');
|
|
||||||
|
|
||||||
if (menuItem) {
|
|
||||||
// Handle context menu item click
|
|
||||||
const action = menuItem.dataset.action;
|
|
||||||
const menu = document.getElementById('contextMenu');
|
|
||||||
const targetPath = menu.dataset.targetPath;
|
|
||||||
const isDir = menu.dataset.targetIsDir === 'true';
|
|
||||||
|
|
||||||
hideContextMenu();
|
|
||||||
|
|
||||||
if (window.fileTreeActions) {
|
|
||||||
await window.fileTreeActions.execute(action, targetPath, isDir);
|
|
||||||
}
|
|
||||||
} else if (!e.target.closest('#contextMenu') && !e.target.closest('.tree-node')) {
|
|
||||||
// Hide on outside click
|
|
||||||
hideContextMenu();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* File Upload Dialog
|
|
||||||
*/
|
|
||||||
function showFileUploadDialog(targetPath, onUpload) {
|
|
||||||
const input = document.createElement('input');
|
|
||||||
input.type = 'file';
|
|
||||||
input.multiple = true;
|
|
||||||
|
|
||||||
input.addEventListener('change', async (e) => {
|
|
||||||
const files = Array.from(e.target.files);
|
|
||||||
if (files.length === 0) return;
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
try {
|
|
||||||
await onUpload(targetPath, file);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Upload failed:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
input.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dark Mode Manager
|
|
||||||
*/
|
|
||||||
class DarkMode {
|
|
||||||
constructor() {
|
|
||||||
this.isDark = localStorage.getItem('darkMode') === 'true';
|
|
||||||
this.apply();
|
|
||||||
}
|
|
||||||
|
|
||||||
toggle() {
|
|
||||||
this.isDark = !this.isDark;
|
|
||||||
localStorage.setItem('darkMode', this.isDark);
|
|
||||||
this.apply();
|
|
||||||
}
|
|
||||||
|
|
||||||
apply() {
|
|
||||||
if (this.isDark) {
|
|
||||||
document.body.classList.add('dark-mode');
|
|
||||||
const btn = document.getElementById('darkModeBtn');
|
|
||||||
if (btn) btn.textContent = '☀️';
|
|
||||||
|
|
||||||
// Update mermaid theme
|
|
||||||
if (window.mermaid) {
|
|
||||||
mermaid.initialize({ theme: 'dark' });
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
document.body.classList.remove('dark-mode');
|
|
||||||
const btn = document.getElementById('darkModeBtn');
|
|
||||||
if (btn) btn.textContent = '🌙';
|
|
||||||
|
|
||||||
// Update mermaid theme
|
|
||||||
if (window.mermaid) {
|
|
||||||
mermaid.initialize({ theme: 'default' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Collection Selector
|
|
||||||
*/
|
|
||||||
class CollectionSelector {
|
|
||||||
constructor(selectId, webdavClient) {
|
|
||||||
this.select = document.getElementById(selectId);
|
|
||||||
this.webdavClient = webdavClient;
|
|
||||||
this.onChange = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async load() {
|
|
||||||
try {
|
|
||||||
const collections = await this.webdavClient.getCollections();
|
|
||||||
this.select.innerHTML = '';
|
|
||||||
|
|
||||||
collections.forEach(collection => {
|
|
||||||
const option = document.createElement('option');
|
|
||||||
option.value = collection;
|
|
||||||
option.textContent = collection;
|
|
||||||
this.select.appendChild(option);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Select first collection
|
|
||||||
if (collections.length > 0) {
|
|
||||||
this.select.value = collections[0];
|
|
||||||
this.webdavClient.setCollection(collections[0]);
|
|
||||||
if (this.onChange) {
|
|
||||||
this.onChange(collections[0]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add change listener
|
|
||||||
this.select.addEventListener('change', () => {
|
|
||||||
const collection = this.select.value;
|
|
||||||
this.webdavClient.setCollection(collection);
|
|
||||||
if (this.onChange) {
|
|
||||||
this.onChange(collection);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load collections:', error);
|
|
||||||
showNotification('Failed to load collections', 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Editor Drop Handler
|
|
||||||
* Handles file drops into the editor
|
|
||||||
*/
|
|
||||||
class EditorDropHandler {
|
|
||||||
constructor(editorElement, onFileDrop) {
|
|
||||||
this.editorElement = editorElement;
|
|
||||||
this.onFileDrop = onFileDrop;
|
|
||||||
this.setupHandlers();
|
|
||||||
}
|
|
||||||
|
|
||||||
setupHandlers() {
|
|
||||||
this.editorElement.addEventListener('dragover', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
this.editorElement.classList.add('drag-over');
|
|
||||||
});
|
|
||||||
|
|
||||||
this.editorElement.addEventListener('dragleave', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
this.editorElement.classList.remove('drag-over');
|
|
||||||
});
|
|
||||||
|
|
||||||
this.editorElement.addEventListener('drop', async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
this.editorElement.classList.remove('drag-over');
|
|
||||||
|
|
||||||
const files = Array.from(e.dataTransfer.files);
|
|
||||||
if (files.length === 0) return;
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
try {
|
|
||||||
if (this.onFileDrop) {
|
|
||||||
await this.onFileDrop(file);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Drop failed:', error);
|
|
||||||
showNotification(`Failed to upload ${file.name}`, 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Make showNotification globally available
|
||||||
|
window.showNotification = showNotification;
|
||||||
|
|||||||
429
static/js/utils.js
Normal file
@@ -0,0 +1,429 @@
|
|||||||
|
/**
|
||||||
|
* Utilities Module
|
||||||
|
* Common utility functions used throughout the application
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Path Utilities
|
||||||
|
* Helper functions for path manipulation
|
||||||
|
*/
|
||||||
|
const PathUtils = {
|
||||||
|
/**
|
||||||
|
* Get the filename from a path
|
||||||
|
* @param {string} path - The file path
|
||||||
|
* @returns {string} The filename
|
||||||
|
* @example PathUtils.getFileName('folder/subfolder/file.md') // 'file.md'
|
||||||
|
*/
|
||||||
|
getFileName(path) {
|
||||||
|
if (!path) return '';
|
||||||
|
return path.split('/').pop();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the parent directory path
|
||||||
|
* @param {string} path - The file path
|
||||||
|
* @returns {string} The parent directory path
|
||||||
|
* @example PathUtils.getParentPath('folder/subfolder/file.md') // 'folder/subfolder'
|
||||||
|
*/
|
||||||
|
getParentPath(path) {
|
||||||
|
if (!path) return '';
|
||||||
|
const lastSlash = path.lastIndexOf('/');
|
||||||
|
return lastSlash === -1 ? '' : path.substring(0, lastSlash);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a path by removing duplicate slashes
|
||||||
|
* @param {string} path - The path to normalize
|
||||||
|
* @returns {string} The normalized path
|
||||||
|
* @example PathUtils.normalizePath('folder//subfolder///file.md') // 'folder/subfolder/file.md'
|
||||||
|
*/
|
||||||
|
normalizePath(path) {
|
||||||
|
if (!path) return '';
|
||||||
|
return path.replace(/\/+/g, '/');
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Join multiple path segments
|
||||||
|
* @param {...string} paths - Path segments to join
|
||||||
|
* @returns {string} The joined path
|
||||||
|
* @example PathUtils.joinPaths('folder', 'subfolder', 'file.md') // 'folder/subfolder/file.md'
|
||||||
|
*/
|
||||||
|
joinPaths(...paths) {
|
||||||
|
return PathUtils.normalizePath(paths.filter(p => p).join('/'));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the file extension
|
||||||
|
* @param {string} path - The file path
|
||||||
|
* @returns {string} The file extension (without dot)
|
||||||
|
* @example PathUtils.getExtension('file.md') // 'md'
|
||||||
|
*/
|
||||||
|
getExtension(path) {
|
||||||
|
if (!path) return '';
|
||||||
|
const fileName = PathUtils.getFileName(path);
|
||||||
|
const lastDot = fileName.lastIndexOf('.');
|
||||||
|
return lastDot === -1 ? '' : fileName.substring(lastDot + 1);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a path is a descendant of another path
|
||||||
|
* @param {string} path - The path to check
|
||||||
|
* @param {string} ancestorPath - The potential ancestor path
|
||||||
|
* @returns {boolean} True if path is a descendant of ancestorPath
|
||||||
|
* @example PathUtils.isDescendant('folder/subfolder/file.md', 'folder') // true
|
||||||
|
*/
|
||||||
|
isDescendant(path, ancestorPath) {
|
||||||
|
if (!path || !ancestorPath) return false;
|
||||||
|
return path.startsWith(ancestorPath + '/');
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a file is a binary/non-editable file based on extension
|
||||||
|
* @param {string} path - The file path
|
||||||
|
* @returns {boolean} True if the file is binary/non-editable
|
||||||
|
* @example PathUtils.isBinaryFile('image.png') // true
|
||||||
|
* @example PathUtils.isBinaryFile('document.md') // false
|
||||||
|
*/
|
||||||
|
isBinaryFile(path) {
|
||||||
|
const extension = PathUtils.getExtension(path).toLowerCase();
|
||||||
|
const binaryExtensions = [
|
||||||
|
// Images
|
||||||
|
'png', 'jpg', 'jpeg', 'gif', 'bmp', 'ico', 'svg', 'webp', 'tiff', 'tif',
|
||||||
|
// Documents
|
||||||
|
'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx',
|
||||||
|
// Archives
|
||||||
|
'zip', 'rar', '7z', 'tar', 'gz', 'bz2',
|
||||||
|
// Executables
|
||||||
|
'exe', 'dll', 'so', 'dylib', 'app',
|
||||||
|
// Media
|
||||||
|
'mp3', 'mp4', 'avi', 'mov', 'wmv', 'flv', 'wav', 'ogg',
|
||||||
|
// Other binary formats
|
||||||
|
'bin', 'dat', 'db', 'sqlite'
|
||||||
|
];
|
||||||
|
return binaryExtensions.includes(extension);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a directory is an image directory based on its name
|
||||||
|
* @param {string} path - The directory path
|
||||||
|
* @returns {boolean} True if the directory is for images
|
||||||
|
* @example PathUtils.isImageDirectory('images') // true
|
||||||
|
* @example PathUtils.isImageDirectory('assets/images') // true
|
||||||
|
* @example PathUtils.isImageDirectory('docs') // false
|
||||||
|
*/
|
||||||
|
isImageDirectory(path) {
|
||||||
|
const dirName = PathUtils.getFileName(path).toLowerCase();
|
||||||
|
const imageDirectoryNames = [
|
||||||
|
'images',
|
||||||
|
'image',
|
||||||
|
'img',
|
||||||
|
'imgs',
|
||||||
|
'pictures',
|
||||||
|
'pics',
|
||||||
|
'photos',
|
||||||
|
'assets',
|
||||||
|
'media',
|
||||||
|
'static'
|
||||||
|
];
|
||||||
|
return imageDirectoryNames.includes(dirName);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a human-readable file type description
|
||||||
|
* @param {string} path - The file path
|
||||||
|
* @returns {string} The file type description
|
||||||
|
* @example PathUtils.getFileType('image.png') // 'Image'
|
||||||
|
*/
|
||||||
|
getFileType(path) {
|
||||||
|
const extension = PathUtils.getExtension(path).toLowerCase();
|
||||||
|
|
||||||
|
const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'ico', 'svg', 'webp', 'tiff', 'tif'];
|
||||||
|
const documentExtensions = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'];
|
||||||
|
const archiveExtensions = ['zip', 'rar', '7z', 'tar', 'gz', 'bz2'];
|
||||||
|
const mediaExtensions = ['mp3', 'mp4', 'avi', 'mov', 'wmv', 'flv', 'wav', 'ogg'];
|
||||||
|
|
||||||
|
if (imageExtensions.includes(extension)) return 'Image';
|
||||||
|
if (documentExtensions.includes(extension)) return 'Document';
|
||||||
|
if (archiveExtensions.includes(extension)) return 'Archive';
|
||||||
|
if (mediaExtensions.includes(extension)) return 'Media';
|
||||||
|
if (extension === 'pdf') return 'PDF';
|
||||||
|
|
||||||
|
return 'File';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DOM Utilities
|
||||||
|
* Helper functions for DOM manipulation
|
||||||
|
*/
|
||||||
|
const DOMUtils = {
|
||||||
|
/**
|
||||||
|
* Create an element with optional class and attributes
|
||||||
|
* @param {string} tag - The HTML tag name
|
||||||
|
* @param {string} [className] - Optional class name(s)
|
||||||
|
* @param {Object} [attributes] - Optional attributes object
|
||||||
|
* @returns {HTMLElement} The created element
|
||||||
|
*/
|
||||||
|
createElement(tag, className = '', attributes = {}) {
|
||||||
|
const element = document.createElement(tag);
|
||||||
|
if (className) {
|
||||||
|
element.className = className;
|
||||||
|
}
|
||||||
|
Object.entries(attributes).forEach(([key, value]) => {
|
||||||
|
element.setAttribute(key, value);
|
||||||
|
});
|
||||||
|
return element;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove all children from an element
|
||||||
|
* @param {HTMLElement} element - The element to clear
|
||||||
|
*/
|
||||||
|
removeAllChildren(element) {
|
||||||
|
while (element.firstChild) {
|
||||||
|
element.removeChild(element.firstChild);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle a class on an element
|
||||||
|
* @param {HTMLElement} element - The element
|
||||||
|
* @param {string} className - The class name
|
||||||
|
* @param {boolean} [force] - Optional force add/remove
|
||||||
|
*/
|
||||||
|
toggleClass(element, className, force) {
|
||||||
|
if (force !== undefined) {
|
||||||
|
element.classList.toggle(className, force);
|
||||||
|
} else {
|
||||||
|
element.classList.toggle(className);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query selector with error handling
|
||||||
|
* @param {string} selector - The CSS selector
|
||||||
|
* @param {HTMLElement} [parent] - Optional parent element
|
||||||
|
* @returns {HTMLElement|null} The found element or null
|
||||||
|
*/
|
||||||
|
querySelector(selector, parent = document) {
|
||||||
|
try {
|
||||||
|
return parent.querySelector(selector);
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(`Invalid selector: ${selector}`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query selector all with error handling
|
||||||
|
* @param {string} selector - The CSS selector
|
||||||
|
* @param {HTMLElement} [parent] - Optional parent element
|
||||||
|
* @returns {NodeList|Array} The found elements or empty array
|
||||||
|
*/
|
||||||
|
querySelectorAll(selector, parent = document) {
|
||||||
|
try {
|
||||||
|
return parent.querySelectorAll(selector);
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(`Invalid selector: ${selector}`, error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Timing Utilities
|
||||||
|
* Helper functions for timing and throttling
|
||||||
|
*/
|
||||||
|
const TimingUtils = {
|
||||||
|
/**
|
||||||
|
* Debounce a function
|
||||||
|
* @param {Function} func - The function to debounce
|
||||||
|
* @param {number} wait - The wait time in milliseconds
|
||||||
|
* @returns {Function} The debounced function
|
||||||
|
*/
|
||||||
|
debounce(func, wait) {
|
||||||
|
let timeout;
|
||||||
|
return function executedFunction(...args) {
|
||||||
|
const later = () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
func(...args);
|
||||||
|
};
|
||||||
|
clearTimeout(timeout);
|
||||||
|
timeout = setTimeout(later, wait);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Throttle a function
|
||||||
|
* @param {Function} func - The function to throttle
|
||||||
|
* @param {number} wait - The wait time in milliseconds
|
||||||
|
* @returns {Function} The throttled function
|
||||||
|
*/
|
||||||
|
throttle(func, wait) {
|
||||||
|
let inThrottle;
|
||||||
|
return function executedFunction(...args) {
|
||||||
|
if (!inThrottle) {
|
||||||
|
func(...args);
|
||||||
|
inThrottle = true;
|
||||||
|
setTimeout(() => inThrottle = false, wait);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delay execution
|
||||||
|
* @param {number} ms - Milliseconds to delay
|
||||||
|
* @returns {Promise} Promise that resolves after delay
|
||||||
|
*/
|
||||||
|
delay(ms) {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download Utilities
|
||||||
|
* Helper functions for file downloads
|
||||||
|
*/
|
||||||
|
const DownloadUtils = {
|
||||||
|
/**
|
||||||
|
* Trigger a download in the browser
|
||||||
|
* @param {string|Blob} content - The content to download
|
||||||
|
* @param {string} filename - The filename for the download
|
||||||
|
*/
|
||||||
|
triggerDownload(content, filename) {
|
||||||
|
const blob = content instanceof Blob ? content : new Blob([content]);
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download content as a blob
|
||||||
|
* @param {Blob} blob - The blob to download
|
||||||
|
* @param {string} filename - The filename for the download
|
||||||
|
*/
|
||||||
|
downloadAsBlob(blob, filename) {
|
||||||
|
DownloadUtils.triggerDownload(blob, filename);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation Utilities
|
||||||
|
* Helper functions for input validation
|
||||||
|
*/
|
||||||
|
const ValidationUtils = {
|
||||||
|
/**
|
||||||
|
* Validate and sanitize a filename
|
||||||
|
* @param {string} name - The filename to validate
|
||||||
|
* @param {boolean} [isFolder=false] - Whether this is a folder name
|
||||||
|
* @returns {Object} Validation result with {valid, sanitized, message}
|
||||||
|
*/
|
||||||
|
validateFileName(name, isFolder = false) {
|
||||||
|
const type = isFolder ? 'folder' : 'file';
|
||||||
|
|
||||||
|
if (!name || name.trim().length === 0) {
|
||||||
|
return { valid: false, sanitized: '', message: `${type} name cannot be empty` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for invalid characters using pattern from Config
|
||||||
|
const validPattern = Config.FILENAME_PATTERN;
|
||||||
|
|
||||||
|
if (!validPattern.test(name)) {
|
||||||
|
const sanitized = ValidationUtils.sanitizeFileName(name);
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
sanitized,
|
||||||
|
message: `Invalid characters in ${type} name. Only lowercase letters, numbers, and underscores allowed.\n\nSuggestion: "${sanitized}"`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true, sanitized: name, message: '' };
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize a filename by removing/replacing invalid characters
|
||||||
|
* @param {string} name - The filename to sanitize
|
||||||
|
* @returns {string} The sanitized filename
|
||||||
|
*/
|
||||||
|
sanitizeFileName(name) {
|
||||||
|
return name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(Config.FILENAME_INVALID_CHARS, '_')
|
||||||
|
.replace(/_+/g, '_')
|
||||||
|
.replace(/^_+|_+$/g, '');
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a string is empty or whitespace
|
||||||
|
* @param {string} str - The string to check
|
||||||
|
* @returns {boolean} True if empty or whitespace
|
||||||
|
*/
|
||||||
|
isEmpty(str) {
|
||||||
|
return !str || str.trim().length === 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a value is a valid email
|
||||||
|
* @param {string} email - The email to validate
|
||||||
|
* @returns {boolean} True if valid email
|
||||||
|
*/
|
||||||
|
isValidEmail(email) {
|
||||||
|
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
return emailPattern.test(email);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* String Utilities
|
||||||
|
* Helper functions for string manipulation
|
||||||
|
*/
|
||||||
|
const StringUtils = {
|
||||||
|
/**
|
||||||
|
* Truncate a string to a maximum length
|
||||||
|
* @param {string} str - The string to truncate
|
||||||
|
* @param {number} maxLength - Maximum length
|
||||||
|
* @param {string} [suffix='...'] - Suffix to add if truncated
|
||||||
|
* @returns {string} The truncated string
|
||||||
|
*/
|
||||||
|
truncate(str, maxLength, suffix = '...') {
|
||||||
|
if (!str || str.length <= maxLength) return str;
|
||||||
|
return str.substring(0, maxLength - suffix.length) + suffix;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capitalize the first letter of a string
|
||||||
|
* @param {string} str - The string to capitalize
|
||||||
|
* @returns {string} The capitalized string
|
||||||
|
*/
|
||||||
|
capitalize(str) {
|
||||||
|
if (!str) return '';
|
||||||
|
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a string to kebab-case
|
||||||
|
* @param {string} str - The string to convert
|
||||||
|
* @returns {string} The kebab-case string
|
||||||
|
*/
|
||||||
|
toKebabCase(str) {
|
||||||
|
return str
|
||||||
|
.replace(/([a-z])([A-Z])/g, '$1-$2')
|
||||||
|
.replace(/[\s_]+/g, '-')
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Make utilities globally available
|
||||||
|
window.PathUtils = PathUtils;
|
||||||
|
window.DOMUtils = DOMUtils;
|
||||||
|
window.TimingUtils = TimingUtils;
|
||||||
|
window.DownloadUtils = DownloadUtils;
|
||||||
|
window.ValidationUtils = ValidationUtils;
|
||||||
|
window.StringUtils = StringUtils;
|
||||||
|
|
||||||
@@ -29,6 +29,24 @@ class WebDAVClient {
|
|||||||
return await response.json();
|
return await response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createCollection(collectionName) {
|
||||||
|
// Use POST API to create collection (not MKCOL, as collections are managed by the server)
|
||||||
|
const response = await fetch(this.baseUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ name: collectionName })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({ error: response.statusText }));
|
||||||
|
throw new Error(errorData.error || `Failed to create collection: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
async propfind(path = '', depth = '1') {
|
async propfind(path = '', depth = '1') {
|
||||||
const url = this.getFullUrl(path);
|
const url = this.getFullUrl(path);
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
@@ -47,6 +65,33 @@ class WebDAVClient {
|
|||||||
return this.parseMultiStatus(xml);
|
return this.parseMultiStatus(xml);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List files and directories in a path
|
||||||
|
* Returns only direct children (depth=1) to avoid infinite recursion
|
||||||
|
* @param {string} path - Path to list
|
||||||
|
* @param {boolean} recursive - If true, returns all nested items (depth=infinity)
|
||||||
|
* @returns {Promise<Array>} Array of items
|
||||||
|
*/
|
||||||
|
async list(path = '', recursive = false) {
|
||||||
|
const depth = recursive ? 'infinity' : '1';
|
||||||
|
const items = await this.propfind(path, depth);
|
||||||
|
|
||||||
|
// If not recursive, filter to only direct children
|
||||||
|
if (!recursive && path) {
|
||||||
|
// Normalize path (remove trailing slash)
|
||||||
|
const normalizedPath = path.endsWith('/') ? path.slice(0, -1) : path;
|
||||||
|
const pathDepth = normalizedPath.split('/').length;
|
||||||
|
|
||||||
|
// Filter items to only include direct children
|
||||||
|
return items.filter(item => {
|
||||||
|
const itemDepth = item.path.split('/').length;
|
||||||
|
return itemDepth === pathDepth + 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
async get(path) {
|
async get(path) {
|
||||||
const url = this.getFullUrl(path);
|
const url = this.getFullUrl(path);
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
@@ -162,6 +207,41 @@ class WebDAVClient {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Alias for mkcol
|
||||||
|
async createFolder(path) {
|
||||||
|
return await this.mkcol(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure all parent directories exist for a given path
|
||||||
|
* Creates missing parent directories recursively
|
||||||
|
*/
|
||||||
|
async ensureParentDirectories(filePath) {
|
||||||
|
const parts = filePath.split('/');
|
||||||
|
|
||||||
|
// Remove the filename (last part)
|
||||||
|
parts.pop();
|
||||||
|
|
||||||
|
// If no parent directories, nothing to do
|
||||||
|
if (parts.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create each parent directory level
|
||||||
|
let currentPath = '';
|
||||||
|
for (const part of parts) {
|
||||||
|
currentPath = currentPath ? `${currentPath}/${part}` : part;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.mkcol(currentPath);
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore errors - directory might already exist
|
||||||
|
// Only log for debugging
|
||||||
|
console.debug(`Directory ${currentPath} might already exist:`, error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async includeFile(path) {
|
async includeFile(path) {
|
||||||
try {
|
try {
|
||||||
// Parse path: "collection:path/to/file" or "path/to/file"
|
// Parse path: "collection:path/to/file" or "path/to/file"
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ body.dark-mode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Global styles */
|
/* Global styles */
|
||||||
html, body {
|
html,
|
||||||
|
body {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -48,12 +49,6 @@ body {
|
|||||||
transition: background-color 0.3s ease, color 0.3s ease;
|
transition: background-color 0.3s ease, color 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.container-fluid {
|
|
||||||
flex: 1;
|
|
||||||
padding: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row {
|
.row {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
@@ -206,7 +201,12 @@ body.dark-mode .CodeMirror-linenumber {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Markdown preview styles */
|
/* Markdown preview styles */
|
||||||
#preview h1, #preview h2, #preview h3, #preview h4, #preview h5, #preview h6 {
|
#preview h1,
|
||||||
|
#preview h2,
|
||||||
|
#preview h3,
|
||||||
|
#preview h4,
|
||||||
|
#preview h5,
|
||||||
|
#preview h6 {
|
||||||
margin-top: 24px;
|
margin-top: 24px;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -286,7 +286,8 @@ body.dark-mode .CodeMirror-linenumber {
|
|||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#preview ul, #preview ol {
|
#preview ul,
|
||||||
|
#preview ol {
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
padding-left: 2em;
|
padding-left: 2em;
|
||||||
}
|
}
|
||||||
@@ -591,4 +592,3 @@ body.dark-mode .sidebar h6 {
|
|||||||
body.dark-mode .tree-children {
|
body.dark-mode .tree-children {
|
||||||
border-left-color: var(--border-color);
|
border-left-color: var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,20 +30,47 @@
|
|||||||
<!-- Navbar -->
|
<!-- Navbar -->
|
||||||
<nav class="navbar navbar-expand-lg">
|
<nav class="navbar navbar-expand-lg">
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<span class="navbar-brand">
|
<!-- Left: Sidebar Toggle + Logo and Title -->
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<!-- Sidebar Toggle Button -->
|
||||||
|
<button id="sidebarToggleBtn" class="btn-flat btn-flat-secondary" title="Toggle Sidebar">
|
||||||
|
<i class="bi bi-layout-sidebar"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Logo and Title (Clickable) -->
|
||||||
|
<a href="/" class="navbar-brand mb-0" id="navbarBrand" style="cursor: pointer; text-decoration: none;">
|
||||||
<i class="bi bi-markdown"></i> Markdown Editor
|
<i class="bi bi-markdown"></i> Markdown Editor
|
||||||
</span>
|
</a>
|
||||||
<div class="d-flex gap-2">
|
</div>
|
||||||
<button id="newBtn" class="btn btn-success btn-sm">
|
|
||||||
|
<!-- Right: All Buttons -->
|
||||||
|
<div class="ms-auto d-flex gap-2 align-items-center">
|
||||||
|
<!-- View Mode Button -->
|
||||||
|
<button id="editModeBtn" class="btn-flat btn-flat" style="display: none;">
|
||||||
|
<i class="bi bi-pencil-square"></i> Edit this file
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Edit Mode Buttons -->
|
||||||
|
<button id="newBtn" class="btn-flat btn-flat-success">
|
||||||
<i class="bi bi-file-plus"></i> New
|
<i class="bi bi-file-plus"></i> New
|
||||||
</button>
|
</button>
|
||||||
<button id="saveBtn" class="btn btn-primary btn-sm">
|
<button id="saveBtn" class="btn-flat btn-flat-primary">
|
||||||
<i class="bi bi-save"></i> Save
|
<i class="bi bi-save"></i> Save
|
||||||
</button>
|
</button>
|
||||||
<button id="deleteBtn" class="btn btn-danger btn-sm">
|
<button id="deleteBtn" class="btn-flat btn-flat-danger">
|
||||||
<i class="bi bi-trash"></i> Delete
|
<i class="bi bi-trash"></i> Delete
|
||||||
</button>
|
</button>
|
||||||
<button id="darkModeBtn" class="btn btn-secondary btn-sm">🌙</button>
|
<button id="exitEditModeBtn" class="btn-flat btn-flat-secondary">
|
||||||
|
<i class="bi bi-eye"></i> Exit Edit Mode
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Divider -->
|
||||||
|
<div class="vr" style="height: 40px;"></div>
|
||||||
|
|
||||||
|
<!-- Dark Mode Toggle -->
|
||||||
|
<button id="darkModeBtn" class="btn-flat btn-flat-secondary">
|
||||||
|
<i class="bi bi-moon-fill"></i>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -56,7 +83,13 @@
|
|||||||
<!-- Collection Selector -->
|
<!-- Collection Selector -->
|
||||||
<div class="collection-selector">
|
<div class="collection-selector">
|
||||||
<label class="form-label small">Collection:</label>
|
<label class="form-label small">Collection:</label>
|
||||||
<select id="collectionSelect" class="form-select form-select-sm"></select>
|
<div class="d-flex gap-1">
|
||||||
|
<select id="collectionSelect" class="form-select form-select-sm flex-grow-1"></select>
|
||||||
|
<button id="newCollectionBtn" class="btn btn-sm new-collection-btn"
|
||||||
|
title="Create New Collection">
|
||||||
|
<i class="bi bi-plus-lg"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- File Tree -->
|
<!-- File Tree -->
|
||||||
<div id="fileTree" class="file-tree"></div>
|
<div id="fileTree" class="file-tree"></div>
|
||||||
@@ -120,13 +153,21 @@
|
|||||||
<i class="bi bi-clipboard"></i> Paste
|
<i class="bi bi-clipboard"></i> Paste
|
||||||
</div>
|
</div>
|
||||||
<div class="context-menu-divider"></div>
|
<div class="context-menu-divider"></div>
|
||||||
|
<div class="context-menu-item" data-action="copy-to-collection">
|
||||||
|
<i class="bi bi-box-arrow-right"></i> Copy to Collection...
|
||||||
|
</div>
|
||||||
|
<div class="context-menu-item" data-action="move-to-collection">
|
||||||
|
<i class="bi bi-arrow-right-square"></i> Move to Collection...
|
||||||
|
</div>
|
||||||
|
<div class="context-menu-divider"></div>
|
||||||
<div class="context-menu-item text-danger" data-action="delete">
|
<div class="context-menu-item text-danger" data-action="delete">
|
||||||
<i class="bi bi-trash"></i> Delete
|
<i class="bi bi-trash"></i> Delete
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Confirmation Modal -->
|
<!-- Confirmation Modal -->
|
||||||
<div class="modal fade" id="confirmationModal" tabindex="-1" aria-labelledby="confirmationModalLabel" aria-hidden="true">
|
<div class="modal fade" id="confirmationModal" tabindex="-1" aria-labelledby="confirmationModalLabel"
|
||||||
|
aria-hidden="true">
|
||||||
<div class="modal-dialog">
|
<div class="modal-dialog">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
@@ -138,8 +179,12 @@
|
|||||||
<input type="text" id="confirmationInput" class="form-control" style="display: none;">
|
<input type="text" id="confirmationInput" class="form-control" style="display: none;">
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
<button type="button" class="btn-flat btn-flat-secondary" data-bs-dismiss="modal">
|
||||||
<button type="button" class="btn btn-primary" id="confirmButton">OK</button>
|
<i class="bi bi-x-circle"></i> Cancel
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn-flat btn-flat-primary" id="confirmButton">
|
||||||
|
<i class="bi bi-check-circle"></i> OK
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -178,10 +223,27 @@
|
|||||||
<!-- Mermaid for diagrams -->
|
<!-- Mermaid for diagrams -->
|
||||||
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
|
||||||
|
|
||||||
|
<!-- Application Configuration (must load first) -->
|
||||||
|
<script src="/static/js/config.js"></script>
|
||||||
|
<script src="/static/js/logger.js"></script>
|
||||||
|
<script src="/static/js/event-bus.js"></script>
|
||||||
|
<script src="/static/js/utils.js"></script>
|
||||||
|
<script src="/static/js/notification-service.js"></script>
|
||||||
|
|
||||||
|
<!-- UI Components -->
|
||||||
|
<script src="/static/js/ui-utils.js" defer></script>
|
||||||
|
<script src="/static/js/context-menu.js" defer></script>
|
||||||
|
<script src="/static/js/file-upload.js" defer></script>
|
||||||
|
<script src="/static/js/dark-mode.js" defer></script>
|
||||||
|
<script src="/static/js/sidebar-toggle.js" defer></script>
|
||||||
|
<script src="/static/js/collection-selector.js" defer></script>
|
||||||
|
<script src="/static/js/editor-drop-handler.js" defer></script>
|
||||||
|
<script src="/static/js/loading-spinner.js" defer></script>
|
||||||
|
|
||||||
|
<!-- Core Application Modules -->
|
||||||
<script src="/static/js/webdav-client.js" defer></script>
|
<script src="/static/js/webdav-client.js" defer></script>
|
||||||
<script src="/static/js/file-tree.js" defer></script>
|
<script src="/static/js/file-tree.js" defer></script>
|
||||||
<script src="/static/js/editor.js" defer></script>
|
<script src="/static/js/editor.js" defer></script>
|
||||||
<script src="/static/js/ui-utils.js" defer></script>
|
|
||||||
<script src="/static/js/confirmation.js" defer></script>
|
<script src="/static/js/confirmation.js" defer></script>
|
||||||
<script src="/static/js/file-tree-actions.js" defer></script>
|
<script src="/static/js/file-tree-actions.js" defer></script>
|
||||||
<script src="/static/js/column-resizer.js" defer></script>
|
<script src="/static/js/column-resizer.js" defer></script>
|
||||||
|
|||||||