This commit is contained in:
2025-10-26 07:17:49 +04:00
commit e41e49f7ea
23 changed files with 5070 additions and 0 deletions

866
static/app-tree.js Normal file
View File

@@ -0,0 +1,866 @@
// Markdown Editor Application with File Tree
(function() {
'use strict';
// State management
let currentFile = null;
let currentFilePath = null;
let editor = null;
let isScrollingSynced = true;
let scrollTimeout = null;
let isDarkMode = false;
let fileTree = [];
let contextMenuTarget = null;
let clipboard = null; // For copy/move operations
// Dark mode management
function initDarkMode() {
const savedMode = localStorage.getItem('darkMode');
if (savedMode === 'true') {
enableDarkMode();
}
}
function enableDarkMode() {
isDarkMode = true;
document.body.classList.add('dark-mode');
document.getElementById('darkModeIcon').textContent = '☀️';
localStorage.setItem('darkMode', 'true');
mermaid.initialize({
startOnLoad: false,
theme: 'dark',
securityLevel: 'loose'
});
if (editor && editor.getValue()) {
updatePreview();
}
}
function disableDarkMode() {
isDarkMode = false;
document.body.classList.remove('dark-mode');
document.getElementById('darkModeIcon').textContent = '🌙';
localStorage.setItem('darkMode', 'false');
mermaid.initialize({
startOnLoad: false,
theme: 'default',
securityLevel: 'loose'
});
if (editor && editor.getValue()) {
updatePreview();
}
}
function toggleDarkMode() {
if (isDarkMode) {
disableDarkMode();
} else {
enableDarkMode();
}
}
// Initialize Mermaid
mermaid.initialize({
startOnLoad: false,
theme: 'default',
securityLevel: 'loose'
});
// Configure marked.js for markdown parsing
marked.setOptions({
breaks: true,
gfm: true,
headerIds: true,
mangle: false,
sanitize: false,
smartLists: true,
smartypants: true,
xhtml: false
});
// Handle image upload
async function uploadImage(file) {
const formData = new FormData();
formData.append('file', file);
try {
const response = await fetch('/api/upload-image', {
method: 'POST',
body: formData
});
if (!response.ok) throw new Error('Upload failed');
const result = await response.json();
return result.url;
} catch (error) {
console.error('Error uploading image:', error);
showNotification('Error uploading image', 'danger');
return null;
}
}
// Handle drag and drop for images
function setupDragAndDrop() {
const editorElement = document.querySelector('.CodeMirror');
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
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 =>
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) {
const cursor = editor.getCursor();
const imageMarkdown = `![${file.name}](${url})`;
editor.replaceRange(imageMarkdown, cursor);
editor.setCursor(cursor.line, cursor.ch + imageMarkdown.length);
showNotification(`Image uploaded: ${file.name}`, 'success');
}
}
}, 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();
const file = item.getAsFile();
if (file) {
showNotification('Uploading pasted image...', 'info');
const url = await uploadImage(file);
if (url) {
const cursor = editor.getCursor();
const imageMarkdown = `![pasted-image](${url})`;
editor.replaceRange(imageMarkdown, cursor);
showNotification('Image uploaded successfully', 'success');
}
}
}
}
});
}
// Initialize CodeMirror editor
function initEditor() {
editor = CodeMirror.fromTextArea(document.getElementById('editor'), {
mode: 'markdown',
theme: 'monokai',
lineNumbers: true,
lineWrapping: true,
autofocus: true,
extraKeys: {
'Ctrl-S': function() { saveFile(); },
'Cmd-S': function() { saveFile(); }
}
});
editor.on('change', debounce(updatePreview, 300));
setTimeout(setupDragAndDrop, 100);
setupScrollSync();
}
// Debounce function
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
// 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;
});
}
// Update preview
async function updatePreview() {
const markdown = editor.getValue();
const previewDiv = document.getElementById('preview');
if (!markdown.trim()) {
previewDiv.innerHTML = `
<div class="text-muted text-center mt-5">
<h4>Preview</h4>
<p>Start typing in the editor to see the preview</p>
</div>
`;
return;
}
try {
let html = marked.parse(markdown);
html = html.replace(
/<pre><code class="language-mermaid">([\s\S]*?)<\/code><\/pre>/g,
'<div class="mermaid">$1</div>'
);
previewDiv.innerHTML = html;
const codeBlocks = previewDiv.querySelectorAll('pre code');
codeBlocks.forEach(block => {
const languageClass = Array.from(block.classList).find(cls => cls.startsWith('language-'));
if (languageClass && languageClass !== 'language-mermaid') {
Prism.highlightElement(block);
}
});
const mermaidElements = previewDiv.querySelectorAll('.mermaid');
if (mermaidElements.length > 0) {
try {
await mermaid.run({
nodes: mermaidElements,
suppressErrors: false
});
} catch (error) {
console.error('Mermaid rendering error:', error);
}
}
} catch (error) {
console.error('Preview rendering error:', error);
previewDiv.innerHTML = `
<div class="alert alert-danger" role="alert">
<strong>Error rendering preview:</strong> ${error.message}
</div>
`;
}
}
// ========================================================================
// File Tree Management
// ========================================================================
async function loadFileTree() {
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) {
console.error('Error loading file tree:', error);
showNotification('Error loading files', 'danger');
}
}
function renderFileTree() {
const container = document.getElementById('fileTree');
container.innerHTML = '';
if (fileTree.length === 0) {
container.innerHTML = '<div class="text-muted text-center p-3">No files yet</div>';
return;
}
fileTree.forEach(node => {
container.appendChild(createTreeNode(node));
});
}
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);
nodeContent.addEventListener('dragend', handleDragEnd);
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);
});
contentWrapper.appendChild(toggle);
} else {
const spacer = document.createElement('span');
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');
}
}
function formatFileSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
// ========================================================================
// Drag and Drop for Files
// ========================================================================
let draggedNode = null;
function handleDragStart(e) {
draggedNode = {
path: e.currentTarget.dataset.path,
type: e.currentTarget.dataset.type,
name: e.currentTarget.dataset.name
};
e.currentTarget.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', draggedNode.path);
}
function handleDragEnd(e) {
e.currentTarget.classList.remove('dragging');
document.querySelectorAll('.drag-over').forEach(el => {
el.classList.remove('drag-over');
});
}
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');
}
}
function handleDragLeave(e) {
e.currentTarget.classList.remove('drag-over');
}
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',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
source: sourcePath,
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;
}
// ========================================================================
// Context Menu
// ========================================================================
function showContextMenu(e, node) {
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);
}
function hideContextMenu() {
const menu = document.getElementById('contextMenu');
menu.style.display = 'none';
document.removeEventListener('click', hideContextMenu);
}
// ========================================================================
// File Operations
// ========================================================================
async function loadFile(path) {
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);
showNotification('Error loading file', 'danger');
}
}
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) {
console.error('Error saving file:', error);
showNotification('Error saving file', 'danger');
}
}
async function deleteFile() {
if (!currentFilePath) {
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);
showNotification('Error deleting file', 'danger');
}
}
function newFile() {
// Clear editor for new file
currentFile = null;
currentFilePath = null;
document.getElementById('filenameInput').value = '';
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) {
console.error('Error creating folder:', error);
showNotification('Error creating folder', 'danger');
}
}
// ========================================================================
// Context Menu Actions
// ========================================================================
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;
}
}
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, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
old_path: oldPath,
new_path: newPath
})
});
if (!response.ok) throw new Error('Rename failed');
showNotification(`Renamed to ${newName}`, 'success');
loadFileTree();
} catch (error) {
console.error('Error renaming:', error);
showNotification('Error renaming', 'danger');
}
}
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
const response = await fetch('/api/file/copy', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
source: sourcePath,
destination: destPath
})
});
if (!response.ok) throw new Error('Copy failed');
showNotification(`Copied ${fileName} to ${contextMenuTarget.name}`, 'success');
} else if (clipboard.operation === 'move') {
// Move operation
const response = await fetch('/api/file/move', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
source: sourcePath,
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);
showNotification('Error pasting file', 'danger');
}
}
async function deleteItem() {
if (!confirm(`Are you sure you want to delete ${contextMenuTarget.name}?`)) {
return;
}
try {
let response;
if (contextMenuTarget.type === 'directory') {
response = await fetch(`/api/directory?path=${encodeURIComponent(contextMenuTarget.path)}&recursive=true`, {
method: 'DELETE'
});
} else {
response = await fetch(`/api/file?path=${encodeURIComponent(contextMenuTarget.path)}`, {
method: 'DELETE'
});
}
if (!response.ok) throw new Error('Delete failed');
showNotification(`Deleted ${contextMenuTarget.name}`, 'success');
loadFileTree();
} catch (error) {
console.error('Error deleting:', error);
showNotification('Error deleting', 'danger');
}
}
// ========================================================================
// Notifications
// ========================================================================
function showNotification(message, type = 'info') {
let toastContainer = document.getElementById('toastContainer');
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');
toast.innerHTML = `
<div class="d-flex">
<div class="toast-body">${message}</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
`;
toastContainer.appendChild(toast);
const bsToast = new bootstrap.Toast(toast, { delay: 3000 });
bsToast.show();
toast.addEventListener('hidden.bs.toast', () => {
toast.remove();
});
}
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';
document.body.appendChild(container);
return container;
}
// ========================================================================
// Initialization
// ========================================================================
function init() {
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', () => {
const action = item.dataset.action;
handleContextMenuAction(action);
hideContextMenu();
});
});
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
saveFile();
}
});
console.log('Markdown Editor with File Tree initialized');
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();

