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
This commit is contained in:
Mahmoud-Emad
2025-10-26 15:42:15 +03:00
parent 23a24d42e2
commit 0ed6bcf1f2
34 changed files with 4136 additions and 940 deletions

View File

@@ -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 = `
<div class="text-muted text-center mt-5">
<p>No content available</p>
</div>
`;
}
} catch (error) {
console.error('Failed to auto-load page in view mode:', error);
editor.previewElement.innerHTML = `
<div class="alert alert-danger">
<p>Failed to load content</p>
</div>
`;
}
};
window.eventBus = eventBus;
}
/**
* Show directory preview with list of files
* @param {string} dirPath - The directory path
*/
async function showDirectoryPreview(dirPath) {
if (!editor || !fileTree || !webdavClient) return;
try {
const dirName = dirPath.split('/').pop() || dirPath;
const files = fileTree.getDirectoryFiles(dirPath);
// Start building the preview HTML
let html = `<div class="directory-preview">`;
html += `<h2>${dirName}</h2>`;
if (files.length === 0) {
html += `<p>This directory is empty</p>`;
} else {
html += `<div class="directory-files">`;
// Create cards for each file
for (const file of files) {
const fileName = file.name;
let fileDescription = '';
// Try to get file description from markdown files
if (file.name.endsWith('.md')) {
try {
const content = await webdavClient.get(file.path);
// Extract first heading or first line as description
const lines = content.split('\n');
for (const line of lines) {
if (line.trim().startsWith('#')) {
fileDescription = line.replace(/^#+\s*/, '').trim();
break;
} else if (line.trim() && !line.startsWith('---')) {
fileDescription = line.trim().substring(0, 100);
break;
}
}
} catch (error) {
console.error('Failed to read file description:', error);
}
}
html += `
<div class="file-card" data-path="${file.path}">
<div class="file-card-header">
<i class="bi bi-file-earmark-text"></i>
<span class="file-card-name">${fileName}</span>
</div>
${fileDescription ? `<div class="file-card-description">${fileDescription}</div>` : ''}
</div>
`;
}
html += `</div>`;
}
html += `</div>`;
// Set the preview content
editor.previewElement.innerHTML = html;
// Add click handlers to file cards
editor.previewElement.querySelectorAll('.file-card').forEach(card => {
card.addEventListener('click', async () => {
const filePath = card.dataset.path;
await editor.loadFile(filePath);
fileTree.selectAndExpandPath(filePath);
});
});
} catch (error) {
console.error('Failed to show directory preview:', error);
editor.previewElement.innerHTML = `
<div class="alert alert-danger">
<p>Failed to load directory preview</p>
</div>
`;
}
}
/**
* Parse URL to extract collection and file path
* URL format: /<collection>/<file_path> or /<collection>/<dir>/<file>
* @returns {Object} {collection, filePath} or {collection, null} if only collection
*/
function parseURLPath() {
const pathname = window.location.pathname;
const parts = pathname.split('/').filter(p => p); // Remove empty parts
if (parts.length === 0) {
return { collection: null, filePath: null };
}
const collection = parts[0];
const filePath = parts.length > 1 ? parts.slice(1).join('/') : null;
return { collection, filePath };
}
/**
* Update URL based on current collection and file
* @param {string} collection - The collection name
* @param {string} filePath - The file path (optional)
* @param {boolean} isEditMode - Whether in edit mode
*/
function updateURL(collection, filePath, isEditMode) {
let url = `/${collection}`;
if (filePath) {
url += `/${filePath}`;
}
if (isEditMode) {
url += '?edit=true';
}
// Use pushState to update URL without reloading
window.history.pushState({ collection, filePath }, '', url);
}
/**
* Load file from URL path
* Assumes the collection is already set and file tree is loaded
* @param {string} collection - The collection name (for validation)
* @param {string} filePath - The file path
*/
async function loadFileFromURL(collection, filePath) {
console.log('[loadFileFromURL] Called with:', { collection, filePath });
if (!fileTree || !editor || !collectionSelector) {
console.error('[loadFileFromURL] Missing dependencies:', { fileTree: !!fileTree, editor: !!editor, collectionSelector: !!collectionSelector });
return;
}
try {
// Verify we're on the right collection
const currentCollection = collectionSelector.getCurrentCollection();
if (currentCollection !== collection) {
console.error(`[loadFileFromURL] Collection mismatch: expected ${collection}, got ${currentCollection}`);
return;
}
// Load the file or directory
if (filePath) {
// Check if the path is a directory or a file
const node = fileTree.findNode(filePath);
console.log('[loadFileFromURL] Found node:', node);
if (node && node.isDirectory) {
// It's a directory, show directory preview
console.log('[loadFileFromURL] Loading directory preview');
await showDirectoryPreview(filePath);
fileTree.selectAndExpandPath(filePath);
} else if (node) {
// It's a file, 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) {