From 0ed6bcf1f28053771cfb514fe94604a9660c5f12 Mon Sep 17 00:00:00 2001 From: Mahmoud-Emad Date: Sun, 26 Oct 2025 15:42:15 +0300 Subject: [PATCH] refactor: Modularize UI components and utilities - Extract UI components into separate JS files - Centralize configuration values in config.js - Introduce a dedicated logger module - Improve file tree drag-and-drop and undo functionality - Refactor modal handling to a single manager - Add URL routing support for SPA navigation - Implement view mode for read-only access --- .gitignore | 2 + collections/notes/images/logo-blue.png | Bin 0 -> 100305 bytes collections/notes/new_folder/zeko.md | 2 + collections/notes/test.md | 10 - collections/notes/{ttt => tests}/test.md | 0 collections/notes/{ttt => tests}/test2.md | 0 collections/notes/tests/test3.md | 426 +++++++++++++ refactor-plan.md | 426 +++++++++++++ server_webdav.py | 21 +- static/app-tree.js | 233 ++++---- static/css/components.css | 75 ++- static/css/editor.css | 31 +- static/css/file-tree.css | 98 ++- static/css/layout.css | 191 ++++-- static/js/app.js | 484 ++++++++++++--- static/js/collection-selector.js | 100 ++++ static/js/column-resizer.js | 36 +- static/js/config.js | 202 +++++++ static/js/confirmation.js | 151 ++++- static/js/context-menu.js | 89 +++ static/js/dark-mode.js | 77 +++ static/js/editor-drop-handler.js | 67 +++ static/js/editor.js | 161 +++-- static/js/event-bus.js | 126 ++++ static/js/file-tree-actions.js | 376 +++++------- static/js/file-tree.js | 691 ++++++++++++++++++++-- static/js/file-upload.js | 37 ++ static/js/logger.js | 174 ++++++ static/js/macro-processor.js | 35 +- static/js/notification-service.js | 77 +++ static/js/ui-utils.js | 260 +------- static/js/utils.js | 355 +++++++++++ static/style.css | 24 +- templates/index.html | 39 +- 34 files changed, 4136 insertions(+), 940 deletions(-) create mode 100644 .gitignore create mode 100644 collections/notes/images/logo-blue.png create mode 100644 collections/notes/new_folder/zeko.md delete mode 100644 collections/notes/test.md rename collections/notes/{ttt => tests}/test.md (100%) rename collections/notes/{ttt => tests}/test2.md (100%) create mode 100644 collections/notes/tests/test3.md create mode 100644 refactor-plan.md create mode 100644 static/js/collection-selector.js create mode 100644 static/js/config.js create mode 100644 static/js/context-menu.js create mode 100644 static/js/dark-mode.js create mode 100644 static/js/editor-drop-handler.js create mode 100644 static/js/event-bus.js create mode 100644 static/js/file-upload.js create mode 100644 static/js/logger.js create mode 100644 static/js/notification-service.js create mode 100644 static/js/utils.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..443f468 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.venv +server.log diff --git a/collections/notes/images/logo-blue.png b/collections/notes/images/logo-blue.png new file mode 100644 index 0000000000000000000000000000000000000000..7790f5200a187bf824aeef4ed48841fddf11de51 GIT binary patch literal 100305 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�Su-~lD(gy5rqbuGMOtph{COlZtw2es| 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 { editorElement.addEventListener(eventName, preventDefaults, false); }); - + function preventDefaults(e) { e.preventDefault(); e.stopPropagation(); } - + ['dragenter', 'dragover'].forEach(eventName => { editorElement.addEventListener(eventName, () => { editorElement.classList.add('drag-over'); }, false); }); - + ['dragleave', 'drop'].forEach(eventName => { editorElement.addEventListener(eventName, () => { editorElement.classList.remove('drag-over'); }, false); }); - + editorElement.addEventListener('drop', async (e) => { const files = e.dataTransfer.files; - + if (files.length === 0) return; - - const imageFiles = Array.from(files).filter(file => + + const imageFiles = Array.from(files).filter(file => file.type.startsWith('image/') ); - + if (imageFiles.length === 0) { showNotification('Please drop image files only', 'warning'); return; } - + showNotification(`Uploading ${imageFiles.length} image(s)...`, 'info'); - + for (const file of imageFiles) { const url = await uploadImage(file); if (url) { @@ -156,11 +156,11 @@ } } }, false); - + editorElement.addEventListener('paste', async (e) => { const items = e.clipboardData?.items; if (!items) return; - + for (const item of items) { if (item.type.startsWith('image/')) { e.preventDefault(); @@ -189,15 +189,15 @@ lineWrapping: true, autofocus: true, extraKeys: { - 'Ctrl-S': function() { saveFile(); }, - 'Cmd-S': function() { saveFile(); } + 'Ctrl-S': function () { saveFile(); }, + 'Cmd-S': function () { saveFile(); } } }); editor.on('change', debounce(updatePreview, 300)); - + setTimeout(setupDragAndDrop, 100); - + setupScrollSync(); } @@ -217,13 +217,13 @@ // Setup synchronized scrolling function setupScrollSync() { const previewDiv = document.getElementById('preview'); - + editor.on('scroll', () => { if (!isScrollingSynced) return; - + const scrollInfo = editor.getScrollInfo(); const scrollPercentage = scrollInfo.top / (scrollInfo.height - scrollInfo.clientHeight); - + const previewScrollHeight = previewDiv.scrollHeight - previewDiv.clientHeight; previewDiv.scrollTop = previewScrollHeight * scrollPercentage; }); @@ -233,7 +233,7 @@ async function updatePreview() { const markdown = editor.getValue(); const previewDiv = document.getElementById('preview'); - + if (!markdown.trim()) { previewDiv.innerHTML = `
@@ -243,17 +243,17 @@ `; return; } - + try { let html = marked.parse(markdown); - + html = html.replace( /
([\s\S]*?)<\/code><\/pre>/g,
                 '
$1
' ); - + previewDiv.innerHTML = html; - + const codeBlocks = previewDiv.querySelectorAll('pre code'); codeBlocks.forEach(block => { const languageClass = Array.from(block.classList).find(cls => cls.startsWith('language-')); @@ -261,7 +261,7 @@ Prism.highlightElement(block); } }); - + const mermaidElements = previewDiv.querySelectorAll('.mermaid'); if (mermaidElements.length > 0) { try { @@ -291,7 +291,7 @@ try { const response = await fetch('/api/tree'); if (!response.ok) throw new Error('Failed to load file tree'); - + fileTree = await response.json(); renderFileTree(); } catch (error) { @@ -303,12 +303,12 @@ function renderFileTree() { const container = document.getElementById('fileTree'); container.innerHTML = ''; - + if (fileTree.length === 0) { container.innerHTML = '
No files yet
'; return; } - + fileTree.forEach(node => { container.appendChild(createTreeNode(node)); }); @@ -317,13 +317,13 @@ function createTreeNode(node, level = 0) { const nodeDiv = document.createElement('div'); nodeDiv.className = 'tree-node-wrapper'; - + const nodeContent = document.createElement('div'); nodeContent.className = 'tree-node'; nodeContent.dataset.path = node.path; nodeContent.dataset.type = node.type; nodeContent.dataset.name = node.name; - + // Make draggable nodeContent.draggable = true; nodeContent.addEventListener('dragstart', handleDragStart); @@ -331,14 +331,13 @@ nodeContent.addEventListener('dragover', handleDragOver); nodeContent.addEventListener('dragleave', handleDragLeave); nodeContent.addEventListener('drop', handleDrop); - + const contentWrapper = document.createElement('div'); contentWrapper.className = 'tree-node-content'; - + if (node.type === 'directory') { const toggle = document.createElement('span'); toggle.className = 'tree-node-toggle'; - toggle.innerHTML = '▶'; toggle.addEventListener('click', (e) => { e.stopPropagation(); toggleNode(nodeDiv); @@ -349,56 +348,56 @@ spacer.style.width = '16px'; contentWrapper.appendChild(spacer); } - + const icon = document.createElement('i'); icon.className = node.type === 'directory' ? 'bi bi-folder tree-node-icon' : 'bi bi-file-earmark-text tree-node-icon'; contentWrapper.appendChild(icon); - + const name = document.createElement('span'); name.className = 'tree-node-name'; name.textContent = node.name; contentWrapper.appendChild(name); - + if (node.type === 'file' && node.size) { const size = document.createElement('span'); size.className = 'file-size-badge'; size.textContent = formatFileSize(node.size); contentWrapper.appendChild(size); } - + nodeContent.appendChild(contentWrapper); - + nodeContent.addEventListener('click', (e) => { if (node.type === 'file') { loadFile(node.path); } }); - + nodeContent.addEventListener('contextmenu', (e) => { e.preventDefault(); showContextMenu(e, node); }); - + nodeDiv.appendChild(nodeContent); - + if (node.children && node.children.length > 0) { const childrenDiv = document.createElement('div'); childrenDiv.className = 'tree-children collapsed'; - + node.children.forEach(child => { childrenDiv.appendChild(createTreeNode(child, level + 1)); }); - + nodeDiv.appendChild(childrenDiv); } - + return nodeDiv; } function toggleNode(nodeWrapper) { const toggle = nodeWrapper.querySelector('.tree-node-toggle'); const children = nodeWrapper.querySelector('.tree-children'); - + if (children) { children.classList.toggle('collapsed'); toggle.classList.toggle('expanded'); @@ -437,10 +436,10 @@ function handleDragOver(e) { if (!draggedNode) return; - + e.preventDefault(); e.dataTransfer.dropEffect = 'move'; - + const targetType = e.currentTarget.dataset.type; if (targetType === 'directory') { e.currentTarget.classList.add('drag-over'); @@ -454,18 +453,18 @@ async function handleDrop(e) { e.preventDefault(); e.currentTarget.classList.remove('drag-over'); - + if (!draggedNode) return; - + const targetPath = e.currentTarget.dataset.path; const targetType = e.currentTarget.dataset.type; - + if (targetType !== 'directory') return; if (draggedNode.path === targetPath) return; - + const sourcePath = draggedNode.path; const destPath = targetPath + '/' + draggedNode.name; - + try { const response = await fetch('/api/file/move', { method: 'POST', @@ -475,16 +474,16 @@ destination: destPath }) }); - + if (!response.ok) throw new Error('Move failed'); - + showNotification(`Moved ${draggedNode.name}`, 'success'); loadFileTree(); } catch (error) { console.error('Error moving file:', error); showNotification('Error moving file', 'danger'); } - + draggedNode = null; } @@ -496,18 +495,18 @@ contextMenuTarget = node; const menu = document.getElementById('contextMenu'); const pasteItem = document.getElementById('pasteMenuItem'); - + // Show paste option only if clipboard has something and target is a directory if (clipboard && node.type === 'directory') { pasteItem.style.display = 'flex'; } else { pasteItem.style.display = 'none'; } - + menu.style.display = 'block'; menu.style.left = e.pageX + 'px'; menu.style.top = e.pageY + 'px'; - + document.addEventListener('click', hideContextMenu); } @@ -525,20 +524,20 @@ try { const response = await fetch(`/api/file?path=${encodeURIComponent(path)}`); if (!response.ok) throw new Error('Failed to load file'); - + const data = await response.json(); currentFile = data.filename; currentFilePath = path; - + document.getElementById('filenameInput').value = path; editor.setValue(data.content); updatePreview(); - + document.querySelectorAll('.tree-node').forEach(node => { node.classList.remove('active'); }); document.querySelector(`[data-path="${path}"]`)?.classList.add('active'); - + showNotification(`Loaded ${data.filename}`, 'info'); } catch (error) { console.error('Error loading file:', error); @@ -548,27 +547,27 @@ async function saveFile() { const path = document.getElementById('filenameInput').value.trim(); - + if (!path) { showNotification('Please enter a filename', 'warning'); return; } - + const content = editor.getValue(); - + try { const response = await fetch('/api/file', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path, content }) }); - + if (!response.ok) throw new Error('Failed to save file'); - + const result = await response.json(); currentFile = path.split('/').pop(); currentFilePath = result.path; - + showNotification(`Saved ${currentFile}`, 'success'); loadFileTree(); } catch (error) { @@ -582,26 +581,26 @@ showNotification('No file selected', 'warning'); return; } - + if (!confirm(`Are you sure you want to delete ${currentFile}?`)) { return; } - + try { const response = await fetch(`/api/file?path=${encodeURIComponent(currentFilePath)}`, { method: 'DELETE' }); - + if (!response.ok) throw new Error('Failed to delete file'); - + showNotification(`Deleted ${currentFile}`, 'success'); - + currentFile = null; currentFilePath = null; document.getElementById('filenameInput').value = ''; editor.setValue(''); updatePreview(); - + loadFileTree(); } catch (error) { console.error('Error deleting file:', error); @@ -617,27 +616,27 @@ document.getElementById('filenameInput').focus(); editor.setValue(''); updatePreview(); - + document.querySelectorAll('.tree-node').forEach(node => { node.classList.remove('active'); }); - + showNotification('Enter filename and start typing', 'info'); } async function createFolder() { const folderName = prompt('Enter folder name:'); if (!folderName) return; - + try { const response = await fetch('/api/directory', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: folderName }) }); - + if (!response.ok) throw new Error('Failed to create folder'); - + showNotification(`Created folder ${folderName}`, 'success'); loadFileTree(); } catch (error) { @@ -652,32 +651,32 @@ async function handleContextMenuAction(action) { if (!contextMenuTarget) return; - + switch (action) { case 'open': if (contextMenuTarget.type === 'file') { loadFile(contextMenuTarget.path); } break; - + case 'rename': await renameItem(); break; - + case 'copy': clipboard = { ...contextMenuTarget, operation: 'copy' }; showNotification(`Copied ${contextMenuTarget.name}`, 'info'); break; - + case 'move': clipboard = { ...contextMenuTarget, operation: 'move' }; showNotification(`Cut ${contextMenuTarget.name}`, 'info'); break; - + case 'paste': await pasteItem(); break; - + case 'delete': await deleteItem(); break; @@ -687,10 +686,10 @@ async function renameItem() { const newName = prompt(`Rename ${contextMenuTarget.name}:`, contextMenuTarget.name); if (!newName || newName === contextMenuTarget.name) return; - + const oldPath = contextMenuTarget.path; const newPath = oldPath.substring(0, oldPath.lastIndexOf('/') + 1) + newName; - + try { const endpoint = contextMenuTarget.type === 'directory' ? '/api/directory/rename' : '/api/file/rename'; const response = await fetch(endpoint, { @@ -701,9 +700,9 @@ new_path: newPath }) }); - + if (!response.ok) throw new Error('Rename failed'); - + showNotification(`Renamed to ${newName}`, 'success'); loadFileTree(); } catch (error) { @@ -714,12 +713,12 @@ async function pasteItem() { if (!clipboard) return; - + const destDir = contextMenuTarget.path; const sourcePath = clipboard.path; const fileName = clipboard.name; const destPath = destDir + '/' + fileName; - + try { if (clipboard.operation === 'copy') { // Copy operation @@ -731,7 +730,7 @@ destination: destPath }) }); - + if (!response.ok) throw new Error('Copy failed'); showNotification(`Copied ${fileName} to ${contextMenuTarget.name}`, 'success'); } else if (clipboard.operation === 'move') { @@ -744,12 +743,12 @@ destination: destPath }) }); - + if (!response.ok) throw new Error('Move failed'); showNotification(`Moved ${fileName} to ${contextMenuTarget.name}`, 'success'); clipboard = null; // Clear clipboard after move } - + loadFileTree(); } catch (error) { console.error('Error pasting:', error); @@ -761,7 +760,7 @@ if (!confirm(`Are you sure you want to delete ${contextMenuTarget.name}?`)) { return; } - + try { let response; if (contextMenuTarget.type === 'directory') { @@ -773,9 +772,9 @@ method: 'DELETE' }); } - + if (!response.ok) throw new Error('Delete failed'); - + showNotification(`Deleted ${contextMenuTarget.name}`, 'success'); loadFileTree(); } catch (error) { @@ -793,7 +792,7 @@ if (!toastContainer) { toastContainer = createToastContainer(); } - + const toast = document.createElement('div'); toast.className = `toast align-items-center text-white bg-${type} border-0`; toast.setAttribute('role', 'alert'); @@ -803,12 +802,12 @@
`; - + toastContainer.appendChild(toast); - + const bsToast = new bootstrap.Toast(toast, { delay: 3000 }); bsToast.show(); - + toast.addEventListener('hidden.bs.toast', () => { toast.remove(); }); @@ -831,13 +830,13 @@ initDarkMode(); initEditor(); loadFileTree(); - + document.getElementById('saveBtn').addEventListener('click', saveFile); document.getElementById('deleteBtn').addEventListener('click', deleteFile); document.getElementById('newFileBtn').addEventListener('click', newFile); document.getElementById('newFolderBtn').addEventListener('click', createFolder); document.getElementById('darkModeToggle').addEventListener('click', toggleDarkMode); - + // Context menu actions document.querySelectorAll('.context-menu-item').forEach(item => { item.addEventListener('click', () => { @@ -846,14 +845,14 @@ hideContextMenu(); }); }); - + document.addEventListener('keydown', (e) => { if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); saveFile(); } }); - + console.log('Markdown Editor with File Tree initialized'); } diff --git a/static/css/components.css b/static/css/components.css index 1caaf93..0512ff5 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; @@ -137,6 +148,7 @@ body.dark-mode .context-menu { transform: translateX(400px); opacity: 0; } + to { transform: translateX(0); opacity: 1; @@ -152,6 +164,7 @@ body.dark-mode .context-menu { transform: translateX(0); opacity: 1; } + to { transform: translateX(400px); opacity: 0; @@ -205,4 +218,62 @@ 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; } \ 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..86369aa 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); diff --git a/static/css/layout.css b/static/css/layout.css index 08ba397..52c30b7 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,6 +112,8 @@ html, body { flex-direction: row; margin: 0; height: 100%; + overflow: hidden; + /* Prevent row scrolling */ } #sidebarPane { @@ -57,6 +121,9 @@ html, body { min-width: 150px; max-width: 40%; padding: 0; + height: 100%; + overflow: hidden; + /* Prevent pane scrolling */ } #editorPane { @@ -64,25 +131,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 +157,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 +202,78 @@ 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; +} + +/* 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..a12f615 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -12,100 +12,430 @@ 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) { + await editor.loadFile(pageToLoad); + // Highlight the file in the tree and expand parent directories + 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, load it + console.log('[loadFileFromURL] Loading file'); + 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 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 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(); + // Initialize file tree (needed in both modes) + fileTree = new FileTree('fileTree', webdavClient); + fileTree.onFileSelect = async (item) => { + try { + 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 + const currentCollection = collectionSelector.getCurrentCollection(); + updateURL(currentCollection, item.path, isEditMode); + } catch (error) { + Logger.error('Failed to select file:', error); + if (window.showNotification) { + window.showNotification('Failed to load file', 'error'); + } } - }, 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 actions manager - window.fileTreeActions = new FileTreeActions(webdavClient, fileTree, editor); + 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 && urlFilePath) { + console.log('[URL LOAD] Loading from URL:', urlCollection, urlFilePath); + + // 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(); + } + + // Now load the file from URL + console.log('[URL LOAD] Calling loadFileFromURL'); + await loadFileFromURL(urlCollection, urlFilePath); + } else if (!isEditMode) { + // In view mode, auto-load last viewed page if no URL file specified + 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); + } 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'; + + // Auto-load last viewed page or first file + await autoLoadPageInViewMode(); + } + + // 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 +456,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 +493,16 @@ 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 const isImage = file.type.startsWith('image/'); - const link = isImage + const link = isImage ? `![${file.name}](/${webdavClient.currentCollection}/${uploadedPath})` : `[${file.name}](/${webdavClient.currentCollection}/${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..b40ee5d --- /dev/null +++ b/static/js/collection-selector.js @@ -0,0 +1,100 @@ +/** + * 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); + }); + + // Try to restore previously selected collection from localStorage + const savedCollection = localStorage.getItem(this.storageKey); + let collectionToSelect = collections[0]; // Default to first + + if (savedCollection && collections.includes(savedCollection)) { + collectionToSelect = 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}`); + + 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}`); + + if (this.onChange) { + this.onChange(collection); + } + } else { + Logger.warn(`Collection "${collection}" not found in available collections`); + } + } +} + +// 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..7a48845 --- /dev/null +++ b/static/js/config.js @@ -0,0 +1,202 @@ +/** + * 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' + }, + + // ===== 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..7c95a8a 100644 --- a/static/js/confirmation.js +++ b/static/js/confirmation.js @@ -1,68 +1,169 @@ /** - * 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 btn-danger'; + this.confirmButton.textContent = 'Delete'; } else { - this.inputElement.style.display = 'none'; + this.confirmButton.className = 'btn btn-primary'; + this.confirmButton.textContent = '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 }); + 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 btn-primary'; + this.confirmButton.textContent = '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 }); + + 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; - } - - confirm(message, title = 'Confirmation') { - return this._show(message, title, false); - } - - prompt(message, defaultValue = '', title = 'Prompt') { - return this._show(message, title, true, defaultValue); + this.isShowing = false; + this.modal.hide(); } } // 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..7a1906e --- /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.textContent = '☀️'; + + // 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.textContent = '🌙'; + + // 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..c98169c 100644 --- a/static/js/editor.js +++ b/static/js/editor.js @@ -4,15 +4,21 @@ */ 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 + + // Only initialize CodeMirror if not in read-only mode (view mode) + if (!readOnly) { + this.initCodeMirror(); + } this.initMarkdown(); this.initMermaid(); } @@ -21,22 +27,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 +58,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 }); } /** @@ -87,7 +119,7 @@ class MarkdownEditor { */ setWebDAVClient(client) { this.webdavClient = client; - + // Update macro processor with client if (this.macroProcessor) { this.macroProcessor.webdavClient = client; @@ -101,13 +133,23 @@ class MarkdownEditor { try { 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); + // No notification for successful file load - it's not critical } catch (error) { console.error('Failed to load file:', error); if (window.showNotification) { @@ -116,6 +158,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 +201,7 @@ class MarkdownEditor { try { await this.webdavClient.put(path, content); this.currentFile = path; - + if (window.showNotification) { window.showNotification('✅ Saved', 'success'); } @@ -159,10 +227,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 +239,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 +254,12 @@ class MarkdownEditor { } /** - * Update preview + * Render preview from markdown content + * Can be called with explicit content (for view mode) or from editor (for edit mode) */ - async updatePreview() { - const markdown = this.editor.getValue(); + 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()) { @@ -207,24 +274,19 @@ 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); - } } - + // Step 2: Parse markdown to HTML if (!this.marked) { console.error("Markdown parser (marked) not initialized."); previewDiv.innerHTML = `
Preview engine not loaded.
`; return; } - + let html = this.marked.parse(processedContent); // Replace mermaid code blocks @@ -270,13 +332,25 @@ class MarkdownEditor { } } + /** + * Update preview (backward compatibility wrapper) + * Calls renderPreview with content from editor + */ + async updatePreview() { + if (this.editor) { + await this.renderPreview(); + } + } + /** * Sync scroll between editor and preview */ syncScroll() { + if (!this.editor) return; // Skip if no editor (view mode) + const scrollInfo = this.editor.getScrollInfo(); const scrollPercent = scrollInfo.top / (scrollInfo.height - scrollInfo.clientHeight); - + const previewHeight = this.previewElement.scrollHeight - this.previewElement.clientHeight; this.previewElement.scrollTop = previewHeight * scrollPercent; } @@ -289,10 +363,10 @@ class MarkdownEditor { const filename = await this.webdavClient.uploadImage(file); const imageUrl = `/fs/${this.webdavClient.currentCollection}/images/${filename}`; const markdown = `![${file.name}](${imageUrl})`; - + // Insert at cursor this.editor.replaceSelection(markdown); - + if (window.showNotification) { window.showNotification('Image uploaded', 'success'); } @@ -310,7 +384,7 @@ class MarkdownEditor { getValue() { return this.editor.getValue(); } - + insertAtCursor(text) { const doc = this.editor.getDoc(); const cursor = doc.getCursor(); @@ -324,20 +398,7 @@ class MarkdownEditor { this.editor.setValue(content); } - /** - * Debounce function - */ - debounce(func, wait) { - let timeout; - return function executedFunction(...args) { - const later = () => { - clearTimeout(timeout); - func(...args); - }; - clearTimeout(timeout); - timeout = setTimeout(later, wait); - }; - } + // Debounce function moved to TimingUtils in utils.js } // Export for use in other modules diff --git a/static/js/event-bus.js b/static/js/event-bus.js new file mode 100644 index 0000000..5e986c8 --- /dev/null +++ b/static/js/event-bus.js @@ -0,0 +1,126 @@ +/** + * Event Bus Module + * Provides a centralized event system for application-wide communication + * Allows components to communicate without tight coupling + */ + +class EventBus { + constructor() { + /** + * Map of event names to arrays of listener functions + * @type {Object.} + */ + this.listeners = {}; + } + + /** + * Register an event listener + * @param {string} event - The event name to listen for + * @param {Function} callback - The function to call when the event is dispatched + * @returns {Function} A function to unregister this listener + */ + on(event, callback) { + if (!this.listeners[event]) { + this.listeners[event] = []; + } + this.listeners[event].push(callback); + + // Return unsubscribe function + return () => this.off(event, callback); + } + + /** + * Register a one-time event listener + * The listener will be automatically removed after being called once + * @param {string} event - The event name to listen for + * @param {Function} callback - The function to call when the event is dispatched + * @returns {Function} A function to unregister this listener + */ + once(event, callback) { + const onceWrapper = (data) => { + callback(data); + this.off(event, onceWrapper); + }; + return this.on(event, onceWrapper); + } + + /** + * Unregister an event listener + * @param {string} event - The event name + * @param {Function} callback - The callback function to remove + */ + off(event, callback) { + if (!this.listeners[event]) { + return; + } + + this.listeners[event] = this.listeners[event].filter( + listener => listener !== callback + ); + + // Clean up empty listener arrays + if (this.listeners[event].length === 0) { + delete this.listeners[event]; + } + } + + /** + * Dispatch an event to all registered listeners + * @param {string} event - The event name to dispatch + * @param {any} data - The data to pass to the listeners + */ + dispatch(event, data) { + if (!this.listeners[event]) { + return; + } + + // Create a copy of the listeners array to avoid issues if listeners are added/removed during dispatch + const listeners = [...this.listeners[event]]; + + listeners.forEach(callback => { + try { + callback(data); + } catch (error) { + Logger.error(`Error in event listener for "${event}":`, error); + } + }); + } + + /** + * Remove all listeners for a specific event + * If no event is specified, removes all listeners for all events + * @param {string} [event] - The event name (optional) + */ + clear(event) { + if (event) { + delete this.listeners[event]; + } else { + this.listeners = {}; + } + } + + /** + * Get the number of listeners for an event + * @param {string} event - The event name + * @returns {number} The number of listeners + */ + listenerCount(event) { + return this.listeners[event] ? this.listeners[event].length : 0; + } + + /** + * Get all event names that have listeners + * @returns {string[]} Array of event names + */ + eventNames() { + return Object.keys(this.listeners); + } +} + +// Create and export the global event bus instance +const eventBus = new EventBus(); + +// Make it globally available +window.eventBus = eventBus; +window.EventBus = EventBus; + diff --git a/static/js/file-tree-actions.js b/static/js/file-tree-actions.js index 399a1c1..a391b61 100644 --- a/static/js/file-tree-actions.js +++ b/static/js/file-tree-actions.js @@ -14,32 +14,10 @@ class FileTreeActions { /** * Validate and sanitize filename/folder name * Returns { valid: boolean, sanitized: string, message: string } + * Now uses ValidationUtils from utils.js */ validateFileName(name, isFolder = false) { - const type = isFolder ? 'folder' : 'file'; - - if (!name || name.trim().length === 0) { - return { valid: false, message: `${type} name cannot be empty` }; - } - - // Check for invalid characters - const validPattern = /^[a-z0-9_]+(\.[a-z0-9_]+)*$/; - - if (!validPattern.test(name)) { - const sanitized = name - .toLowerCase() - .replace(/[^a-z0-9_.]/g, '_') - .replace(/_+/g, '_') - .replace(/^_+|_+$/g, ''); - - return { - valid: false, - sanitized, - message: `Invalid characters in ${type} name. Only lowercase letters, numbers, and underscores allowed.\n\nSuggestion: "${sanitized}"` - }; - } - - return { valid: true, sanitized: name, message: '' }; + return ValidationUtils.validateFileName(name, isFolder); } async execute(action, targetPath, isDirectory) { @@ -48,7 +26,7 @@ class FileTreeActions { console.error(`Unknown action: ${action}`); return; } - + try { await handler.call(this, targetPath, isDirectory); } catch (error) { @@ -58,140 +36,198 @@ class FileTreeActions { } actions = { - open: async function(path, isDir) { + open: async function (path, isDir) { if (!isDir) { await this.editor.loadFile(path); } }, - 'new-file': async function(path, isDir) { + 'new-file': async function (path, isDir) { if (!isDir) return; - - await this.showInputDialog('Enter filename (lowercase, underscore only):', 'new_file.md', async (filename) => { - if (!filename) return; - - const validation = this.validateFileName(filename, false); - - if (!validation.valid) { - showNotification(validation.message, 'warning'); - - // Ask if user wants to use sanitized version - if (validation.sanitized) { - if (await this.showConfirmDialog('Use sanitized name?', `${filename} → ${validation.sanitized}`)) { - filename = validation.sanitized; - } else { - return; - } + + const filename = await window.ModalManager.prompt( + 'Enter filename (lowercase, underscore only):', + 'new_file.md', + 'New File' + ); + + if (!filename) return; + + let finalFilename = filename; + const validation = this.validateFileName(filename, false); + + if (!validation.valid) { + showNotification(validation.message, 'warning'); + + // Ask if user wants to use sanitized version + if (validation.sanitized) { + const useSanitized = await window.ModalManager.confirm( + `${filename} → ${validation.sanitized}`, + 'Use sanitized name?', + false + ); + if (useSanitized) { + finalFilename = validation.sanitized; } else { return; } + } else { + return; } - - const fullPath = `${path}/${filename}`.replace(/\/+/g, '/'); - await this.webdavClient.put(fullPath, '# New File\n\n'); - await this.fileTree.load(); - showNotification(`Created ${filename}`, 'success'); - await this.editor.loadFile(fullPath); - }); + } + + const fullPath = `${path}/${finalFilename}`.replace(/\/+/g, '/'); + await this.webdavClient.put(fullPath, '# New File\n\n'); + + // Clear undo history since new file was created + if (this.fileTree.lastMoveOperation) { + this.fileTree.lastMoveOperation = null; + } + + await this.fileTree.load(); + showNotification(`Created ${finalFilename}`, 'success'); + await this.editor.loadFile(fullPath); }, - 'new-folder': async function(path, isDir) { + 'new-folder': async function (path, isDir) { if (!isDir) return; - - await this.showInputDialog('Enter folder name (lowercase, underscore only):', 'new_folder', async (foldername) => { - if (!foldername) return; - - const validation = this.validateFileName(foldername, true); - - if (!validation.valid) { - showNotification(validation.message, 'warning'); - - if (validation.sanitized) { - if (await this.showConfirmDialog('Use sanitized name?', `${foldername} → ${validation.sanitized}`)) { - foldername = validation.sanitized; - } else { - return; - } + + const foldername = await window.ModalManager.prompt( + 'Enter folder name (lowercase, underscore only):', + 'new_folder', + 'New Folder' + ); + + if (!foldername) return; + + let finalFoldername = foldername; + const validation = this.validateFileName(foldername, true); + + if (!validation.valid) { + showNotification(validation.message, 'warning'); + + if (validation.sanitized) { + const useSanitized = await window.ModalManager.confirm( + `${foldername} → ${validation.sanitized}`, + 'Use sanitized name?', + false + ); + if (useSanitized) { + finalFoldername = validation.sanitized; } else { return; } + } else { + return; } - - const fullPath = `${path}/${foldername}`.replace(/\/+/g, '/'); - await this.webdavClient.mkcol(fullPath); - await this.fileTree.load(); - showNotification(`Created folder ${foldername}`, 'success'); - }); + } + + const fullPath = `${path}/${finalFoldername}`.replace(/\/+/g, '/'); + await this.webdavClient.mkcol(fullPath); + + // Clear undo history since new folder was created + if (this.fileTree.lastMoveOperation) { + this.fileTree.lastMoveOperation = null; + } + + await this.fileTree.load(); + showNotification(`Created folder ${finalFoldername}`, 'success'); }, - rename: async function(path, isDir) { + rename: async function (path, isDir) { const oldName = path.split('/').pop(); - const newName = await this.showInputDialog('Rename to:', oldName); + const newName = await window.ModalManager.prompt( + 'Rename to:', + oldName, + 'Rename' + ); + if (newName && newName !== oldName) { const parentPath = path.substring(0, path.lastIndexOf('/')); const newPath = parentPath ? `${parentPath}/${newName}` : newName; await this.webdavClient.move(path, newPath); + + // Clear undo history since manual rename occurred + if (this.fileTree.lastMoveOperation) { + this.fileTree.lastMoveOperation = null; + } + await this.fileTree.load(); showNotification('Renamed', 'success'); } }, - copy: async function(path, isDir) { + copy: async function (path, isDir) { this.clipboard = { path, operation: 'copy', isDirectory: isDir }; - showNotification(`Copied: ${path.split('/').pop()}`, 'info'); + // No notification for copy - it's a quick operation this.updatePasteMenuItem(); }, - cut: async function(path, isDir) { + cut: async function (path, isDir) { this.clipboard = { path, operation: 'cut', isDirectory: isDir }; - showNotification(`Cut: ${path.split('/').pop()}`, 'warning'); + // No notification for cut - it's a quick operation this.updatePasteMenuItem(); }, - paste: async function(targetPath, isDir) { + paste: async function (targetPath, isDir) { if (!this.clipboard || !isDir) return; - + const itemName = this.clipboard.path.split('/').pop(); const destPath = `${targetPath}/${itemName}`.replace(/\/+/g, '/'); - + if (this.clipboard.operation === 'copy') { await this.webdavClient.copy(this.clipboard.path, destPath); - showNotification('Copied', 'success'); + // No notification for paste - file tree updates show the result } else { await this.webdavClient.move(this.clipboard.path, destPath); this.clipboard = null; this.updatePasteMenuItem(); - showNotification('Moved', 'success'); + // No notification for move - file tree updates show the result } - + await this.fileTree.load(); }, - delete: async function(path, isDir) { + delete: async function (path, isDir) { const name = path.split('/').pop(); const type = isDir ? 'folder' : 'file'; - - if (!await this.showConfirmDialog(`Delete this ${type}?`, `${name}`)) { - return; - } - + + const confirmed = await window.ModalManager.confirm( + `Are you sure you want to delete ${name}?`, + `Delete this ${type}?`, + true + ); + + if (!confirmed) return; + await this.webdavClient.delete(path); + + // Clear undo history since manual delete occurred + if (this.fileTree.lastMoveOperation) { + this.fileTree.lastMoveOperation = null; + } + await this.fileTree.load(); showNotification(`Deleted ${name}`, 'success'); }, - download: async function(path, isDir) { - showNotification('Downloading...', 'info'); - // Implementation here + download: async function (path, isDir) { + Logger.info(`Downloading ${isDir ? 'folder' : 'file'}: ${path}`); + + if (isDir) { + await this.fileTree.downloadFolder(path); + } else { + await this.fileTree.downloadFile(path); + } }, - upload: async function(path, isDir) { + upload: async function (path, isDir) { if (!isDir) return; - + const input = document.createElement('input'); input.type = 'file'; input.multiple = true; - + input.onchange = async (e) => { const files = Array.from(e.target.files); for (const file of files) { @@ -202,156 +238,12 @@ class FileTreeActions { } await this.fileTree.load(); }; - + input.click(); } }; - // Modern dialog implementations - async showInputDialog(title, placeholder = '', callback) { - return new Promise((resolve) => { - const dialog = this.createInputDialog(title, placeholder); - const input = dialog.querySelector('input'); - const confirmBtn = dialog.querySelector('.btn-primary'); - const cancelBtn = dialog.querySelector('.btn-secondary'); - - const cleanup = (value) => { - const modalInstance = bootstrap.Modal.getInstance(dialog); - if (modalInstance) { - modalInstance.hide(); - } - dialog.remove(); - const backdrop = document.querySelector('.modal-backdrop'); - if (backdrop) backdrop.remove(); - document.body.classList.remove('modal-open'); - resolve(value); - if (callback) callback(value); - }; - - confirmBtn.onclick = () => { - cleanup(input.value.trim()); - }; - - cancelBtn.onclick = () => { - cleanup(null); - }; - - dialog.addEventListener('hidden.bs.modal', () => { - cleanup(null); - }); - - input.onkeypress = (e) => { - if (e.key === 'Enter') confirmBtn.click(); - }; - - document.body.appendChild(dialog); - const modal = new bootstrap.Modal(dialog); - modal.show(); - input.focus(); - input.select(); - }); - } - - async showConfirmDialog(title, message = '', callback) { - return new Promise((resolve) => { - const dialog = this.createConfirmDialog(title, message); - const confirmBtn = dialog.querySelector('.btn-danger'); - const cancelBtn = dialog.querySelector('.btn-secondary'); - - const cleanup = (value) => { - const modalInstance = bootstrap.Modal.getInstance(dialog); - if (modalInstance) { - modalInstance.hide(); - } - dialog.remove(); - const backdrop = document.querySelector('.modal-backdrop'); - if (backdrop) backdrop.remove(); - document.body.classList.remove('modal-open'); - resolve(value); - if (callback) callback(value); - }; - - confirmBtn.onclick = () => { - cleanup(true); - }; - - cancelBtn.onclick = () => { - cleanup(false); - }; - - dialog.addEventListener('hidden.bs.modal', () => { - cleanup(false); - }); - - document.body.appendChild(dialog); - const modal = new bootstrap.Modal(dialog); - modal.show(); - confirmBtn.focus(); - }); - } - - createInputDialog(title, placeholder) { - const backdrop = document.createElement('div'); - backdrop.className = 'modal-backdrop fade show'; - - const dialog = document.createElement('div'); - dialog.className = 'modal fade show d-block'; - dialog.setAttribute('tabindex', '-1'); - dialog.style.display = 'block'; - - dialog.innerHTML = ` - - `; - - document.body.appendChild(backdrop); - return dialog; - } - - createConfirmDialog(title, message) { - const backdrop = document.createElement('div'); - backdrop.className = 'modal-backdrop fade show'; - - const dialog = document.createElement('div'); - dialog.className = 'modal fade show d-block'; - dialog.setAttribute('tabindex', '-1'); - dialog.style.display = 'block'; - - dialog.innerHTML = ` - - `; - - document.body.appendChild(backdrop); - return dialog; - } + // Old deprecated modal methods removed - all modals now use window.ModalManager updatePasteMenuItem() { const pasteItem = document.getElementById('pasteMenuItem'); diff --git a/static/js/file-tree.js b/static/js/file-tree.js index 29a3fd6..3b33394 100644 --- a/static/js/file-tree.js +++ b/static/js/file-tree.js @@ -11,23 +11,41 @@ class FileTree { this.selectedPath = null; this.onFileSelect = null; this.onFolderSelect = null; - + + // Drag and drop state + this.draggedNode = null; + this.draggedPath = null; + this.draggedIsDir = false; + + // Long-press detection + this.longPressTimer = null; + this.longPressThreshold = Config.LONG_PRESS_THRESHOLD; + this.isDraggingEnabled = false; + this.mouseDownNode = null; + + // Undo functionality + this.lastMoveOperation = null; + this.setupEventListeners(); + this.setupUndoListener(); } - + setupEventListeners() { // Click handler for tree nodes this.container.addEventListener('click', (e) => { - console.log('Container clicked', e.target); const node = e.target.closest('.tree-node'); if (!node) return; - - console.log('Node found', node); + const path = node.dataset.path; const isDir = node.dataset.isdir === 'true'; - - // The toggle is handled inside renderNodes now - + + // Check if toggle was clicked (icon or toggle button) + const toggle = e.target.closest('.tree-node-toggle'); + if (toggle) { + // Toggle is handled by its own click listener in renderNodes + return; + } + // Select node if (isDir) { this.selectFolder(path); @@ -35,9 +53,19 @@ class FileTree { this.selectFile(path); } }); - - // Context menu + + // Context menu (only in edit mode) this.container.addEventListener('contextmenu', (e) => { + // Check if we're in edit mode + const isEditMode = document.body.classList.contains('edit-mode'); + + // In view mode, disable custom context menu entirely + if (!isEditMode) { + e.preventDefault(); // Prevent default browser context menu + return; // Don't show custom context menu + } + + // Edit mode: show custom context menu const node = e.target.closest('.tree-node'); e.preventDefault(); @@ -51,8 +79,335 @@ class FileTree { window.showContextMenu(e.clientX, e.clientY, { path: '', isDir: true }); } }); + + // Drag and drop event listeners (only in edit mode) + this.setupDragAndDrop(); } - + + setupUndoListener() { + // Listen for Ctrl+Z (Windows/Linux) or Cmd+Z (Mac) + document.addEventListener('keydown', async (e) => { + // Check for Ctrl+Z or Cmd+Z + const isUndo = (e.ctrlKey || e.metaKey) && e.key === 'z'; + + if (isUndo && this.isEditMode() && this.lastMoveOperation) { + e.preventDefault(); + await this.undoLastMove(); + } + }); + } + + async undoLastMove() { + if (!this.lastMoveOperation) { + return; + } + + const { sourcePath, destPath, fileName, isDirectory } = this.lastMoveOperation; + + try { + // Move the item back to its original location + await this.webdavClient.move(destPath, sourcePath); + + // Get the parent folder name for the notification + const sourceParent = PathUtils.getParentPath(sourcePath); + const parentName = sourceParent ? sourceParent + '/' : 'root'; + + // Clear the undo history + this.lastMoveOperation = null; + + // Reload the tree + await this.load(); + + // Re-select the moved item + this.selectAndExpandPath(sourcePath); + + showNotification(`Undo: Moved ${fileName} back to ${parentName}`, 'success'); + } catch (error) { + console.error('Failed to undo move:', error); + showNotification('Failed to undo move: ' + error.message, 'danger'); + } + } + + setupDragAndDrop() { + // Dragover event on container to allow dropping on root level + this.container.addEventListener('dragover', (e) => { + if (!this.isEditMode() || !this.draggedPath) return; + + const node = e.target.closest('.tree-node'); + if (!node) { + // Hovering over empty space (root level) + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + + // Highlight the entire container as a drop target + this.container.classList.add('drag-over-root'); + } + }); + + // Dragleave event on container to remove root-level highlighting + this.container.addEventListener('dragleave', (e) => { + if (!this.isEditMode()) return; + + // Only remove if we're actually leaving the container + // Check if the related target is outside the container + if (!this.container.contains(e.relatedTarget)) { + this.container.classList.remove('drag-over-root'); + } + }); + + // Dragenter event to manage highlighting + this.container.addEventListener('dragenter', (e) => { + if (!this.isEditMode() || !this.draggedPath) return; + + const node = e.target.closest('.tree-node'); + if (!node) { + // Entering empty space + this.container.classList.add('drag-over-root'); + } else { + // Entering a node, remove root highlighting + this.container.classList.remove('drag-over-root'); + } + }); + + // Drop event on container for root level drops + this.container.addEventListener('drop', async (e) => { + if (!this.isEditMode()) return; + + const node = e.target.closest('.tree-node'); + if (!node && this.draggedPath) { + // Dropped on root level + e.preventDefault(); + this.container.classList.remove('drag-over-root'); + await this.handleDrop('', true); + } + }); + } + + isEditMode() { + return document.body.classList.contains('edit-mode'); + } + + setupNodeDragHandlers(nodeElement, node) { + // Dragstart - when user starts dragging + nodeElement.addEventListener('dragstart', (e) => { + this.draggedNode = nodeElement; + this.draggedPath = node.path; + this.draggedIsDir = node.isDirectory; + + nodeElement.classList.add('dragging'); + document.body.classList.add('dragging-active'); + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', node.path); + + // Create a custom drag image with fixed width + const dragImage = nodeElement.cloneNode(true); + dragImage.style.position = 'absolute'; + dragImage.style.top = '-9999px'; + dragImage.style.left = '-9999px'; + dragImage.style.width = `${Config.DRAG_PREVIEW_WIDTH}px`; + dragImage.style.maxWidth = `${Config.DRAG_PREVIEW_WIDTH}px`; + dragImage.style.opacity = Config.DRAG_PREVIEW_OPACITY; + dragImage.style.backgroundColor = 'var(--bg-secondary)'; + dragImage.style.border = '1px solid var(--border-color)'; + dragImage.style.borderRadius = '4px'; + dragImage.style.padding = '4px 8px'; + dragImage.style.whiteSpace = 'nowrap'; + dragImage.style.overflow = 'hidden'; + dragImage.style.textOverflow = 'ellipsis'; + + document.body.appendChild(dragImage); + e.dataTransfer.setDragImage(dragImage, 10, 10); + setTimeout(() => { + if (dragImage.parentNode) { + document.body.removeChild(dragImage); + } + }, 0); + }); + + // Dragend - when drag operation ends + nodeElement.addEventListener('dragend', () => { + nodeElement.classList.remove('dragging'); + nodeElement.classList.remove('drag-ready'); + document.body.classList.remove('dragging-active'); + this.container.classList.remove('drag-over-root'); + this.clearDragOverStates(); + + // Reset draggable state + nodeElement.draggable = false; + nodeElement.style.cursor = ''; + this.isDraggingEnabled = false; + + this.draggedNode = null; + this.draggedPath = null; + this.draggedIsDir = false; + }); + + // Dragover - when dragging over this node + nodeElement.addEventListener('dragover', (e) => { + if (!this.draggedPath) return; + + const targetPath = node.path; + const targetIsDir = node.isDirectory; + + // Only allow dropping on directories + if (!targetIsDir) { + e.dataTransfer.dropEffect = 'none'; + return; + } + + // Check if this is a valid drop target + if (this.isValidDropTarget(this.draggedPath, this.draggedIsDir, targetPath)) { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + nodeElement.classList.add('drag-over'); + } else { + e.dataTransfer.dropEffect = 'none'; + } + }); + + // Dragleave - when drag leaves this node + nodeElement.addEventListener('dragleave', (e) => { + // Only remove if we're actually leaving the node (not entering a child) + if (e.target === nodeElement) { + nodeElement.classList.remove('drag-over'); + + // If leaving a node and not entering another node, might be going to root + const relatedNode = e.relatedTarget?.closest('.tree-node'); + if (!relatedNode && this.container.contains(e.relatedTarget)) { + // Moving to empty space (root area) + this.container.classList.add('drag-over-root'); + } + } + }); + + // Drop - when item is dropped on this node + nodeElement.addEventListener('drop', async (e) => { + e.preventDefault(); + e.stopPropagation(); + + nodeElement.classList.remove('drag-over'); + + if (!this.draggedPath) return; + + const targetPath = node.path; + const targetIsDir = node.isDirectory; + + if (targetIsDir && this.isValidDropTarget(this.draggedPath, this.draggedIsDir, targetPath)) { + await this.handleDrop(targetPath, targetIsDir); + } + }); + } + + clearDragOverStates() { + this.container.querySelectorAll('.drag-over').forEach(node => { + node.classList.remove('drag-over'); + }); + } + + isValidDropTarget(sourcePath, sourceIsDir, targetPath) { + // Can't drop on itself + if (sourcePath === targetPath) { + return false; + } + + // If dragging a directory, can't drop into its own descendants + if (sourceIsDir) { + // Check if target is a descendant of source + if (targetPath.startsWith(sourcePath + '/')) { + return false; + } + } + + // Can't drop into the same parent directory + const sourceParent = PathUtils.getParentPath(sourcePath); + if (sourceParent === targetPath) { + return false; + } + + return true; + } + + async handleDrop(targetPath, targetIsDir) { + if (!this.draggedPath) return; + + try { + const sourcePath = this.draggedPath; + const fileName = PathUtils.getFileName(sourcePath); + const isDirectory = this.draggedIsDir; + + // Construct destination path + let destPath; + if (targetPath === '') { + // Dropping to root + destPath = fileName; + } else { + destPath = `${targetPath}/${fileName}`; + } + + // Check if destination already exists + const destNode = this.findNode(destPath); + if (destNode) { + const overwrite = await window.ModalManager.confirm( + `A ${destNode.isDirectory ? 'folder' : 'file'} named "${fileName}" already exists in the destination. Do you want to overwrite it?`, + 'Name Conflict', + true + ); + + if (!overwrite) { + return; + } + + // Delete existing item first + await this.webdavClient.delete(destPath); + + // Clear undo history since we're overwriting + this.lastMoveOperation = null; + } + + // Perform the move + await this.webdavClient.move(sourcePath, destPath); + + // Store undo information (only if not overwriting) + if (!destNode) { + this.lastMoveOperation = { + sourcePath: sourcePath, + destPath: destPath, + fileName: fileName, + isDirectory: isDirectory + }; + } + + // If the moved item was the currently selected file, update the selection + if (this.selectedPath === sourcePath) { + this.selectedPath = destPath; + + // Update editor's current file path if it's the file being moved + if (!this.draggedIsDir && window.editor && window.editor.currentFile === sourcePath) { + window.editor.currentFile = destPath; + if (window.editor.filenameInput) { + window.editor.filenameInput.value = destPath; + } + } + + // Notify file select callback if it's a file + if (!this.draggedIsDir && this.onFileSelect) { + this.onFileSelect({ path: destPath, isDirectory: false }); + } + } + + // Reload the tree + await this.load(); + + // Re-select the moved item + this.selectAndExpandPath(destPath); + + showNotification(`Moved ${fileName} successfully`, 'success'); + } catch (error) { + console.error('Failed to move item:', error); + showNotification('Failed to move item: ' + error.message, 'danger'); + } + } + async load() { try { const items = await this.webdavClient.propfind('', 'infinity'); @@ -63,12 +418,12 @@ class FileTree { showNotification('Failed to load files', 'error'); } } - + render() { this.container.innerHTML = ''; this.renderNodes(this.tree, this.container, 0); } - + renderNodes(nodes, parentElement, level) { nodes.forEach(node => { const nodeWrapper = document.createElement('div'); @@ -78,40 +433,56 @@ class FileTree { const nodeElement = this.createNodeElement(node, level); nodeWrapper.appendChild(nodeElement); - // Create children container ONLY if has children - if (node.children && node.children.length > 0) { + // Create children container for directories + if (node.isDirectory) { const childContainer = document.createElement('div'); childContainer.className = 'tree-children'; childContainer.style.display = 'none'; childContainer.dataset.parent = node.path; childContainer.style.marginLeft = `${(level + 1) * 12}px`; - // Recursively render children - this.renderNodes(node.children, childContainer, level + 1); + // Only render children if they exist + if (node.children && node.children.length > 0) { + this.renderNodes(node.children, childContainer, level + 1); + } else { + // Empty directory - show empty state message + const emptyMessage = document.createElement('div'); + emptyMessage.className = 'tree-empty-message'; + emptyMessage.textContent = 'Empty folder'; + childContainer.appendChild(emptyMessage); + } + nodeWrapper.appendChild(childContainer); - // Make toggle functional + // Make toggle functional for ALL directories (including empty ones) const toggle = nodeElement.querySelector('.tree-node-toggle'); if (toggle) { - toggle.addEventListener('click', (e) => { - console.log('Toggle clicked', e.target); + const toggleHandler = (e) => { e.stopPropagation(); const isHidden = childContainer.style.display === 'none'; - console.log('Is hidden?', isHidden); childContainer.style.display = isHidden ? 'block' : 'none'; - toggle.innerHTML = isHidden ? '▼' : '▶'; + toggle.style.transform = isHidden ? 'rotate(90deg)' : 'rotate(0deg)'; toggle.classList.toggle('expanded'); - }); + }; + + // Add click listener to toggle icon + toggle.addEventListener('click', toggleHandler); + + // Also allow double-click on the node to toggle + nodeElement.addEventListener('dblclick', toggleHandler); + + // Make toggle cursor pointer for all directories + toggle.style.cursor = 'pointer'; } } parentElement.appendChild(nodeWrapper); }); } - - + + // toggleFolder is no longer needed as the event listener is added in renderNodes. - + selectFile(path) { this.selectedPath = path; this.updateSelection(); @@ -119,7 +490,7 @@ class FileTree { this.onFileSelect({ path, isDirectory: false }); } } - + selectFolder(path) { this.selectedPath = path; this.updateSelection(); @@ -127,18 +498,111 @@ class FileTree { this.onFolderSelect({ path, isDirectory: true }); } } - + + /** + * Find a node by path + * @param {string} path - The path to find + * @returns {Object|null} The node or null if not found + */ + findNode(path) { + const search = (nodes, targetPath) => { + for (const node of nodes) { + if (node.path === targetPath) { + return node; + } + if (node.children && node.children.length > 0) { + const found = search(node.children, targetPath); + if (found) return found; + } + } + return null; + }; + + return search(this.tree, path); + } + + /** + * Get all files in a directory (direct children only) + * @param {string} dirPath - The directory path + * @returns {Array} Array of file nodes + */ + getDirectoryFiles(dirPath) { + const dirNode = this.findNode(dirPath); + if (dirNode && dirNode.children) { + return dirNode.children.filter(child => !child.isDirectory); + } + return []; + } + updateSelection() { // Remove previous selection this.container.querySelectorAll('.tree-node').forEach(node => { - node.classList.remove('selected'); + node.classList.remove('active'); }); - - // Add selection to current + + // Add selection to current and all parent directories if (this.selectedPath) { + // Add active class to the selected file/folder const node = this.container.querySelector(`[data-path="${this.selectedPath}"]`); if (node) { - node.classList.add('selected'); + node.classList.add('active'); + } + + // Add active class to all parent directories + const parts = this.selectedPath.split('/'); + let currentPath = ''; + for (let i = 0; i < parts.length - 1; i++) { + currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i]; + const parentNode = this.container.querySelector(`[data-path="${currentPath}"]`); + if (parentNode) { + parentNode.classList.add('active'); + } + } + } + } + + /** + * Highlight a file as active and expand all parent directories + * @param {string} path - The file path to highlight + */ + selectAndExpandPath(path) { + this.selectedPath = path; + + // Expand all parent directories + this.expandParentDirectories(path); + + // Update selection + this.updateSelection(); + } + + /** + * Expand all parent directories of a given path + * @param {string} path - The file path + */ + expandParentDirectories(path) { + // Get all parent paths + const parts = path.split('/'); + let currentPath = ''; + + for (let i = 0; i < parts.length - 1; i++) { + currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i]; + + // Find the node with this path + const parentNode = this.container.querySelector(`[data-path="${currentPath}"]`); + if (parentNode && parentNode.dataset.isdir === 'true') { + // Find the children container + const wrapper = parentNode.closest('.tree-node-wrapper'); + if (wrapper) { + const childContainer = wrapper.querySelector('.tree-children'); + if (childContainer && childContainer.style.display === 'none') { + // Expand it + childContainer.style.display = 'block'; + const toggle = parentNode.querySelector('.tree-node-toggle'); + if (toggle) { + toggle.classList.add('expanded'); + } + } + } } } } @@ -150,25 +614,111 @@ class FileTree { nodeElement.dataset.isdir = node.isDirectory; nodeElement.style.paddingLeft = `${level * 12}px`; - const icon = document.createElement('span'); - icon.className = 'tree-node-icon'; + // Enable drag and drop in edit mode with long-press detection + if (this.isEditMode()) { + // Start with draggable disabled + nodeElement.draggable = false; + this.setupNodeDragHandlers(nodeElement, node); + this.setupLongPressDetection(nodeElement, node); + } + + // Create toggle/icon container + const iconContainer = document.createElement('span'); + iconContainer.className = 'tree-node-icon'; + if (node.isDirectory) { - icon.innerHTML = '▶'; // Collapsed by default - icon.classList.add('tree-node-toggle'); + // Create toggle icon for folders + const toggle = document.createElement('i'); + toggle.className = 'bi bi-chevron-right tree-node-toggle'; + toggle.style.fontSize = '12px'; + iconContainer.appendChild(toggle); } else { - icon.innerHTML = '●'; // File icon + // Create file icon + const fileIcon = document.createElement('i'); + fileIcon.className = 'bi bi-file-earmark-text'; + fileIcon.style.fontSize = '14px'; + iconContainer.appendChild(fileIcon); } const title = document.createElement('span'); title.className = 'tree-node-title'; title.textContent = node.name; - nodeElement.appendChild(icon); + nodeElement.appendChild(iconContainer); nodeElement.appendChild(title); return nodeElement; } - + + setupLongPressDetection(nodeElement, node) { + // Mouse down - start long-press timer + nodeElement.addEventListener('mousedown', (e) => { + // Ignore if clicking on toggle button + if (e.target.closest('.tree-node-toggle')) { + return; + } + + this.mouseDownNode = nodeElement; + + // Start timer for long-press + this.longPressTimer = setTimeout(() => { + // Long-press threshold met - enable dragging + this.isDraggingEnabled = true; + nodeElement.draggable = true; + nodeElement.classList.add('drag-ready'); + + // Change cursor to grab + nodeElement.style.cursor = 'grab'; + }, this.longPressThreshold); + }); + + // Mouse up - cancel long-press timer + nodeElement.addEventListener('mouseup', () => { + this.clearLongPressTimer(); + }); + + // Mouse leave - cancel long-press timer + nodeElement.addEventListener('mouseleave', () => { + this.clearLongPressTimer(); + }); + + // Mouse move - cancel long-press if moved too much + let startX, startY; + nodeElement.addEventListener('mousedown', (e) => { + startX = e.clientX; + startY = e.clientY; + }); + + nodeElement.addEventListener('mousemove', (e) => { + if (this.longPressTimer && !this.isDraggingEnabled) { + const deltaX = Math.abs(e.clientX - startX); + const deltaY = Math.abs(e.clientY - startY); + + // If mouse moved more than threshold, cancel long-press + if (deltaX > Config.MOUSE_MOVE_THRESHOLD || deltaY > Config.MOUSE_MOVE_THRESHOLD) { + this.clearLongPressTimer(); + } + } + }); + } + + clearLongPressTimer() { + if (this.longPressTimer) { + clearTimeout(this.longPressTimer); + this.longPressTimer = null; + } + + // Reset dragging state if not currently dragging + if (!this.draggedPath && this.mouseDownNode) { + this.mouseDownNode.draggable = false; + this.mouseDownNode.classList.remove('drag-ready'); + this.mouseDownNode.style.cursor = ''; + this.isDraggingEnabled = false; + } + + this.mouseDownNode = null; + } + formatSize(bytes) { if (bytes === 0) return '0 B'; const k = 1024; @@ -176,7 +726,7 @@ class FileTree { const i = Math.floor(Math.log(bytes) / Math.log(k)); return Math.round(bytes / Math.pow(k, i) * 10) / 10 + ' ' + sizes[i]; } - + newFile() { this.selectedPath = null; this.updateSelection(); @@ -200,7 +750,7 @@ class FileTree { throw error; } } - + async createFolder(parentPath, foldername) { try { const fullPath = parentPath ? `${parentPath}/${foldername}` : foldername; @@ -214,7 +764,7 @@ class FileTree { throw error; } } - + async uploadFile(parentPath, file) { try { const fullPath = parentPath ? `${parentPath}/${file.name}` : file.name; @@ -229,63 +779,76 @@ class FileTree { throw error; } } - + async downloadFile(path) { try { const content = await this.webdavClient.get(path); - const filename = path.split('/').pop(); - this.triggerDownload(content, filename); + const filename = PathUtils.getFileName(path); + DownloadUtils.triggerDownload(content, filename); showNotification('Downloaded', 'success'); } catch (error) { console.error('Failed to download file:', error); showNotification('Failed to download file', 'error'); } } - + async downloadFolder(path) { try { showNotification('Creating zip...', 'info'); // Get all files in folder const items = await this.webdavClient.propfind(path, 'infinity'); const files = items.filter(item => !item.isDirectory); - + // Use JSZip to create zip file const JSZip = window.JSZip; if (!JSZip) { throw new Error('JSZip not loaded'); } - + const zip = new JSZip(); - const folder = zip.folder(path.split('/').pop() || 'download'); - + const folder = zip.folder(PathUtils.getFileName(path) || 'download'); + // Add all files to zip for (const file of files) { const content = await this.webdavClient.get(file.path); const relativePath = file.path.replace(path + '/', ''); folder.file(relativePath, content); } - + // Generate zip const zipBlob = await zip.generateAsync({ type: 'blob' }); - const zipFilename = `${path.split('/').pop() || 'download'}.zip`; - this.triggerDownload(zipBlob, zipFilename); + const zipFilename = `${PathUtils.getFileName(path) || 'download'}.zip`; + DownloadUtils.triggerDownload(zipBlob, zipFilename); showNotification('Downloaded', 'success'); } catch (error) { console.error('Failed to download folder:', error); showNotification('Failed to download folder', 'error'); } } - - triggerDownload(content, filename) { - const blob = content instanceof Blob ? content : new Blob([content]); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = filename; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); + + // triggerDownload method moved to DownloadUtils in utils.js + + /** + * Get the first markdown file in the tree + * Returns the path of the first .md file found, or null if none exist + */ + getFirstMarkdownFile() { + const findFirstFile = (nodes) => { + for (const node of nodes) { + // If it's a file and ends with .md, return it + if (!node.isDirectory && node.path.endsWith('.md')) { + return node.path; + } + // If it's a directory with children, search recursively + if (node.isDirectory && node.children && node.children.length > 0) { + const found = findFirstFile(node.children); + if (found) return found; + } + } + return null; + }; + + return findFirstFile(this.tree); } } diff --git a/static/js/file-upload.js b/static/js/file-upload.js new file mode 100644 index 0000000..7c88021 --- /dev/null +++ b/static/js/file-upload.js @@ -0,0 +1,37 @@ +/** + * File Upload Module + * Handles file upload dialog for uploading files to the file tree + */ + +/** + * Show file upload dialog + * @param {string} targetPath - The target directory path + * @param {Function} onUpload - Callback function to handle file upload + */ +function showFileUploadDialog(targetPath, onUpload) { + const input = document.createElement('input'); + input.type = 'file'; + input.multiple = true; + + input.addEventListener('change', async (e) => { + const files = Array.from(e.target.files); + if (files.length === 0) return; + + for (const file of files) { + try { + await onUpload(targetPath, file); + } catch (error) { + Logger.error('Upload failed:', error); + if (window.showNotification) { + window.showNotification(`Failed to upload ${file.name}`, 'error'); + } + } + } + }); + + input.click(); +} + +// Make function globally available +window.showFileUploadDialog = showFileUploadDialog; + diff --git a/static/js/logger.js b/static/js/logger.js new file mode 100644 index 0000000..a9f904b --- /dev/null +++ b/static/js/logger.js @@ -0,0 +1,174 @@ +/** + * Logger Module + * Provides structured logging with different levels + * Can be configured to show/hide different log levels + */ + +class Logger { + /** + * Log levels + */ + static LEVELS = { + DEBUG: 0, + INFO: 1, + WARN: 2, + ERROR: 3, + NONE: 4 + }; + + /** + * Current log level + * Set to DEBUG by default, can be changed via setLevel() + */ + static currentLevel = Logger.LEVELS.DEBUG; + + /** + * Enable/disable logging + */ + static enabled = true; + + /** + * Set the minimum log level + * @param {number} level - One of Logger.LEVELS + */ + static setLevel(level) { + if (typeof level === 'number' && level >= 0 && level <= 4) { + Logger.currentLevel = level; + } + } + + /** + * Enable or disable logging + * @param {boolean} enabled - Whether to enable logging + */ + static setEnabled(enabled) { + Logger.enabled = enabled; + } + + /** + * Log a debug message + * @param {string} message - The message to log + * @param {...any} args - Additional arguments to log + */ + static debug(message, ...args) { + if (!Logger.enabled || Logger.currentLevel > Logger.LEVELS.DEBUG) { + return; + } + console.log(`[DEBUG] ${message}`, ...args); + } + + /** + * Log an info message + * @param {string} message - The message to log + * @param {...any} args - Additional arguments to log + */ + static info(message, ...args) { + if (!Logger.enabled || Logger.currentLevel > Logger.LEVELS.INFO) { + return; + } + console.info(`[INFO] ${message}`, ...args); + } + + /** + * Log a warning message + * @param {string} message - The message to log + * @param {...any} args - Additional arguments to log + */ + static warn(message, ...args) { + if (!Logger.enabled || Logger.currentLevel > Logger.LEVELS.WARN) { + return; + } + console.warn(`[WARN] ${message}`, ...args); + } + + /** + * Log an error message + * @param {string} message - The message to log + * @param {...any} args - Additional arguments to log + */ + static error(message, ...args) { + if (!Logger.enabled || Logger.currentLevel > Logger.LEVELS.ERROR) { + return; + } + console.error(`[ERROR] ${message}`, ...args); + } + + /** + * Log a message with a custom prefix + * @param {string} prefix - The prefix to use + * @param {string} message - The message to log + * @param {...any} args - Additional arguments to log + */ + static log(prefix, message, ...args) { + if (!Logger.enabled) { + return; + } + console.log(`[${prefix}] ${message}`, ...args); + } + + /** + * Group related log messages + * @param {string} label - The group label + */ + static group(label) { + if (!Logger.enabled) { + return; + } + console.group(label); + } + + /** + * End a log group + */ + static groupEnd() { + if (!Logger.enabled) { + return; + } + console.groupEnd(); + } + + /** + * Log a table (useful for arrays of objects) + * @param {any} data - The data to display as a table + */ + static table(data) { + if (!Logger.enabled) { + return; + } + console.table(data); + } + + /** + * Start a timer + * @param {string} label - The timer label + */ + static time(label) { + if (!Logger.enabled) { + return; + } + console.time(label); + } + + /** + * End a timer and log the elapsed time + * @param {string} label - The timer label + */ + static timeEnd(label) { + if (!Logger.enabled) { + return; + } + console.timeEnd(label); + } +} + +// Make Logger globally available +window.Logger = Logger; + +// Set default log level based on environment +// In production, you might want to set this to WARN or ERROR +if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') { + Logger.setLevel(Logger.LEVELS.DEBUG); +} else { + Logger.setLevel(Logger.LEVELS.INFO); +} + diff --git a/static/js/macro-processor.js b/static/js/macro-processor.js index 3a7174d..3d20662 100644 --- a/static/js/macro-processor.js +++ b/static/js/macro-processor.js @@ -10,7 +10,7 @@ class MacroProcessor { this.includeStack = []; // Track includes to detect cycles this.registerDefaultPlugins(); } - + /** * Register a macro plugin * Plugin must implement: { canHandle(actor, method), process(macro, webdavClient) } @@ -19,27 +19,23 @@ class MacroProcessor { const key = `${actor}.${method}`; this.plugins.set(key, plugin); } - + /** * Process all macros in content * Returns { success: boolean, content: string, errors: [] } */ async processMacros(content) { - console.log('MacroProcessor: Starting macro processing for content:', content); const macros = MacroParser.extractMacros(content); - console.log('MacroProcessor: Extracted macros:', macros); const errors = []; let processedContent = content; - + // Process macros in reverse order to preserve positions for (let i = macros.length - 1; i >= 0; i--) { const macro = macros[i]; - console.log('MacroProcessor: Processing macro:', macro); - + try { const result = await this.processMacro(macro); - console.log('MacroProcessor: Macro processing result:', result); - + if (result.success) { // Replace macro with result processedContent = @@ -51,7 +47,7 @@ class MacroProcessor { macro: macro.fullMatch, error: result.error }); - + // Replace with error message const errorMsg = `\n\n⚠️ **Macro Error**: ${result.error}\n\n`; processedContent = @@ -64,7 +60,7 @@ class MacroProcessor { macro: macro.fullMatch, error: error.message }); - + const errorMsg = `\n\n⚠️ **Macro Error**: ${error.message}\n\n`; processedContent = processedContent.substring(0, macro.start) + @@ -72,15 +68,14 @@ class MacroProcessor { processedContent.substring(macro.end); } } - - console.log('MacroProcessor: Final processed content:', processedContent); + return { success: errors.length === 0, content: processedContent, errors }; } - + /** * Process single macro */ @@ -98,20 +93,20 @@ class MacroProcessor { }; } } - + if (!plugin) { return { success: false, error: `Unknown macro: !!${key}` }; } - + // Validate macro const validation = MacroParser.validateMacro(macro); if (!validation.valid) { return { success: false, error: validation.error }; } - + // Execute plugin try { return await plugin.process(macro, this.webdavClient); @@ -122,7 +117,7 @@ class MacroProcessor { }; } } - + /** * Register default plugins */ @@ -131,14 +126,14 @@ class MacroProcessor { this.registerPlugin('core', 'include', { process: async (macro, webdavClient) => { const path = macro.params.path || macro.params['']; - + if (!path) { return { success: false, error: 'include macro requires "path" parameter' }; } - + try { // Add to include stack this.includeStack.push(path); diff --git a/static/js/notification-service.js b/static/js/notification-service.js new file mode 100644 index 0000000..bcbd820 --- /dev/null +++ b/static/js/notification-service.js @@ -0,0 +1,77 @@ +/** + * Notification Service + * Provides a standardized way to show toast notifications + * Wraps the showNotification function from ui-utils.js + */ + +class NotificationService { + /** + * Show a success notification + * @param {string} message - The message to display + */ + static success(message) { + if (window.showNotification) { + window.showNotification(message, Config.NOTIFICATION_TYPES.SUCCESS); + } else { + Logger.warn('showNotification not available, falling back to console'); + console.log(`✅ ${message}`); + } + } + + /** + * Show an error notification + * @param {string} message - The message to display + */ + static error(message) { + if (window.showNotification) { + window.showNotification(message, Config.NOTIFICATION_TYPES.ERROR); + } else { + Logger.warn('showNotification not available, falling back to console'); + console.error(`❌ ${message}`); + } + } + + /** + * Show a warning notification + * @param {string} message - The message to display + */ + static warning(message) { + if (window.showNotification) { + window.showNotification(message, Config.NOTIFICATION_TYPES.WARNING); + } else { + Logger.warn('showNotification not available, falling back to console'); + console.warn(`⚠️ ${message}`); + } + } + + /** + * Show an info notification + * @param {string} message - The message to display + */ + static info(message) { + if (window.showNotification) { + window.showNotification(message, Config.NOTIFICATION_TYPES.INFO); + } else { + Logger.warn('showNotification not available, falling back to console'); + console.info(`ℹ️ ${message}`); + } + } + + /** + * Show a notification with a custom type + * @param {string} message - The message to display + * @param {string} type - The notification type (success, danger, warning, primary, etc.) + */ + static show(message, type = 'primary') { + if (window.showNotification) { + window.showNotification(message, type); + } else { + Logger.warn('showNotification not available, falling back to console'); + console.log(`[${type.toUpperCase()}] ${message}`); + } + } +} + +// Make NotificationService globally available +window.NotificationService = NotificationService; + diff --git a/static/js/ui-utils.js b/static/js/ui-utils.js index afc5057..2ba0838 100644 --- a/static/js/ui-utils.js +++ b/static/js/ui-utils.js @@ -1,270 +1,60 @@ /** * UI Utilities Module - * Toast notifications, context menu, dark mode, file upload dialog + * Toast notifications (kept for backward compatibility) + * + * Other utilities have been moved to separate modules: + * - Context menu: context-menu.js + * - File upload: file-upload.js + * - Dark mode: dark-mode.js + * - Collection selector: collection-selector.js + * - Editor drop handler: editor-drop-handler.js */ /** * Show toast notification + * @param {string} message - The message to display + * @param {string} type - The notification type (info, success, error, warning, danger, primary) */ function showNotification(message, type = 'info') { const container = document.getElementById('toastContainer') || createToastContainer(); - + const toast = document.createElement('div'); const bgClass = type === 'error' ? 'danger' : type === 'success' ? 'success' : type === 'warning' ? 'warning' : 'primary'; toast.className = `toast align-items-center text-white bg-${bgClass} border-0`; toast.setAttribute('role', 'alert'); - + toast.innerHTML = `
${message}
`; - + container.appendChild(toast); - - const bsToast = new bootstrap.Toast(toast, { delay: 3000 }); + + const bsToast = new bootstrap.Toast(toast, { delay: Config.TOAST_DURATION }); bsToast.show(); - + toast.addEventListener('hidden.bs.toast', () => { toast.remove(); }); } +/** + * Create the toast container if it doesn't exist + * @returns {HTMLElement} The toast container element + */ function createToastContainer() { const container = document.createElement('div'); container.id = 'toastContainer'; container.className = 'toast-container position-fixed top-0 end-0 p-3'; - container.style.zIndex = '9999'; + container.style.zIndex = Config.TOAST_Z_INDEX; document.body.appendChild(container); return container; } -/** - * Enhanced Context Menu - */ -function showContextMenu(x, y, target) { - const menu = document.getElementById('contextMenu'); - if (!menu) return; - - // Store target data - menu.dataset.targetPath = target.path; - menu.dataset.targetIsDir = target.isDir; - - // Show/hide menu items based on target type - const items = { - 'new-file': target.isDir, - 'new-folder': target.isDir, - 'upload': target.isDir, - 'download': true, - 'paste': target.isDir && window.fileTreeActions?.clipboard, - 'open': !target.isDir - }; - - Object.entries(items).forEach(([action, show]) => { - const item = menu.querySelector(`[data-action="${action}"]`); - if (item) { - item.style.display = show ? 'flex' : 'none'; - } - }); - - // Position menu - menu.style.display = 'block'; - menu.style.left = x + 'px'; - menu.style.top = y + 'px'; - - // Adjust if off-screen - setTimeout(() => { - const rect = menu.getBoundingClientRect(); - if (rect.right > window.innerWidth) { - menu.style.left = (window.innerWidth - rect.width - 10) + 'px'; - } - if (rect.bottom > window.innerHeight) { - menu.style.top = (window.innerHeight - rect.height - 10) + 'px'; - } - }, 0); -} - -function hideContextMenu() { - const menu = document.getElementById('contextMenu'); - if (menu) { - menu.style.display = 'none'; - } -} - -// Combined click handler for context menu and outside clicks -document.addEventListener('click', async (e) => { - const menuItem = e.target.closest('.context-menu-item'); - - if (menuItem) { - // Handle context menu item click - const action = menuItem.dataset.action; - const menu = document.getElementById('contextMenu'); - const targetPath = menu.dataset.targetPath; - const isDir = menu.dataset.targetIsDir === 'true'; - - hideContextMenu(); - - if (window.fileTreeActions) { - await window.fileTreeActions.execute(action, targetPath, isDir); - } - } else if (!e.target.closest('#contextMenu') && !e.target.closest('.tree-node')) { - // Hide on outside click - hideContextMenu(); - } -}); - -/** - * File Upload Dialog - */ -function showFileUploadDialog(targetPath, onUpload) { - const input = document.createElement('input'); - input.type = 'file'; - input.multiple = true; - - input.addEventListener('change', async (e) => { - const files = Array.from(e.target.files); - if (files.length === 0) return; - - for (const file of files) { - try { - await onUpload(targetPath, file); - } catch (error) { - console.error('Upload failed:', error); - } - } - }); - - input.click(); -} - -/** - * Dark Mode Manager - */ -class DarkMode { - constructor() { - this.isDark = localStorage.getItem('darkMode') === 'true'; - this.apply(); - } - - toggle() { - this.isDark = !this.isDark; - localStorage.setItem('darkMode', this.isDark); - this.apply(); - } - - apply() { - if (this.isDark) { - document.body.classList.add('dark-mode'); - const btn = document.getElementById('darkModeBtn'); - if (btn) btn.textContent = '☀️'; - - // Update mermaid theme - if (window.mermaid) { - mermaid.initialize({ theme: 'dark' }); - } - } else { - document.body.classList.remove('dark-mode'); - const btn = document.getElementById('darkModeBtn'); - if (btn) btn.textContent = '🌙'; - - // Update mermaid theme - if (window.mermaid) { - mermaid.initialize({ theme: 'default' }); - } - } - } -} - -/** - * Collection Selector - */ -class CollectionSelector { - constructor(selectId, webdavClient) { - this.select = document.getElementById(selectId); - this.webdavClient = webdavClient; - this.onChange = null; - } - - async load() { - try { - const collections = await this.webdavClient.getCollections(); - this.select.innerHTML = ''; - - collections.forEach(collection => { - const option = document.createElement('option'); - option.value = collection; - option.textContent = collection; - this.select.appendChild(option); - }); - - // Select first collection - if (collections.length > 0) { - this.select.value = collections[0]; - this.webdavClient.setCollection(collections[0]); - if (this.onChange) { - this.onChange(collections[0]); - } - } - - // Add change listener - this.select.addEventListener('change', () => { - const collection = this.select.value; - this.webdavClient.setCollection(collection); - if (this.onChange) { - this.onChange(collection); - } - }); - } catch (error) { - console.error('Failed to load collections:', error); - showNotification('Failed to load collections', 'error'); - } - } -} - -/** - * Editor Drop Handler - * Handles file drops into the editor - */ -class EditorDropHandler { - constructor(editorElement, onFileDrop) { - this.editorElement = editorElement; - this.onFileDrop = onFileDrop; - this.setupHandlers(); - } - - setupHandlers() { - this.editorElement.addEventListener('dragover', (e) => { - e.preventDefault(); - e.stopPropagation(); - this.editorElement.classList.add('drag-over'); - }); - - this.editorElement.addEventListener('dragleave', (e) => { - e.preventDefault(); - e.stopPropagation(); - this.editorElement.classList.remove('drag-over'); - }); - - this.editorElement.addEventListener('drop', async (e) => { - e.preventDefault(); - e.stopPropagation(); - this.editorElement.classList.remove('drag-over'); - - const files = Array.from(e.dataTransfer.files); - if (files.length === 0) return; - - for (const file of files) { - try { - if (this.onFileDrop) { - await this.onFileDrop(file); - } - } catch (error) { - console.error('Drop failed:', error); - showNotification(`Failed to upload ${file.name}`, 'error'); - } - } - }); - } -} +// All other UI utilities have been moved to separate modules +// See the module list at the top of this file +// Make showNotification globally available +window.showNotification = showNotification; diff --git a/static/js/utils.js b/static/js/utils.js new file mode 100644 index 0000000..8bee004 --- /dev/null +++ b/static/js/utils.js @@ -0,0 +1,355 @@ +/** + * Utilities Module + * Common utility functions used throughout the application + */ + +/** + * Path Utilities + * Helper functions for path manipulation + */ +const PathUtils = { + /** + * Get the filename from a path + * @param {string} path - The file path + * @returns {string} The filename + * @example PathUtils.getFileName('folder/subfolder/file.md') // 'file.md' + */ + getFileName(path) { + if (!path) return ''; + return path.split('/').pop(); + }, + + /** + * Get the parent directory path + * @param {string} path - The file path + * @returns {string} The parent directory path + * @example PathUtils.getParentPath('folder/subfolder/file.md') // 'folder/subfolder' + */ + getParentPath(path) { + if (!path) return ''; + const lastSlash = path.lastIndexOf('/'); + return lastSlash === -1 ? '' : path.substring(0, lastSlash); + }, + + /** + * Normalize a path by removing duplicate slashes + * @param {string} path - The path to normalize + * @returns {string} The normalized path + * @example PathUtils.normalizePath('folder//subfolder///file.md') // 'folder/subfolder/file.md' + */ + normalizePath(path) { + if (!path) return ''; + return path.replace(/\/+/g, '/'); + }, + + /** + * Join multiple path segments + * @param {...string} paths - Path segments to join + * @returns {string} The joined path + * @example PathUtils.joinPaths('folder', 'subfolder', 'file.md') // 'folder/subfolder/file.md' + */ + joinPaths(...paths) { + return PathUtils.normalizePath(paths.filter(p => p).join('/')); + }, + + /** + * Get the file extension + * @param {string} path - The file path + * @returns {string} The file extension (without dot) + * @example PathUtils.getExtension('file.md') // 'md' + */ + getExtension(path) { + if (!path) return ''; + const fileName = PathUtils.getFileName(path); + const lastDot = fileName.lastIndexOf('.'); + return lastDot === -1 ? '' : fileName.substring(lastDot + 1); + }, + + /** + * Check if a path is a descendant of another path + * @param {string} path - The path to check + * @param {string} ancestorPath - The potential ancestor path + * @returns {boolean} True if path is a descendant of ancestorPath + * @example PathUtils.isDescendant('folder/subfolder/file.md', 'folder') // true + */ + isDescendant(path, ancestorPath) { + if (!path || !ancestorPath) return false; + return path.startsWith(ancestorPath + '/'); + } +}; + +/** + * DOM Utilities + * Helper functions for DOM manipulation + */ +const DOMUtils = { + /** + * Create an element with optional class and attributes + * @param {string} tag - The HTML tag name + * @param {string} [className] - Optional class name(s) + * @param {Object} [attributes] - Optional attributes object + * @returns {HTMLElement} The created element + */ + createElement(tag, className = '', attributes = {}) { + const element = document.createElement(tag); + if (className) { + element.className = className; + } + Object.entries(attributes).forEach(([key, value]) => { + element.setAttribute(key, value); + }); + return element; + }, + + /** + * Remove all children from an element + * @param {HTMLElement} element - The element to clear + */ + removeAllChildren(element) { + while (element.firstChild) { + element.removeChild(element.firstChild); + } + }, + + /** + * Toggle a class on an element + * @param {HTMLElement} element - The element + * @param {string} className - The class name + * @param {boolean} [force] - Optional force add/remove + */ + toggleClass(element, className, force) { + if (force !== undefined) { + element.classList.toggle(className, force); + } else { + element.classList.toggle(className); + } + }, + + /** + * Query selector with error handling + * @param {string} selector - The CSS selector + * @param {HTMLElement} [parent] - Optional parent element + * @returns {HTMLElement|null} The found element or null + */ + querySelector(selector, parent = document) { + try { + return parent.querySelector(selector); + } catch (error) { + Logger.error(`Invalid selector: ${selector}`, error); + return null; + } + }, + + /** + * Query selector all with error handling + * @param {string} selector - The CSS selector + * @param {HTMLElement} [parent] - Optional parent element + * @returns {NodeList|Array} The found elements or empty array + */ + querySelectorAll(selector, parent = document) { + try { + return parent.querySelectorAll(selector); + } catch (error) { + Logger.error(`Invalid selector: ${selector}`, error); + return []; + } + } +}; + +/** + * Timing Utilities + * Helper functions for timing and throttling + */ +const TimingUtils = { + /** + * Debounce a function + * @param {Function} func - The function to debounce + * @param {number} wait - The wait time in milliseconds + * @returns {Function} The debounced function + */ + debounce(func, wait) { + let timeout; + return function executedFunction(...args) { + const later = () => { + clearTimeout(timeout); + func(...args); + }; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + }; + }, + + /** + * Throttle a function + * @param {Function} func - The function to throttle + * @param {number} wait - The wait time in milliseconds + * @returns {Function} The throttled function + */ + throttle(func, wait) { + let inThrottle; + return function executedFunction(...args) { + if (!inThrottle) { + func(...args); + inThrottle = true; + setTimeout(() => inThrottle = false, wait); + } + }; + }, + + /** + * Delay execution + * @param {number} ms - Milliseconds to delay + * @returns {Promise} Promise that resolves after delay + */ + delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } +}; + +/** + * Download Utilities + * Helper functions for file downloads + */ +const DownloadUtils = { + /** + * Trigger a download in the browser + * @param {string|Blob} content - The content to download + * @param {string} filename - The filename for the download + */ + triggerDownload(content, filename) { + const blob = content instanceof Blob ? content : new Blob([content]); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }, + + /** + * Download content as a blob + * @param {Blob} blob - The blob to download + * @param {string} filename - The filename for the download + */ + downloadAsBlob(blob, filename) { + DownloadUtils.triggerDownload(blob, filename); + } +}; + +/** + * Validation Utilities + * Helper functions for input validation + */ +const ValidationUtils = { + /** + * Validate and sanitize a filename + * @param {string} name - The filename to validate + * @param {boolean} [isFolder=false] - Whether this is a folder name + * @returns {Object} Validation result with {valid, sanitized, message} + */ + validateFileName(name, isFolder = false) { + const type = isFolder ? 'folder' : 'file'; + + if (!name || name.trim().length === 0) { + return { valid: false, sanitized: '', message: `${type} name cannot be empty` }; + } + + // Check for invalid characters using pattern from Config + const validPattern = Config.FILENAME_PATTERN; + + if (!validPattern.test(name)) { + const sanitized = ValidationUtils.sanitizeFileName(name); + + return { + valid: false, + sanitized, + message: `Invalid characters in ${type} name. Only lowercase letters, numbers, and underscores allowed.\n\nSuggestion: "${sanitized}"` + }; + } + + return { valid: true, sanitized: name, message: '' }; + }, + + /** + * Sanitize a filename by removing/replacing invalid characters + * @param {string} name - The filename to sanitize + * @returns {string} The sanitized filename + */ + sanitizeFileName(name) { + return name + .toLowerCase() + .replace(Config.FILENAME_INVALID_CHARS, '_') + .replace(/_+/g, '_') + .replace(/^_+|_+$/g, ''); + }, + + /** + * Check if a string is empty or whitespace + * @param {string} str - The string to check + * @returns {boolean} True if empty or whitespace + */ + isEmpty(str) { + return !str || str.trim().length === 0; + }, + + /** + * Check if a value is a valid email + * @param {string} email - The email to validate + * @returns {boolean} True if valid email + */ + isValidEmail(email) { + const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailPattern.test(email); + } +}; + +/** + * String Utilities + * Helper functions for string manipulation + */ +const StringUtils = { + /** + * Truncate a string to a maximum length + * @param {string} str - The string to truncate + * @param {number} maxLength - Maximum length + * @param {string} [suffix='...'] - Suffix to add if truncated + * @returns {string} The truncated string + */ + truncate(str, maxLength, suffix = '...') { + if (!str || str.length <= maxLength) return str; + return str.substring(0, maxLength - suffix.length) + suffix; + }, + + /** + * Capitalize the first letter of a string + * @param {string} str - The string to capitalize + * @returns {string} The capitalized string + */ + capitalize(str) { + if (!str) return ''; + return str.charAt(0).toUpperCase() + str.slice(1); + }, + + /** + * Convert a string to kebab-case + * @param {string} str - The string to convert + * @returns {string} The kebab-case string + */ + toKebabCase(str) { + return str + .replace(/([a-z])([A-Z])/g, '$1-$2') + .replace(/[\s_]+/g, '-') + .toLowerCase(); + } +}; + +// Make utilities globally available +window.PathUtils = PathUtils; +window.DOMUtils = DOMUtils; +window.TimingUtils = TimingUtils; +window.DownloadUtils = DownloadUtils; +window.ValidationUtils = ValidationUtils; +window.StringUtils = StringUtils; + diff --git a/static/style.css b/static/style.css index ed66b87..42a5e4d 100644 --- a/static/style.css +++ b/static/style.css @@ -33,7 +33,8 @@ body.dark-mode { } /* Global styles */ -html, body { +html, +body { height: 100%; margin: 0; padding: 0; @@ -48,12 +49,6 @@ body { transition: background-color 0.3s ease, color 0.3s ease; } -.container-fluid { - flex: 1; - padding: 0; - overflow: hidden; -} - .row { margin: 0; } @@ -206,7 +201,12 @@ body.dark-mode .CodeMirror-linenumber { } /* Markdown preview styles */ -#preview h1, #preview h2, #preview h3, #preview h4, #preview h5, #preview h6 { +#preview h1, +#preview h2, +#preview h3, +#preview h4, +#preview h5, +#preview h6 { margin-top: 24px; margin-bottom: 16px; font-weight: 600; @@ -286,7 +286,8 @@ body.dark-mode .CodeMirror-linenumber { margin-bottom: 16px; } -#preview ul, #preview ol { +#preview ul, +#preview ol { margin-bottom: 16px; padding-left: 2em; } @@ -378,7 +379,7 @@ body.dark-mode .mermaid svg { .sidebar { display: none; } - + .editor-pane, .preview-pane { height: 50vh; @@ -590,5 +591,4 @@ body.dark-mode .sidebar h6 { body.dark-mode .tree-children { border-left-color: var(--border-color); -} - +} \ No newline at end of file diff --git a/templates/index.html b/templates/index.html index b59e6f1..e5865d9 100644 --- a/templates/index.html +++ b/templates/index.html @@ -30,10 +30,13 @@ @@ -126,7 +135,8 @@ -