527
static/app.js Normal file
View File

@@ -0,0 +1,527 @@
// Markdown Editor Application
(function() {
'use strict';
// State management
let currentFile = null;
let editor = null;
let isScrollingSynced = true;
let scrollTimeout = null;
let isDarkMode = false;
// Dark mode management
function initDarkMode() {
// Check for saved preference
const savedMode = localStorage.getItem('darkMode');
if (savedMode === 'true') {
enableDarkMode();
}
}
function enableDarkMode() {
isDarkMode = true;
document.body.classList.add('dark-mode');
document.getElementById('darkModeIcon').textContent = '☀️';
localStorage.setItem('darkMode', 'true');
// Update mermaid theme
mermaid.initialize({
startOnLoad: false,
theme: 'dark',
securityLevel: 'loose'
});
// Re-render preview if there's content
if (editor && editor.getValue()) {
updatePreview();
}
}
function disableDarkMode() {
isDarkMode = false;
document.body.classList.remove('dark-mode');
document.getElementById('darkModeIcon').textContent = '🌙';
localStorage.setItem('darkMode', 'false');
// Update mermaid theme
mermaid.initialize({
startOnLoad: false,
theme: 'default',
securityLevel: 'loose'
});
// Re-render preview if there's content
if (editor && editor.getValue()) {
updatePreview();
}
}
function toggleDarkMode() {
if (isDarkMode) {
disableDarkMode();
} else {
enableDarkMode();
}
}
// Initialize Mermaid
mermaid.initialize({
startOnLoad: false,
theme: 'default',
securityLevel: 'loose'
});
// Configure marked.js for markdown parsing
marked.setOptions({
breaks: true,
gfm: true,
headerIds: true,
mangle: false,
sanitize: false, // Allow HTML in markdown
smartLists: true,
smartypants: true,
xhtml: false
});
// Handle image upload
async function uploadImage(file) {
const formData = new FormData();
formData.append('file', file);
try {
const response = await fetch('/api/upload-image', {
method: 'POST',
body: formData
});
if (!response.ok) throw new Error('Upload failed');
const result = await response.json();
return result.url;
} catch (error) {
console.error('Error uploading image:', error);
showNotification('Error uploading image', 'danger');
return null;
}
}
// Handle drag and drop
function setupDragAndDrop() {
const editorElement = document.querySelector('.CodeMirror');
// Prevent default drag behavior
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
editorElement.addEventListener(eventName, preventDefaults, false);
});
function preventDefaults(e) {
e.preventDefault();
e.stopPropagation();
}
// Highlight drop zone
['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);
});
// Handle drop
editorElement.addEventListener('drop', async (e) => {
const files = e.dataTransfer.files;
if (files.length === 0) return;
// Filter for images only
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');
// Upload images
for (const file of imageFiles) {
const url = await uploadImage(file);
if (url) {
// Insert markdown image syntax at cursor
const cursor = editor.getCursor();
const imageMarkdown = `![${file.name}](${url})`;
editor.replaceRange(imageMarkdown, cursor);
editor.setCursor(cursor.line, cursor.ch + imageMarkdown.length);
showNotification(`Image uploaded: ${file.name}`, 'success');
}
}
}, false);
// Also handle paste events for images
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();
const file = item.getAsFile();
if (file) {
showNotification('Uploading pasted image...', 'info');
const url = await uploadImage(file);
if (url) {
const cursor = editor.getCursor();
const imageMarkdown = `![pasted-image](${url})`;
editor.replaceRange(imageMarkdown, cursor);
showNotification('Image uploaded successfully', 'success');
}
}
}
}
});
}
// Initialize CodeMirror editor
function initEditor() {
const textarea = document.getElementById('editor');
editor = CodeMirror.fromTextArea(textarea, {
mode: 'markdown',
theme: 'monokai',
lineNumbers: true,
lineWrapping: true,
autofocus: true,
extraKeys: {
'Ctrl-S': function() { saveFile(); },
'Cmd-S': function() { saveFile(); }
}
});
// Update preview on change
editor.on('change', debounce(updatePreview, 300));
// Setup drag and drop after editor is ready
setTimeout(setupDragAndDrop, 100);
// Sync scroll
editor.on('scroll', handleEditorScroll);
}
// Debounce function to limit update frequency
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
// Update preview with markdown content
async function updatePreview() {
const content = editor.getValue();
const previewDiv = document.getElementById('preview');
if (!content.trim()) {
previewDiv.innerHTML = `
<div class="text-muted text-center mt-5">
<h4>Preview</h4>
<p>Start typing in the editor to see the preview</p>
</div>
`;
return;
}
try {
// Parse markdown to HTML
let html = marked.parse(content);
// Replace mermaid code blocks with div containers
html = html.replace(
/<pre><code class="language-mermaid">([\s\S]*?)<\/code><\/pre>/g,
'<div class="mermaid">$1</div>'
);
previewDiv.innerHTML = html;
// Apply syntax highlighting to code blocks
const codeBlocks = previewDiv.querySelectorAll('pre code');
codeBlocks.forEach(block => {
// Detect language from class name
const languageClass = Array.from(block.classList).find(cls => cls.startsWith('language-'));
if (languageClass && languageClass !== 'language-mermaid') {
Prism.highlightElement(block);
}
});
// Render mermaid diagrams
const mermaidElements = previewDiv.querySelectorAll('.mermaid');
if (mermaidElements.length > 0) {
try {
await mermaid.run({
nodes: mermaidElements,
suppressErrors: false
});
} catch (error) {
console.error('Mermaid rendering error:', error);
}
}
} catch (error) {
console.error('Preview rendering error:', error);
previewDiv.innerHTML = `
<div class="alert alert-danger" role="alert">
<strong>Error rendering preview:</strong> ${error.message}
</div>
`;
}
}
// Handle editor scroll for synchronized scrolling
function handleEditorScroll() {
if (!isScrollingSynced) return;
clearTimeout(scrollTimeout);
scrollTimeout = setTimeout(() => {
const editorScrollInfo = editor.getScrollInfo();
const editorScrollPercentage = editorScrollInfo.top / (editorScrollInfo.height - editorScrollInfo.clientHeight);
const previewPane = document.querySelector('.preview-pane');
const previewScrollHeight = previewPane.scrollHeight - previewPane.clientHeight;
if (previewScrollHeight > 0) {
previewPane.scrollTop = editorScrollPercentage * previewScrollHeight;
}
}, 10);
}
// Load file list from server
async function loadFileList() {
try {
const response = await fetch('/api/files');
if (!response.ok) throw new Error('Failed to load file list');
const files = await response.json();
const fileListDiv = document.getElementById('fileList');
if (files.length === 0) {
fileListDiv.innerHTML = '<div class="text-muted p-2 small">No files yet</div>';
return;
}
fileListDiv.innerHTML = files.map(file => `
<a href="#" class="list-group-item list-group-item-action file-item" data-filename="${file.filename}">
<span class="file-name">${file.filename}</span>
<span class="file-size">${formatFileSize(file.size)}</span>
</a>
`).join('');
// Add click handlers
document.querySelectorAll('.file-item').forEach(item => {
item.addEventListener('click', (e) => {
e.preventDefault();
const filename = item.dataset.filename;
loadFile(filename);
});
});
} catch (error) {
console.error('Error loading file list:', error);
showNotification('Error loading file list', 'danger');
}
}
// Load a specific file
async function loadFile(filename) {
try {
const response = await fetch(`/api/files/${filename}`);
if (!response.ok) throw new Error('Failed to load file');
const data = await response.json();
currentFile = data.filename;
// Update UI
document.getElementById('filenameInput').value = data.filename;
editor.setValue(data.content);
// Update active state in file list
document.querySelectorAll('.file-item').forEach(item => {
item.classList.toggle('active', item.dataset.filename === filename);
});
updatePreview();
showNotification(`Loaded ${filename}`, 'success');
} catch (error) {
console.error('Error loading file:', error);
showNotification('Error loading file', 'danger');
}
}
// Save current file
async function saveFile() {
const filename = document.getElementById('filenameInput').value.trim();
if (!filename) {
showNotification('Please enter a filename', 'warning');
return;
}
const content = editor.getValue();
try {
const response = await fetch('/api/files', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ filename, content })
});
if (!response.ok) throw new Error('Failed to save file');
const result = await response.json();
currentFile = result.filename;
showNotification(`Saved ${result.filename}`, 'success');
loadFileList();
} catch (error) {
console.error('Error saving file:', error);
showNotification('Error saving file', 'danger');
}
}
// Delete current file
async function deleteFile() {
const filename = document.getElementById('filenameInput').value.trim();
if (!filename) {
showNotification('No file selected', 'warning');
return;
}
if (!confirm(`Are you sure you want to delete ${filename}?`)) {
return;
}
try {
const response = await fetch(`/api/files/${filename}`, {
method: 'DELETE'
});
if (!response.ok) throw new Error('Failed to delete file');
showNotification(`Deleted ${filename}`, 'success');
// Clear editor
currentFile = null;
document.getElementById('filenameInput').value = '';
editor.setValue('');
updatePreview();
loadFileList();
} catch (error) {
console.error('Error deleting file:', error);
showNotification('Error deleting file', 'danger');
}
}
// Create new file
function newFile() {
currentFile = null;
document.getElementById('filenameInput').value = '';
editor.setValue('');
updatePreview();
// Remove active state from all file items
document.querySelectorAll('.file-item').forEach(item => {
item.classList.remove('active');
});
showNotification('New file created', 'info');
}
// Format file size for display
function formatFileSize(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
}
// Show notification
function showNotification(message, type = 'info') {
// Create toast notification
const toastContainer = document.getElementById('toastContainer') || createToastContainer();
const toast = document.createElement('div');
toast.className = `toast align-items-center text-white bg-${type} border-0`;
toast.setAttribute('role', 'alert');
toast.setAttribute('aria-live', 'assertive');
toast.setAttribute('aria-atomic', 'true');
toast.innerHTML = `
<div class="d-flex">
<div class="toast-body">${message}</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
`;
toastContainer.appendChild(toast);
const bsToast = new bootstrap.Toast(toast, { delay: 3000 });
bsToast.show();
toast.addEventListener('hidden.bs.toast', () => {
toast.remove();
});
}
// Create toast container if it doesn't exist
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';
document.body.appendChild(container);
return container;
}
// Initialize application
function init() {
initDarkMode();
initEditor();
loadFileList();
// Set up event listeners
document.getElementById('saveBtn').addEventListener('click', saveFile);
document.getElementById('deleteBtn').addEventListener('click', deleteFile);
document.getElementById('newFileBtn').addEventListener('click', newFile);
document.getElementById('darkModeToggle').addEventListener('click', toggleDarkMode);
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
saveFile();
}
});
console.log('Markdown Editor initialized');
}
// Start application when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();

