diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1d17dae --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.venv diff --git a/collections/7madah/tests/sub_tests/file1.md b/collections/7madah/tests/sub_tests/file1.md new file mode 100644 index 0000000..c83a7dc --- /dev/null +++ b/collections/7madah/tests/sub_tests/file1.md @@ -0,0 +1,22 @@ +# Start to end file + +### Graph + +--- + +This is just for testing + +--- + +**See what i did?** + +--- + +```mermaid +graph TD + A[Start] --> B{Process}; + B --> C{Decision}; + C -- Yes --> D[End Yes]; + C -- No --> E[End No]; +``` + diff --git a/collections/notes/ttt/test.md b/collections/7madah/tests/test.md similarity index 100% rename from collections/notes/ttt/test.md rename to collections/7madah/tests/test.md diff --git a/collections/notes/ttt/test2.md b/collections/7madah/tests/test2.md similarity index 100% rename from collections/notes/ttt/test2.md rename to collections/7madah/tests/test2.md diff --git a/collections/7madah/tests/test3.md b/collections/7madah/tests/test3.md new file mode 100644 index 0000000..06bcd72 --- /dev/null +++ b/collections/7madah/tests/test3.md @@ -0,0 +1,426 @@ +# UI Code Refactoring Plan + +**Project:** Markdown Editor +**Date:** 2025-10-26 +**Status:** In Progress + +--- + +## Executive Summary + +This document outlines a comprehensive refactoring plan for the UI codebase to improve maintainability, remove dead code, extract utilities, and standardize patterns. The refactoring is organized into 6 phases with 14 tasks, prioritized by risk and impact. + +**Key Metrics:** + +- Total Lines of Code: ~3,587 +- Dead Code to Remove: 213 lines (6%) +- Estimated Effort: 5-8 days +- Risk Level: Mostly LOW to MEDIUM + +--- + +## Phase 1: Analysis Summary + +### Files Reviewed + +**JavaScript Files (10):** + +- `/static/js/app.js` (484 lines) +- `/static/js/column-resizer.js` (100 lines) +- `/static/js/confirmation.js` (170 lines) +- `/static/js/editor.js` (420 lines) +- `/static/js/file-tree-actions.js` (482 lines) +- `/static/js/file-tree.js` (865 lines) +- `/static/js/macro-parser.js` (103 lines) +- `/static/js/macro-processor.js` (157 lines) +- `/static/js/ui-utils.js` (305 lines) +- `/static/js/webdav-client.js` (266 lines) + +**CSS Files (6):** + +- `/static/css/variables.css` (32 lines) +- `/static/css/layout.css` +- `/static/css/file-tree.css` +- `/static/css/editor.css` +- `/static/css/components.css` +- `/static/css/modal.css` + +**HTML Templates (1):** + +- `/templates/index.html` (203 lines) + +--- + +## Issues Found + +### 🔴 HIGH PRIORITY + +1. **Deprecated Modal Code (Dead Code)** + - Location: `/static/js/file-tree-actions.js` lines 262-474 + - Impact: 213 lines of unused code (44% of file) + - Risk: LOW to remove + +2. **Duplicated Event Bus Implementation** + - Location: `/static/js/app.js` lines 16-30 + - Should be extracted to reusable module + +3. **Duplicated Debounce Function** + - Location: `/static/js/editor.js` lines 404-414 + - Should be shared utility + +4. **Inconsistent Notification Usage** + - Mixed usage of `window.showNotification` vs `showNotification` + +5. **Duplicated File Download Logic** + - Location: `/static/js/file-tree.js` lines 829-839 + - Should be shared utility + +6. **Hard-coded Values** + - Long-press threshold: 400ms + - Debounce delay: 300ms + - Drag preview width: 200px + - Toast delay: 3000ms + +### 🟡 MEDIUM PRIORITY + +7. **Global State Management** + - Location: `/static/js/app.js` lines 6-13 + - Makes testing difficult + +8. **Duplicated Path Manipulation** + - `path.split('/').pop()` appears 10+ times + - `path.substring(0, path.lastIndexOf('/'))` appears 5+ times + +9. **Mixed Responsibility in ui-utils.js** + - Contains 6 different classes/utilities + - Should be split into separate modules + +10. **Deprecated Event Handler** + - Location: `/static/js/file-tree-actions.js` line 329 + - Uses deprecated `onkeypress` + +### 🟢 LOW PRIORITY + +11. **Unused Function Parameters** +12. **Magic Numbers in Styling** +13. **Inconsistent Comment Styles** +14. **Console.log Statements** + +--- + +## Phase 2: Proposed Reusable Components + +### 1. Config Module (`/static/js/config.js`) + +Centralize all configuration values: + +```javascript +export const Config = { + // Timing + LONG_PRESS_THRESHOLD: 400, + DEBOUNCE_DELAY: 300, + TOAST_DURATION: 3000, + + // UI + DRAG_PREVIEW_WIDTH: 200, + TREE_INDENT_PX: 12, + MOUSE_MOVE_THRESHOLD: 5, + + // Validation + FILENAME_PATTERN: /^[a-z0-9_]+(\.[a-z0-9_]+)*$/, + + // Storage Keys + STORAGE_KEYS: { + DARK_MODE: 'darkMode', + SELECTED_COLLECTION: 'selectedCollection', + LAST_VIEWED_PAGE: 'lastViewedPage', + COLUMN_DIMENSIONS: 'columnDimensions' + } +}; +``` + +### 2. Logger Module (`/static/js/logger.js`) + +Structured logging with levels: + +```javascript +export class Logger { + static debug(message, ...args) + static info(message, ...args) + static warn(message, ...args) + static error(message, ...args) + static setLevel(level) +} +``` + +### 3. Event Bus Module (`/static/js/event-bus.js`) + +Centralized event system: + +```javascript +export class EventBus { + on(event, callback) + off(event, callback) + once(event, callback) + dispatch(event, data) + clear(event) +} +``` + +### 4. Utilities Module (`/static/js/utils.js`) + +Common utility functions: + +```javascript +export const PathUtils = { + getFileName(path), + getParentPath(path), + normalizePath(path), + joinPaths(...paths), + getExtension(path) +}; + +export const TimingUtils = { + debounce(func, wait), + throttle(func, wait) +}; + +export const DownloadUtils = { + triggerDownload(content, filename), + downloadAsBlob(blob, filename) +}; + +export const ValidationUtils = { + validateFileName(name, isFolder), + sanitizeFileName(name) +}; +``` + +### 5. Notification Service (`/static/js/notification-service.js`) + +Standardized notifications: + +```javascript +export class NotificationService { + static success(message) + static error(message) + static warning(message) + static info(message) +} +``` + +--- + +## Phase 3: Refactoring Tasks + +### 🔴 HIGH PRIORITY + +**Task 1: Remove Dead Code** + +- Files: `/static/js/file-tree-actions.js` +- Lines: 262-474 (213 lines) +- Risk: LOW +- Dependencies: None + +**Task 2: Extract Event Bus** + +- Files: NEW `/static/js/event-bus.js`, MODIFY `app.js`, `editor.js` +- Risk: MEDIUM +- Dependencies: None + +**Task 3: Create Utilities Module** + +- Files: NEW `/static/js/utils.js`, MODIFY multiple files +- Risk: MEDIUM +- Dependencies: None + +**Task 4: Create Config Module** + +- Files: NEW `/static/js/config.js`, MODIFY multiple files +- Risk: LOW +- Dependencies: None + +**Task 5: Standardize Notification Usage** + +- Files: NEW `/static/js/notification-service.js`, MODIFY multiple files +- Risk: LOW +- Dependencies: None + +### 🟡 MEDIUM PRIORITY + +**Task 6: Fix Deprecated Event Handler** + +- Files: `/static/js/file-tree-actions.js` line 329 +- Risk: LOW +- Dependencies: None + +**Task 7: Refactor ui-utils.js** + +- Files: DELETE `ui-utils.js`, CREATE 5 new modules +- Risk: HIGH +- Dependencies: Task 5 + +**Task 8: Standardize Class Export Pattern** + +- Files: All class files +- Risk: MEDIUM +- Dependencies: None + +**Task 9: Create Logger Module** + +- Files: NEW `/static/js/logger.js`, MODIFY multiple files +- Risk: LOW +- Dependencies: None + +**Task 10: Implement Download Action** + +- Files: `/static/js/file-tree-actions.js` +- Risk: LOW +- Dependencies: Task 3 + +### 🟢 LOW PRIORITY + +**Task 11: Standardize JSDoc Comments** +**Task 12: Extract Magic Numbers to CSS** +**Task 13: Add Error Boundaries** +**Task 14: Cache DOM Elements** + +--- + +## Phase 4: Implementation Order + +### Step 1: Foundation (Do First) + +1. Create Config Module (Task 4) +2. Create Logger Module (Task 9) +3. Create Event Bus Module (Task 2) + +### Step 2: Utilities (Do Second) + +4. Create Utilities Module (Task 3) +5. Create Notification Service (Task 5) + +### Step 3: Cleanup (Do Third) + +6. Remove Dead Code (Task 1) +7. Fix Deprecated Event Handler (Task 6) + +### Step 4: Restructuring (Do Fourth) + +8. Refactor ui-utils.js (Task 7) +9. Standardize Class Export Pattern (Task 8) + +### Step 5: Enhancements (Do Fifth) + +10. Implement Download Action (Task 10) +11. Add Error Boundaries (Task 13) + +### Step 6: Polish (Do Last) + +12. Standardize JSDoc Comments (Task 11) +13. Extract Magic Numbers to CSS (Task 12) +14. Cache DOM Elements (Task 14) + +--- + +## Phase 5: Testing Checklist + +### Core Functionality + +- [ ] File tree loads and displays correctly +- [ ] Files can be selected and opened +- [ ] Folders can be expanded/collapsed +- [ ] Editor loads file content +- [ ] Preview renders markdown correctly +- [ ] Save button saves files +- [ ] Delete button deletes files +- [ ] New button creates new files + +### Context Menu Actions + +- [ ] Right-click shows context menu +- [ ] New file action works +- [ ] New folder action works +- [ ] Rename action works +- [ ] Delete action works +- [ ] Copy/Cut/Paste actions work +- [ ] Upload action works + +### Drag and Drop + +- [ ] Long-press detection works +- [ ] Drag preview appears correctly +- [ ] Drop targets highlight properly +- [ ] Files can be moved +- [ ] Undo (Ctrl+Z) works + +### Modals + +- [ ] Confirmation modals appear +- [ ] Prompt modals appear +- [ ] Modals don't double-open +- [ ] Enter/Escape keys work + +### UI Features + +- [ ] Dark mode toggle works +- [ ] Collection selector works +- [ ] Column resizers work +- [ ] Notifications appear +- [ ] URL routing works +- [ ] View/Edit modes work + +--- + +## Recommendations + +### Immediate Actions (Before Production) + +1. Remove dead code (Task 1) +2. Fix deprecated event handler (Task 6) +3. Create config module (Task 4) + +### Short-term Actions (Next Sprint) + +4. Extract utilities (Task 3) +5. Standardize notifications (Task 5) +6. Create event bus (Task 2) + +### Medium-term Actions (Future Sprints) + +7. Refactor ui-utils.js (Task 7) +8. Add logger (Task 9) +9. Standardize exports (Task 8) + +--- + +## Success Metrics + +**Before Refactoring:** + +- Total Lines: ~3,587 +- Dead Code: 213 lines (6%) +- Duplicated Code: ~50 lines +- Hard-coded Values: 15+ + +**After Refactoring:** + +- Total Lines: ~3,400 (-5%) +- Dead Code: 0 lines +- Duplicated Code: 0 lines +- Hard-coded Values: 0 + +**Estimated Effort:** 5-8 days + +--- + +## Conclusion + +The UI codebase is generally well-structured. Main improvements needed: + +1. Remove dead code +2. Extract duplicated utilities +3. Centralize configuration +4. Standardize patterns + +Start with high-impact, low-risk changes first to ensure production readiness. diff --git a/collections/documents/docusaurus.md b/collections/documents/docusaurus.md new file mode 100644 index 0000000..74a9422 --- /dev/null +++ b/collections/documents/docusaurus.md @@ -0,0 +1,44 @@ +## Using Docusaurus + +Once you've set up Hero, you can use it to develop, manage and publish Docusaurus websites. + +## Launch the Hero Website + +To start a Hero Docusaurus website in development mode: + +- Build the book then close the prompt with `Ctrl+C` + + ```bash + hero docs -d + ``` + +- See the book on the local browser + + ``` + bash /root/hero/var/docusaurus/develop.sh + ``` + +You can then view the website in your browser at `https://localhost:3100`. + +## Publish a Website + +- To build and publish a Hero website: + - Development + + ```bash + hero docs -bpd + ``` + + - Production + + ```bash + hero docs -bp + ``` + +If you want to specify a different SSH key, use `-dk`: + +```bash +hero docs -bpd -dk ~/.ssh/id_ed25519 +``` + +> Note: The container handles the SSH agent and key management automatically on startup, so in most cases, you won't need to manually specify keys. \ No newline at end of file diff --git a/collections/documents/getting_started/hero_docker.md b/collections/documents/getting_started/hero_docker.md new file mode 100644 index 0000000..6488df8 --- /dev/null +++ b/collections/documents/getting_started/hero_docker.md @@ -0,0 +1,67 @@ +You can build Hero as a Docker container. + +The code is availabe at this [open-source repository](https://github.com/mik-tf/hero-container). + +## Prerequisites + +- Docker installed on your system (More info [here](https://manual.grid.tf/documentation/system_administrators/computer_it_basics/docker_basics.html#install-docker-desktop-and-docker-engine)) +- SSH keys for deploying Hero websites (if publishing) + +## Build the Image + +- Clone the repository + + ``` + git clone https://github.com/mik-tf/hero-container + cd hero-container + ``` + +- Build the Docker image: + + ```bash + docker build -t heroc . + ``` + +## Pull the Image from Docker Hub + +If you don't want to build the image, you can pull it from Docker Hub. + +``` +docker pull logismosis/heroc +``` + +In this case, use `logismosi/heroc` instead of `heroc` to use the container. + +## Run the Hero Container + +You can run the container with an interactive shell: + +```bash +docker run -it heroc +``` + +You can run the container with an interactive shell, while setting the host as your local network, mounting your current directory as the workspace and adding your SSH keys: + +```bash +docker run --network=host \ + -v $(pwd):/workspace \ + -v ~/.ssh:/root/ssh \ + -it heroc +``` + +By default, the container will: + +- Start Redis server in the background +- Copy your SSH keys to the proper location +- Initialize the SSH agent +- Add your default SSH key (`id_ed25519`) + +To use a different SSH key, specify it with the KEY environment variable (e.g. `KEY=id_ed25519`): + +```bash +docker run --network=host \ + -v $(pwd):/workspace \ + -v ~/.ssh:/root/ssh \ + -e KEY=your_custom_key_name \ + -it heroc +``` \ No newline at end of file diff --git a/collections/documents/getting_started/hero_native.md b/collections/documents/getting_started/hero_native.md new file mode 100644 index 0000000..9555bb6 --- /dev/null +++ b/collections/documents/getting_started/hero_native.md @@ -0,0 +1,22 @@ +## Basic Hero + +You can build Hero natively with the following lines: + +``` +curl https://raw.githubusercontent.com/freeflowuniverse/herolib/refs/heads/development/install_hero.sh > /tmp/install_hero.sh +bash /tmp/install_hero.sh +``` + +## Hero for Developers + +For developers, use the following commands: + +``` +curl 'https://raw.githubusercontent.com/freeflowuniverse/herolib/refs/heads/development/install_v.sh' > /tmp/install_v.sh +bash /tmp/install_v.sh --analyzer --herolib +#DONT FORGET TO START A NEW SHELL (otherwise the paths will not be set) +``` + +## Hero with Docker + +If you have issues running Hero natively, you can use the [Docker version of Hero](hero_docker.md). \ No newline at end of file diff --git a/collections/documents/intro.md b/collections/documents/intro.md new file mode 100644 index 0000000..4f2e13f --- /dev/null +++ b/collections/documents/intro.md @@ -0,0 +1,5 @@ +This ebook contains the basic information to get you started with the Hero tool. + +## What is Hero? + +Hero is an open-source toolset to work with Git, AI, mdBook, Docusaurus, Starlight and more. \ No newline at end of file diff --git a/collections/documents/support.md b/collections/documents/support.md new file mode 100644 index 0000000..1dadeae --- /dev/null +++ b/collections/documents/support.md @@ -0,0 +1 @@ +If you need help with Hero, reach out to the ThreeFold Support team [here](https://threefoldfaq.crisp.help/en/). \ No newline at end of file diff --git a/collections/notes/images/logo-blue.png b/collections/notes/images/logo-blue.png new file mode 100644 index 0000000..7790f52 Binary files /dev/null and b/collections/notes/images/logo-blue.png differ diff --git a/collections/notes/introduction.md b/collections/notes/introduction.md new file mode 100644 index 0000000..6cbbf1e --- /dev/null +++ b/collections/notes/introduction.md @@ -0,0 +1,18 @@ +# Introduction + +### This is an introduction + + +* **This is an internal image** + +--- + + + +--- + +* **This is an external image** + + + +--- diff --git a/collections/notes/new_folder/zeko.md b/collections/notes/new_folder/zeko.md new file mode 100644 index 0000000..09f37b3 --- /dev/null +++ b/collections/notes/new_folder/zeko.md @@ -0,0 +1,2 @@ +# New File + diff --git a/collections/notes/presentation.md b/collections/notes/presentation.md new file mode 100644 index 0000000..d9efbc8 --- /dev/null +++ b/collections/notes/presentation.md @@ -0,0 +1,40 @@ +## Mycelium Product Presentation + +This document provides an overview of the Mycelium technology stack (as commercially sold my our company GeoMind). + +
([\s\S]*?)<\/code><\/pre>/g,
'$1'
);
-
+
previewDiv.innerHTML = html;
-
+
const codeBlocks = previewDiv.querySelectorAll('pre code');
codeBlocks.forEach(block => {
const languageClass = Array.from(block.classList).find(cls => cls.startsWith('language-'));
@@ -261,7 +261,7 @@
Prism.highlightElement(block);
}
});
-
+
const mermaidElements = previewDiv.querySelectorAll('.mermaid');
if (mermaidElements.length > 0) {
try {
@@ -291,7 +291,7 @@
try {
const response = await fetch('/api/tree');
if (!response.ok) throw new Error('Failed to load file tree');
-
+
fileTree = await response.json();
renderFileTree();
} catch (error) {
@@ -303,12 +303,12 @@
function renderFileTree() {
const container = document.getElementById('fileTree');
container.innerHTML = '';
-
+
if (fileTree.length === 0) {
container.innerHTML = 'No files yet';
return;
}
-
+
fileTree.forEach(node => {
container.appendChild(createTreeNode(node));
});
@@ -317,13 +317,13 @@
function createTreeNode(node, level = 0) {
const nodeDiv = document.createElement('div');
nodeDiv.className = 'tree-node-wrapper';
-
+
const nodeContent = document.createElement('div');
nodeContent.className = 'tree-node';
nodeContent.dataset.path = node.path;
nodeContent.dataset.type = node.type;
nodeContent.dataset.name = node.name;
-
+
// Make draggable
nodeContent.draggable = true;
nodeContent.addEventListener('dragstart', handleDragStart);
@@ -331,14 +331,13 @@
nodeContent.addEventListener('dragover', handleDragOver);
nodeContent.addEventListener('dragleave', handleDragLeave);
nodeContent.addEventListener('drop', handleDrop);
-
+
const contentWrapper = document.createElement('div');
contentWrapper.className = 'tree-node-content';
-
+
if (node.type === 'directory') {
const toggle = document.createElement('span');
toggle.className = 'tree-node-toggle';
- toggle.innerHTML = '▶';
toggle.addEventListener('click', (e) => {
e.stopPropagation();
toggleNode(nodeDiv);
@@ -349,56 +348,56 @@
spacer.style.width = '16px';
contentWrapper.appendChild(spacer);
}
-
+
const icon = document.createElement('i');
icon.className = node.type === 'directory' ? 'bi bi-folder tree-node-icon' : 'bi bi-file-earmark-text tree-node-icon';
contentWrapper.appendChild(icon);
-
+
const name = document.createElement('span');
name.className = 'tree-node-name';
name.textContent = node.name;
contentWrapper.appendChild(name);
-
+
if (node.type === 'file' && node.size) {
const size = document.createElement('span');
size.className = 'file-size-badge';
size.textContent = formatFileSize(node.size);
contentWrapper.appendChild(size);
}
-
+
nodeContent.appendChild(contentWrapper);
-
+
nodeContent.addEventListener('click', (e) => {
if (node.type === 'file') {
loadFile(node.path);
}
});
-
+
nodeContent.addEventListener('contextmenu', (e) => {
e.preventDefault();
showContextMenu(e, node);
});
-
+
nodeDiv.appendChild(nodeContent);
-
+
if (node.children && node.children.length > 0) {
const childrenDiv = document.createElement('div');
childrenDiv.className = 'tree-children collapsed';
-
+
node.children.forEach(child => {
childrenDiv.appendChild(createTreeNode(child, level + 1));
});
-
+
nodeDiv.appendChild(childrenDiv);
}
-
+
return nodeDiv;
}
function toggleNode(nodeWrapper) {
const toggle = nodeWrapper.querySelector('.tree-node-toggle');
const children = nodeWrapper.querySelector('.tree-children');
-
+
if (children) {
children.classList.toggle('collapsed');
toggle.classList.toggle('expanded');
@@ -437,10 +436,10 @@
function handleDragOver(e) {
if (!draggedNode) return;
-
+
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
-
+
const targetType = e.currentTarget.dataset.type;
if (targetType === 'directory') {
e.currentTarget.classList.add('drag-over');
@@ -454,18 +453,18 @@
async function handleDrop(e) {
e.preventDefault();
e.currentTarget.classList.remove('drag-over');
-
+
if (!draggedNode) return;
-
+
const targetPath = e.currentTarget.dataset.path;
const targetType = e.currentTarget.dataset.type;
-
+
if (targetType !== 'directory') return;
if (draggedNode.path === targetPath) return;
-
+
const sourcePath = draggedNode.path;
const destPath = targetPath + '/' + draggedNode.name;
-
+
try {
const response = await fetch('/api/file/move', {
method: 'POST',
@@ -475,16 +474,16 @@
destination: destPath
})
});
-
+
if (!response.ok) throw new Error('Move failed');
-
+
showNotification(`Moved ${draggedNode.name}`, 'success');
loadFileTree();
} catch (error) {
console.error('Error moving file:', error);
showNotification('Error moving file', 'danger');
}
-
+
draggedNode = null;
}
@@ -496,18 +495,18 @@
contextMenuTarget = node;
const menu = document.getElementById('contextMenu');
const pasteItem = document.getElementById('pasteMenuItem');
-
+
// Show paste option only if clipboard has something and target is a directory
if (clipboard && node.type === 'directory') {
pasteItem.style.display = 'flex';
} else {
pasteItem.style.display = 'none';
}
-
+
menu.style.display = 'block';
menu.style.left = e.pageX + 'px';
menu.style.top = e.pageY + 'px';
-
+
document.addEventListener('click', hideContextMenu);
}
@@ -525,20 +524,20 @@
try {
const response = await fetch(`/api/file?path=${encodeURIComponent(path)}`);
if (!response.ok) throw new Error('Failed to load file');
-
+
const data = await response.json();
currentFile = data.filename;
currentFilePath = path;
-
+
document.getElementById('filenameInput').value = path;
editor.setValue(data.content);
updatePreview();
-
+
document.querySelectorAll('.tree-node').forEach(node => {
node.classList.remove('active');
});
document.querySelector(`[data-path="${path}"]`)?.classList.add('active');
-
+
showNotification(`Loaded ${data.filename}`, 'info');
} catch (error) {
console.error('Error loading file:', error);
@@ -548,27 +547,27 @@
async function saveFile() {
const path = document.getElementById('filenameInput').value.trim();
-
+
if (!path) {
showNotification('Please enter a filename', 'warning');
return;
}
-
+
const content = editor.getValue();
-
+
try {
const response = await fetch('/api/file', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path, content })
});
-
+
if (!response.ok) throw new Error('Failed to save file');
-
+
const result = await response.json();
currentFile = path.split('/').pop();
currentFilePath = result.path;
-
+
showNotification(`Saved ${currentFile}`, 'success');
loadFileTree();
} catch (error) {
@@ -582,26 +581,26 @@
showNotification('No file selected', 'warning');
return;
}
-
+
if (!confirm(`Are you sure you want to delete ${currentFile}?`)) {
return;
}
-
+
try {
const response = await fetch(`/api/file?path=${encodeURIComponent(currentFilePath)}`, {
method: 'DELETE'
});
-
+
if (!response.ok) throw new Error('Failed to delete file');
-
+
showNotification(`Deleted ${currentFile}`, 'success');
-
+
currentFile = null;
currentFilePath = null;
document.getElementById('filenameInput').value = '';
editor.setValue('');
updatePreview();
-
+
loadFileTree();
} catch (error) {
console.error('Error deleting file:', error);
@@ -617,27 +616,27 @@
document.getElementById('filenameInput').focus();
editor.setValue('');
updatePreview();
-
+
document.querySelectorAll('.tree-node').forEach(node => {
node.classList.remove('active');
});
-
+
showNotification('Enter filename and start typing', 'info');
}
async function createFolder() {
const folderName = prompt('Enter folder name:');
if (!folderName) return;
-
+
try {
const response = await fetch('/api/directory', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: folderName })
});
-
+
if (!response.ok) throw new Error('Failed to create folder');
-
+
showNotification(`Created folder ${folderName}`, 'success');
loadFileTree();
} catch (error) {
@@ -652,32 +651,32 @@
async function handleContextMenuAction(action) {
if (!contextMenuTarget) return;
-
+
switch (action) {
case 'open':
if (contextMenuTarget.type === 'file') {
loadFile(contextMenuTarget.path);
}
break;
-
+
case 'rename':
await renameItem();
break;
-
+
case 'copy':
clipboard = { ...contextMenuTarget, operation: 'copy' };
showNotification(`Copied ${contextMenuTarget.name}`, 'info');
break;
-
+
case 'move':
clipboard = { ...contextMenuTarget, operation: 'move' };
showNotification(`Cut ${contextMenuTarget.name}`, 'info');
break;
-
+
case 'paste':
await pasteItem();
break;
-
+
case 'delete':
await deleteItem();
break;
@@ -687,10 +686,10 @@
async function renameItem() {
const newName = prompt(`Rename ${contextMenuTarget.name}:`, contextMenuTarget.name);
if (!newName || newName === contextMenuTarget.name) return;
-
+
const oldPath = contextMenuTarget.path;
const newPath = oldPath.substring(0, oldPath.lastIndexOf('/') + 1) + newName;
-
+
try {
const endpoint = contextMenuTarget.type === 'directory' ? '/api/directory/rename' : '/api/file/rename';
const response = await fetch(endpoint, {
@@ -701,9 +700,9 @@
new_path: newPath
})
});
-
+
if (!response.ok) throw new Error('Rename failed');
-
+
showNotification(`Renamed to ${newName}`, 'success');
loadFileTree();
} catch (error) {
@@ -714,12 +713,12 @@
async function pasteItem() {
if (!clipboard) return;
-
+
const destDir = contextMenuTarget.path;
const sourcePath = clipboard.path;
const fileName = clipboard.name;
const destPath = destDir + '/' + fileName;
-
+
try {
if (clipboard.operation === 'copy') {
// Copy operation
@@ -731,7 +730,7 @@
destination: destPath
})
});
-
+
if (!response.ok) throw new Error('Copy failed');
showNotification(`Copied ${fileName} to ${contextMenuTarget.name}`, 'success');
} else if (clipboard.operation === 'move') {
@@ -744,12 +743,12 @@
destination: destPath
})
});
-
+
if (!response.ok) throw new Error('Move failed');
showNotification(`Moved ${fileName} to ${contextMenuTarget.name}`, 'success');
clipboard = null; // Clear clipboard after move
}
-
+
loadFileTree();
} catch (error) {
console.error('Error pasting:', error);
@@ -761,7 +760,7 @@
if (!confirm(`Are you sure you want to delete ${contextMenuTarget.name}?`)) {
return;
}
-
+
try {
let response;
if (contextMenuTarget.type === 'directory') {
@@ -773,9 +772,9 @@
method: 'DELETE'
});
}
-
+
if (!response.ok) throw new Error('Delete failed');
-
+
showNotification(`Deleted ${contextMenuTarget.name}`, 'success');
loadFileTree();
} catch (error) {
@@ -793,7 +792,7 @@
if (!toastContainer) {
toastContainer = createToastContainer();
}
-
+
const toast = document.createElement('div');
toast.className = `toast align-items-center text-white bg-${type} border-0`;
toast.setAttribute('role', 'alert');
@@ -803,12 +802,12 @@
([\s\S]*?)<\/code><\/pre>/g,
'$1'
);
-
+
previewDiv.innerHTML = html;
-
+
// Apply syntax highlighting to code blocks
const codeBlocks = previewDiv.querySelectorAll('pre code');
codeBlocks.forEach(block => {
@@ -262,7 +262,7 @@
Prism.highlightElement(block);
}
});
-
+
// Render mermaid diagrams
const mermaidElements = previewDiv.querySelectorAll('.mermaid');
if (mermaidElements.length > 0) {
@@ -288,15 +288,15 @@
// Handle editor scroll for synchronized scrolling
function handleEditorScroll() {
if (!isScrollingSynced) return;
-
+
clearTimeout(scrollTimeout);
scrollTimeout = setTimeout(() => {
const editorScrollInfo = editor.getScrollInfo();
const editorScrollPercentage = editorScrollInfo.top / (editorScrollInfo.height - editorScrollInfo.clientHeight);
-
+
const previewPane = document.querySelector('.preview-pane');
const previewScrollHeight = previewPane.scrollHeight - previewPane.clientHeight;
-
+
if (previewScrollHeight > 0) {
previewPane.scrollTop = editorScrollPercentage * previewScrollHeight;
}
@@ -308,22 +308,22 @@
try {
const response = await fetch('/api/files');
if (!response.ok) throw new Error('Failed to load file list');
-
+
const files = await response.json();
const fileListDiv = document.getElementById('fileList');
-
+
if (files.length === 0) {
fileListDiv.innerHTML = 'No files yet';
return;
}
-
+
fileListDiv.innerHTML = files.map(file => `
${file.filename}
${formatFileSize(file.size)}
`).join('');
-
+
// Add click handlers
document.querySelectorAll('.file-item').forEach(item => {
item.addEventListener('click', (e) => {
@@ -343,19 +343,19 @@
try {
const response = await fetch(`/api/files/${filename}`);
if (!response.ok) throw new Error('Failed to load file');
-
+
const data = await response.json();
currentFile = data.filename;
-
+
// Update UI
document.getElementById('filenameInput').value = data.filename;
editor.setValue(data.content);
-
+
// Update active state in file list
document.querySelectorAll('.file-item').forEach(item => {
item.classList.toggle('active', item.dataset.filename === filename);
});
-
+
updatePreview();
showNotification(`Loaded ${filename}`, 'success');
} catch (error) {
@@ -367,14 +367,14 @@
// Save current file
async function saveFile() {
const filename = document.getElementById('filenameInput').value.trim();
-
+
if (!filename) {
showNotification('Please enter a filename', 'warning');
return;
}
-
+
const content = editor.getValue();
-
+
try {
const response = await fetch('/api/files', {
method: 'POST',
@@ -383,12 +383,12 @@
},
body: JSON.stringify({ filename, content })
});
-
+
if (!response.ok) throw new Error('Failed to save file');
-
+
const result = await response.json();
currentFile = result.filename;
-
+
showNotification(`Saved ${result.filename}`, 'success');
loadFileList();
} catch (error) {
@@ -400,31 +400,31 @@
// Delete current file
async function deleteFile() {
const filename = document.getElementById('filenameInput').value.trim();
-
+
if (!filename) {
showNotification('No file selected', 'warning');
return;
}
-
+
if (!confirm(`Are you sure you want to delete ${filename}?`)) {
return;
}
-
+
try {
const response = await fetch(`/api/files/${filename}`, {
method: 'DELETE'
});
-
+
if (!response.ok) throw new Error('Failed to delete file');
-
+
showNotification(`Deleted ${filename}`, 'success');
-
+
// Clear editor
currentFile = null;
document.getElementById('filenameInput').value = '';
editor.setValue('');
updatePreview();
-
+
loadFileList();
} catch (error) {
console.error('Error deleting file:', error);
@@ -438,12 +438,12 @@
document.getElementById('filenameInput').value = '';
editor.setValue('');
updatePreview();
-
+
// Remove active state from all file items
document.querySelectorAll('.file-item').forEach(item => {
item.classList.remove('active');
});
-
+
showNotification('New file created', 'info');
}
@@ -460,25 +460,25 @@
function showNotification(message, type = 'info') {
// Create toast notification
const toastContainer = document.getElementById('toastContainer') || createToastContainer();
-
+
const toast = document.createElement('div');
toast.className = `toast align-items-center text-white bg-${type} border-0`;
toast.setAttribute('role', 'alert');
toast.setAttribute('aria-live', 'assertive');
toast.setAttribute('aria-atomic', 'true');
-
+
toast.innerHTML = `
${message}
`;
-
+
toastContainer.appendChild(toast);
-
+
const bsToast = new bootstrap.Toast(toast, { delay: 3000 });
bsToast.show();
-
+
toast.addEventListener('hidden.bs.toast', () => {
toast.remove();
});
@@ -499,13 +499,13 @@
initDarkMode();
initEditor();
loadFileList();
-
+
// Set up event listeners
document.getElementById('saveBtn').addEventListener('click', saveFile);
document.getElementById('deleteBtn').addEventListener('click', deleteFile);
document.getElementById('newFileBtn').addEventListener('click', newFile);
document.getElementById('darkModeToggle').addEventListener('click', toggleDarkMode);
-
+
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
@@ -513,7 +513,7 @@
saveFile();
}
});
-
+
console.log('Markdown Editor initialized');
}
diff --git a/static/css/components.css b/static/css/components.css
index 1caaf93..f524953 100644
--- a/static/css/components.css
+++ b/static/css/components.css
@@ -2,10 +2,21 @@
.preview-pane {
font-size: 16px;
line-height: 1.6;
+ color: var(--text-primary);
+ background-color: var(--bg-primary);
}
-.preview-pane h1, .preview-pane h2, .preview-pane h3,
-.preview-pane h4, .preview-pane h5, .preview-pane h6 {
+#preview {
+ color: var(--text-primary);
+ background-color: var(--bg-primary);
+}
+
+.preview-pane h1,
+.preview-pane h2,
+.preview-pane h3,
+.preview-pane h4,
+.preview-pane h5,
+.preview-pane h6 {
margin-top: 24px;
margin-bottom: 16px;
font-weight: 600;
@@ -132,11 +143,21 @@ body.dark-mode .context-menu {
animation: slideIn 0.3s ease;
}
+/* Override Bootstrap warning background to be darker for better text contrast */
+.toast.bg-warning {
+ background-color: #cc9a06 !important;
+}
+
+body.dark-mode .toast.bg-warning {
+ background-color: #b8860b !important;
+}
+
@keyframes slideIn {
from {
transform: translateX(400px);
opacity: 0;
}
+
to {
transform: translateX(0);
opacity: 1;
@@ -152,6 +173,7 @@ body.dark-mode .context-menu {
transform: translateX(0);
opacity: 1;
}
+
to {
transform: translateX(400px);
opacity: 0;
@@ -205,4 +227,227 @@ body.dark-mode .modal-footer {
color: var(--text-primary);
border-color: var(--link-color);
box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25);
+}
+
+/* Directory Preview Styles */
+.directory-preview {
+ padding: 20px;
+}
+
+.directory-preview h2 {
+ margin-bottom: 20px;
+ /* color: var(--text-primary); */
+}
+
+.directory-files {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
+ gap: 16px;
+ margin-top: 20px;
+}
+
+.file-card {
+ background-color: var(--bg-tertiary);
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ padding: 16px;
+ cursor: pointer;
+ transition: all 0.2s ease;
+}
+
+.file-card:hover {
+ background-color: var(--bg-secondary);
+ border-color: var(--link-color);
+ transform: translateY(-2px);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+}
+
+.file-card-header {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 8px;
+}
+
+.file-card-header i {
+ color: var(--link-color);
+ font-size: 18px;
+}
+
+.file-card-name {
+ font-weight: 500;
+ color: var(--text-primary);
+ word-break: break-word;
+}
+
+.file-card-description {
+ font-size: 13px;
+ color: var(--text-secondary);
+ line-height: 1.4;
+ margin-top: 8px;
+}
+
+/* Flat Button Styles */
+.btn-flat {
+ border: none;
+ border-radius: 0;
+ padding: 6px 12px;
+ font-size: 14px;
+ font-weight: 500;
+ transition: all 0.2s ease;
+ background-color: transparent;
+ color: var(--text-primary);
+ position: relative;
+}
+
+.btn-flat:hover {
+ background-color: var(--bg-tertiary);
+}
+
+.btn-flat:active {
+ transform: scale(0.95);
+}
+
+/* Flat button variants */
+.btn-flat-primary {
+ color: #0d6efd;
+}
+
+.btn-flat-primary:hover {
+ background-color: rgba(13, 110, 253, 0.1);
+}
+
+.btn-flat-success {
+ color: #198754;
+}
+
+.btn-flat-success:hover {
+ background-color: rgba(25, 135, 84, 0.1);
+}
+
+.btn-flat-danger {
+ color: #dc3545;
+}
+
+.btn-flat-danger:hover {
+ background-color: rgba(220, 53, 69, 0.1);
+}
+
+.btn-flat-warning {
+ color: #ffc107;
+}
+
+.btn-flat-warning:hover {
+ background-color: rgba(255, 193, 7, 0.1);
+}
+
+.btn-flat-secondary {
+ color: var(--text-secondary);
+}
+
+.btn-flat-secondary:hover {
+ background-color: var(--bg-tertiary);
+}
+
+/* Dark mode adjustments */
+body.dark-mode .btn-flat-primary {
+ color: #6ea8fe;
+}
+
+body.dark-mode .btn-flat-success {
+ color: #75b798;
+}
+
+body.dark-mode .btn-flat-danger {
+ color: #ea868f;
+}
+
+body.dark-mode .btn-flat-warning {
+ color: #ffda6a;
+}
+
+/* Dark Mode Button Icon Styles */
+#darkModeBtn i {
+ font-size: 16px;
+ color: inherit;
+ /* Inherit color from parent button */
+}
+
+/* Light mode: moon icon */
+body:not(.dark-mode) #darkModeBtn i {
+ color: var(--text-secondary);
+}
+
+/* Dark mode: sun icon */
+body.dark-mode #darkModeBtn i {
+ color: #ffc107;
+ /* Warm sun color */
+}
+
+/* Hover effects */
+#darkModeBtn:hover i {
+ color: inherit;
+ /* Inherit hover color from parent */
+}
+
+/* ===================================
+ Loading Spinner Component
+ =================================== */
+
+/* Loading overlay - covers the target container */
+.loading-overlay {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: var(--bg-primary);
+ opacity: 0.95;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+ transition: opacity 0.2s ease;
+}
+
+/* Loading spinner */
+.loading-spinner {
+ width: 48px;
+ height: 48px;
+ border: 4px solid var(--border-color);
+ border-top-color: var(--primary-color);
+ border-radius: 50%;
+ animation: spin 0.8s linear infinite;
+}
+
+/* Spinner animation */
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+/* Loading text */
+.loading-text {
+ margin-top: 16px;
+ color: var(--text-secondary);
+ font-size: 14px;
+ text-align: center;
+}
+
+/* Loading container with spinner and text */
+.loading-content {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 12px;
+}
+
+/* Hide loading overlay by default */
+.loading-overlay.hidden {
+ display: none;
+}
+
+.language-bash {
+ color: var(--text-primary) !important;
}
\ No newline at end of file
diff --git a/static/css/editor.css b/static/css/editor.css
index 6ee84a6..8ba1d70 100644
--- a/static/css/editor.css
+++ b/static/css/editor.css
@@ -6,6 +6,8 @@
display: flex;
gap: 10px;
align-items: center;
+ flex-shrink: 0;
+ /* Prevent header from shrinking */
}
.editor-header input {
@@ -19,18 +21,42 @@
.editor-container {
flex: 1;
+ /* Take remaining space */
overflow: hidden;
+ /* Prevent container overflow, CodeMirror handles its own scrolling */
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ /* Important: allows flex child to shrink below content size */
+ position: relative;
+}
+
+#editor {
+ flex: 1;
+ /* Take all available space */
+ min-height: 0;
+ /* Allow shrinking */
+ overflow: hidden;
+ /* CodeMirror will handle scrolling */
}
/* CodeMirror customization */
.CodeMirror {
- height: 100%;
+ height: 100% !important;
+ /* Force full height */
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 14px;
background-color: var(--bg-primary);
color: var(--text-primary);
}
+.CodeMirror-scroll {
+ overflow-y: auto !important;
+ /* Ensure vertical scrolling is enabled */
+ overflow-x: auto !important;
+ /* Ensure horizontal scrolling is enabled */
+}
+
body.dark-mode .CodeMirror {
background-color: #1c2128;
color: #e6edf3;
@@ -71,5 +97,4 @@ body.dark-mode .CodeMirror-gutters {
color: var(--info-color);
pointer-events: none;
z-index: 1000;
-}
-
+}
\ No newline at end of file
diff --git a/static/css/file-tree.css b/static/css/file-tree.css
index 13cbf87..ab5844e 100644
--- a/static/css/file-tree.css
+++ b/static/css/file-tree.css
@@ -20,8 +20,9 @@
color: var(--text-primary);
transition: all 0.15s ease;
white-space: nowrap;
- overflow: hidden;
+ overflow: visible;
text-overflow: ellipsis;
+ min-height: 28px;
}
.tree-node:hover {
@@ -29,14 +30,16 @@
}
.tree-node.active {
- background-color: var(--link-color);
- color: white;
+ color: var(--link-color);
font-weight: 500;
}
.tree-node.active:hover {
- background-color: var(--link-color);
- filter: brightness(1.1);
+ filter: brightness(1.2);
+}
+
+.tree-node.active .tree-node-icon {
+ color: var(--link-color);
}
/* Toggle arrow */
@@ -46,16 +49,25 @@
justify-content: center;
width: 16px;
height: 16px;
- font-size: 10px;
+ min-width: 16px;
+ min-height: 16px;
color: var(--text-secondary);
flex-shrink: 0;
transition: transform 0.2s ease;
+ position: relative;
+ z-index: 1;
+ overflow: visible;
+ cursor: pointer;
}
.tree-node-toggle.expanded {
transform: rotate(90deg);
}
+.tree-node-toggle:hover {
+ color: var(--link-color);
+}
+
/* Icon styling */
.tree-node-icon {
width: 16px;
@@ -67,10 +79,6 @@
color: var(--text-secondary);
}
-.tree-node.active .tree-node-icon {
- color: white;
-}
-
/* Content wrapper */
.tree-node-content {
display: flex;
@@ -112,13 +120,54 @@
}
/* Drag and drop */
+/* Default cursor is pointer, not grab (only show grab after long-press) */
+.tree-node {
+ cursor: pointer;
+}
+
+/* Show grab cursor only when drag is ready (after long-press) */
+.tree-node.drag-ready {
+ cursor: grab !important;
+}
+
+.tree-node.drag-ready:active {
+ cursor: grabbing !important;
+}
+
.tree-node.dragging {
- opacity: 0.5;
+ opacity: 0.4;
+ background-color: var(--bg-tertiary);
+ cursor: grabbing !important;
}
.tree-node.drag-over {
- background-color: rgba(13, 110, 253, 0.2);
- border: 1px dashed var(--link-color);
+ background-color: rgba(13, 110, 253, 0.15) !important;
+ border: 2px dashed var(--link-color) !important;
+ box-shadow: 0 0 8px rgba(13, 110, 253, 0.3);
+}
+
+/* Root-level drop target highlighting */
+.file-tree.drag-over-root {
+ background-color: rgba(13, 110, 253, 0.08);
+ border: 2px dashed var(--link-color);
+ border-radius: 6px;
+ box-shadow: inset 0 0 12px rgba(13, 110, 253, 0.2);
+ margin: 4px;
+ padding: 4px;
+}
+
+/* Only show drag cursor on directories when dragging */
+body.dragging-active .tree-node[data-isdir="true"] {
+ cursor: copy;
+}
+
+body.dragging-active .tree-node[data-isdir="false"] {
+ cursor: no-drop;
+}
+
+/* Show move cursor when hovering over root-level empty space */
+body.dragging-active .file-tree.drag-over-root {
+ cursor: move;
}
/* Collection selector - Bootstrap styled */
@@ -156,13 +205,34 @@ body.dark-mode .tree-node:hover {
}
body.dark-mode .tree-node.active {
- background-color: var(--link-color);
+ color: var(--link-color);
+}
+
+body.dark-mode .tree-node.active .tree-node-icon {
+ color: var(--link-color);
+}
+
+body.dark-mode .tree-node.active .tree-node-icon .tree-node-toggle {
+ color: var(--link-color);
}
body.dark-mode .tree-children {
border-left-color: var(--border-color);
}
+/* Empty directory message */
+.tree-empty-message {
+ padding: 8px 12px;
+ color: var(--text-secondary);
+ font-size: 12px;
+ font-style: italic;
+ user-select: none;
+}
+
+body.dark-mode .tree-empty-message {
+ color: var(--text-secondary);
+}
+
/* Scrollbar in sidebar */
.sidebar::-webkit-scrollbar-thumb {
background-color: var(--border-color);
@@ -170,4 +240,14 @@ body.dark-mode .tree-children {
.sidebar::-webkit-scrollbar-thumb:hover {
background-color: var(--text-secondary);
+}
+
+.new-collection-btn {
+ padding: 0.375rem 0.75rem;
+ font-size: 1rem;
+ border-radius: 0.25rem;
+ transition: all 0.2s ease;
+ color: var(--text-primary);
+ border: 1px solid var(--border-color);
+ background-color: transparent;
}
\ No newline at end of file
diff --git a/static/css/layout.css b/static/css/layout.css
index 08ba397..192d5ed 100644
--- a/static/css/layout.css
+++ b/static/css/layout.css
@@ -1,14 +1,22 @@
/* Base layout styles */
-html, body {
- height: 100%;
+html,
+body {
+ height: 100vh;
margin: 0;
padding: 0;
+ overflow: hidden;
+ /* Prevent page-level scrolling */
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif;
background-color: var(--bg-primary);
color: var(--text-primary);
transition: background-color 0.3s ease, color 0.3s ease;
}
+body {
+ display: flex;
+ flex-direction: column;
+}
+
/* Column Resizer */
.column-resizer {
width: 1px;
@@ -17,14 +25,21 @@ html, body {
transition: background-color 0.2s ease, width 0.2s ease, box-shadow 0.2s ease;
user-select: none;
flex-shrink: 0;
- padding: 0 3px; /* Add invisible padding for easier grab */
- margin: 0 -3px; /* Compensate for padding */
+ padding: 0 3px;
+ /* Add invisible padding for easier grab */
+ margin: 0 -3px;
+ /* Compensate for padding */
+ height: 100%;
+ /* Take full height of parent */
+ align-self: stretch;
+ /* Ensure it stretches to full height */
}
.column-resizer:hover {
background-color: var(--link-color);
width: 1px;
- box-shadow: 0 0 6px rgba(13, 110, 253, 0.3); /* Visual feedback instead of width change */
+ box-shadow: 0 0 6px rgba(13, 110, 253, 0.3);
+ /* Visual feedback instead of width change */
}
.column-resizer.dragging {
@@ -36,12 +51,59 @@ html, body {
background-color: var(--link-color);
}
-/* Adjust container for flex layout */
-.container-fluid {
+/* Navbar */
+.navbar {
+ background-color: var(--bg-secondary);
+ border-bottom: 1px solid var(--border-color);
+ transition: background-color 0.3s ease;
+ flex-shrink: 0;
+ /* Prevent navbar from shrinking */
+ padding: 0.5rem 1rem;
+}
+
+.navbar .container-fluid {
display: flex;
flex-direction: row;
- height: calc(100% - 56px);
+ align-items: center;
+ justify-content: space-between;
padding: 0;
+ overflow: visible;
+ /* Override the hidden overflow for navbar */
+}
+
+.navbar-brand {
+ color: var(--text-primary) !important;
+ font-weight: 600;
+ font-size: 1.1rem;
+ margin: 0;
+ flex-shrink: 0;
+}
+
+.navbar-brand i {
+ font-size: 1.2rem;
+ margin-right: 0.5rem;
+}
+
+.navbar-center {
+ flex: 1;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+}
+
+.navbar-right {
+ flex-shrink: 0;
+}
+
+/* Adjust container for flex layout */
+.container-fluid {
+ flex: 1;
+ /* Take remaining space after navbar */
+ padding: 0;
+ overflow: hidden;
+ /* Prevent container scrolling */
+ display: flex;
+ flex-direction: column;
}
.row {
@@ -50,13 +112,75 @@ html, body {
flex-direction: row;
margin: 0;
height: 100%;
+ overflow: hidden;
+ /* Prevent row scrolling */
}
#sidebarPane {
flex: 0 0 20%;
min-width: 150px;
- max-width: 40%;
+ max-width: 20%;
padding: 0;
+ height: 100%;
+ overflow: hidden;
+ /* Prevent pane scrolling */
+ transition: flex 0.3s ease, min-width 0.3s ease, max-width 0.3s ease;
+}
+
+/* Collapsed sidebar state - mini sidebar */
+#sidebarPane.collapsed {
+ flex: 0 0 50px;
+ min-width: 50px;
+ max-width: 50px;
+ border-right: 1px solid var(--border-color);
+ position: relative;
+ cursor: pointer;
+}
+
+/* Hide file tree content when collapsed */
+#sidebarPane.collapsed #fileTree {
+ display: none;
+}
+
+/* Hide collection selector when collapsed */
+#sidebarPane.collapsed .collection-selector {
+ display: none;
+}
+
+/* Visual indicator in the mini sidebar */
+#sidebarPane.collapsed::before {
+ content: '';
+ display: block;
+ width: 100%;
+ height: 100%;
+ background: var(--bg-secondary);
+ transition: background 0.2s ease;
+}
+
+/* Hover effect on mini sidebar */
+#sidebarPane.collapsed:hover::before {
+ background: var(--hover-bg);
+}
+
+/* Right arrow icon in the center of mini sidebar */
+#sidebarPane.collapsed::after {
+ content: '\F285';
+ /* Bootstrap icon chevron-right */
+ font-family: 'bootstrap-icons';
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ font-size: 20px;
+ color: var(--text-secondary);
+ pointer-events: none;
+ opacity: 0.5;
+ transition: opacity 0.2s ease;
+ cursor: pointer;
+}
+
+#sidebarPane.collapsed:hover::after {
+ opacity: 1;
}
#editorPane {
@@ -64,25 +188,23 @@ html, body {
min-width: 250px;
max-width: 70%;
padding: 0;
-}
-
-#previewPane {
- flex: 1 1 40%;
- min-width: 250px;
- max-width: 70%;
- padding: 0;
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ overflow: hidden;
+ /* Prevent pane scrolling */
}
/* Sidebar - improved */
.sidebar {
background-color: var(--bg-secondary);
border-right: 1px solid var(--border-color);
- overflow-y: auto;
- overflow-x: hidden;
height: 100%;
transition: background-color 0.3s ease;
display: flex;
flex-direction: column;
+ overflow: hidden;
+ /* Prevent sidebar container scrolling */
}
.sidebar h6 {
@@ -92,25 +214,27 @@ html, body {
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
+ flex-shrink: 0;
+ /* Prevent header from shrinking */
+}
+
+/* Collection selector - fixed height */
+.collection-selector {
+ flex-shrink: 0;
+ /* Prevent selector from shrinking */
+ padding: 12px 10px;
+ background-color: var(--bg-secondary);
}
#fileTree {
flex: 1;
+ /* Take remaining space */
overflow-y: auto;
+ /* Enable vertical scrolling */
overflow-x: hidden;
- padding: 4px 0;
-}
-
-/* Navbar */
-.navbar {
- background-color: var(--bg-secondary);
- border-bottom: 1px solid var(--border-color);
- transition: background-color 0.3s ease;
-}
-
-.navbar-brand {
- color: var(--text-primary) !important;
- font-weight: 600;
+ padding: 4px 10px;
+ min-height: 0;
+ /* Important: allows flex child to shrink below content size */
}
/* Scrollbar styling */
@@ -135,28 +259,86 @@ html, body {
/* Preview Pane Styling */
#previewPane {
- flex: 1 1 40%;
min-width: 250px;
max-width: 70%;
padding: 0;
- overflow-y: auto;
- overflow-x: hidden;
background-color: var(--bg-primary);
border-left: 1px solid var(--border-color);
+ flex: 1;
+ height: 100%;
+ overflow-y: auto;
+ /* Enable vertical scrolling for preview pane */
+ overflow-x: hidden;
}
#preview {
padding: 20px;
- min-height: 100%;
overflow-wrap: break-word;
word-wrap: break-word;
+ color: var(--text-primary);
+ min-height: 100%;
+ /* Ensure content fills at least the full height */
}
-#preview > p:first-child {
+#preview>p:first-child {
margin-top: 0;
}
-#preview > h1:first-child,
-#preview > h2:first-child {
+#preview>h1:first-child,
+#preview>h2:first-child {
margin-top: 0;
+}
+
+/* Iframe styles in preview - minimal defaults that can be overridden */
+#preview iframe {
+ border: none;
+ /* Default to no border, can be overridden by inline styles */
+ display: block;
+ /* Prevent inline spacing issues */
+}
+
+/* View Mode Styles */
+body.view-mode #editorPane {
+ display: none;
+}
+
+body.view-mode #resizer1 {
+ display: none;
+}
+
+body.view-mode #resizer2 {
+ display: none;
+}
+
+body.view-mode #previewPane {
+ max-width: 100%;
+ min-width: auto;
+}
+
+body.view-mode #sidebarPane {
+ display: flex;
+ flex: 0 0 20%;
+ height: 100%;
+ /* Keep sidebar at 20% width in view mode */
+}
+
+body.edit-mode #editorPane {
+ display: flex;
+}
+
+body.edit-mode #resizer1 {
+ display: block;
+}
+
+body.edit-mode #resizer2 {
+ display: block;
+}
+
+body.edit-mode #previewPane {
+ max-width: 70%;
+}
+
+body.edit-mode #sidebarPane {
+ display: flex;
+ height: 100%;
}
\ No newline at end of file
diff --git a/static/js/app.js b/static/js/app.js
index c608778..5660ffe 100644
--- a/static/js/app.js
+++ b/static/js/app.js
@@ -12,100 +12,628 @@ let collectionSelector;
let clipboard = null;
let currentFilePath = null;
-// Simple event bus
-const eventBus = {
- listeners: {},
- on(event, callback) {
- if (!this.listeners[event]) {
- this.listeners[event] = [];
+// Event bus is now loaded from event-bus.js module
+// No need to define it here - it's available as window.eventBus
+
+/**
+ * Auto-load page in view mode
+ * Tries to load the last viewed page, falls back to first file if none saved
+ */
+async function autoLoadPageInViewMode() {
+ if (!editor || !fileTree) return;
+
+ try {
+ // Try to get last viewed page
+ let pageToLoad = editor.getLastViewedPage();
+
+ // If no last viewed page, get the first markdown file
+ if (!pageToLoad) {
+ pageToLoad = fileTree.getFirstMarkdownFile();
}
- this.listeners[event].push(callback);
- },
- dispatch(event, data) {
- if (this.listeners[event]) {
- this.listeners[event].forEach(callback => callback(data));
+
+ // If we found a page to load, load it
+ if (pageToLoad) {
+ // Use fileTree.onFileSelect to handle both text and binary files
+ if (fileTree.onFileSelect) {
+ fileTree.onFileSelect({ path: pageToLoad, isDirectory: false });
+ } else {
+ // Fallback to direct loading (for text files only)
+ await editor.loadFile(pageToLoad);
+ fileTree.selectAndExpandPath(pageToLoad);
+ }
+ } else {
+ // No files found, show empty state message
+ editor.previewElement.innerHTML = `
+
+ No content available
+
+ `;
}
+ } catch (error) {
+ console.error('Failed to auto-load page in view mode:', error);
+ editor.previewElement.innerHTML = `
+
+ Failed to load content
+
+ `;
}
-};
-window.eventBus = eventBus;
+}
+
+/**
+ * Show directory preview with list of files
+ * @param {string} dirPath - The directory path
+ */
+async function showDirectoryPreview(dirPath) {
+ if (!editor || !fileTree || !webdavClient) return;
+
+ try {
+ const dirName = dirPath.split('/').pop() || dirPath;
+ const files = fileTree.getDirectoryFiles(dirPath);
+
+ // Start building the preview HTML
+ let html = ``;
+ html += `${dirName}
`;
+
+ if (files.length === 0) {
+ html += `This directory is empty
`;
+ } else {
+ html += ``;
+
+ // Create cards for each file
+ for (const file of files) {
+ const fileName = file.name;
+ let fileDescription = '';
+
+ // Try to get file description from markdown files
+ if (file.name.endsWith('.md')) {
+ try {
+ const content = await webdavClient.get(file.path);
+ // Extract first heading or first line as description
+ const lines = content.split('\n');
+ for (const line of lines) {
+ if (line.trim().startsWith('#')) {
+ fileDescription = line.replace(/^#+\s*/, '').trim();
+ break;
+ } else if (line.trim() && !line.startsWith('---')) {
+ fileDescription = line.trim().substring(0, 100);
+ break;
+ }
+ }
+ } catch (error) {
+ console.error('Failed to read file description:', error);
+ }
+ }
+
+ html += `
+
+
+
+ ${fileName}
+
+ ${fileDescription ? `${fileDescription}` : ''}
+
+ `;
+ }
+
+ html += ``;
+ }
+
+ html += ``;
+
+ // Set the preview content
+ editor.previewElement.innerHTML = html;
+
+ // Add click handlers to file cards
+ editor.previewElement.querySelectorAll('.file-card').forEach(card => {
+ card.addEventListener('click', async () => {
+ const filePath = card.dataset.path;
+ await editor.loadFile(filePath);
+ fileTree.selectAndExpandPath(filePath);
+ });
+ });
+ } catch (error) {
+ console.error('Failed to show directory preview:', error);
+ editor.previewElement.innerHTML = `
+
+ Failed to load directory preview
+
+ `;
+ }
+}
+
+/**
+ * Parse URL to extract collection and file path
+ * URL format: // or ///
+ * @returns {Object} {collection, filePath} or {collection, null} if only collection
+ */
+function parseURLPath() {
+ const pathname = window.location.pathname;
+ const parts = pathname.split('/').filter(p => p); // Remove empty parts
+
+ if (parts.length === 0) {
+ return { collection: null, filePath: null };
+ }
+
+ const collection = parts[0];
+ const filePath = parts.length > 1 ? parts.slice(1).join('/') : null;
+
+ return { collection, filePath };
+}
+
+/**
+ * Update URL based on current collection and file
+ * @param {string} collection - The collection name
+ * @param {string} filePath - The file path (optional)
+ * @param {boolean} isEditMode - Whether in edit mode
+ */
+function updateURL(collection, filePath, isEditMode) {
+ let url = `/${collection}`;
+ if (filePath) {
+ url += `/${filePath}`;
+ }
+ if (isEditMode) {
+ url += '?edit=true';
+ }
+
+ // Use pushState to update URL without reloading
+ window.history.pushState({ collection, filePath }, '', url);
+}
+
+/**
+ * Load file from URL path
+ * Assumes the collection is already set and file tree is loaded
+ * @param {string} collection - The collection name (for validation)
+ * @param {string} filePath - The file path
+ */
+async function loadFileFromURL(collection, filePath) {
+ console.log('[loadFileFromURL] Called with:', { collection, filePath });
+
+ if (!fileTree || !editor || !collectionSelector) {
+ console.error('[loadFileFromURL] Missing dependencies:', { fileTree: !!fileTree, editor: !!editor, collectionSelector: !!collectionSelector });
+ return;
+ }
+
+ try {
+ // Verify we're on the right collection
+ const currentCollection = collectionSelector.getCurrentCollection();
+ if (currentCollection !== collection) {
+ console.error(`[loadFileFromURL] Collection mismatch: expected ${collection}, got ${currentCollection}`);
+ return;
+ }
+
+ // Load the file or directory
+ if (filePath) {
+ // Check if the path is a directory or a file
+ const node = fileTree.findNode(filePath);
+ console.log('[loadFileFromURL] Found node:', node);
+
+ if (node && node.isDirectory) {
+ // It's a directory, show directory preview
+ console.log('[loadFileFromURL] Loading directory preview');
+ await showDirectoryPreview(filePath);
+ fileTree.selectAndExpandPath(filePath);
+ } else if (node) {
+ // It's a file, check if it's binary
+ console.log('[loadFileFromURL] Loading file');
+
+ // Use the fileTree.onFileSelect callback to handle both text and binary files
+ if (fileTree.onFileSelect) {
+ fileTree.onFileSelect({ path: filePath, isDirectory: false });
+ } else {
+ // Fallback to direct loading
+ await editor.loadFile(filePath);
+ fileTree.selectAndExpandPath(filePath);
+ }
+ } else {
+ console.error(`[loadFileFromURL] Path not found in file tree: ${filePath}`);
+ }
+ }
+ } catch (error) {
+ console.error('[loadFileFromURL] Failed to load file from URL:', error);
+ }
+}
+
+/**
+ * Handle browser back/forward navigation
+ */
+function setupPopStateListener() {
+ window.addEventListener('popstate', async (event) => {
+ const { collection, filePath } = parseURLPath();
+ if (collection) {
+ // Ensure the collection is set
+ const currentCollection = collectionSelector.getCurrentCollection();
+ if (currentCollection !== collection) {
+ await collectionSelector.setCollection(collection);
+ await fileTree.load();
+ }
+
+ // Load the file/directory
+ await loadFileFromURL(collection, filePath);
+ }
+ });
+}
// Initialize application
document.addEventListener('DOMContentLoaded', async () => {
+ // Determine view mode from URL parameter
+ const urlParams = new URLSearchParams(window.location.search);
+ const isEditMode = urlParams.get('edit') === 'true';
+
+ // Set view mode class on body
+ if (isEditMode) {
+ document.body.classList.add('edit-mode');
+ document.body.classList.remove('view-mode');
+ } else {
+ document.body.classList.add('view-mode');
+ document.body.classList.remove('edit-mode');
+ }
+
// Initialize WebDAV client
webdavClient = new WebDAVClient('/fs/');
-
+
// Initialize dark mode
darkMode = new DarkMode();
document.getElementById('darkModeBtn').addEventListener('click', () => {
darkMode.toggle();
});
-
- // Initialize file tree
- fileTree = new FileTree('fileTree', webdavClient);
- fileTree.onFileSelect = async (item) => {
- await editor.loadFile(item.path);
- };
-
- // Initialize collection selector
+
+ // Initialize sidebar toggle
+ const sidebarToggle = new SidebarToggle('sidebarPane', 'sidebarToggleBtn');
+
+ // Initialize collection selector (always needed)
collectionSelector = new CollectionSelector('collectionSelect', webdavClient);
- collectionSelector.onChange = async (collection) => {
- await fileTree.load();
- };
await collectionSelector.load();
- await fileTree.load();
-
- // Initialize editor
- editor = new MarkdownEditor('editor', 'preview', 'filenameInput');
+
+ // Setup New Collection button
+ document.getElementById('newCollectionBtn').addEventListener('click', async () => {
+ try {
+ const collectionName = await window.ModalManager.prompt(
+ 'Enter new collection name (lowercase, underscore only):',
+ 'new_collection'
+ );
+
+ if (!collectionName) return;
+
+ // Validate collection name
+ const validation = ValidationUtils.validateFileName(collectionName, true);
+ if (!validation.valid) {
+ window.showNotification(validation.message, 'warning');
+ return;
+ }
+
+ // Create the collection
+ await webdavClient.createCollection(validation.sanitized);
+
+ // Reload collections and switch to the new one
+ await collectionSelector.load();
+ await collectionSelector.setCollection(validation.sanitized);
+
+ window.showNotification(`Collection "${validation.sanitized}" created`, 'success');
+ } catch (error) {
+ Logger.error('Failed to create collection:', error);
+ window.showNotification('Failed to create collection', 'error');
+ }
+ });
+
+ // Setup URL routing
+ setupPopStateListener();
+
+ // Initialize editor (always needed for preview)
+ // In view mode, editor is read-only
+ editor = new MarkdownEditor('editor', 'preview', 'filenameInput', !isEditMode);
editor.setWebDAVClient(webdavClient);
- // Add test content to verify preview works
- setTimeout(() => {
- if (!editor.editor.getValue()) {
- editor.editor.setValue('# Welcome to Markdown Editor\n\nStart typing to see preview...\n');
- editor.updatePreview();
- }
- }, 200);
-
- // Setup editor drop handler
- const editorDropHandler = new EditorDropHandler(
- document.querySelector('.editor-container'),
- async (file) => {
- await handleEditorFileDrop(file);
- }
- );
-
- // Setup button handlers
- document.getElementById('newBtn').addEventListener('click', () => {
- editor.newFile();
- });
-
- document.getElementById('saveBtn').addEventListener('click', async () => {
- await editor.save();
- });
-
- document.getElementById('deleteBtn').addEventListener('click', async () => {
- await editor.deleteFile();
- });
-
- // Setup context menu handlers
- setupContextMenuHandlers();
-
- // Initialize mermaid
- mermaid.initialize({ startOnLoad: true, theme: darkMode.isDark ? 'dark' : 'default' });
+ // Initialize file tree (needed in both modes)
+ // Pass isEditMode to control image filtering (hide images only in view mode)
+ fileTree = new FileTree('fileTree', webdavClient, isEditMode);
+ fileTree.onFileSelect = async (item) => {
+ try {
+ const currentCollection = collectionSelector.getCurrentCollection();
- // Initialize file tree actions manager
- window.fileTreeActions = new FileTreeActions(webdavClient, fileTree, editor);
+ // Check if the file is a binary/non-editable file
+ if (PathUtils.isBinaryFile(item.path)) {
+ const fileType = PathUtils.getFileType(item.path);
+ const fileName = PathUtils.getFileName(item.path);
+
+ Logger.info(`Previewing binary file: ${item.path}`);
+
+ // Initialize and show loading spinner for binary file preview
+ editor.initLoadingSpinners();
+ if (editor.previewSpinner) {
+ editor.previewSpinner.show(`Loading ${fileType.toLowerCase()}...`);
+ }
+
+ // Set flag to prevent auto-update of preview
+ editor.isShowingCustomPreview = true;
+
+ // In edit mode, show a warning notification
+ if (isEditMode) {
+ if (window.showNotification) {
+ window.showNotification(
+ `"${fileName}" is read-only. Showing preview only.`,
+ 'warning'
+ );
+ }
+
+ // Hide the editor pane temporarily
+ const editorPane = document.getElementById('editorPane');
+ const resizer1 = document.getElementById('resizer1');
+ if (editorPane) editorPane.style.display = 'none';
+ if (resizer1) resizer1.style.display = 'none';
+ }
+
+ // Clear the editor (but don't trigger preview update due to flag)
+ if (editor.editor) {
+ editor.editor.setValue('');
+ }
+ editor.filenameInput.value = item.path;
+ editor.currentFile = item.path;
+
+ // Build the file URL using the WebDAV client's method
+ const fileUrl = webdavClient.getFullUrl(item.path);
+ Logger.debug(`Binary file URL: ${fileUrl}`);
+
+ // Generate preview HTML based on file type
+ let previewHtml = '';
+
+ if (fileType === 'Image') {
+ // Preview images
+ previewHtml = `
+
+ ${fileName}
+ Image Preview (Read-only)
+
+
+ `;
+ } else if (fileType === 'PDF') {
+ // Preview PDFs
+ previewHtml = `
+
+ ${fileName}
+ PDF Preview (Read-only)
+
+
+ `;
+ } else {
+ // For other binary files, show download link
+ previewHtml = `
+
+ ${fileName}
+ ${fileType} File (Read-only)
+ This file cannot be previewed in the browser.
+ Download ${fileName}
+
+ `;
+ }
+
+ // Display in preview pane
+ editor.previewElement.innerHTML = previewHtml;
+
+ // Hide loading spinner after content is set
+ // Add small delay for images to start loading
+ setTimeout(() => {
+ if (editor.previewSpinner) {
+ editor.previewSpinner.hide();
+ }
+ }, fileType === 'Image' ? 300 : 100);
+
+ // Highlight the file in the tree
+ fileTree.selectAndExpandPath(item.path);
+
+ // Save as last viewed page (for binary files too)
+ editor.saveLastViewedPage(item.path);
+
+ // Update URL to reflect current file
+ updateURL(currentCollection, item.path, isEditMode);
+
+ return;
+ }
+
+ // For text files, restore the editor pane if it was hidden
+ if (isEditMode) {
+ const editorPane = document.getElementById('editorPane');
+ const resizer1 = document.getElementById('resizer1');
+ if (editorPane) editorPane.style.display = '';
+ if (resizer1) resizer1.style.display = '';
+ }
+
+ await editor.loadFile(item.path);
+ // Highlight the file in the tree and expand parent directories
+ fileTree.selectAndExpandPath(item.path);
+ // Update URL to reflect current file
+ updateURL(currentCollection, item.path, isEditMode);
+ } catch (error) {
+ Logger.error('Failed to select file:', error);
+ if (window.showNotification) {
+ window.showNotification('Failed to load file', 'error');
+ }
+ }
+ };
+
+ fileTree.onFolderSelect = async (item) => {
+ try {
+ // Show directory preview
+ await showDirectoryPreview(item.path);
+ // Highlight the directory in the tree and expand parent directories
+ fileTree.selectAndExpandPath(item.path);
+ // Update URL to reflect current directory
+ const currentCollection = collectionSelector.getCurrentCollection();
+ updateURL(currentCollection, item.path, isEditMode);
+ } catch (error) {
+ Logger.error('Failed to select folder:', error);
+ if (window.showNotification) {
+ window.showNotification('Failed to load folder', 'error');
+ }
+ }
+ };
+
+ collectionSelector.onChange = async (collection) => {
+ try {
+ await fileTree.load();
+ // In view mode, auto-load last viewed page when collection changes
+ if (!isEditMode) {
+ await autoLoadPageInViewMode();
+ }
+ } catch (error) {
+ Logger.error('Failed to change collection:', error);
+ if (window.showNotification) {
+ window.showNotification('Failed to change collection', 'error');
+ }
+ }
+ };
+ await fileTree.load();
+
+ // Parse URL to load file if specified
+ const { collection: urlCollection, filePath: urlFilePath } = parseURLPath();
+ console.log('[URL PARSE]', { urlCollection, urlFilePath });
+
+ if (urlCollection) {
+ // First ensure the collection is set
+ const currentCollection = collectionSelector.getCurrentCollection();
+ if (currentCollection !== urlCollection) {
+ console.log('[URL LOAD] Switching collection from', currentCollection, 'to', urlCollection);
+ await collectionSelector.setCollection(urlCollection);
+ await fileTree.load();
+ }
+
+ // If there's a file path in the URL, load it
+ if (urlFilePath) {
+ console.log('[URL LOAD] Loading file from URL:', urlCollection, urlFilePath);
+ await loadFileFromURL(urlCollection, urlFilePath);
+ } else if (!isEditMode) {
+ // Collection-only URL in view mode: auto-load last viewed page
+ console.log('[URL LOAD] Collection-only URL, auto-loading page');
+ await autoLoadPageInViewMode();
+ }
+ } else if (!isEditMode) {
+ // No URL collection specified, in view mode: auto-load last viewed page
+ await autoLoadPageInViewMode();
+ }
+
+ // Initialize file tree and editor-specific features only in edit mode
+ if (isEditMode) {
+ // Add test content to verify preview works
+ setTimeout(() => {
+ if (!editor.editor.getValue()) {
+ editor.editor.setValue('# Welcome to Markdown Editor\n\nStart typing to see preview...\n');
+ editor.updatePreview();
+ }
+ }, 200);
+
+ // Setup editor drop handler
+ const editorDropHandler = new EditorDropHandler(
+ document.querySelector('.editor-container'),
+ async (file) => {
+ try {
+ await handleEditorFileDrop(file);
+ } catch (error) {
+ Logger.error('Failed to handle file drop:', error);
+ }
+ }
+ );
+
+ // Setup button handlers
+ document.getElementById('newBtn').addEventListener('click', () => {
+ editor.newFile();
+ });
+
+ document.getElementById('saveBtn').addEventListener('click', async () => {
+ try {
+ await editor.save();
+ } catch (error) {
+ Logger.error('Failed to save file:', error);
+ if (window.showNotification) {
+ window.showNotification('Failed to save file', 'error');
+ }
+ }
+ });
+
+ document.getElementById('deleteBtn').addEventListener('click', async () => {
+ try {
+ await editor.deleteFile();
+ } catch (error) {
+ Logger.error('Failed to delete file:', error);
+ if (window.showNotification) {
+ window.showNotification('Failed to delete file', 'error');
+ }
+ }
+ });
+
+ // Setup context menu handlers
+ setupContextMenuHandlers();
+
+ // Initialize file tree actions manager
+ window.fileTreeActions = new FileTreeActions(webdavClient, fileTree, editor);
+
+ // Setup Exit Edit Mode button
+ document.getElementById('exitEditModeBtn').addEventListener('click', () => {
+ // Switch to view mode by removing edit=true from URL
+ const url = new URL(window.location.href);
+ url.searchParams.delete('edit');
+ window.location.href = url.toString();
+ });
+
+ // Hide Edit Mode button in edit mode
+ document.getElementById('editModeBtn').style.display = 'none';
+ } else {
+ // In view mode, hide editor buttons
+ document.getElementById('newBtn').style.display = 'none';
+ document.getElementById('saveBtn').style.display = 'none';
+ document.getElementById('deleteBtn').style.display = 'none';
+ document.getElementById('exitEditModeBtn').style.display = 'none';
+
+ // Show Edit Mode button in view mode
+ document.getElementById('editModeBtn').style.display = 'block';
+
+ // Setup Edit Mode button
+ document.getElementById('editModeBtn').addEventListener('click', () => {
+ // Switch to edit mode by adding edit=true to URL
+ const url = new URL(window.location.href);
+ url.searchParams.set('edit', 'true');
+ window.location.href = url.toString();
+ });
+
+ // Auto-load last viewed page or first file
+ await autoLoadPageInViewMode();
+ }
+
+ // Setup clickable navbar brand (logo/title)
+ const navbarBrand = document.getElementById('navbarBrand');
+ if (navbarBrand) {
+ navbarBrand.addEventListener('click', (e) => {
+ e.preventDefault();
+ const currentCollection = collectionSelector ? collectionSelector.getCurrentCollection() : null;
+ if (currentCollection) {
+ // Navigate to collection root
+ window.location.href = `/${currentCollection}/`;
+ } else {
+ // Navigate to home page
+ window.location.href = '/';
+ }
+ });
+ }
+
+ // Initialize mermaid (always needed)
+ mermaid.initialize({ startOnLoad: true, theme: darkMode.isDark ? 'dark' : 'default' });
// Listen for file-saved event to reload file tree
window.eventBus.on('file-saved', async (path) => {
- if (fileTree) {
- await fileTree.load();
- fileTree.selectNode(path);
+ try {
+ if (fileTree) {
+ await fileTree.load();
+ fileTree.selectNode(path);
+ }
+ } catch (error) {
+ Logger.error('Failed to reload file tree after save:', error);
}
});
window.eventBus.on('file-deleted', async () => {
- if (fileTree) {
- await fileTree.load();
+ try {
+ if (fileTree) {
+ await fileTree.load();
+ }
+ } catch (error) {
+ Logger.error('Failed to reload file tree after delete:', error);
}
});
});
@@ -126,17 +654,17 @@ window.addEventListener('column-resize', () => {
*/
function setupContextMenuHandlers() {
const menu = document.getElementById('contextMenu');
-
+
menu.addEventListener('click', async (e) => {
const item = e.target.closest('.context-menu-item');
if (!item) return;
-
+
const action = item.dataset.action;
const targetPath = menu.dataset.targetPath;
const isDir = menu.dataset.targetIsDir === 'true';
-
+
hideContextMenu();
-
+
await window.fileTreeActions.execute(action, targetPath, isDir);
});
}
@@ -163,16 +691,17 @@ async function handleEditorFileDrop(file) {
parts.pop(); // Remove filename
targetDir = parts.join('/');
}
-
+
// Upload file
const uploadedPath = await fileTree.uploadFile(targetDir, file);
-
+
// Insert markdown link at cursor
+ // Use relative path (without collection name) so the image renderer can resolve it correctly
const isImage = file.type.startsWith('image/');
- const link = isImage
- ? ``
- : `[${file.name}](/${webdavClient.currentCollection}/${uploadedPath})`;
-
+ const link = isImage
+ ? ``
+ : `[${file.name}](${uploadedPath})`;
+
editor.insertAtCursor(link);
showNotification(`Uploaded and inserted link`, 'success');
} catch (error) {
diff --git a/static/js/collection-selector.js b/static/js/collection-selector.js
new file mode 100644
index 0000000..3c24665
--- /dev/null
+++ b/static/js/collection-selector.js
@@ -0,0 +1,152 @@
+/**
+ * Collection Selector Module
+ * Manages the collection dropdown selector and persistence
+ */
+
+class CollectionSelector {
+ constructor(selectId, webdavClient) {
+ this.select = document.getElementById(selectId);
+ this.webdavClient = webdavClient;
+ this.onChange = null;
+ this.storageKey = Config.STORAGE_KEYS.SELECTED_COLLECTION;
+ }
+
+ /**
+ * Load collections from WebDAV and populate the selector
+ */
+ async load() {
+ try {
+ const collections = await this.webdavClient.getCollections();
+ this.select.innerHTML = '';
+
+ collections.forEach(collection => {
+ const option = document.createElement('option');
+ option.value = collection;
+ option.textContent = collection;
+ this.select.appendChild(option);
+ });
+
+ // Determine which collection to select (priority: URL > localStorage > first)
+ let collectionToSelect = collections[0]; // Default to first
+
+ // Check URL first (highest priority)
+ const urlCollection = this.getCollectionFromURL();
+ if (urlCollection && collections.includes(urlCollection)) {
+ collectionToSelect = urlCollection;
+ Logger.info(`Using collection from URL: ${urlCollection}`);
+ } else {
+ // Fall back to localStorage
+ const savedCollection = localStorage.getItem(this.storageKey);
+ if (savedCollection && collections.includes(savedCollection)) {
+ collectionToSelect = savedCollection;
+ Logger.info(`Using collection from localStorage: ${savedCollection}`);
+ }
+ }
+
+ if (collections.length > 0) {
+ this.select.value = collectionToSelect;
+ this.webdavClient.setCollection(collectionToSelect);
+ if (this.onChange) {
+ this.onChange(collectionToSelect);
+ }
+ }
+
+ // Add change listener
+ this.select.addEventListener('change', () => {
+ const collection = this.select.value;
+ // Save to localStorage
+ localStorage.setItem(this.storageKey, collection);
+ this.webdavClient.setCollection(collection);
+
+ Logger.info(`Collection changed to: ${collection}`);
+
+ // Update URL to reflect collection change
+ this.updateURLForCollection(collection);
+
+ if (this.onChange) {
+ this.onChange(collection);
+ }
+ });
+
+ Logger.debug(`Loaded ${collections.length} collections`);
+ } catch (error) {
+ Logger.error('Failed to load collections:', error);
+ if (window.showNotification) {
+ window.showNotification('Failed to load collections', 'error');
+ }
+ }
+ }
+
+ /**
+ * Get the currently selected collection
+ * @returns {string} The collection name
+ */
+ getCurrentCollection() {
+ return this.select.value;
+ }
+
+ /**
+ * Set the collection to a specific value
+ * @param {string} collection - The collection name to set
+ */
+ async setCollection(collection) {
+ const collections = Array.from(this.select.options).map(opt => opt.value);
+ if (collections.includes(collection)) {
+ this.select.value = collection;
+ localStorage.setItem(this.storageKey, collection);
+ this.webdavClient.setCollection(collection);
+
+ Logger.info(`Collection set to: ${collection}`);
+
+ // Update URL to reflect collection change
+ this.updateURLForCollection(collection);
+
+ if (this.onChange) {
+ this.onChange(collection);
+ }
+ } else {
+ Logger.warn(`Collection "${collection}" not found in available collections`);
+ }
+ }
+
+ /**
+ * Update the browser URL to reflect the current collection
+ * @param {string} collection - The collection name
+ */
+ updateURLForCollection(collection) {
+ // Get current URL parameters
+ const urlParams = new URLSearchParams(window.location.search);
+ const isEditMode = urlParams.get('edit') === 'true';
+
+ // Build new URL with collection
+ let url = `/${collection}/`;
+ if (isEditMode) {
+ url += '?edit=true';
+ }
+
+ // Use pushState to update URL without reloading
+ window.history.pushState({ collection, filePath: null }, '', url);
+ Logger.debug(`Updated URL to: ${url}`);
+ }
+
+ /**
+ * Extract collection name from current URL
+ * URL format: // or //
+ * @returns {string|null} The collection name or null if not found
+ */
+ getCollectionFromURL() {
+ const pathname = window.location.pathname;
+ const parts = pathname.split('/').filter(p => p); // Remove empty parts
+
+ if (parts.length === 0) {
+ return null;
+ }
+
+ // First part is the collection
+ return parts[0];
+ }
+}
+
+// Make CollectionSelector globally available
+window.CollectionSelector = CollectionSelector;
+
diff --git a/static/js/column-resizer.js b/static/js/column-resizer.js
index c00ef06..f571eec 100644
--- a/static/js/column-resizer.js
+++ b/static/js/column-resizer.js
@@ -10,68 +10,67 @@ class ColumnResizer {
this.sidebarPane = document.getElementById('sidebarPane');
this.editorPane = document.getElementById('editorPane');
this.previewPane = document.getElementById('previewPane');
-
+
// Load saved dimensions
this.loadDimensions();
-
+
// Setup listeners
this.setupResizers();
}
-
+
setupResizers() {
this.resizer1.addEventListener('mousedown', (e) => this.startResize(e, 1));
this.resizer2.addEventListener('mousedown', (e) => this.startResize(e, 2));
}
-
+
startResize(e, resizerId) {
e.preventDefault();
-
+
const startX = e.clientX;
const startWidth1 = this.sidebarPane.offsetWidth;
const startWidth2 = this.editorPane.offsetWidth;
const containerWidth = this.sidebarPane.parentElement.offsetWidth;
-
+
const resizer = resizerId === 1 ? this.resizer1 : this.resizer2;
resizer.classList.add('dragging');
-
+
const handleMouseMove = (moveEvent) => {
const deltaX = moveEvent.clientX - startX;
-
+
if (resizerId === 1) {
// Resize sidebar and editor
const newWidth1 = Math.max(150, Math.min(40 * containerWidth / 100, startWidth1 + deltaX));
const newWidth2 = startWidth2 - (newWidth1 - startWidth1);
-
+
this.sidebarPane.style.flex = `0 0 ${newWidth1}px`;
this.editorPane.style.flex = `1 1 ${newWidth2}px`;
} else if (resizerId === 2) {
// Resize editor and preview
const newWidth2 = Math.max(250, Math.min(70 * containerWidth / 100, startWidth2 + deltaX));
const containerFlex = this.sidebarPane.offsetWidth;
-
+
this.editorPane.style.flex = `0 0 ${newWidth2}px`;
- this.previewPane.style.flex = `1 1 auto`;
}
};
-
+
const handleMouseUp = () => {
resizer.classList.remove('dragging');
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
-
+
// Save dimensions
this.saveDimensions();
-
+
// Trigger editor resize
if (window.editor && window.editor.editor) {
window.editor.editor.refresh();
}
};
-
+
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
}
-
+
saveDimensions() {
const dimensions = {
sidebar: this.sidebarPane.offsetWidth,
@@ -80,16 +79,15 @@ class ColumnResizer {
};
localStorage.setItem('columnDimensions', JSON.stringify(dimensions));
}
-
+
loadDimensions() {
const saved = localStorage.getItem('columnDimensions');
if (!saved) return;
-
+
try {
const { sidebar, editor, preview } = JSON.parse(saved);
this.sidebarPane.style.flex = `0 0 ${sidebar}px`;
this.editorPane.style.flex = `0 0 ${editor}px`;
- this.previewPane.style.flex = `1 1 auto`;
} catch (error) {
console.error('Failed to load column dimensions:', error);
}
diff --git a/static/js/config.js b/static/js/config.js
new file mode 100644
index 0000000..219a6c2
--- /dev/null
+++ b/static/js/config.js
@@ -0,0 +1,207 @@
+/**
+ * Application Configuration
+ * Centralized configuration values for the markdown editor
+ */
+
+const Config = {
+ // ===== TIMING CONFIGURATION =====
+
+ /**
+ * Long-press threshold in milliseconds
+ * Used for drag-and-drop detection in file tree
+ */
+ LONG_PRESS_THRESHOLD: 400,
+
+ /**
+ * Debounce delay in milliseconds
+ * Used for editor preview updates
+ */
+ DEBOUNCE_DELAY: 300,
+
+ /**
+ * Toast notification duration in milliseconds
+ */
+ TOAST_DURATION: 3000,
+
+ /**
+ * Mouse move threshold in pixels
+ * Used to detect if user is dragging vs clicking
+ */
+ MOUSE_MOVE_THRESHOLD: 5,
+
+ // ===== UI CONFIGURATION =====
+
+ /**
+ * Drag preview width in pixels
+ * Width of the drag ghost image during drag-and-drop
+ */
+ DRAG_PREVIEW_WIDTH: 200,
+
+ /**
+ * Tree indentation in pixels
+ * Indentation per level in the file tree
+ */
+ TREE_INDENT_PX: 12,
+
+ /**
+ * Toast container z-index
+ * Ensures toasts appear above other elements
+ */
+ TOAST_Z_INDEX: 9999,
+
+ /**
+ * Minimum sidebar width in pixels
+ */
+ MIN_SIDEBAR_WIDTH: 150,
+
+ /**
+ * Maximum sidebar width as percentage of container
+ */
+ MAX_SIDEBAR_WIDTH_PERCENT: 40,
+
+ /**
+ * Minimum editor width in pixels
+ */
+ MIN_EDITOR_WIDTH: 250,
+
+ /**
+ * Maximum editor width as percentage of container
+ */
+ MAX_EDITOR_WIDTH_PERCENT: 70,
+
+ // ===== VALIDATION CONFIGURATION =====
+
+ /**
+ * Valid filename pattern
+ * Only lowercase letters, numbers, underscores, and dots allowed
+ */
+ FILENAME_PATTERN: /^[a-z0-9_]+(\.[a-z0-9_]+)*$/,
+
+ /**
+ * Characters to replace in filenames
+ * All invalid characters will be replaced with underscore
+ */
+ FILENAME_INVALID_CHARS: /[^a-z0-9_.]/g,
+
+ // ===== STORAGE KEYS =====
+
+ /**
+ * LocalStorage keys used throughout the application
+ */
+ STORAGE_KEYS: {
+ /**
+ * Dark mode preference
+ */
+ DARK_MODE: 'darkMode',
+
+ /**
+ * Currently selected collection
+ */
+ SELECTED_COLLECTION: 'selectedCollection',
+
+ /**
+ * Last viewed page (per collection)
+ * Actual key will be: lastViewedPage:{collection}
+ */
+ LAST_VIEWED_PAGE: 'lastViewedPage',
+
+ /**
+ * Column dimensions (sidebar, editor, preview widths)
+ */
+ COLUMN_DIMENSIONS: 'columnDimensions',
+
+ /**
+ * Sidebar collapsed state
+ */
+ SIDEBAR_COLLAPSED: 'sidebarCollapsed'
+ },
+
+ // ===== EDITOR CONFIGURATION =====
+
+ /**
+ * CodeMirror theme for light mode
+ */
+ EDITOR_THEME_LIGHT: 'default',
+
+ /**
+ * CodeMirror theme for dark mode
+ */
+ EDITOR_THEME_DARK: 'monokai',
+
+ /**
+ * Mermaid theme for light mode
+ */
+ MERMAID_THEME_LIGHT: 'default',
+
+ /**
+ * Mermaid theme for dark mode
+ */
+ MERMAID_THEME_DARK: 'dark',
+
+ // ===== FILE TREE CONFIGURATION =====
+
+ /**
+ * Default content for new files
+ */
+ DEFAULT_FILE_CONTENT: '# New File\n\n',
+
+ /**
+ * Default filename for new files
+ */
+ DEFAULT_NEW_FILENAME: 'new_file.md',
+
+ /**
+ * Default folder name for new folders
+ */
+ DEFAULT_NEW_FOLDERNAME: 'new_folder',
+
+ // ===== WEBDAV CONFIGURATION =====
+
+ /**
+ * WebDAV base URL
+ */
+ WEBDAV_BASE_URL: '/fs/',
+
+ /**
+ * PROPFIND depth for file tree loading
+ */
+ PROPFIND_DEPTH: 'infinity',
+
+ // ===== DRAG AND DROP CONFIGURATION =====
+
+ /**
+ * Drag preview opacity
+ */
+ DRAG_PREVIEW_OPACITY: 0.8,
+
+ /**
+ * Dragging item opacity
+ */
+ DRAGGING_OPACITY: 0.4,
+
+ /**
+ * Drag preview offset X in pixels
+ */
+ DRAG_PREVIEW_OFFSET_X: 10,
+
+ /**
+ * Drag preview offset Y in pixels
+ */
+ DRAG_PREVIEW_OFFSET_Y: 10,
+
+ // ===== NOTIFICATION TYPES =====
+
+ /**
+ * Bootstrap notification type mappings
+ */
+ NOTIFICATION_TYPES: {
+ SUCCESS: 'success',
+ ERROR: 'danger',
+ WARNING: 'warning',
+ INFO: 'primary'
+ }
+};
+
+// Make Config globally available
+window.Config = Config;
+
diff --git a/static/js/confirmation.js b/static/js/confirmation.js
index 6582ac6..1d412ef 100644
--- a/static/js/confirmation.js
+++ b/static/js/confirmation.js
@@ -1,68 +1,180 @@
/**
- * Confirmation Modal Manager
+ * Unified Modal Manager
* Handles showing and hiding a Bootstrap modal for confirmations and prompts.
+ * Uses a single reusable modal element to prevent double-opening issues.
*/
-class Confirmation {
+class ModalManager {
constructor(modalId) {
this.modalElement = document.getElementById(modalId);
- this.modal = new bootstrap.Modal(this.modalElement);
+ if (!this.modalElement) {
+ console.error(`Modal element with id "${modalId}" not found`);
+ return;
+ }
+
+ this.modal = new bootstrap.Modal(this.modalElement, {
+ backdrop: 'static',
+ keyboard: true
+ });
+
this.messageElement = this.modalElement.querySelector('#confirmationMessage');
this.inputElement = this.modalElement.querySelector('#confirmationInput');
this.confirmButton = this.modalElement.querySelector('#confirmButton');
+ this.cancelButton = this.modalElement.querySelector('[data-bs-dismiss="modal"]');
this.titleElement = this.modalElement.querySelector('.modal-title');
this.currentResolver = null;
+ this.isShowing = false;
}
- _show(message, title, showInput = false, defaultValue = '') {
+ /**
+ * Show a confirmation dialog
+ * @param {string} message - The message to display
+ * @param {string} title - The dialog title
+ * @param {boolean} isDangerous - Whether this is a dangerous action (shows red button)
+ * @returns {Promise} - Resolves to true if confirmed, false/null if cancelled
+ */
+ confirm(message, title = 'Confirmation', isDangerous = false) {
return new Promise((resolve) => {
+ // Prevent double-opening
+ if (this.isShowing) {
+ console.warn('Modal is already showing, ignoring duplicate request');
+ resolve(null);
+ return;
+ }
+
+ this.isShowing = true;
this.currentResolver = resolve;
this.titleElement.textContent = title;
this.messageElement.textContent = message;
+ this.inputElement.style.display = 'none';
- if (showInput) {
- this.inputElement.style.display = 'block';
- this.inputElement.value = defaultValue;
- this.inputElement.focus();
+ // Update button styling based on danger level
+ if (isDangerous) {
+ this.confirmButton.className = 'btn-flat btn-flat-danger';
+ this.confirmButton.innerHTML = ' Delete';
} else {
- this.inputElement.style.display = 'none';
+ this.confirmButton.className = 'btn-flat btn-flat-primary';
+ this.confirmButton.innerHTML = ' OK';
}
- this.confirmButton.onclick = () => this._handleConfirm(showInput);
- this.modalElement.addEventListener('hidden.bs.modal', () => this._handleCancel(), { once: true });
-
+ // Set up event handlers
+ this.confirmButton.onclick = (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ this._handleConfirm(false);
+ };
+
+ // Handle modal hidden event for cleanup
+ this.modalElement.addEventListener('hidden.bs.modal', () => {
+ if (this.currentResolver) {
+ this._handleCancel();
+ }
+ }, { once: true });
+
+ // Remove aria-hidden before showing to prevent accessibility warning
+ this.modalElement.removeAttribute('aria-hidden');
+
this.modal.show();
+
+ // Focus confirm button after modal is shown
+ this.modalElement.addEventListener('shown.bs.modal', () => {
+ this.confirmButton.focus();
+ }, { once: true });
+ });
+ }
+
+ /**
+ * Show a prompt dialog (input dialog)
+ * @param {string} message - The message/label to display
+ * @param {string} defaultValue - The default input value
+ * @param {string} title - The dialog title
+ * @returns {Promise} - Resolves to input value if confirmed, null if cancelled
+ */
+ prompt(message, defaultValue = '', title = 'Input') {
+ return new Promise((resolve) => {
+ // Prevent double-opening
+ if (this.isShowing) {
+ console.warn('Modal is already showing, ignoring duplicate request');
+ resolve(null);
+ return;
+ }
+
+ this.isShowing = true;
+ this.currentResolver = resolve;
+ this.titleElement.textContent = title;
+ this.messageElement.textContent = message;
+ this.inputElement.style.display = 'block';
+ this.inputElement.value = defaultValue;
+
+ // Reset button to primary style for prompts
+ this.confirmButton.className = 'btn-flat btn-flat-primary';
+ this.confirmButton.innerHTML = ' OK';
+
+ // Set up event handlers
+ this.confirmButton.onclick = (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ this._handleConfirm(true);
+ };
+
+ // Handle Enter key in input
+ this.inputElement.onkeydown = (e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ this._handleConfirm(true);
+ }
+ };
+
+ // Handle modal hidden event for cleanup
+ this.modalElement.addEventListener('hidden.bs.modal', () => {
+ if (this.currentResolver) {
+ this._handleCancel();
+ }
+ }, { once: true });
+
+ // Remove aria-hidden before showing to prevent accessibility warning
+ this.modalElement.removeAttribute('aria-hidden');
+
+ this.modal.show();
+
+ // Focus and select input after modal is shown
+ this.modalElement.addEventListener('shown.bs.modal', () => {
+ this.inputElement.focus();
+ this.inputElement.select();
+ }, { once: true });
});
}
_handleConfirm(isPrompt) {
if (this.currentResolver) {
- const value = isPrompt ? this.inputElement.value : true;
- this.currentResolver(value);
+ const value = isPrompt ? this.inputElement.value.trim() : true;
+ const resolver = this.currentResolver;
this._cleanup();
+ resolver(value);
}
}
_handleCancel() {
if (this.currentResolver) {
- this.currentResolver(null); // Resolve with null for cancellation
+ const resolver = this.currentResolver;
this._cleanup();
+ resolver(null);
}
}
_cleanup() {
this.confirmButton.onclick = null;
- this.modal.hide();
+ this.inputElement.onkeydown = null;
this.currentResolver = null;
- }
+ this.isShowing = false;
+ this.modal.hide();
- confirm(message, title = 'Confirmation') {
- return this._show(message, title, false);
- }
-
- prompt(message, defaultValue = '', title = 'Prompt') {
- return this._show(message, title, true, defaultValue);
+ // Restore aria-hidden after modal is hidden
+ this.modalElement.addEventListener('hidden.bs.modal', () => {
+ this.modalElement.setAttribute('aria-hidden', 'true');
+ }, { once: true });
}
}
// Make it globally available
-window.ConfirmationManager = new Confirmation('confirmationModal');
+window.ConfirmationManager = new ModalManager('confirmationModal');
+window.ModalManager = window.ConfirmationManager; // Alias for clarity
diff --git a/static/js/context-menu.js b/static/js/context-menu.js
new file mode 100644
index 0000000..27b8722
--- /dev/null
+++ b/static/js/context-menu.js
@@ -0,0 +1,89 @@
+/**
+ * Context Menu Module
+ * Handles the right-click context menu for file tree items
+ */
+
+/**
+ * Show context menu at specified position
+ * @param {number} x - X coordinate
+ * @param {number} y - Y coordinate
+ * @param {Object} target - Target object with path and isDir properties
+ */
+function showContextMenu(x, y, target) {
+ const menu = document.getElementById('contextMenu');
+ if (!menu) return;
+
+ // Store target data
+ menu.dataset.targetPath = target.path;
+ menu.dataset.targetIsDir = target.isDir;
+
+ // Show/hide menu items based on target type
+ const items = {
+ 'new-file': target.isDir,
+ 'new-folder': target.isDir,
+ 'upload': target.isDir,
+ 'download': true,
+ 'paste': target.isDir && window.fileTreeActions?.clipboard,
+ 'open': !target.isDir
+ };
+
+ Object.entries(items).forEach(([action, show]) => {
+ const item = menu.querySelector(`[data-action="${action}"]`);
+ if (item) {
+ item.style.display = show ? 'flex' : 'none';
+ }
+ });
+
+ // Position menu
+ menu.style.display = 'block';
+ menu.style.left = x + 'px';
+ menu.style.top = y + 'px';
+
+ // Adjust if off-screen
+ setTimeout(() => {
+ const rect = menu.getBoundingClientRect();
+ if (rect.right > window.innerWidth) {
+ menu.style.left = (window.innerWidth - rect.width - 10) + 'px';
+ }
+ if (rect.bottom > window.innerHeight) {
+ menu.style.top = (window.innerHeight - rect.height - 10) + 'px';
+ }
+ }, 0);
+}
+
+/**
+ * Hide the context menu
+ */
+function hideContextMenu() {
+ const menu = document.getElementById('contextMenu');
+ if (menu) {
+ menu.style.display = 'none';
+ }
+}
+
+// Combined click handler for context menu and outside clicks
+document.addEventListener('click', async (e) => {
+ const menuItem = e.target.closest('.context-menu-item');
+
+ if (menuItem) {
+ // Handle context menu item click
+ const action = menuItem.dataset.action;
+ const menu = document.getElementById('contextMenu');
+ const targetPath = menu.dataset.targetPath;
+ const isDir = menu.dataset.targetIsDir === 'true';
+
+ hideContextMenu();
+
+ if (window.fileTreeActions) {
+ await window.fileTreeActions.execute(action, targetPath, isDir);
+ }
+ } else if (!e.target.closest('#contextMenu') && !e.target.closest('.tree-node')) {
+ // Hide on outside click
+ hideContextMenu();
+ }
+});
+
+// Make functions globally available
+window.showContextMenu = showContextMenu;
+window.hideContextMenu = hideContextMenu;
+
diff --git a/static/js/dark-mode.js b/static/js/dark-mode.js
new file mode 100644
index 0000000..8798467
--- /dev/null
+++ b/static/js/dark-mode.js
@@ -0,0 +1,77 @@
+/**
+ * Dark Mode Module
+ * Manages dark mode theme switching and persistence
+ */
+
+class DarkMode {
+ constructor() {
+ this.isDark = localStorage.getItem(Config.STORAGE_KEYS.DARK_MODE) === 'true';
+ this.apply();
+ }
+
+ /**
+ * Toggle dark mode on/off
+ */
+ toggle() {
+ this.isDark = !this.isDark;
+ localStorage.setItem(Config.STORAGE_KEYS.DARK_MODE, this.isDark);
+ this.apply();
+
+ Logger.debug(`Dark mode ${this.isDark ? 'enabled' : 'disabled'}`);
+ }
+
+ /**
+ * Apply the current dark mode state
+ */
+ apply() {
+ if (this.isDark) {
+ document.body.classList.add('dark-mode');
+ const btn = document.getElementById('darkModeBtn');
+ if (btn) btn.innerHTML = '';
+
+ // Update mermaid theme
+ if (window.mermaid) {
+ mermaid.initialize({ theme: Config.MERMAID_THEME_DARK });
+ }
+ } else {
+ document.body.classList.remove('dark-mode');
+ const btn = document.getElementById('darkModeBtn');
+ if (btn) btn.innerHTML = '';
+
+ // Update mermaid theme
+ if (window.mermaid) {
+ mermaid.initialize({ theme: Config.MERMAID_THEME_LIGHT });
+ }
+ }
+ }
+
+ /**
+ * Check if dark mode is currently enabled
+ * @returns {boolean} True if dark mode is enabled
+ */
+ isEnabled() {
+ return this.isDark;
+ }
+
+ /**
+ * Enable dark mode
+ */
+ enable() {
+ if (!this.isDark) {
+ this.toggle();
+ }
+ }
+
+ /**
+ * Disable dark mode
+ */
+ disable() {
+ if (this.isDark) {
+ this.toggle();
+ }
+ }
+}
+
+// Make DarkMode globally available
+window.DarkMode = DarkMode;
+
diff --git a/static/js/editor-drop-handler.js b/static/js/editor-drop-handler.js
new file mode 100644
index 0000000..cb8312f
--- /dev/null
+++ b/static/js/editor-drop-handler.js
@@ -0,0 +1,67 @@
+/**
+ * Editor Drop Handler Module
+ * Handles file drops into the editor for uploading
+ */
+
+class EditorDropHandler {
+ constructor(editorElement, onFileDrop) {
+ this.editorElement = editorElement;
+ this.onFileDrop = onFileDrop;
+ this.setupHandlers();
+ }
+
+ /**
+ * Setup drag and drop event handlers
+ */
+ setupHandlers() {
+ this.editorElement.addEventListener('dragover', (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ this.editorElement.classList.add('drag-over');
+ });
+
+ this.editorElement.addEventListener('dragleave', (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ this.editorElement.classList.remove('drag-over');
+ });
+
+ this.editorElement.addEventListener('drop', async (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ this.editorElement.classList.remove('drag-over');
+
+ const files = Array.from(e.dataTransfer.files);
+ if (files.length === 0) return;
+
+ Logger.debug(`Dropped ${files.length} file(s) into editor`);
+
+ for (const file of files) {
+ try {
+ if (this.onFileDrop) {
+ await this.onFileDrop(file);
+ }
+ } catch (error) {
+ Logger.error('Drop failed:', error);
+ if (window.showNotification) {
+ window.showNotification(`Failed to upload ${file.name}`, 'error');
+ }
+ }
+ }
+ });
+ }
+
+ /**
+ * Remove event handlers
+ */
+ destroy() {
+ // Note: We can't easily remove the event listeners without keeping references
+ // This is a limitation of the current implementation
+ // In a future refactor, we could store the bound handlers
+ Logger.debug('EditorDropHandler destroyed');
+ }
+}
+
+// Make EditorDropHandler globally available
+window.EditorDropHandler = EditorDropHandler;
+
diff --git a/static/js/editor.js b/static/js/editor.js
index c7042ca..d718c48 100644
--- a/static/js/editor.js
+++ b/static/js/editor.js
@@ -4,15 +4,26 @@
*/
class MarkdownEditor {
- constructor(editorId, previewId, filenameInputId) {
+ constructor(editorId, previewId, filenameInputId, readOnly = false) {
this.editorElement = document.getElementById(editorId);
this.previewElement = document.getElementById(previewId);
this.filenameInput = document.getElementById(filenameInputId);
this.currentFile = null;
this.webdavClient = null;
this.macroProcessor = new MacroProcessor(null); // Will be set later
-
- this.initCodeMirror();
+ this.lastViewedStorageKey = 'lastViewedPage'; // localStorage key for tracking last viewed page
+ this.readOnly = readOnly; // Whether editor is in read-only mode
+ this.editor = null; // Will be initialized later
+ this.isShowingCustomPreview = false; // Flag to prevent auto-update when showing binary files
+
+ // Initialize loading spinners (will be created lazily when needed)
+ this.editorSpinner = null;
+ this.previewSpinner = null;
+
+ // Only initialize CodeMirror if not in read-only mode (view mode)
+ if (!readOnly) {
+ this.initCodeMirror();
+ }
this.initMarkdown();
this.initMermaid();
}
@@ -21,22 +32,27 @@ class MarkdownEditor {
* Initialize CodeMirror
*/
initCodeMirror() {
+ // Determine theme based on dark mode
+ const isDarkMode = document.body.classList.contains('dark-mode');
+ const theme = isDarkMode ? 'monokai' : 'default';
+
this.editor = CodeMirror(this.editorElement, {
mode: 'markdown',
- theme: 'monokai',
+ theme: theme,
lineNumbers: true,
lineWrapping: true,
- autofocus: true,
- extraKeys: {
+ autofocus: !this.readOnly, // Don't autofocus in read-only mode
+ readOnly: this.readOnly, // Set read-only mode
+ extraKeys: this.readOnly ? {} : {
'Ctrl-S': () => this.save(),
'Cmd-S': () => this.save()
}
});
// Update preview on change with debouncing
- this.editor.on('change', this.debounce(() => {
+ this.editor.on('change', TimingUtils.debounce(() => {
this.updatePreview();
- }, 300));
+ }, Config.DEBOUNCE_DELAY));
// Initial preview render
setTimeout(() => {
@@ -47,6 +63,27 @@ class MarkdownEditor {
this.editor.on('scroll', () => {
this.syncScroll();
});
+
+ // Listen for dark mode changes
+ this.setupThemeListener();
+ }
+
+ /**
+ * Setup listener for dark mode changes
+ */
+ setupThemeListener() {
+ // Watch for dark mode class changes
+ const observer = new MutationObserver((mutations) => {
+ mutations.forEach((mutation) => {
+ if (mutation.attributeName === 'class') {
+ const isDarkMode = document.body.classList.contains('dark-mode');
+ const newTheme = isDarkMode ? 'monokai' : 'default';
+ this.editor.setOption('theme', newTheme);
+ }
+ });
+ });
+
+ observer.observe(document.body, { attributes: true });
}
/**
@@ -55,9 +92,88 @@ class MarkdownEditor {
initMarkdown() {
if (window.marked) {
this.marked = window.marked;
+
+ // Create custom renderer for images
+ const renderer = new marked.Renderer();
+
+ renderer.image = (token) => {
+ // Handle both old API (string params) and new API (token object)
+ let href, title, text;
+
+ if (typeof token === 'object' && token !== null) {
+ // New API: token is an object
+ href = token.href || '';
+ title = token.title || '';
+ text = token.text || '';
+ } else {
+ // Old API: separate parameters (href, title, text)
+ href = arguments[0] || '';
+ title = arguments[1] || '';
+ text = arguments[2] || '';
+ }
+
+ // Ensure all are strings
+ href = String(href || '');
+ title = String(title || '');
+ text = String(text || '');
+
+ Logger.debug(`Image renderer called with href="${href}", title="${title}", text="${text}"`);
+
+ // Check if href contains binary data (starts with non-printable characters)
+ if (href && href.length > 100 && /^[\x00-\x1F\x7F-\xFF]/.test(href)) {
+ Logger.error('Image href contains binary data - this should not happen!');
+ Logger.error('First 50 chars:', href.substring(0, 50));
+ // Return a placeholder image
+ return `⚠️ Invalid image data detected. Please re-upload the image.`;
+ }
+
+ // Fix relative image paths to use WebDAV base URL
+ if (href && !href.startsWith('http://') && !href.startsWith('https://') && !href.startsWith('data:')) {
+ // Get the directory of the current file
+ const currentDir = this.currentFile ? PathUtils.getParentPath(this.currentFile) : '';
+
+ // Resolve relative path
+ let imagePath = href;
+ if (href.startsWith('./')) {
+ // Relative to current directory
+ imagePath = PathUtils.joinPaths(currentDir, href.substring(2));
+ } else if (href.startsWith('../')) {
+ // Relative to parent directory
+ imagePath = PathUtils.joinPaths(currentDir, href);
+ } else if (!href.startsWith('/')) {
+ // Relative to current directory (no ./)
+ imagePath = PathUtils.joinPaths(currentDir, href);
+ } else {
+ // Absolute path from collection root
+ imagePath = href.substring(1); // Remove leading /
+ }
+
+ // Build WebDAV URL - ensure no double slashes
+ if (this.webdavClient && this.webdavClient.currentCollection) {
+ // Remove trailing slash from baseUrl if present
+ const baseUrl = this.webdavClient.baseUrl.endsWith('/')
+ ? this.webdavClient.baseUrl.slice(0, -1)
+ : this.webdavClient.baseUrl;
+
+ // Ensure imagePath doesn't start with /
+ const cleanImagePath = imagePath.startsWith('/') ? imagePath.substring(1) : imagePath;
+
+ href = `${baseUrl}/${this.webdavClient.currentCollection}/${cleanImagePath}`;
+
+ Logger.debug(`Resolved image URL: ${href}`);
+ }
+ }
+
+ // Generate HTML directly
+ const titleAttr = title ? ` title="${title}"` : '';
+ const altAttr = text ? ` alt="${text}"` : '';
+ return `
`;
+ };
+
this.marked.setOptions({
breaks: true,
gfm: true,
+ renderer: renderer,
highlight: (code, lang) => {
if (lang && window.Prism.languages[lang]) {
return window.Prism.highlight(code, window.Prism.languages[lang], lang);
@@ -87,28 +203,81 @@ class MarkdownEditor {
*/
setWebDAVClient(client) {
this.webdavClient = client;
-
+
// Update macro processor with client
if (this.macroProcessor) {
this.macroProcessor.webdavClient = client;
}
}
+ /**
+ * Initialize loading spinners (lazy initialization)
+ */
+ initLoadingSpinners() {
+ if (!this.editorSpinner && !this.readOnly && this.editorElement) {
+ this.editorSpinner = new LoadingSpinner(this.editorElement, 'Loading file...');
+ }
+ if (!this.previewSpinner && this.previewElement) {
+ this.previewSpinner = new LoadingSpinner(this.previewElement, 'Rendering preview...');
+ }
+ }
+
/**
* Load file
*/
async loadFile(path) {
try {
+ // Initialize loading spinners if not already done
+ this.initLoadingSpinners();
+
+ // Show loading spinners
+ if (this.editorSpinner) {
+ this.editorSpinner.show('Loading file...');
+ }
+ if (this.previewSpinner) {
+ this.previewSpinner.show('Loading preview...');
+ }
+
+ // Reset custom preview flag when loading text files
+ this.isShowingCustomPreview = false;
+
const content = await this.webdavClient.get(path);
this.currentFile = path;
- this.filenameInput.value = path;
- this.editor.setValue(content);
- this.updatePreview();
-
- if (window.showNotification) {
- window.showNotification(`Loaded ${path}`, 'info');
+
+ // Update filename input if it exists
+ if (this.filenameInput) {
+ this.filenameInput.value = path;
}
+
+ // Update editor if it exists (edit mode)
+ if (this.editor) {
+ this.editor.setValue(content);
+ }
+
+ // Update preview with the loaded content
+ await this.renderPreview(content);
+
+ // Save as last viewed page
+ this.saveLastViewedPage(path);
+
+ // Hide loading spinners
+ if (this.editorSpinner) {
+ this.editorSpinner.hide();
+ }
+ if (this.previewSpinner) {
+ this.previewSpinner.hide();
+ }
+
+ // No notification for successful file load - it's not critical
} catch (error) {
+ // Hide loading spinners on error
+ if (this.editorSpinner) {
+ this.editorSpinner.hide();
+ }
+ if (this.previewSpinner) {
+ this.previewSpinner.hide();
+ }
+
console.error('Failed to load file:', error);
if (window.showNotification) {
window.showNotification('Failed to load file', 'danger');
@@ -116,6 +285,32 @@ class MarkdownEditor {
}
}
+ /**
+ * Save the last viewed page to localStorage
+ * Stores per collection so different collections can have different last viewed pages
+ */
+ saveLastViewedPage(path) {
+ if (!this.webdavClient || !this.webdavClient.currentCollection) {
+ return;
+ }
+ const collection = this.webdavClient.currentCollection;
+ const storageKey = `${this.lastViewedStorageKey}:${collection}`;
+ localStorage.setItem(storageKey, path);
+ }
+
+ /**
+ * Get the last viewed page from localStorage
+ * Returns null if no page was previously viewed
+ */
+ getLastViewedPage() {
+ if (!this.webdavClient || !this.webdavClient.currentCollection) {
+ return null;
+ }
+ const collection = this.webdavClient.currentCollection;
+ const storageKey = `${this.lastViewedStorageKey}:${collection}`;
+ return localStorage.getItem(storageKey);
+ }
+
/**
* Save file
*/
@@ -133,7 +328,7 @@ class MarkdownEditor {
try {
await this.webdavClient.put(path, content);
this.currentFile = path;
-
+
if (window.showNotification) {
window.showNotification('✅ Saved', 'success');
}
@@ -159,10 +354,7 @@ class MarkdownEditor {
this.filenameInput.focus();
this.editor.setValue('# New File\n\nStart typing...\n');
this.updatePreview();
-
- if (window.showNotification) {
- window.showNotification('Enter filename and start typing', 'info');
- }
+ // No notification needed - UI is self-explanatory
}
/**
@@ -174,7 +366,7 @@ class MarkdownEditor {
return;
}
- const confirmed = await window.ConfirmationManager.confirm(`Are you sure you want to delete ${this.currentFile}?`, 'Delete File');
+ const confirmed = await window.ConfirmationManager.confirm(`Are you sure you want to delete ${this.currentFile}?`, 'Delete File', true);
if (confirmed) {
try {
await this.webdavClient.delete(this.currentFile);
@@ -189,10 +381,66 @@ class MarkdownEditor {
}
/**
- * Update preview
+ * Convert JSX-style attributes to HTML attributes
+ * Handles style={{...}} and boolean attributes like allowFullScreen={true}
*/
- async updatePreview() {
- const markdown = this.editor.getValue();
+ convertJSXToHTML(content) {
+ Logger.debug('Converting JSX to HTML...');
+
+ // Convert style={{...}} to style="..."
+ // This regex finds style={{...}} and converts the object notation to CSS string
+ content = content.replace(/style=\{\{([^}]+)\}\}/g, (match, styleContent) => {
+ Logger.debug(`Found JSX style: ${match}`);
+
+ // Parse the object-like syntax and convert to CSS
+ const cssRules = styleContent
+ .split(',')
+ .map(rule => {
+ const colonIndex = rule.indexOf(':');
+ if (colonIndex === -1) return '';
+
+ const key = rule.substring(0, colonIndex).trim();
+ const value = rule.substring(colonIndex + 1).trim();
+
+ if (!key || !value) return '';
+
+ // Convert camelCase to kebab-case (e.g., paddingTop -> padding-top)
+ const cssKey = key.replace(/([A-Z])/g, '-$1').toLowerCase();
+
+ // Remove quotes from value
+ let cssValue = value.replace(/^['"]|['"]$/g, '');
+
+ return `${cssKey}: ${cssValue}`;
+ })
+ .filter(rule => rule)
+ .join('; ');
+
+ Logger.debug(`Converted to CSS: style="${cssRules}"`);
+ return `style="${cssRules}"`;
+ });
+
+ // Convert boolean attributes like allowFullScreen={true} to allowfullscreen
+ content = content.replace(/(\w+)=\{true\}/g, (match, attrName) => {
+ Logger.debug(`Found boolean attribute: ${match}`);
+ // Convert camelCase to lowercase for HTML attributes
+ const htmlAttr = attrName.toLowerCase();
+ Logger.debug(`Converted to: ${htmlAttr}`);
+ return htmlAttr;
+ });
+
+ // Remove attributes set to {false}
+ content = content.replace(/\s+\w+=\{false\}/g, '');
+
+ return content;
+ }
+
+ /**
+ * Render preview from markdown content
+ * Can be called with explicit content (for view mode) or from editor (for edit mode)
+ */
+ async renderPreview(markdownContent = null) {
+ // Get markdown content from editor if not provided
+ const markdown = markdownContent !== null ? markdownContent : (this.editor ? this.editor.getValue() : '');
const previewDiv = this.previewElement;
if (!markdown || !markdown.trim()) {
@@ -205,26 +453,33 @@ class MarkdownEditor {
}
try {
- // Step 1: Process macros
- let processedContent = markdown;
-
- if (this.macroProcessor) {
- const processingResult = await this.macroProcessor.processMacros(markdown);
- processedContent = processingResult.content;
-
- // Log errors if any
- if (processingResult.errors.length > 0) {
- console.warn('Macro processing errors:', processingResult.errors);
- }
+ // Initialize loading spinners if not already done
+ this.initLoadingSpinners();
+
+ // Show preview loading spinner (only if not already shown by loadFile)
+ if (this.previewSpinner && !this.previewSpinner.isVisible()) {
+ this.previewSpinner.show('Rendering preview...');
}
-
+
+ // Step 0: Convert JSX-style syntax to HTML
+ let processedContent = this.convertJSXToHTML(markdown);
+
+ // Step 1: Process macros
+ if (this.macroProcessor) {
+ const processingResult = await this.macroProcessor.processMacros(processedContent);
+ processedContent = processingResult.content;
+ }
+
// Step 2: Parse markdown to HTML
if (!this.marked) {
console.error("Markdown parser (marked) not initialized.");
previewDiv.innerHTML = `Preview engine not loaded.`;
+ if (this.previewSpinner) {
+ this.previewSpinner.hide();
+ }
return;
}
-
+
let html = this.marked.parse(processedContent);
// Replace mermaid code blocks
@@ -259,6 +514,13 @@ class MarkdownEditor {
console.warn('Mermaid rendering error:', error);
}
}
+
+ // Hide preview loading spinner after a small delay to ensure rendering is complete
+ setTimeout(() => {
+ if (this.previewSpinner) {
+ this.previewSpinner.hide();
+ }
+ }, 100);
} catch (error) {
console.error('Preview rendering error:', error);
previewDiv.innerHTML = `
@@ -267,6 +529,27 @@ class MarkdownEditor {
${error.message}