Allow uploading of files and folders.

This commit is contained in:
2025-07-28 11:07:40 +02:00
committed by Jens Luedicke
parent 87471e033e
commit f98e8f3e71
13 changed files with 2805 additions and 8 deletions

View File

@@ -19,6 +19,10 @@
</p>
</div>
<div class="header-actions">
<button type="button" class="btn btn-secondary" id="help-toggle">
<span class="icon"><i class="ti ti-help"></i></span>
Help
</button>
<button type="button" class="btn btn-secondary" id="settings-toggle">
<span class="icon"><i class="ti ti-settings"></i></span>
Settings
@@ -132,6 +136,59 @@
</div>
</div>
<!-- Help Panel -->
<div class="help-panel" id="help-panel" style="display: none;">
<div class="help-card">
<h3 class="help-title">
<span class="icon"><i class="ti ti-help"></i></span>
Markdown & Wiki Syntax Help
</h3>
<div class="help-content">
<div class="help-section">
<h4>Wiki-style Links</h4>
<ul class="help-list">
<li><code>[[Note Title]]</code> - Create a link to another note</li>
<li><code>![[Note Title]]</code> - Embed another note's content</li>
</ul>
<p class="help-note">Wiki links work with note titles or slugs. If a note isn't found, it will show as a broken link.</p>
</div>
<div class="help-section">
<h4>Basic Markdown</h4>
<ul class="help-list">
<li><code># Heading 1</code> / <code>## Heading 2</code> / <code>### Heading 3</code></li>
<li><code>**bold**</code> / <code>*italic*</code> / <code>`code`</code></li>
<li><code>[Link text](url)</code> - Regular links</li>
<li><code>![Alt text](image-url)</code> - Images</li>
<li><code>- Item</code> - Bullet lists</li>
<li><code>1. Item</code> - Numbered lists</li>
<li><code>- [ ] Task</code> - Checklists</li>
<li><code>> Quote</code> - Blockquotes</li>
</ul>
</div>
<div class="help-section">
<h4>Advanced Markdown</h4>
<ul class="help-list">
<li><code>```language<br>code block<br>```</code> - Code blocks with syntax highlighting</li>
<li><code>| Header | Header |<br>|--------|--------|<br>| Cell | Cell |</code> - Tables</li>
<li><code>---</code> - Horizontal rule</li>
</ul>
</div>
<div class="help-section">
<h4>Tips</h4>
<ul class="help-list">
<li>Use frontmatter (YAML at the top) to set metadata</li>
<li>Press <kbd>Ctrl</kbd>+<kbd>S</kbd> to save quickly</li>
<li>The preview updates in real-time as you type</li>
<li>Wiki embeds show a preview of the linked note</li>
</ul>
</div>
</div>
</div>
</div>
<!-- Editor Form -->
<form method="POST" id="note-form">
<input type="hidden" id="title" name="title" value="{{ note.title if note else '' }}" required>
@@ -222,12 +279,33 @@
<i class="ti ti-minus"></i>
</button>
</div>
<div class="toolbar-divider"></div>
<div class="toolbar-group">
<button type="button" class="toolbar-btn" onclick="insertMarkdown('[[', ']]')" title="Wiki Link - Link to another note">
<i class="ti ti-file-symlink"></i>
</button>
<button type="button" class="toolbar-btn" onclick="insertMarkdown('![[', ']]')" title="Wiki Embed - Embed another note">
<i class="ti ti-file-import"></i>
</button>
</div>
</div>
<!-- Ace Editor Container -->
<div id="ace-editor" class="ace-editor-container">{{ note.content if note else '# New Note\n\nStart writing here...' }}</div>
<textarea id="content" name="content" style="display: none;" required>{{ note.content if note else '# New Note\n\nStart writing here...' }}</textarea>
<!-- Wiki Autocomplete Popup -->
<div id="wiki-autocomplete" class="wiki-autocomplete" style="display: none;">
<div class="autocomplete-search">
<input type="text" id="autocomplete-query" class="autocomplete-input" placeholder="Search notes...">
</div>
<div id="autocomplete-results" class="autocomplete-results">
<!-- Results will be populated here -->
</div>
</div>
<!-- Editor Footer -->
<div class="editor-footer">
<div class="editor-stats">
@@ -601,6 +679,196 @@
margin-top: 2rem;
}
/* Help Panel */
.help-panel {
margin-bottom: 2rem;
animation: slideDown 0.3s ease-out;
}
.help-card {
background: white;
border: 1px solid #e5e7eb;
border-radius: 16px;
padding: 2rem;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
}
.help-title {
margin: 0 0 1.5rem 0;
font-size: 1.25rem;
color: #2c3e50;
display: flex;
align-items: center;
gap: 0.5rem;
}
.help-content {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 2rem;
}
.help-section {
background: #f8f9fa;
padding: 1.5rem;
border-radius: 8px;
}
.help-section h4 {
margin: 0 0 1rem 0;
color: #495057;
font-size: 1rem;
}
.help-list {
list-style: none;
padding: 0;
margin: 0;
}
.help-list li {
padding: 0.5rem 0;
border-bottom: 1px solid #e9ecef;
}
.help-list li:last-child {
border-bottom: none;
}
.help-list code {
background: #fff;
padding: 0.2rem 0.4rem;
border-radius: 4px;
font-size: 0.875rem;
color: #e83e8c;
border: 1px solid #dee2e6;
}
.help-note {
margin: 1rem 0 0 0;
font-size: 0.875rem;
color: #6c757d;
font-style: italic;
}
kbd {
background-color: #f7f7f7;
border: 1px solid #ccc;
border-radius: 3px;
box-shadow: 0 1px 0 rgba(0,0,0,0.2);
display: inline-block;
font-size: 0.85em;
font-family: monospace;
line-height: 1;
padding: 2px 4px;
white-space: nowrap;
}
/* Wiki Autocomplete */
.wiki-autocomplete {
position: absolute;
z-index: 1000;
background: white;
border: 1px solid #dee2e6;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
width: 300px;
max-height: 250px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.autocomplete-search {
padding: 0.375rem;
border-bottom: 1px solid #e9ecef;
background: #f8f9fa;
}
.autocomplete-input {
width: 100%;
padding: 0.375rem 0.5rem;
border: 1px solid #ced4da;
border-radius: 4px;
font-size: 0.8125rem;
outline: none;
}
.autocomplete-input:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 0.15rem rgba(0, 123, 255, 0.25);
}
.autocomplete-results {
flex: 1;
overflow-y: auto;
max-height: 180px;
}
.autocomplete-item {
padding: 0.5rem 0.75rem;
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
transition: background 0.1s ease;
}
.autocomplete-item:last-child {
border-bottom: none;
}
.autocomplete-item:hover,
.autocomplete-item.selected {
background: #f8f9fa;
}
.autocomplete-item.selected {
background: #e7f3ff;
}
.autocomplete-item-title {
font-weight: 500;
color: #333;
font-size: 0.875rem;
margin-bottom: 0.125rem;
}
.autocomplete-item-meta {
display: flex;
gap: 0.75rem;
font-size: 0.6875rem;
color: #6c757d;
}
.autocomplete-item-folder,
.autocomplete-item-visibility {
display: flex;
align-items: center;
gap: 0.125rem;
}
.autocomplete-item-folder i,
.autocomplete-item-visibility i {
font-size: 0.75rem;
}
.autocomplete-item-preview {
display: none; /* Hide preview to save space */
}
.autocomplete-empty {
padding: 1rem;
text-align: center;
color: #6c757d;
font-size: 0.8125rem;
}
.autocomplete-loading {
padding: 1rem;
text-align: center;
color: #6c757d;
font-size: 0.8125rem;
}
/* Animations */
@keyframes slideDown {
from {
@@ -952,7 +1220,8 @@ function initializeAceEditor() {
useSoftTabs: true,
wrap: true,
showInvisibles: false,
scrollPastEnd: 0.5
scrollPastEnd: 0.5,
behavioursEnabled: true // Keep auto-closing brackets enabled
});
// Set initial content from hidden textarea
@@ -1057,17 +1326,37 @@ document.addEventListener('DOMContentLoaded', function() {
// Settings toggle
const settingsBtn = document.getElementById('settings-toggle');
const settingsPanel = document.getElementById('settings-panel');
const helpBtn = document.getElementById('help-toggle');
const helpPanel = document.getElementById('help-panel');
settingsBtn.addEventListener('click', function() {
if (settingsPanel.style.display === 'none' || !settingsPanel.style.display) {
settingsPanel.style.display = 'block';
this.classList.add('active');
// Close help panel if open
helpPanel.style.display = 'none';
helpBtn.classList.remove('active');
} else {
settingsPanel.style.display = 'none';
this.classList.remove('active');
}
});
// Help toggle
helpBtn.addEventListener('click', function() {
if (helpPanel.style.display === 'none' || !helpPanel.style.display) {
helpPanel.style.display = 'block';
this.classList.add('active');
// Close settings panel if open
settingsPanel.style.display = 'none';
settingsBtn.classList.remove('active');
} else {
helpPanel.style.display = 'none';
this.classList.remove('active');
}
});
// Preview toggle
const previewToggle = document.getElementById('preview-toggle');
const previewPanel = document.getElementById('preview-panel');
@@ -1130,6 +1419,285 @@ document.addEventListener('DOMContentLoaded', function() {
syncContentAndUpdatePreview(false);
syncSettingsToHiddenFields(); // Sync to hidden fields
});
// Wiki Autocomplete functionality
let autocompleteActive = false;
let autocompleteType = ''; // 'link' or 'embed'
let autocompleteStartPos = null;
let selectedIndex = -1;
let autocompleteResults = [];
const autocompletePopup = document.getElementById('wiki-autocomplete');
const autocompleteInput = document.getElementById('autocomplete-query');
const autocompleteResultsDiv = document.getElementById('autocomplete-results');
// Listen for [[ or ![[ triggers in ACE editor
aceEditor.commands.on("afterExec", function(e) {
if (e.command.name === "insertstring") {
const cursor = aceEditor.getCursorPosition();
const line = aceEditor.session.getLine(cursor.row);
const textBeforeCursor = line.substring(0, cursor.column);
// Check for [[ or ![[ triggers
if (textBeforeCursor.endsWith('[[')) {
// Regular link
autocompleteType = 'link';
autocompleteStartPos = {row: cursor.row, column: cursor.column};
// Check if ACE auto-closed the brackets
const textAfterCursor = line.substring(cursor.column);
if (textAfterCursor.startsWith(']]')) {
// Move cursor back inside the brackets
aceEditor.moveCursorToPosition({row: cursor.row, column: cursor.column});
}
showAutocomplete();
} else if (textBeforeCursor.endsWith('![[')) {
// Embed
autocompleteType = 'embed';
autocompleteStartPos = {row: cursor.row, column: cursor.column};
// Check if ACE auto-closed the brackets
const textAfterCursor = line.substring(cursor.column);
if (textAfterCursor.startsWith(']]')) {
// Move cursor back inside the brackets
aceEditor.moveCursorToPosition({row: cursor.row, column: cursor.column});
}
showAutocomplete();
}
}
});
function showAutocomplete() {
autocompleteActive = true;
selectedIndex = -1;
// Get cursor position in the editor
const cursorPixelPos = aceEditor.renderer.textToScreenCoordinates(
autocompleteStartPos.row,
autocompleteStartPos.column
);
// Get editor container position
const editorContainer = aceEditor.container;
const editorRect = editorContainer.getBoundingClientRect();
// Calculate position relative to the editor container
// The popup should appear right below the cursor
const lineHeight = aceEditor.renderer.lineHeight;
const left = cursorPixelPos.pageX - editorRect.left - window.scrollX;
const top = cursorPixelPos.pageY - editorRect.top + lineHeight + 5 - window.scrollY;
// Make sure popup doesn't go off the right edge
const maxLeft = editorRect.width - 300; // 300px is popup width
autocompletePopup.style.left = Math.min(left, maxLeft) + 'px';
autocompletePopup.style.top = top + 'px';
autocompletePopup.style.display = 'block';
// Focus input and load initial results
autocompleteInput.value = '';
autocompleteInput.focus();
searchNotes('');
}
function hideAutocomplete() {
autocompleteActive = false;
autocompletePopup.style.display = 'none';
aceEditor.focus();
}
function searchNotes(query) {
// Show loading state
autocompleteResultsDiv.innerHTML = '<div class="autocomplete-loading">Loading...</div>';
// Fetch notes from API
fetch(`/api/notes/autocomplete?q=${encodeURIComponent(query)}&limit=20`)
.then(response => response.json())
.then(data => {
if (data.success) {
autocompleteResults = data.results;
renderResults(data.results);
}
})
.catch(error => {
console.error('Error fetching notes:', error);
autocompleteResultsDiv.innerHTML = '<div class="autocomplete-empty">Error loading notes</div>';
});
}
function renderResults(results) {
if (results.length === 0) {
autocompleteResultsDiv.innerHTML = '<div class="autocomplete-empty">No notes found</div>';
return;
}
let html = '';
results.forEach((note, index) => {
const visibilityIcon = note.visibility === 'Private' ? 'lock' :
note.visibility === 'Team' ? 'users' : 'building';
html += `
<div class="autocomplete-item ${index === selectedIndex ? 'selected' : ''}"
data-index="${index}">
<div class="autocomplete-item-title">${escapeHtml(note.title)}</div>
<div class="autocomplete-item-meta">
${note.folder ? `
<div class="autocomplete-item-folder">
<i class="ti ti-folder"></i>
${escapeHtml(note.folder)}
</div>
` : ''}
<div class="autocomplete-item-visibility">
<i class="ti ti-${visibilityIcon}"></i>
${note.visibility}
</div>
</div>
${note.preview ? `
<div class="autocomplete-item-preview">${escapeHtml(note.preview)}</div>
` : ''}
</div>
`;
});
autocompleteResultsDiv.innerHTML = html;
// Add click handlers
document.querySelectorAll('.autocomplete-item').forEach(item => {
item.addEventListener('click', function() {
const index = parseInt(this.dataset.index);
selectNote(autocompleteResults[index]);
});
});
}
function selectNote(note) {
if (!note) return;
// Get current cursor position
const cursor = aceEditor.getCursorPosition();
const line = aceEditor.session.getLine(cursor.row);
// Check if there are already closing brackets after cursor
const textAfterCursor = line.substring(cursor.column);
const hasClosingBrackets = textAfterCursor.startsWith(']]');
// Insert only the title if closing brackets already exist
const insertText = hasClosingBrackets ? note.title : note.title + ']]';
// Insert the text
aceEditor.session.insert(cursor, insertText);
// If we didn't add closing brackets, move cursor past existing ones
if (hasClosingBrackets) {
const newPos = {
row: cursor.row,
column: cursor.column + note.title.length + 2 // +2 for ]]
};
aceEditor.moveCursorToPosition(newPos);
}
// Hide autocomplete
hideAutocomplete();
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Handle input changes
let searchTimeout;
autocompleteInput.addEventListener('input', function(e) {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
searchNotes(e.target.value);
}, 200);
});
// Handle keyboard navigation
autocompleteInput.addEventListener('keydown', function(e) {
if (e.key === 'ArrowDown') {
e.preventDefault();
if (selectedIndex < autocompleteResults.length - 1) {
selectedIndex++;
updateSelection();
}
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (selectedIndex > 0) {
selectedIndex--;
updateSelection();
}
} else if (e.key === 'Enter') {
e.preventDefault();
if (selectedIndex >= 0 && selectedIndex < autocompleteResults.length) {
selectNote(autocompleteResults[selectedIndex]);
}
} else if (e.key === 'Escape') {
e.preventDefault();
hideAutocomplete();
}
});
function updateSelection() {
document.querySelectorAll('.autocomplete-item').forEach((item, index) => {
if (index === selectedIndex) {
item.classList.add('selected');
item.scrollIntoView({ block: 'nearest' });
} else {
item.classList.remove('selected');
}
});
}
// Close autocomplete when clicking outside
document.addEventListener('click', function(e) {
if (autocompleteActive && !autocompletePopup.contains(e.target)) {
hideAutocomplete();
}
});
// Handle closing brackets and escape
aceEditor.on('change', function(e) {
if (!autocompleteActive) return;
if (e.action === 'insert') {
const cursor = aceEditor.getCursorPosition();
const line = aceEditor.session.getLine(cursor.row);
// Check if user typed ]] to close the link
if (e.lines[0].includes(']')) {
const textBeforeCursor = line.substring(0, cursor.column);
if (textBeforeCursor.endsWith(']]')) {
hideAutocomplete();
}
}
} else if (e.action === 'remove') {
// Check if user deleted the opening [[
const cursor = aceEditor.getCursorPosition();
const line = aceEditor.session.getLine(cursor.row);
const textBeforeCursor = line.substring(0, cursor.column);
if (!textBeforeCursor.includes('[[') && !textBeforeCursor.includes('![[')) {
hideAutocomplete();
}
}
});
// Handle escape key in editor
aceEditor.commands.addCommand({
name: 'closeAutocomplete',
bindKey: {win: 'Escape', mac: 'Escape'},
exec: function() {
if (autocompleteActive) {
hideAutocomplete();
return true;
}
return false;
}
});
});
</script>