160
static/css/components.css Normal file
View File

@@ -0,0 +1,160 @@
/* Preview pane styles */
.preview-pane {
font-size: 16px;
line-height: 1.6;
}
.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;
line-height: 1.25;
color: var(--text-primary);
}
.preview-pane a {
color: var(--link-color);
text-decoration: none;
}
.preview-pane a:hover {
text-decoration: underline;
}
.preview-pane code {
background-color: var(--bg-tertiary);
padding: 2px 6px;
border-radius: 3px;
font-size: 85%;
}
.preview-pane pre {
background-color: var(--bg-tertiary);
padding: 16px;
border-radius: 6px;
overflow-x: auto;
}
.preview-pane pre code {
background-color: transparent;
padding: 0;
}
.preview-pane table {
border-collapse: collapse;
width: 100%;
margin: 16px 0;
}
.preview-pane table th,
.preview-pane table td {
border: 1px solid var(--border-color);
padding: 8px 12px;
}
.preview-pane table th {
background-color: var(--bg-secondary);
font-weight: 600;
}
.preview-pane blockquote {
border-left: 4px solid var(--border-color);
padding-left: 16px;
margin-left: 0;
color: var(--text-secondary);
}
.preview-pane img {
max-width: 100%;
height: auto;
}
/* Context Menu */
.context-menu {
position: fixed;
background-color: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 10000;
min-width: 180px;
max-width: 200px;
width: auto;
padding: 4px 0;
display: none;
}
body.dark-mode .context-menu {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
}
.context-menu-item {
padding: 8px 16px;
cursor: pointer;
display: flex;
align-items: center;
gap: 10px;
color: var(--text-primary);
transition: background-color 0.15s ease;
}
.context-menu-item:hover {
background-color: var(--bg-tertiary);
}
.context-menu-item i {
width: 16px;
text-align: center;
}
.context-menu-divider {
height: 1px;
background-color: var(--border-color);
margin: 4px 0;
}
/* Toast Notifications */
.toast-container {
position: fixed;
top: 70px;
right: 20px;
z-index: 9999;
}
.toast {
min-width: 250px;
margin-bottom: 10px;
background-color: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
animation: slideIn 0.3s ease;
}
@keyframes slideIn {
from {
transform: translateX(400px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.toast.hiding {
animation: slideOut 0.3s ease;
}
@keyframes slideOut {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(400px);
opacity: 0;
}
}

75
static/css/editor.css Normal file
View File

@@ -0,0 +1,75 @@
/* Editor styles */
.editor-header {
padding: 10px;
background-color: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
display: flex;
gap: 10px;
align-items: center;
}
.editor-header input {
flex: 1;
padding: 6px 12px;
background-color: var(--bg-primary);
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: 4px;
}
.editor-container {
flex: 1;
overflow: hidden;
}
/* CodeMirror customization */
.CodeMirror {
height: 100%;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 14px;
background-color: var(--bg-primary);
color: var(--text-primary);
}
body.dark-mode .CodeMirror {
background-color: #1c2128;
color: #e6edf3;
}
.CodeMirror-gutters {
background-color: var(--bg-secondary);
border-right: 1px solid var(--border-color);
}
body.dark-mode .CodeMirror-gutters {
background-color: #161b22;
border-right-color: #30363d;
}
.CodeMirror-linenumber {
color: var(--text-secondary);
}
.CodeMirror-cursor {
border-left-color: var(--text-primary);
}
/* Drag and drop overlay */
.editor-container.drag-over::after {
content: 'Drop images here';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(9, 105, 218, 0.1);
border: 2px dashed var(--info-color);
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
color: var(--info-color);
pointer-events: none;
z-index: 1000;
}

88
static/css/file-tree.css Normal file
View File

@@ -0,0 +1,88 @@
/* File tree styles */
.file-tree {
font-size: 14px;
user-select: none;
}
.tree-node {
padding: 6px 8px;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
border-radius: 4px;
margin: 2px 0;
color: var(--text-primary);
transition: background-color 0.15s ease;
}
.tree-node:hover {
background-color: var(--bg-tertiary);
}
.tree-node.active {
background-color: #0969da;
color: white;
}
body.dark-mode .tree-node.active {
background-color: #1f6feb;
}
.tree-node-icon {
width: 16px;
text-align: center;
flex-shrink: 0;
}
.tree-node-content {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
min-width: 0;
}
.tree-node-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tree-node-size {
font-size: 11px;
color: var(--text-secondary);
flex-shrink: 0;
}
.tree-children {
margin-left: 16px;
}
.tree-node.dragging {
opacity: 0.5;
}
.tree-node.drag-over {
background-color: var(--info-color);
color: white;
}
/* Collection selector */
.collection-selector {
margin-bottom: 10px;
padding: 8px;
background-color: var(--bg-tertiary);
border-radius: 4px;
}
.collection-selector select {
width: 100%;
padding: 6px;
background-color: var(--bg-primary);
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: 4px;
}

69
static/css/layout.css Normal file
View File

@@ -0,0 +1,69 @@
/* Base layout styles */
html, body {
height: 100%;
margin: 0;
padding: 0;
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;
}
.container-fluid {
height: calc(100% - 56px);
}
.sidebar {
background-color: var(--bg-secondary);
border-right: 1px solid var(--border-color);
overflow-y: auto;
height: 100%;
transition: background-color 0.3s ease;
}
.editor-pane {
background-color: var(--bg-primary);
height: 100%;
display: flex;
flex-direction: column;
border-right: 1px solid var(--border-color);
}
.preview-pane {
background-color: var(--bg-primary);
height: 100%;
overflow-y: auto;
padding: 20px;
}
/* Navbar */
.navbar {
background-color: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
transition: background-color 0.3s ease;
}
.navbar-brand {
color: var(--text-primary) !important;
font-weight: 600;
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: var(--bg-secondary);
}
::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 5px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-secondary);
}

