Merge pull request #34 from gamosoft/features/perf-improvements

Features/perf improvements
This commit is contained in:
Gamosoft 2025-11-24 14:13:25 +01:00 committed by GitHub
commit 29fafb76d4
8 changed files with 292 additions and 150 deletions

View File

@ -2,6 +2,8 @@
> Your Self-Hosted Knowledge Base > Your Self-Hosted Knowledge Base
🌐 **[Visit the official website](https://www.notediscovery.com)**
## What is NoteDiscovery? ## What is NoteDiscovery?
NoteDiscovery is a **lightweight, self-hosted note-taking application** that puts you in complete control of your knowledge base. Write, organize, and discover your notes with a beautiful, modern interface—all running on your own server. NoteDiscovery is a **lightweight, self-hosted note-taking application** that puts you in complete control of your knowledge base. Write, organize, and discover your notes with a beautiful, modern interface—all running on your own server.

View File

@ -23,7 +23,6 @@ from .utils import (
save_note, save_note,
delete_note, delete_note,
search_notes, search_notes,
parse_wiki_links,
create_note_metadata, create_note_metadata,
ensure_directories, ensure_directories,
create_folder, create_folder,
@ -336,8 +335,8 @@ async def api_documentation():
{ {
"method": "GET", "method": "GET",
"path": "/api/graph", "path": "/api/graph",
"description": "Get graph data for note visualization (wiki links)", "description": "Get graph data for note visualization",
"response": "{ nodes: [{ id, label }], edges: [{ from, to }] }" "response": "{ nodes: [{ id, label }], edges: [] }"
}, },
{ {
"method": "GET", "method": "GET",
@ -651,13 +650,9 @@ async def get_note(note_path: str):
if transformed_content is not None: if transformed_content is not None:
content = transformed_content content = transformed_content
# Parse wiki links
links = parse_wiki_links(content)
return { return {
"path": note_path, "path": note_path,
"content": content, "content": content,
"links": links,
"metadata": create_note_metadata(config['storage']['notes_dir'], note_path) "metadata": create_note_metadata(config['storage']['notes_dir'], note_path)
} }
except HTTPException: except HTTPException:
@ -745,28 +740,22 @@ async def search(q: str):
@api_router.get("/graph") @api_router.get("/graph")
async def get_graph(): async def get_graph():
"""Get graph data for visualization""" """Get graph data for note visualization (currently only returns nodes, link detection not implemented)"""
try: try:
notes = get_all_notes(config['storage']['notes_dir']) notes = get_all_notes(config['storage']['notes_dir'])
nodes = [] nodes = []
edges = [] edges = []
# Build graph structure # Build graph structure - only nodes for now
for note in notes: for note in notes:
nodes.append({ if note.get('type') == 'note': # Only include actual notes
"id": note['path'], nodes.append({
"label": note['name'] "id": note['path'],
}) "label": note['name']
})
# Get links from this note # Note: Link detection between notes could be implemented here using markdown link parsing
content = get_note_content(config['storage']['notes_dir'], note['path']) # For now, returning empty edges
if content:
links = parse_wiki_links(content)
for link in links:
edges.append({
"from": note['path'],
"to": link
})
return {"nodes": nodes, "edges": edges} return {"nodes": nodes, "edges": edges}
except Exception as e: except Exception as e:

View File

@ -236,13 +236,6 @@ def delete_note(notes_dir: str, note_path: str) -> bool:
return True return True
def parse_wiki_links(content: str) -> List[str]:
"""Extract wiki-style links [[link]] from markdown content"""
pattern = r'\[\[([^\]]+)\]\]'
matches = re.findall(pattern, content)
return matches
def search_notes(notes_dir: str, query: str) -> List[Dict]: def search_notes(notes_dir: str, query: str) -> List[Dict]:
"""Simple full-text search through all notes""" """Simple full-text search through all notes"""
results = [] results = []

View File

@ -641,8 +641,8 @@
<div class="benefit-item"> <div class="benefit-item">
<span class="emoji">🔗</span> <span class="emoji">🔗</span>
<div class="text"> <div class="text">
<strong>Wiki Links</strong> <strong>Internal Links</strong>
Connect notes with internal links Connect notes with markdown links
</div> </div>
</div> </div>
@ -725,7 +725,7 @@
"url": "https://www.notediscovery.com", "url": "https://www.notediscovery.com",
"applicationCategory": "ProductivityApplication", "applicationCategory": "ProductivityApplication",
"operatingSystem": "Linux, Windows, macOS", "operatingSystem": "Linux, Windows, macOS",
"featureList": "Markdown editor, LaTeX math equations, Mermaid diagrams, Code syntax highlighting, Dark mode, Plugin system, Internal wiki links, HTML export, Full-text search, Self-hosted, Obsidian, Evernote, Notion, Onenote, Second brain", "featureList": "Markdown editor, LaTeX math equations, Mermaid diagrams, Code syntax highlighting, Dark mode, Plugin system, Internal links, HTML export, Full-text search, Self-hosted, Obsidian, Evernote, Notion, Onenote, Second brain",
"offers": { "offers": {
"@type": "Offer", "@type": "Offer",
"price": "0", "price": "0",

View File

@ -28,7 +28,7 @@
## 🔗 Linking & Discovery ## 🔗 Linking & Discovery
### Internal Links ### Internal Links
- **Wiki-style links** - `[[Note Name]]` syntax - **Markdown links** - `[Note Name](note.md)` syntax
- **Drag to link** - Drag notes or images into the editor to insert links - **Drag to link** - Drag notes or images into the editor to insert links
- **Click to navigate** - Jump between notes seamlessly - **Click to navigate** - Jump between notes seamlessly
- **External links** - Open in new tabs automatically - **External links** - Open in new tabs automatically
@ -139,6 +139,8 @@ graph TD
## ⚡ Keyboard Shortcuts ## ⚡ Keyboard Shortcuts
### General
| Windows/Linux | Mac | Action | | Windows/Linux | Mac | Action |
|---------------|-----|--------| |---------------|-----|--------|
| `Ctrl+S` | `Cmd+S` | Save note | | `Ctrl+S` | `Cmd+S` | Save note |
@ -149,6 +151,14 @@ graph TD
| `F3` | `F3` | Next search match | | `F3` | `F3` | Next search match |
| `Shift+F3` | `Shift+F3` | Previous search match | | `Shift+F3` | `Shift+F3` | Previous search match |
### Markdown Formatting
| Windows/Linux | Mac | Action | Result |
|---------------|-----|--------|--------|
| `Ctrl+B` | `Cmd+B` | Bold | `**text**` |
| `Ctrl+I` | `Cmd+I` | Italic | `*text*` |
| `Ctrl+K` | `Cmd+K` | Insert link | `[text](url)` |
## 🚀 Performance ## 🚀 Performance
- **Instant loading** - No lag, no loading spinners - **Instant loading** - No lag, no loading spinners

View File

@ -55,7 +55,7 @@ curl -X POST http://localhost:8000/api/plugins/note_stats/toggle \
### 🔗 Links & References ### 🔗 Links & References
- **Total Links** - All `[text](url)` links - **Total Links** - All `[text](url)` links
- **Internal Links** - `[[WikiLinks]]` to other notes - **Internal Links** - Links to other notes (`.md` files)
- **External Links** - HTTP/HTTPS URLs - **External Links** - HTTP/HTTPS URLs
- **Images** - `![alt](image.png)` count - **Images** - `![alt](image.png)` count
@ -191,7 +191,7 @@ Find orphaned notes or track reference density.
**Words:** Split on whitespace, excluding code blocks **Words:** Split on whitespace, excluding code blocks
**Reading Time:** `words / 200 minutes` **Reading Time:** `words / 200 minutes`
**Links:** Regex match `[text](url)` and `[[WikiLink]]` **Links:** Regex match `[text](url)` markdown links
**Tasks:** Match `- [ ]` and `- [x]` **Tasks:** Match `- [ ]` and `- [x]`
**Code Blocks:** Match ` ```language ` ` fences **Code Blocks:** Match ` ```language ` ` fences
**Headings:** Match `#`, `##`, `###` at line start **Headings:** Match `#`, `##`, `###` at line start

View File

@ -94,89 +94,112 @@ function noteApp() {
// Dropdown state // Dropdown state
showNewDropdown: false, showNewDropdown: false,
// Homepage folder navigation // Homepage state
selectedHomepageFolder: '', selectedHomepageFolder: '',
_homepageCache: {
folderPath: null,
notes: null,
folders: null,
breadcrumb: null
},
// Computed-like helpers for homepage (use methods for safer evaluation) // Homepage constants
HOMEPAGE_MAX_NOTES: 50,
// Computed-like helpers for homepage (cached for performance)
homepageNotes() { homepageNotes() {
// Return cached result if folder hasn't changed
if (this._homepageCache.folderPath === this.selectedHomepageFolder && this._homepageCache.notes) {
return this._homepageCache.notes;
}
if (!this.folderTree || typeof this.folderTree !== 'object') { if (!this.folderTree || typeof this.folderTree !== 'object') {
return []; return [];
} }
const folderNode = this.getFolderNode(this.selectedHomepageFolder || ''); const folderNode = this.getFolderNode(this.selectedHomepageFolder || '');
if (!folderNode || !Array.isArray(folderNode.notes)) { const result = (folderNode && Array.isArray(folderNode.notes)) ? folderNode.notes : [];
return [];
}
return folderNode.notes; // Cache the result
}, this._homepageCache.notes = result;
this._homepageCache.folderPath = this.selectedHomepageFolder;
// Helper: Format file size nicely return result;
formatSize(bytes) {
if (bytes === 0 || !bytes) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}, },
homepageFolders() { homepageFolders() {
// Return cached result if folder hasn't changed
if (this._homepageCache.folderPath === this.selectedHomepageFolder && this._homepageCache.folders) {
return this._homepageCache.folders;
}
if (!this.folderTree || typeof this.folderTree !== 'object') { if (!this.folderTree || typeof this.folderTree !== 'object') {
return []; return [];
} }
// Get child folders
let childFolders = []; let childFolders = [];
if (!this.selectedHomepageFolder) { if (!this.selectedHomepageFolder) {
// Root level: all top-level folders
childFolders = Object.entries(this.folderTree) childFolders = Object.entries(this.folderTree)
.filter(([key]) => key !== '__root__') .filter(([key]) => key !== '__root__')
.map(([, folder]) => folder); .map(([, folder]) => folder);
} else { } else {
// Inside a folder: get its children
const parentFolder = this.getFolderNode(this.selectedHomepageFolder); const parentFolder = this.getFolderNode(this.selectedHomepageFolder);
if (!parentFolder || !parentFolder.children) { if (parentFolder && parentFolder.children) {
return []; childFolders = Object.values(parentFolder.children);
} }
childFolders = Object.values(parentFolder.children);
} }
if (!Array.isArray(childFolders) || childFolders.length === 0) { // Map to simplified structure (note count already cached in folder node)
return []; const result = childFolders
}
return childFolders
.map(folder => ({ .map(folder => ({
name: folder.name, name: folder.name,
path: folder.path, path: folder.path,
noteCount: this.calculateFolderNoteCount(folder) noteCount: folder.noteCount || 0 // Use pre-calculated count
})) }))
.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())); .sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
// Cache the result
this._homepageCache.folders = result;
this._homepageCache.folderPath = this.selectedHomepageFolder;
return result;
}, },
homepageBreadcrumb() { homepageBreadcrumb() {
// Always return at least Home breadcrumb // Return cached result if folder hasn't changed
// Safety check - ensure we always return an array if (this._homepageCache.folderPath === this.selectedHomepageFolder && this._homepageCache.breadcrumb) {
try { return this._homepageCache.breadcrumb;
const breadcrumb = [{ name: 'Home', path: '' }]; }
if (!this.selectedHomepageFolder) { const breadcrumb = [{ name: 'Home', path: '' }];
return breadcrumb;
}
const parts = (this.selectedHomepageFolder || '').split('/').filter(part => part.trim() !== '');
if (this.selectedHomepageFolder) {
const parts = this.selectedHomepageFolder.split('/').filter(Boolean);
let currentPath = ''; let currentPath = '';
parts.forEach(part => { parts.forEach(part => {
currentPath = currentPath ? currentPath + '/' + part : part; currentPath = currentPath ? `${currentPath}/${part}` : part;
breadcrumb.push({ name: part, path: currentPath }); breadcrumb.push({ name: part, path: currentPath });
}); });
return breadcrumb;
} catch (error) {
// Fallback to just Home if anything goes wrong
return [{ name: 'Home', path: '' }];
} }
// Cache the result
this._homepageCache.breadcrumb = breadcrumb;
this._homepageCache.folderPath = this.selectedHomepageFolder;
return breadcrumb;
},
// Helper: Format file size nicely
formatSize(bytes) {
if (!bytes) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}, },
getFolderNode(folderPath = '') { getFolderNode(folderPath = '') {
@ -185,10 +208,7 @@ function noteApp() {
} }
if (!folderPath) { if (!folderPath) {
if (this.folderTree['__root__']) { return this.folderTree['__root__'] || { name: '', path: '', children: {}, notes: [], noteCount: 0 };
return this.folderTree['__root__'];
}
return { name: '', path: '', children: {}, notes: [] };
} }
const parts = folderPath.split('/').filter(Boolean); const parts = folderPath.split('/').filter(Boolean);
@ -206,22 +226,6 @@ function noteApp() {
return node; return node;
}, },
calculateFolderNoteCount(folderNode) {
if (!folderNode || typeof folderNode !== 'object') {
return 0;
}
const directNotes = Array.isArray(folderNode.notes) ? folderNode.notes.length : 0;
if (!folderNode.children || Object.keys(folderNode.children).length === 0) {
return directNotes;
}
return Object.values(folderNode.children).reduce(
(total, child) => total + this.calculateFolderNoteCount(child),
directNotes
);
},
// Check if app is empty (no notes and no folders) // Check if app is empty (no notes and no folders)
get isAppEmpty() { get isAppEmpty() {
const notesArray = Array.isArray(this.notes) ? this.notes : []; const notesArray = Array.isArray(this.notes) ? this.notes : [];
@ -291,6 +295,14 @@ function noteApp() {
this.selectedHomepageFolder = ''; this.selectedHomepageFolder = '';
} }
// Invalidate cache to force recalculation
this._homepageCache = {
folderPath: null,
notes: null,
folders: null,
breadcrumb: null
};
// Clear search // Clear search
this.searchQuery = ''; this.searchQuery = '';
this.searchResults = []; this.searchResults = [];
@ -373,6 +385,28 @@ function noteApp() {
e.preventDefault(); e.preventDefault();
this.previousMatch(); this.previousMatch();
} }
// Only apply markdown shortcuts when editor is focused and a note is open
const isEditorFocused = document.activeElement?.id === 'note-editor';
if (isEditorFocused && this.currentNote) {
// Ctrl/Cmd + B for bold
if ((e.ctrlKey || e.metaKey) && e.key === 'b') {
e.preventDefault();
this.wrapSelection('**', '**', 'bold text');
}
// Ctrl/Cmd + I for italic
if ((e.ctrlKey || e.metaKey) && e.key === 'i') {
e.preventDefault();
this.wrapSelection('*', '*', 'italic text');
}
// Ctrl/Cmd + K for link
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
this.insertLink();
}
}
}); });
} }
@ -590,8 +624,41 @@ function noteApp() {
} }
}); });
// Force Alpine reactivity by creating a new object reference // Calculate and cache note counts recursively (for performance)
this.folderTree = { ...tree }; const calculateNoteCounts = (folderNode) => {
const directNotes = folderNode.notes ? folderNode.notes.length : 0;
if (!folderNode.children || Object.keys(folderNode.children).length === 0) {
folderNode.noteCount = directNotes;
return directNotes;
}
const childNotesCount = Object.values(folderNode.children).reduce(
(total, child) => total + calculateNoteCounts(child),
0
);
folderNode.noteCount = directNotes + childNotesCount;
return folderNode.noteCount;
};
// Calculate note counts for all folders
Object.values(tree).forEach(folder => {
if (folder.path !== undefined || folder === tree['__root__']) {
calculateNoteCounts(folder);
}
});
// Invalidate homepage cache when tree is rebuilt
this._homepageCache = {
folderPath: null,
notes: null,
folders: null,
breadcrumb: null
};
// Assign new tree (Alpine will detect the change)
this.folderTree = tree;
}, },
// Render folder recursively (helper for deep nesting) // Render folder recursively (helper for deep nesting)
@ -756,8 +823,6 @@ function noteApp() {
} }
// Force Alpine reactivity by creating new Set reference // Force Alpine reactivity by creating new Set reference
this.expandedFolders = new Set(this.expandedFolders); this.expandedFolders = new Set(this.expandedFolders);
// Also trigger folderTree reactivity to re-render x-html
this.folderTree = { ...this.folderTree };
}, },
// Check if folder is expanded // Check if folder is expanded
@ -767,25 +832,22 @@ function noteApp() {
// Expand all folders // Expand all folders
expandAllFolders() { expandAllFolders() {
// Add all folder paths to expandedFolders
this.allFolders.forEach(folder => { this.allFolders.forEach(folder => {
this.expandedFolders.add(folder); this.expandedFolders.add(folder);
}); });
// Force Alpine reactivity by creating new object reference (no rebuild needed) // Force Alpine reactivity
this.folderTree = { ...this.folderTree }; this.expandedFolders = new Set(this.expandedFolders);
}, },
// Collapse all folders // Collapse all folders
collapseAllFolders() { collapseAllFolders() {
this.expandedFolders.clear(); this.expandedFolders.clear();
// Force Alpine reactivity by creating new object reference (no rebuild needed) // Force Alpine reactivity
this.folderTree = { ...this.folderTree }; this.expandedFolders = new Set(this.expandedFolders);
}, },
// Expand folder tree to show a specific note // Expand folder tree to show a specific note
expandFolderForNote(notePath) { expandFolderForNote(notePath) {
// Extract folder path from note path
// e.g., "folder1/folder2/note.md" -> ["folder1", "folder1/folder2"]
const parts = notePath.split('/'); const parts = notePath.split('/');
// If note is in root, no folders to expand // If note is in root, no folders to expand
@ -801,13 +863,8 @@ function noteApp() {
this.expandedFolders.add(currentPath); this.expandedFolders.add(currentPath);
}); });
// Force Alpine reactivity by creating new object reference // Force Alpine reactivity
// This ensures the UI updates to show the expanded folders this.expandedFolders = new Set(this.expandedFolders);
this.folderTree = { ...this.folderTree };
// Also force a re-evaluation by modifying the Set (create new Set)
const oldFolders = this.expandedFolders;
this.expandedFolders = new Set(oldFolders);
}, },
// Scroll note into view in the sidebar navigation // Scroll note into view in the sidebar navigation
@ -1932,6 +1989,68 @@ function noteApp() {
}); });
}, },
// Markdown formatting helpers
wrapSelection(before, after, placeholder) {
const editor = document.getElementById('note-editor');
if (!editor) return;
const start = editor.selectionStart;
const end = editor.selectionEnd;
const selectedText = this.noteContent.substring(start, end);
const textToWrap = selectedText || placeholder;
// Build the new text
const newText = before + textToWrap + after;
// Update content
this.noteContent = this.noteContent.substring(0, start) + newText + this.noteContent.substring(end);
// Set cursor position (select the wrapped text or placeholder)
this.$nextTick(() => {
if (selectedText) {
// If text was selected, keep it selected (inside the wrapper)
editor.setSelectionRange(start + before.length, start + before.length + selectedText.length);
} else {
// If no text selected, select the placeholder
editor.setSelectionRange(start + before.length, start + before.length + placeholder.length);
}
editor.focus();
});
// Trigger autosave
this.autoSave();
},
insertLink() {
const editor = document.getElementById('note-editor');
if (!editor) return;
const start = editor.selectionStart;
const end = editor.selectionEnd;
const selectedText = this.noteContent.substring(start, end);
// If text is selected, use it as link text; otherwise use placeholder
const linkText = selectedText || 'link text';
const linkUrl = 'url';
// Build the markdown link
const newText = `[${linkText}](${linkUrl})`;
// Update content
this.noteContent = this.noteContent.substring(0, start) + newText + this.noteContent.substring(end);
// Set cursor position to select the URL part for easy editing
this.$nextTick(() => {
const urlStart = start + linkText.length + 3; // After "[linkText]("
const urlEnd = urlStart + linkUrl.length;
editor.setSelectionRange(urlStart, urlEnd);
editor.focus();
});
// Trigger autosave
this.autoSave();
},
// Save current note // Save current note
async saveNote() { async saveNote() {
if (!this.currentNote) return; if (!this.currentNote) return;
@ -2202,13 +2321,8 @@ function noteApp() {
} }
}); });
// Convert wiki-style links [[link]] to HTML links
let content = this.noteContent.replace(/\[\[([^\]]+)\]\]/g, (match, linkText) => {
return `<a href="#" style="color: var(--accent-primary);" onclick="return false;">[[${linkText}]]</a>`;
});
// Parse markdown // Parse markdown
let html = marked.parse(content); let html = marked.parse(this.noteContent);
// Post-process: Add target="_blank" to external links and title attributes to images // Post-process: Add target="_blank" to external links and title attributes to images
// Parse as DOM to safely manipulate // Parse as DOM to safely manipulate
@ -2955,12 +3069,37 @@ function noteApp() {
// Homepage folder navigation methods // Homepage folder navigation methods
goToHomepageFolder(folderPath) { goToHomepageFolder(folderPath) {
// Navigate to folder (empty string = root)
this.selectedHomepageFolder = folderPath || ''; this.selectedHomepageFolder = folderPath || '';
// Update browser history // Invalidate cache to force recalculation
const state = { homepageFolder: folderPath || '' }; this._homepageCache = {
window.history.pushState(state, '', '/'); folderPath: null,
notes: null,
folders: null,
breadcrumb: null
};
window.history.pushState({ homepageFolder: folderPath || '' }, '', '/');
},
// Navigate to homepage root and clear all editor state
goHome() {
this.selectedHomepageFolder = '';
this.currentNote = '';
this.currentNoteName = '';
this.noteContent = '';
this.currentImage = '';
this.mobileSidebarOpen = false;
// Invalidate cache to force recalculation
this._homepageCache = {
folderPath: null,
notes: null,
folders: null,
breadcrumb: null
};
window.history.pushState({ homepageFolder: '' }, '', '/');
} }
} }
} }

