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:
484
static/js/app.js
484
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 = `
|
||||
<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})`;
|
||||
|
||||
|
||||
editor.insertAtCursor(link);
|
||||
showNotification(`Uploaded and inserted link`, 'success');
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user