31
static/css/variables.css Normal file
View File

@@ -0,0 +1,31 @@
/* CSS Variables for theming */
:root {
/* Light mode colors */
--bg-primary: #ffffff;
--bg-secondary: #f6f8fa;
--bg-tertiary: #f0f0f0;
--text-primary: #24292f;
--text-secondary: #57606a;
--border-color: #d0d7de;
--link-color: #0969da;
--success-color: #2da44e;
--danger-color: #cf222e;
--warning-color: #bf8700;
--info-color: #0969da;
}
body.dark-mode {
/* Dark mode colors - GitHub style */
--bg-primary: #0d1117;
--bg-secondary: #161b22;
--bg-tertiary: #1c2128;
--text-primary: #e6edf3;
--text-secondary: #8d96a0;
--border-color: #30363d;
--link-color: #4fc3f7;
--success-color: #3fb950;
--danger-color: #f85149;
--warning-color: #d29922;
--info-color: #58a6ff;
}

302
static/js/app.js Normal file
View File

@@ -0,0 +1,302 @@
/**
* Main Application
* Coordinates all modules and handles user interactions
*/
// Global state
let webdavClient;
let fileTree;
let editor;
let darkMode;
let collectionSelector;
let clipboard = null;
let currentFilePath = null;
// Initialize application
document.addEventListener('DOMContentLoaded', async () => {
// Initialize WebDAV client
webdavClient = new WebDAVClient('/fs/');
// Initialize dark mode
darkMode = new DarkMode();
document.getElementById('darkModeBtn').addEventListener('click', () => {
darkMode.toggle();
});
// Initialize collection selector
collectionSelector = new CollectionSelector('collectionSelect', webdavClient);
collectionSelector.onChange = async (collection) => {
await fileTree.load();
};
await collectionSelector.load();
// Initialize file tree
fileTree = new FileTree('fileTree', webdavClient);
fileTree.onFileSelect = async (item) => {
await loadFile(item.path);
};
await fileTree.load();
// Initialize editor
editor = new MarkdownEditor('editor', 'preview');
// 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', () => {
newFile();
});
document.getElementById('saveBtn').addEventListener('click', async () => {
await saveFile();
});
document.getElementById('deleteBtn').addEventListener('click', async () => {
await deleteCurrentFile();
});
// Setup context menu handlers
setupContextMenuHandlers();
// Initialize mermaid
mermaid.initialize({ startOnLoad: true, theme: darkMode.isDark ? 'dark' : 'default' });
});
/**
* File Operations
*/
async function loadFile(path) {
try {
const content = await webdavClient.get(path);
editor.setValue(content);
document.getElementById('filenameInput').value = path;
currentFilePath = path;
showNotification('File loaded', 'success');
} catch (error) {
console.error('Failed to load file:', error);
showNotification('Failed to load file', 'error');
}
}
function newFile() {
editor.setValue('# New File\n\nStart typing...\n');
document.getElementById('filenameInput').value = '';
document.getElementById('filenameInput').focus();
currentFilePath = null;
showNotification('New file', 'info');
}
async function saveFile() {
const filename = document.getElementById('filenameInput').value.trim();
if (!filename) {
showNotification('Please enter a filename', 'warning');
return;
}
try {
const content = editor.getValue();
await webdavClient.put(filename, content);
currentFilePath = filename;
await fileTree.load();
showNotification('Saved', 'success');
} catch (error) {
console.error('Failed to save file:', error);
showNotification('Failed to save file', 'error');
}
}
async function deleteCurrentFile() {
if (!currentFilePath) {
showNotification('No file selected', 'warning');
return;
}
if (!confirm(`Delete ${currentFilePath}?`)) {
return;
}
try {
await webdavClient.delete(currentFilePath);
await fileTree.load();
newFile();
showNotification('Deleted', 'success');
} catch (error) {
console.error('Failed to delete file:', error);
showNotification('Failed to delete file', 'error');
}
}
/**
* Context Menu Handlers
*/
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 handleContextAction(action, targetPath, isDir);
});
}
async function handleContextAction(action, targetPath, isDir) {
switch (action) {
case 'open':
if (!isDir) {
await loadFile(targetPath);
}
break;
case 'new-file':
if (isDir) {
const filename = prompt('Enter filename:');
if (filename) {
await fileTree.createFile(targetPath, filename);
}
}
break;
case 'new-folder':
if (isDir) {
const foldername = prompt('Enter folder name:');
if (foldername) {
await fileTree.createFolder(targetPath, foldername);
}
}
break;
case 'upload':
if (isDir) {
showFileUploadDialog(targetPath, async (path, file) => {
await fileTree.uploadFile(path, file);
});
}
break;
case 'download':
if (isDir) {
await fileTree.downloadFolder(targetPath);
} else {
await fileTree.downloadFile(targetPath);
}
break;
case 'rename':
const newName = prompt('Enter new name:', targetPath.split('/').pop());
if (newName) {
const parentPath = targetPath.split('/').slice(0, -1).join('/');
const newPath = parentPath ? `${parentPath}/${newName}` : newName;
try {
await webdavClient.move(targetPath, newPath);
await fileTree.load();
showNotification('Renamed', 'success');
} catch (error) {
console.error('Failed to rename:', error);
showNotification('Failed to rename', 'error');
}
}
break;
case 'copy':
clipboard = { path: targetPath, operation: 'copy' };
showNotification('Copied to clipboard', 'info');
updatePasteVisibility();
break;
case 'cut':
clipboard = { path: targetPath, operation: 'cut' };
showNotification('Cut to clipboard', 'info');
updatePasteVisibility();
break;
case 'paste':
if (clipboard && isDir) {
const filename = clipboard.path.split('/').pop();
const destPath = `${targetPath}/${filename}`;
try {
if (clipboard.operation === 'copy') {
await webdavClient.copy(clipboard.path, destPath);
showNotification('Copied', 'success');
} else {
await webdavClient.move(clipboard.path, destPath);
showNotification('Moved', 'success');
clipboard = null;
updatePasteVisibility();
}
await fileTree.load();
} catch (error) {
console.error('Failed to paste:', error);
showNotification('Failed to paste', 'error');
}
}
break;
case 'delete':
if (confirm(`Delete ${targetPath}?`)) {
try {
await webdavClient.delete(targetPath);
await fileTree.load();
showNotification('Deleted', 'success');
} catch (error) {
console.error('Failed to delete:', error);
showNotification('Failed to delete', 'error');
}
}
break;
}
}
function updatePasteVisibility() {
const pasteItem = document.getElementById('pasteMenuItem');
if (pasteItem) {
pasteItem.style.display = clipboard ? 'block' : 'none';
}
}
/**
* Editor File Drop Handler
*/
async function handleEditorFileDrop(file) {
try {
// Get current file's directory
let targetDir = '';
if (currentFilePath) {
const parts = currentFilePath.split('/');
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
? `![${file.name}](/${webdavClient.currentCollection}/${uploadedPath})`
: `[${file.name}](/${webdavClient.currentCollection}/${uploadedPath})`;
editor.insertAtCursor(link);
showNotification(`Uploaded and inserted link`, 'success');
} catch (error) {
console.error('Failed to handle file drop:', error);
showNotification('Failed to upload file', 'error');
}
}
// Make showContextMenu global
window.showContextMenu = showContextMenu;

273
static/js/editor.js Normal file
View File

@@ -0,0 +1,273 @@
/**
* Editor Module
* Handles CodeMirror editor and markdown preview
*/
class MarkdownEditor {
constructor(editorId, previewId, filenameInputId) {
this.editorElement = document.getElementById(editorId);
this.previewElement = document.getElementById(previewId);
this.filenameInput = document.getElementById(filenameInputId);
this.currentFile = null;
this.webdavClient = null;
this.initCodeMirror();
this.initMarkdown();
this.initMermaid();
}
/**
* Initialize CodeMirror
*/
initCodeMirror() {
this.editor = CodeMirror(this.editorElement, {
mode: 'markdown',
theme: 'monokai',
lineNumbers: true,
lineWrapping: true,
autofocus: true,
extraKeys: {
'Ctrl-S': () => this.save(),
'Cmd-S': () => this.save()
}
});
// Update preview on change
this.editor.on('change', () => {
this.updatePreview();
});
// Sync scroll
this.editor.on('scroll', () => {
this.syncScroll();
});
}
/**
* Initialize markdown parser
*/
initMarkdown() {
this.marked = window.marked;
this.marked.setOptions({
breaks: true,
gfm: true,
highlight: (code, lang) => {
if (lang && window.Prism.languages[lang]) {
return window.Prism.highlight(code, window.Prism.languages[lang], lang);
}
return code;
}
});
}
/**
* Initialize Mermaid
*/
initMermaid() {
if (window.mermaid) {
window.mermaid.initialize({
startOnLoad: false,
theme: document.body.classList.contains('dark-mode') ? 'dark' : 'default'
});
}
}
/**
* Set WebDAV client
*/
setWebDAVClient(client) {
this.webdavClient = client;
}
/**
* Load file
*/
async loadFile(path) {
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');
}
} catch (error) {
console.error('Failed to load file:', error);
if (window.showNotification) {
window.showNotification('Failed to load file', 'danger');
}
}
}
/**
* Save file
*/
async save() {
const path = this.filenameInput.value.trim();
if (!path) {
if (window.showNotification) {
window.showNotification('Please enter a filename', 'warning');
}
return;
}
const content = this.editor.getValue();
try {
await this.webdavClient.put(path, content);
this.currentFile = path;
if (window.showNotification) {
window.showNotification('✅ Saved', 'success');
}
// Trigger file tree reload
if (window.fileTree) {
await window.fileTree.load();
window.fileTree.selectNode(path);
}
} catch (error) {
console.error('Failed to save file:', error);
if (window.showNotification) {
window.showNotification('Failed to save file', 'danger');
}
}
}
/**
* Create new file
*/
newFile() {
this.currentFile = null;
this.filenameInput.value = '';
this.filenameInput.focus();
this.editor.setValue('');
this.updatePreview();
if (window.showNotification) {
window.showNotification('Enter filename and start typing', 'info');
}
}
/**
* Delete current file
*/
async deleteFile() {
if (!this.currentFile) {
if (window.showNotification) {
window.showNotification('No file selected', 'warning');
}
return;
}
if (!confirm(`Delete ${this.currentFile}?`)) {
return;
}
try {
await this.webdavClient.delete(this.currentFile);
if (window.showNotification) {
window.showNotification(`Deleted ${this.currentFile}`, 'success');
}
this.newFile();
// Trigger file tree reload
if (window.fileTree) {
await window.fileTree.load();
}
} catch (error) {
console.error('Failed to delete file:', error);
if (window.showNotification) {
window.showNotification('Failed to delete file', 'danger');
}
}
}
/**
* Update preview
*/
updatePreview() {
const markdown = this.editor.getValue();
let html = this.marked.parse(markdown);
// Process mermaid diagrams
html = html.replace(/<pre><code class="language-mermaid">([\s\S]*?)<\/code><\/pre>/g, (match, code) => {
const id = 'mermaid-' + Math.random().toString(36).substr(2, 9);
return `<div class="mermaid" id="${id}">${code}</div>`;
});
this.previewElement.innerHTML = html;
// Render mermaid diagrams
if (window.mermaid) {
window.mermaid.init(undefined, this.previewElement.querySelectorAll('.mermaid'));
}
// Highlight code blocks
if (window.Prism) {
window.Prism.highlightAllUnder(this.previewElement);
}
}
/**
* Sync scroll between editor and preview
*/
syncScroll() {
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;
}
/**
* Handle image upload
*/
async uploadImage(file) {
try {
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');
}
} catch (error) {
console.error('Failed to upload image:', error);
if (window.showNotification) {
window.showNotification('Failed to upload image', 'danger');
}
}
}
/**
* Get editor content
*/
getValue() {
return this.editor.getValue();
}
insertAtCursor(text) {
const doc = this.editor.getDoc();
const cursor = doc.getCursor();
doc.replaceRange(text, cursor);
}
/**
* Set editor content
*/
setValue(content) {
this.editor.setValue(content);
}
}
// Export for use in other modules
window.MarkdownEditor = MarkdownEditor;