View File

@ -730,7 +730,7 @@
<div class="p-4 border-b" style="border-color: var(--border-primary);"> <div class="p-4 border-b" style="border-color: var(--border-primary);">
<button <button
class="flex items-center gap-2 mb-3 hover:opacity-80 transition" class="flex items-center gap-2 mb-3 hover:opacity-80 transition"
@click="goToHomepageFolder(''); currentNote = ''; currentImage = ''; currentNoteName = ''; noteContent = ''; mobileSidebarOpen = false" @click="goHome()"
aria-label="Go to homepage" aria-label="Go to homepage"
style="background: none; border: none; outline: none; cursor: pointer;" style="background: none; border: none; outline: none; cursor: pointer;"
> >
@ -858,7 +858,7 @@
style="background-color: var(--bg-secondary); border: 1px solid var(--border-primary);" style="background-color: var(--bg-secondary); border: 1px solid var(--border-primary);"
> >
<div class="py-1"> <div class="py-1">
<button <button
@click="createNote()" @click="createNote()"
class="w-full px-4 py-2.5 text-sm text-left flex items-center gap-3 transition-colors" class="w-full px-4 py-2.5 text-sm text-left flex items-center gap-3 transition-colors"
style="color: var(--text-primary);" style="color: var(--text-primary);"
@ -877,7 +877,7 @@
> >
<span class="text-lg">📁</span> <span class="text-lg">📁</span>
<span>New Folder</span> <span>New Folder</span>
</button> </button>
</div> </div>
</div> </div>
</div> </div>
@ -997,6 +997,17 @@
<span x-text="notes.length"></span> notes <span x-text="notes.length"></span> notes
<span class="mx-1">·</span> <span class="mx-1">·</span>
<span x-text="'v' + appVersion"></span> <span x-text="'v' + appVersion"></span>
<span class="mx-1">·</span>
<a
href="https://www.notediscovery.com"
target="_blank"
rel="noopener noreferrer"
class="hover:underline"
style="color: var(--accent-primary);"
title="Visit NoteDiscovery website"
>
Website
</a>
</div> </div>
</div> </div>
</div> </div>
@ -1019,21 +1030,21 @@
<!-- Welcome Hero - Show when app is empty --> <!-- Welcome Hero - Show when app is empty -->
<template x-if="isAppEmpty"> <template x-if="isAppEmpty">
<div class="flex flex-col items-center justify-center h-full py-16 text-center"> <div class="flex flex-col items-center justify-center h-full py-16 text-center">
<svg class="mx-auto h-16 w-16 mb-4" style="color: var(--text-tertiary);" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="mx-auto h-16 w-16 mb-4" style="color: var(--text-tertiary);" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg> </svg>
<h1 class="text-4xl font-bold mb-4" style="color: var(--text-primary);" x-text="appName"></h1> <h1 class="text-4xl font-bold mb-4" style="color: var(--text-primary);" x-text="appName"></h1>
<p class="text-xl mb-8" style="color: var(--text-secondary);" x-text="appTagline"></p> <p class="text-xl mb-8" style="color: var(--text-secondary);" x-text="appTagline"></p>
<button <button
@click="createNote()" @click="createNote()"
class="px-8 py-4 text-base font-medium text-white rounded-lg transition-colors" class="px-8 py-4 text-base font-medium text-white rounded-lg transition-colors"
style="background-color: var(--accent-primary);" style="background-color: var(--accent-primary);"
onmouseover="this.style.backgroundColor='var(--accent-hover)'" onmouseover="this.style.backgroundColor='var(--accent-hover)'"
onmouseout="this.style.backgroundColor='var(--accent-primary)'" onmouseout="this.style.backgroundColor='var(--accent-primary)'"
> >
Create Your First Note Create Your First Note
</button> </button>
</div> </div>
</template> </template>
<!-- Grid View - Show when app has content --> <!-- Grid View - Show when app has content -->
@ -1061,8 +1072,8 @@
:style="crumb.path === selectedHomepageFolder ? 'color: var(--accent-primary); font-weight: 600;' : 'color: var(--text-secondary);'" :style="crumb.path === selectedHomepageFolder ? 'color: var(--accent-primary); font-weight: 600;' : 'color: var(--text-secondary);'"
x-text="crumb.name" x-text="crumb.name"
></span> ></span>
</div> </div>
</template> </template>
</template> </template>
</div> </div>
@ -1142,7 +1153,7 @@
</template> </template>
<!-- Notes --> <!-- Notes -->
<template x-for="note in homepageNotes().slice(0, 50)" :key="note.path"> <template x-for="note in homepageNotes().slice(0, HOMEPAGE_MAX_NOTES)" :key="note.path">
<div <div
@click="note.path.match(/\.(png|jpg|jpeg|gif|webp)$/i) ? (currentImage = note.path, currentNote = '') : loadNote(note.path)" @click="note.path.match(/\.(png|jpg|jpeg|gif|webp)$/i) ? (currentImage = note.path, currentNote = '') : loadNote(note.path)"
class="p-4 rounded-lg border-2 cursor-pointer transition-all hover:shadow-lg" class="p-4 rounded-lg border-2 cursor-pointer transition-all hover:shadow-lg"
@ -1164,12 +1175,9 @@
</template> </template>
<!-- Show More Indicator --> <!-- Show More Indicator -->
<div x-show="homepageNotes().length > 50" class="mt-6 text-center"> <div x-show="homepageNotes().length > HOMEPAGE_MAX_NOTES" class="mt-6 text-center">
<p class="text-sm" style="color: var(--text-secondary);"> <p class="text-sm" style="color: var(--text-secondary);">
Showing 50 of <span x-text="homepageNotes().length"></span> notes. Showing <span x-text="HOMEPAGE_MAX_NOTES"></span> of <span x-text="homepageNotes().length"></span> notes in this folder.
<span class="cursor-pointer underline" style="color: var(--accent-primary);" @click="goToHomepageFolder('')">
View all in sidebar
</span>
</p> </p>
</div> </div>
</div> </div>
@ -1301,6 +1309,7 @@
:style="viewMode === 'split' ? `width: ${editorWidth}%;` : 'width: 100%;'" :style="viewMode === 'split' ? `width: ${editorWidth}%;` : 'width: 100%;'"
> >
<textarea <textarea
id="note-editor"
x-model="noteContent" x-model="noteContent"
@input="autoSave()" @input="autoSave()"
@drop="onEditorDrop($event)" @drop="onEditorDrop($event)"