290
static/js/file-tree.js Normal file
View File

@@ -0,0 +1,290 @@
/**
* File Tree Component
* Manages the hierarchical file tree display and interactions
*/
class FileTree {
constructor(containerId, webdavClient) {
this.container = document.getElementById(containerId);
this.webdavClient = webdavClient;
this.tree = [];
this.selectedPath = null;
this.onFileSelect = null;
this.onFolderSelect = null;
this.setupEventListeners();
}
setupEventListeners() {
// Click handler for tree nodes
this.container.addEventListener('click', (e) => {
const node = e.target.closest('.tree-node');
if (!node) return;
const path = node.dataset.path;
const isDir = node.dataset.isdir === 'true';
// Toggle folder
if (e.target.closest('.tree-toggle')) {
this.toggleFolder(node);
return;
}
// Select node
if (isDir) {
this.selectFolder(path);
} else {
this.selectFile(path);
}
});
// Context menu
this.container.addEventListener('contextmenu', (e) => {
const node = e.target.closest('.tree-node');
if (!node) return;
e.preventDefault();
const path = node.dataset.path;
const isDir = node.dataset.isdir === 'true';
window.showContextMenu(e.clientX, e.clientY, { path, isDir });
});
}
async load() {
try {
const items = await this.webdavClient.propfind('', 'infinity');
this.tree = this.webdavClient.buildTree(items);
this.render();
} catch (error) {
console.error('Failed to load file tree:', error);
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 nodeElement = this.createNodeElement(node, level);
parentElement.appendChild(nodeElement);
if (node.children && node.children.length > 0) {
const childContainer = document.createElement('div');
childContainer.className = 'tree-children';
childContainer.style.display = 'none';
nodeElement.appendChild(childContainer);
this.renderNodes(node.children, childContainer, level + 1);
}
});
}
createNodeElement(node, level) {
const div = document.createElement('div');
div.className = 'tree-node';
div.dataset.path = node.path;
div.dataset.isdir = node.isDirectory;
div.style.paddingLeft = `${level * 20 + 10}px`;
// Toggle arrow for folders
if (node.isDirectory) {
const toggle = document.createElement('span');
toggle.className = 'tree-toggle';
toggle.innerHTML = '<i class="bi bi-chevron-right"></i>';
div.appendChild(toggle);
} else {
const spacer = document.createElement('span');
spacer.className = 'tree-spacer';
spacer.style.width = '16px';
spacer.style.display = 'inline-block';
div.appendChild(spacer);
}
// Icon
const icon = document.createElement('i');
if (node.isDirectory) {
icon.className = 'bi bi-folder-fill';
icon.style.color = '#dcb67a';
} else {
icon.className = 'bi bi-file-earmark-text';
icon.style.color = '#6a9fb5';
}
div.appendChild(icon);
// Name
const name = document.createElement('span');
name.className = 'tree-name';
name.textContent = node.name;
div.appendChild(name);
// Size for files
if (!node.isDirectory && node.size) {
const size = document.createElement('span');
size.className = 'tree-size';
size.textContent = this.formatSize(node.size);
div.appendChild(size);
}
return div;
}
toggleFolder(nodeElement) {
const childContainer = nodeElement.querySelector('.tree-children');
if (!childContainer) return;
const toggle = nodeElement.querySelector('.tree-toggle i');
const isExpanded = childContainer.style.display !== 'none';
if (isExpanded) {
childContainer.style.display = 'none';
toggle.className = 'bi bi-chevron-right';
} else {
childContainer.style.display = 'block';
toggle.className = 'bi bi-chevron-down';
}
}
selectFile(path) {
this.selectedPath = path;
this.updateSelection();
if (this.onFileSelect) {
this.onFileSelect({ path, isDirectory: false });
}
}
selectFolder(path) {
this.selectedPath = path;
this.updateSelection();
if (this.onFolderSelect) {
this.onFolderSelect({ path, isDirectory: true });
}
}
updateSelection() {
// Remove previous selection
this.container.querySelectorAll('.tree-node').forEach(node => {
node.classList.remove('selected');
});
// Add selection to current
if (this.selectedPath) {
const node = this.container.querySelector(`[data-path="${this.selectedPath}"]`);
if (node) {
node.classList.add('selected');
}
}
}
formatSize(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 10) / 10 + ' ' + sizes[i];
}
async createFile(parentPath, filename) {
try {
const fullPath = parentPath ? `${parentPath}/${filename}` : filename;
await this.webdavClient.put(fullPath, '# New File\n\nStart typing...\n');
await this.load();
showNotification('File created', 'success');
return fullPath;
} catch (error) {
console.error('Failed to create file:', error);
showNotification('Failed to create file', 'error');
throw error;
}
}
async createFolder(parentPath, foldername) {
try {
const fullPath = parentPath ? `${parentPath}/${foldername}` : foldername;
await this.webdavClient.mkcol(fullPath);
await this.load();
showNotification('Folder created', 'success');
return fullPath;
} catch (error) {
console.error('Failed to create folder:', error);
showNotification('Failed to create folder', 'error');
throw error;
}
}
async uploadFile(parentPath, file) {
try {
const fullPath = parentPath ? `${parentPath}/${file.name}` : file.name;
const content = await file.arrayBuffer();
await this.webdavClient.putBinary(fullPath, content);
await this.load();
showNotification(`Uploaded ${file.name}`, 'success');
return fullPath;
} catch (error) {
console.error('Failed to upload file:', error);
showNotification('Failed to upload file', 'error');
throw error;
}
}
async downloadFile(path) {
try {
const content = await this.webdavClient.get(path);
const filename = path.split('/').pop();
this.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');
// 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);
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);
}
}

256
static/js/ui-utils.js Normal file
View File

@@ -0,0 +1,256 @@
/**
* UI Utilities Module
* Toast notifications, context menu, dark mode, file upload dialog
*/
/**
* Show toast notification
*/
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 = `
<div class="d-flex">
<div class="toast-body">${message}</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
`;
container.appendChild(toast);
const bsToast = new bootstrap.Toast(toast, { delay: 3000 });
bsToast.show();
toast.addEventListener('hidden.bs.toast', () => {
toast.remove();
});
}
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';
document.body.appendChild(container);
return container;
}
/**
* Enhanced Context Menu
*/
function showContextMenu(x, y, target) {
const menu = document.getElementById('contextMenu');
if (!menu) return;
// Store target
menu.dataset.targetPath = target.path;
menu.dataset.targetIsDir = target.isDir;
// Show/hide menu items based on target type
const newFileItem = menu.querySelector('[data-action="new-file"]');
const newFolderItem = menu.querySelector('[data-action="new-folder"]');
const uploadItem = menu.querySelector('[data-action="upload"]');
const downloadItem = menu.querySelector('[data-action="download"]');
if (target.isDir) {
// Folder context menu
if (newFileItem) newFileItem.style.display = 'block';
if (newFolderItem) newFolderItem.style.display = 'block';
if (uploadItem) uploadItem.style.display = 'block';
if (downloadItem) downloadItem.style.display = 'block';
} else {
// File context menu
if (newFileItem) newFileItem.style.display = 'none';
if (newFolderItem) newFolderItem.style.display = 'none';
if (uploadItem) uploadItem.style.display = 'none';
if (downloadItem) downloadItem.style.display = 'block';
}
// Position menu
menu.style.display = 'block';
menu.style.left = x + 'px';
menu.style.top = y + 'px';
// Adjust if off-screen
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';
}
}
function hideContextMenu() {
const menu = document.getElementById('contextMenu');
if (menu) {
menu.style.display = 'none';
}
}
// Hide context menu on click outside
document.addEventListener('click', (e) => {
if (!e.target.closest('#contextMenu')) {
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');
}
}
});
}
}

239
static/js/webdav-client.js Normal file
View File

@@ -0,0 +1,239 @@
/**
* WebDAV Client
* Handles all WebDAV protocol operations
*/
class WebDAVClient {
constructor(baseUrl) {
this.baseUrl = baseUrl;
this.currentCollection = null;
}
setCollection(collection) {
this.currentCollection = collection;
}
getFullUrl(path) {
if (!this.currentCollection) {
throw new Error('No collection selected');
}
const cleanPath = path.startsWith('/') ? path.slice(1) : path;
return `${this.baseUrl}${this.currentCollection}/${cleanPath}`;
}
async getCollections() {
const response = await fetch(this.baseUrl);
if (!response.ok) {
throw new Error('Failed to get collections');
}
return await response.json();
}
async propfind(path = '', depth = '1') {
const url = this.getFullUrl(path);
const response = await fetch(url, {
method: 'PROPFIND',
headers: {
'Depth': depth,
'Content-Type': 'application/xml'
}
});
if (!response.ok) {
throw new Error(`PROPFIND failed: ${response.statusText}`);
}
const xml = await response.text();
return this.parseMultiStatus(xml);
}
async get(path) {
const url = this.getFullUrl(path);
const response = await fetch(url);
if (!response.ok) {
throw new Error(`GET failed: ${response.statusText}`);
}
return await response.text();
}
async getBinary(path) {
const url = this.getFullUrl(path);
const response = await fetch(url);
if (!response.ok) {
throw new Error(`GET failed: ${response.statusText}`);
}
return await response.blob();
}
async put(path, content) {
const url = this.getFullUrl(path);
const response = await fetch(url, {
method: 'PUT',
headers: {
'Content-Type': 'text/plain'
},
body: content
});
if (!response.ok) {
throw new Error(`PUT failed: ${response.statusText}`);
}
return true;
}
async putBinary(path, content) {
const url = this.getFullUrl(path);
const response = await fetch(url, {
method: 'PUT',
body: content
});
if (!response.ok) {
throw new Error(`PUT failed: ${response.statusText}`);
}
return true;
}
async delete(path) {
const url = this.getFullUrl(path);
const response = await fetch(url, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error(`DELETE failed: ${response.statusText}`);
}
return true;
}
async copy(sourcePath, destPath) {
const sourceUrl = this.getFullUrl(sourcePath);
const destUrl = this.getFullUrl(destPath);
const response = await fetch(sourceUrl, {
method: 'COPY',
headers: {
'Destination': destUrl
}
});
if (!response.ok) {
throw new Error(`COPY failed: ${response.statusText}`);
}
return true;
}
async move(sourcePath, destPath) {
const sourceUrl = this.getFullUrl(sourcePath);
const destUrl = this.getFullUrl(destPath);
const response = await fetch(sourceUrl, {
method: 'MOVE',
headers: {
'Destination': destUrl
}
});
if (!response.ok) {
throw new Error(`MOVE failed: ${response.statusText}`);
}
return true;
}
async mkcol(path) {
const url = this.getFullUrl(path);
const response = await fetch(url, {
method: 'MKCOL'
});
if (!response.ok && response.status !== 405) { // 405 means already exists
throw new Error(`MKCOL failed: ${response.statusText}`);
}
return true;
}
parseMultiStatus(xml) {
const parser = new DOMParser();
const doc = parser.parseFromString(xml, 'text/xml');
const responses = doc.getElementsByTagNameNS('DAV:', 'response');
const items = [];
for (let i = 0; i < responses.length; i++) {
const response = responses[i];
const href = response.getElementsByTagNameNS('DAV:', 'href')[0].textContent;
const propstat = response.getElementsByTagNameNS('DAV:', 'propstat')[0];
const prop = propstat.getElementsByTagNameNS('DAV:', 'prop')[0];
// Check if it's a collection (directory)
const resourcetype = prop.getElementsByTagNameNS('DAV:', 'resourcetype')[0];
const isDirectory = resourcetype.getElementsByTagNameNS('DAV:', 'collection').length > 0;
// Get size
const contentlengthEl = prop.getElementsByTagNameNS('DAV:', 'getcontentlength')[0];
const size = contentlengthEl ? parseInt(contentlengthEl.textContent) : 0;
// Extract path relative to collection
const pathParts = href.split(`/${this.currentCollection}/`);
const relativePath = pathParts.length > 1 ? pathParts[1] : '';
// Skip the collection root itself
if (!relativePath) continue;
// Remove trailing slash from directories
const cleanPath = relativePath.endsWith('/') ? relativePath.slice(0, -1) : relativePath;
items.push({
path: cleanPath,
name: cleanPath.split('/').pop(),
isDirectory,
size
});
}
return items;
}
buildTree(items) {
const root = [];
const map = {};
// Sort items by path depth and name
items.sort((a, b) => {
const depthA = a.path.split('/').length;
const depthB = b.path.split('/').length;
if (depthA !== depthB) return depthA - depthB;
return a.path.localeCompare(b.path);
});
items.forEach(item => {
const parts = item.path.split('/');
const parentPath = parts.slice(0, -1).join('/');
const node = {
...item,
children: []
};
map[item.path] = node;
if (parentPath && map[parentPath]) {
map[parentPath].children.push(node);
} else {
root.push(node);
}
});
return root;
}
}

594
static/style.css Normal file
View File

@@ -0,0 +1,594 @@
/* CSS Variables for theming */
:root {
--bg-primary: #ffffff;
--bg-secondary: #f8f9fa;
--bg-tertiary: #f6f8fa;
--text-primary: #24292e;
--text-secondary: #6a737d;
--border-color: #dee2e6;
--border-light: #eaecef;
--link-color: #0366d6;
--accent-color: #0d6efd;
--code-bg: rgba(27, 31, 35, 0.05);
--scrollbar-track: #f1f1f1;
--scrollbar-thumb: #888;
--scrollbar-thumb-hover: #555;
}
/* Dark mode variables */
body.dark-mode {
--bg-primary: #0d1117;
--bg-secondary: #161b22;
--bg-tertiary: #1c2128;
--text-primary: #e6edf3;
--text-secondary: #8b949e;
--border-color: #30363d;
--border-light: #21262d;
--link-color: #58a6ff;
--accent-color: #1f6feb;
--code-bg: rgba(110, 118, 129, 0.15);
--scrollbar-track: #161b22;
--scrollbar-thumb: #484f58;
--scrollbar-thumb-hover: #6e7681;
}
/* Global styles */
html, body {
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
}
body {
display: flex;
flex-direction: column;
background-color: var(--bg-primary);
color: var(--text-primary);
transition: background-color 0.3s ease, color 0.3s ease;
}
.container-fluid {
flex: 1;
padding: 0;
overflow: hidden;
}
.row {
margin: 0;
}
/* Navbar */
.navbar {
z-index: 1000;
background-color: var(--bg-secondary) !important;
border-bottom: 1px solid var(--border-color);
transition: background-color 0.3s ease;
}
.navbar-brand {
color: var(--text-primary) !important;
}
.btn {
transition: all 0.2s ease;
}
/* Dark mode toggle button */
.dark-mode-toggle {
background: none;
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 0.375rem 0.75rem;
border-radius: 0.25rem;
cursor: pointer;
font-size: 1.2rem;
transition: all 0.2s ease;
}
.dark-mode-toggle:hover {
background-color: var(--bg-tertiary);
}
/* Sidebar */
.sidebar {
height: calc(100vh - 56px);
overflow-y: auto;
padding: 0;
background-color: var(--bg-secondary);
border-right: 1px solid var(--border-color);
transition: background-color 0.3s ease;
}
.sidebar h6 {
color: var(--text-primary);
background-color: var(--bg-tertiary);
transition: background-color 0.3s ease;
}
.list-group-item {
cursor: pointer;
border-radius: 0;
border-left: 0;
border-right: 0;
background-color: var(--bg-secondary);
color: var(--text-primary);
border-color: var(--border-color);
transition: background-color 0.2s ease, color 0.2s ease;
}
.list-group-item:first-child {
border-top: 0;
}
.list-group-item.active {
background-color: var(--accent-color);
border-color: var(--accent-color);
color: #ffffff;
}
.list-group-item:hover:not(.active) {
background-color: var(--bg-tertiary);
}
/* Editor pane */
.editor-pane {
height: calc(100vh - 56px);
display: flex;
flex-direction: column;
padding: 0;
border-right: 1px solid var(--border-color);
background-color: var(--bg-primary);
transition: background-color 0.3s ease;
}
.editor-pane input[type="text"] {
background-color: var(--bg-primary);
color: var(--text-primary);
border-color: var(--border-color);
transition: background-color 0.3s ease, color 0.3s ease;
}
.editor-pane input[type="text"]:focus {
background-color: var(--bg-primary);
color: var(--text-primary);
border-color: var(--accent-color);
}
#editorContainer {
flex: 1;
overflow: hidden;
}
.CodeMirror {
height: 100%;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 14px;
line-height: 1.6;
transition: all 0.2s ease;
}
.CodeMirror.drag-over {
border: 3px dashed var(--accent-color);
background-color: rgba(13, 110, 253, 0.05);
}
/* Dark mode CodeMirror adjustments */
body.dark-mode .CodeMirror {
background-color: #1c2128;
color: #e6edf3;
}
body.dark-mode .CodeMirror-gutters {
background-color: #161b22;
border-right: 1px solid #30363d;
}
body.dark-mode .CodeMirror-linenumber {
color: #8b949e;
}
/* Preview pane */
.preview-pane {
height: calc(100vh - 56px);
overflow-y: auto;
background-color: var(--bg-primary);
padding: 0;
transition: background-color 0.3s ease;
}
#preview {
padding: 20px;
max-width: 100%;
word-wrap: break-word;
color: var(--text-primary);
transition: color 0.3s ease;
}
/* Markdown preview styles */
#preview h1, #preview h2, #preview h3, #preview h4, #preview h5, #preview h6 {
margin-top: 24px;
margin-bottom: 16px;
font-weight: 600;
line-height: 1.25;
color: var(--text-primary);
}
#preview h1 {
font-size: 2em;
border-bottom: 1px solid var(--border-light);
padding-bottom: 0.3em;
}
#preview h2 {
font-size: 1.5em;
border-bottom: 1px solid var(--border-light);
padding-bottom: 0.3em;
}
#preview h3 {
font-size: 1.25em;
}
#preview p {
margin-bottom: 16px;
line-height: 1.6;
color: var(--text-primary);
}
#preview code {
background-color: var(--code-bg);
border-radius: 3px;
font-size: 85%;
margin: 0;
padding: 0.2em 0.4em;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
color: var(--text-primary);
}
#preview pre {
background-color: #2d2d2d !important;
border-radius: 6px;
padding: 16px;
overflow: auto;
line-height: 1.45;
margin-bottom: 16px;
}
#preview pre code {
background-color: transparent !important;
border: 0;
display: block;
line-height: inherit;
margin: 0;
overflow: visible;
padding: 0 !important;
word-wrap: normal;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 14px;
}
/* Prism theme override for better visibility */
#preview pre[class*="language-"] {
background-color: #2d2d2d !important;
margin: 0;
}
#preview code[class*="language-"] {
background-color: transparent !important;
}
#preview blockquote {
border-left: 4px solid var(--border-light);
color: var(--text-secondary);
padding-left: 16px;
margin-left: 0;
margin-bottom: 16px;
}
#preview ul, #preview ol {
margin-bottom: 16px;
padding-left: 2em;
}
#preview li {
margin-bottom: 4px;
}
#preview table {
border-collapse: collapse;
width: 100%;
margin-bottom: 16px;
}
#preview table th,
#preview table td {
border: 1px solid var(--border-light);
padding: 6px 13px;
}
#preview table th {
background-color: var(--bg-tertiary);
font-weight: 600;
}
#preview table tr:nth-child(2n) {
background-color: var(--bg-tertiary);
}
#preview img {
max-width: 100%;
height: auto;
margin: 16px 0;
}
#preview hr {
height: 4px;
padding: 0;
margin: 24px 0;
background-color: var(--border-light);
border: 0;
}
#preview a {
color: var(--link-color);
text-decoration: none;
}
#preview a:hover {
text-decoration: underline;
}
/* Mermaid diagrams */
.mermaid {
text-align: center;
margin: 20px 0;
}
/* Dark mode mermaid adjustments */
body.dark-mode .mermaid {
filter: invert(0.9) hue-rotate(180deg);
}
body.dark-mode .mermaid svg {
background-color: transparent !important;
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: var(--scrollbar-track);
}
::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border-radius: 5px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-thumb-hover);
}
/* Responsive adjustments */
@media (max-width: 768px) {
.sidebar {
display: none;
}
.editor-pane,
.preview-pane {
height: 50vh;
}
}
/* File list item styling */
.file-item {
display: flex;
justify-content: space-between;
align-items: center;
}
.file-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-size {
font-size: 0.75rem;
color: var(--text-secondary);
margin-left: 8px;
}
/* Toast notifications dark mode */
body.dark-mode .toast {
background-color: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border-color);
}
body.dark-mode .toast-header {
background-color: var(--bg-tertiary);
color: var(--text-primary);
border-bottom: 1px solid var(--border-color);
}
/* File Tree Styles */
.file-tree {
user-select: none;
font-size: 14px;
}
.tree-node {
padding: 4px 8px;
cursor: pointer;
border-radius: 4px;
transition: background-color 0.15s ease;
display: flex;
align-items: center;
gap: 6px;
}
.tree-node:hover {
background-color: var(--bg-tertiary);
}
.tree-node.active {
background-color: var(--accent-color);
color: white;
}
.tree-node.drag-over {
background-color: rgba(13, 110, 253, 0.2);
border: 2px dashed var(--accent-color);
}
.tree-node-content {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
}
.tree-node-icon {
font-size: 16px;
width: 16px;
text-align: center;
}
.tree-node-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tree-node-toggle {
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
transition: transform 0.2s ease;
}
.tree-node-toggle.expanded {
transform: rotate(90deg);
}
.tree-children {
margin-left: 16px;
border-left: 1px solid var(--border-color);
padding-left: 8px;
}
.tree-children.collapsed {
display: none;
}
/* Context Menu */
.context-menu {
position: fixed;
background-color: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 10000;
min-width: 180px;
max-width: 200px;
width: auto;
padding: 4px 0;
}
body.dark-mode .context-menu {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
}
.context-menu-item {
padding: 8px 16px;
cursor: pointer;
display: flex;
align-items: center;
gap: 10px;
color: var(--text-primary);
transition: background-color 0.15s ease;
}
.context-menu-item:hover {
background-color: var(--bg-tertiary);
}
.context-menu-item i {
width: 16px;
text-align: center;
}
.context-menu-divider {
height: 1px;
background-color: var(--border-color);
margin: 4px 0;
}
/* Drag and Drop */
.dragging {
opacity: 0.5;
}
.drop-indicator {
height: 2px;
background-color: var(--accent-color);
margin: 2px 0;
}
/* Modal for rename/new folder */
.modal-backdrop.show {
opacity: 0.5;
}
/* File size badge */
.file-size-badge {
font-size: 10px;
color: var(--text-secondary);
margin-left: auto;
}
/* Dark mode tree and sidebar fixes */
body.dark-mode .sidebar {
background-color: var(--bg-secondary);
}
body.dark-mode .tree-node {
color: var(--text-primary);
}
body.dark-mode .tree-node:hover {
background-color: var(--bg-tertiary);
}
body.dark-mode .tree-node-name {
color: var(--text-primary);
}
body.dark-mode .tree-node-size {
color: var(--text-secondary);
}
body.dark-mode .sidebar h6 {
color: var(--text-primary);
background-color: var(--bg-tertiary);
}
body.dark-mode .tree-children {
border-left-color: var(--border-color);
}