Merge pull request #92 from gamosoft/features/new-features

Features/new features
This commit is contained in:
Guillermo Villar 2025-12-13 10:57:34 +01:00 committed by GitHub
commit bcdc10a5d5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 476 additions and 44 deletions

View File

@ -7,7 +7,7 @@ import re
import shutil import shutil
from pathlib import Path from pathlib import Path
from typing import List, Dict, Optional, Tuple from typing import List, Dict, Optional, Tuple
from datetime import datetime from datetime import datetime, timezone
# In-memory cache for parsed tags # In-memory cache for parsed tags
@ -201,7 +201,7 @@ def get_all_notes(notes_dir: str) -> List[Dict]:
"name": md_file.stem, "name": md_file.stem,
"path": str(relative_path.as_posix()), "path": str(relative_path.as_posix()),
"folder": str(relative_path.parent.as_posix()) if str(relative_path.parent) != "." else "", "folder": str(relative_path.parent.as_posix()) if str(relative_path.parent) != "." else "",
"modified": datetime.fromtimestamp(stat.st_mtime).isoformat(), "modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
"size": stat.st_size, "size": stat.st_size,
"type": "note", "type": "note",
"tags": tags "tags": tags
@ -354,8 +354,8 @@ def create_note_metadata(notes_dir: str, note_path: str) -> Dict:
line_count = sum(1 for _ in f) line_count = sum(1 for _ in f)
return { return {
"created": datetime.fromtimestamp(stat.st_ctime).isoformat(), "created": datetime.fromtimestamp(stat.st_ctime, tz=timezone.utc).isoformat(),
"modified": datetime.fromtimestamp(stat.st_mtime).isoformat(), "modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
"size": stat.st_size, "size": stat.st_size,
"lines": line_count "lines": line_count
} }
@ -477,7 +477,7 @@ def get_all_images(notes_dir: str) -> List[Dict]:
"name": image_file.name, "name": image_file.name,
"path": str(relative_path.as_posix()), "path": str(relative_path.as_posix()),
"folder": str(relative_path.parent.as_posix()), "folder": str(relative_path.parent.as_posix()),
"modified": datetime.fromtimestamp(stat.st_mtime).isoformat(), "modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
"size": stat.st_size, "size": stat.st_size,
"type": "image" "type": "image"
}) })
@ -667,7 +667,7 @@ def get_notes_by_tag(notes_dir: str, tag: str) -> List[Dict]:
"name": md_file.stem, "name": md_file.stem,
"path": str(relative_path.as_posix()), "path": str(relative_path.as_posix()),
"folder": str(relative_path.parent.as_posix()) if str(relative_path.parent) != "." else "", "folder": str(relative_path.parent.as_posix()) if str(relative_path.parent) != "." else "",
"modified": datetime.fromtimestamp(stat.st_mtime).isoformat(), "modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
"size": stat.st_size, "size": stat.st_size,
"tags": tags "tags": tags
}) })
@ -712,7 +712,7 @@ def get_templates(notes_dir: str) -> List[Dict]:
templates.append({ templates.append({
"name": template_file.stem, "name": template_file.stem,
"path": str(template_file.relative_to(notes_dir).as_posix()), "path": str(template_file.relative_to(notes_dir).as_posix()),
"modified": datetime.fromtimestamp(stat.st_mtime).isoformat() "modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat()
}) })
except Exception as e: except Exception as e:
print(f"Error reading template {template_file}: {e}") print(f"Error reading template {template_file}: {e}")

View File

@ -50,6 +50,8 @@
- **Shareable links** - Bookmark or share direct links to notes with highlighted terms - **Shareable links** - Bookmark or share direct links to notes with highlighted terms
- **Refresh safe** - Page reload keeps you on the same note with search context - **Refresh safe** - Page reload keeps you on the same note with search context
- **Copy link button** - One-click copy of note URL to clipboard - **Copy link button** - One-click copy of note URL to clipboard
- **Last edited indicator** - Shows relative time since last edit (e.g., "Edited 2h ago")
- **Favorites** - Star notes for quick access; displayed at top of sidebar
## 🎨 Customization ## 🎨 Customization
@ -265,7 +267,9 @@ date: {{date}}
| `Ctrl+Alt+N` | `Cmd+Option+N` | New note | | `Ctrl+Alt+N` | `Cmd+Option+N` | New note |
| `Ctrl+Alt+F` | `Cmd+Option+F` | New folder | | `Ctrl+Alt+F` | `Cmd+Option+F` | New folder |
| `Ctrl+Z` | `Cmd+Z` | Undo | | `Ctrl+Z` | `Cmd+Z` | Undo |
| `Ctrl+Y` or `Ctrl+Shift+Z` | `Cmd+Y` or `Cmd+Shift+Z` | Redo | | `Ctrl+Y` | `Cmd+Y` | Redo |
| `Ctrl+Shift+Z` | `Cmd+Shift+Z` | Toggle Zen Mode |
| `Esc` | `Esc` | Exit Zen Mode |
| `F3` | `F3` | Next search match | | `F3` | `F3` | Next search match |
| `Shift+F3` | `Shift+F3` | Previous search match | | `Shift+F3` | `Shift+F3` | Previous search match |
@ -278,6 +282,18 @@ date: {{date}}
| `Ctrl+K` | `Cmd+K` | Insert link | `[text](url)` | | `Ctrl+K` | `Cmd+K` | Insert link | `[text](url)` |
| `Ctrl+Alt+T` | `Cmd+Option+T` | Insert table | 3x3 table placeholder | | `Ctrl+Alt+T` | `Cmd+Option+T` | Insert table | 3x3 table placeholder |
## 🧘 Zen Mode
Full immersive distraction-free writing experience:
- **Full screen** - Uses browser Fullscreen API for true immersion
- **Hidden UI** - Sidebar, toolbar, and stats bar disappear
- **Centered editor** - Comfortable width for optimal reading
- **Larger text** - 18px font size with relaxed line spacing
- **Quick access** - Button in toolbar or `Ctrl+Shift+Z` shortcut
- **Easy exit** - Press `Esc`, click exit button, or use shortcut again
- **State preserved** - Returns to your previous view mode on exit
## 🚀 Performance ## 🚀 Performance
- **Instant loading** - No lag, no loading spinners - **Instant loading** - No lag, no loading spinners

View File

@ -56,8 +56,29 @@ function noteApp() {
isSaving: false, isSaving: false,
lastSaved: false, lastSaved: false,
linkCopied: false, linkCopied: false,
zenMode: false,
previousViewMode: 'split',
favorites: [],
favoritesSet: new Set(), // For O(1) lookups
favoritesExpanded: true,
saveTimeout: null, saveTimeout: null,
// Note lookup maps for O(1) wikilink resolution (built on loadNotes)
_noteLookup: {
byPath: new Map(), // path -> true
byPathLower: new Map(), // path.toLowerCase() -> true
byName: new Map(), // name (without .md) -> true
byNameLower: new Map(), // name.toLowerCase() -> true
byEndPath: new Map(), // '/filename' and '/filename.md' -> true
},
// Preview rendering debounce
_previewDebounceTimeout: null,
_lastRenderedContent: '',
_cachedRenderedHTML: '',
_mathDebounceTimeout: null,
_mermaidDebounceTimeout: null,
// Theme state // Theme state
currentTheme: 'light', currentTheme: 'light',
availableThemes: [], availableThemes: [],
@ -306,6 +327,8 @@ function noteApp() {
this.loadEditorWidth(); this.loadEditorWidth();
this.loadViewMode(); this.loadViewMode();
this.loadTagsExpanded(); this.loadTagsExpanded();
this.loadFavorites();
this.loadFavoritesExpanded();
this.loadSyntaxHighlightSetting(); this.loadSyntaxHighlightSetting();
// Parse URL and load specific note if provided // Parse URL and load specific note if provided
@ -396,6 +419,11 @@ function noteApp() {
this.saveTagsExpanded(); this.saveTagsExpanded();
}); });
// Watch favorites expanded state and save to localStorage
this.$watch('favoritesExpanded', () => {
this.saveFavoritesExpanded();
});
// Setup keyboard shortcuts (only once to prevent double triggers) // Setup keyboard shortcuts (only once to prevent double triggers)
if (!window.__noteapp_shortcuts_initialized) { if (!window.__noteapp_shortcuts_initialized) {
window.__noteapp_shortcuts_initialized = true; window.__noteapp_shortcuts_initialized = true;
@ -424,8 +452,8 @@ function noteApp() {
this.undo(); this.undo();
} }
// Ctrl/Cmd + Y or Ctrl/Cmd + Shift + Z for redo // Ctrl/Cmd + Y for redo (Ctrl+Shift+Z now opens Zen mode)
if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.shiftKey && e.key === 'z'))) { if ((e.ctrlKey || e.metaKey) && e.key === 'y') {
e.preventDefault(); e.preventDefault();
this.redo(); this.redo();
} }
@ -468,6 +496,18 @@ function noteApp() {
e.preventDefault(); e.preventDefault();
this.insertTable(); this.insertTable();
} }
// Ctrl/Cmd + Shift + Z for Zen mode (only when note is open)
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'Z') {
e.preventDefault();
this.toggleZenMode();
}
}
// Escape to exit Zen mode (works anywhere)
if (e.key === 'Escape' && this.zenMode) {
e.preventDefault();
this.toggleZenMode();
} }
}); });
} }
@ -482,6 +522,15 @@ function noteApp() {
} }
}); });
} }
// Listen for fullscreen changes (to sync zen mode state)
document.addEventListener('fullscreenchange', () => {
if (!document.fullscreenElement && this.zenMode) {
// User exited fullscreen manually, exit zen mode too
this.zenMode = false;
this.viewMode = this.previousViewMode;
}
});
}, },
// Load app configuration // Load app configuration
@ -707,6 +756,7 @@ function noteApp() {
const data = await response.json(); const data = await response.json();
this.notes = data.notes; this.notes = data.notes;
this.allFolders = data.folders || []; this.allFolders = data.folders || [];
this.buildNoteLookupMaps(); // Build O(1) lookup maps
this.buildFolderTree(); this.buildFolderTree();
await this.loadTags(); // Load tags after notes are loaded await this.loadTags(); // Load tags after notes are loaded
} catch (error) { } catch (error) {
@ -714,6 +764,56 @@ function noteApp() {
} }
}, },
// Build lookup maps for O(1) wikilink resolution
buildNoteLookupMaps() {
// Clear existing maps
this._noteLookup.byPath.clear();
this._noteLookup.byPathLower.clear();
this._noteLookup.byName.clear();
this._noteLookup.byNameLower.clear();
this._noteLookup.byEndPath.clear();
for (const note of this.notes) {
const path = note.path;
const pathLower = path.toLowerCase();
const name = note.name;
const nameLower = name.toLowerCase();
const nameWithoutMd = name.replace(/\.md$/i, '');
const nameWithoutMdLower = nameWithoutMd.toLowerCase();
// Store all variations for fast lookup
this._noteLookup.byPath.set(path, true);
this._noteLookup.byPath.set(path.replace(/\.md$/i, ''), true);
this._noteLookup.byPathLower.set(pathLower, true);
this._noteLookup.byPathLower.set(pathLower.replace(/\.md$/i, ''), true);
this._noteLookup.byName.set(name, true);
this._noteLookup.byName.set(nameWithoutMd, true);
this._noteLookup.byNameLower.set(nameLower, true);
this._noteLookup.byNameLower.set(nameWithoutMdLower, true);
// End path matching (for /folder/note style links)
this._noteLookup.byEndPath.set('/' + nameWithoutMdLower, true);
this._noteLookup.byEndPath.set('/' + nameLower, true);
}
},
// Fast O(1) check if a wikilink target exists
wikiLinkExists(linkTarget) {
const targetLower = linkTarget.toLowerCase();
// Check all lookup maps
return (
this._noteLookup.byPath.has(linkTarget) ||
this._noteLookup.byPath.has(linkTarget + '.md') ||
this._noteLookup.byPathLower.has(targetLower) ||
this._noteLookup.byPathLower.has(targetLower + '.md') ||
this._noteLookup.byName.has(linkTarget) ||
this._noteLookup.byNameLower.has(targetLower) ||
this._noteLookup.byEndPath.has('/' + targetLower) ||
this._noteLookup.byEndPath.has('/' + targetLower + '.md')
);
},
// Load all tags // Load all tags
async loadTags() { async loadTags() {
try { try {
@ -917,6 +1017,111 @@ function noteApp() {
return note && note.tags ? note.tags : []; return note && note.tags ? note.tags : [];
}, },
// ==================== FAVORITES ====================
// Load favorites from localStorage
loadFavorites() {
try {
const stored = localStorage.getItem('noteFavorites');
if (stored) {
this.favorites = JSON.parse(stored);
this.favoritesSet = new Set(this.favorites);
}
} catch (e) {
this.favorites = [];
this.favoritesSet = new Set();
}
},
// Save favorites to localStorage
saveFavorites() {
try {
localStorage.setItem('noteFavorites', JSON.stringify(this.favorites));
} catch (e) {
console.warn('Could not save favorites to localStorage');
}
},
// Check if a note is favorited (O(1) lookup)
isFavorite(notePath) {
return this.favoritesSet.has(notePath);
},
// Toggle favorite status for a note
toggleFavorite(notePath = null) {
const path = notePath || this.currentNote;
if (!path) return;
if (this.favoritesSet.has(path)) {
// Remove from favorites
this.favoritesSet.delete(path);
this.favorites = this.favorites.filter(f => f !== path);
} else {
// Add to favorites
this.favoritesSet.add(path);
this.favorites.push(path);
}
this.saveFavorites();
},
// Get favorite notes with full details (for display)
get favoriteNotes() {
return this.favorites
.map(path => {
const note = this.notes.find(n => n.path === path);
if (!note) return null;
return {
path: note.path,
name: note.path.split('/').pop().replace('.md', ''),
folder: note.folder || ''
};
})
.filter(Boolean); // Remove nulls (deleted notes)
},
loadFavoritesExpanded() {
try {
const saved = localStorage.getItem('favoritesExpanded');
if (saved !== null) {
this.favoritesExpanded = saved === 'true';
}
} catch (e) {
console.error('Error loading favorites expanded state:', e);
}
},
saveFavoritesExpanded() {
try {
localStorage.setItem('favoritesExpanded', this.favoritesExpanded.toString());
} catch (e) {
console.error('Error saving favorites expanded state:', e);
}
},
// Get current note's last modified time as relative string
get lastEditedText() {
if (!this.currentNote) return '';
const note = this.notes.find(n => n.path === this.currentNote);
if (!note || !note.modified) return '';
const modified = new Date(note.modified);
const now = new Date();
const diffMs = now - modified;
const diffSecs = Math.floor(diffMs / 1000);
const diffMins = Math.floor(diffSecs / 60);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffSecs < 60) return 'just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
// For older dates, show the date
return modified.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
},
// Parse tags from markdown content (matches backend logic) // Parse tags from markdown content (matches backend logic)
parseTagsFromContent(content) { parseTagsFromContent(content) {
if (!content || !content.trim().startsWith('---')) { if (!content || !content.trim().startsWith('---')) {
@ -1839,6 +2044,8 @@ function noteApp() {
const data = await response.json(); const data = await response.json();
this.currentNote = notePath; this.currentNote = notePath;
this._lastRenderedContent = ''; // Clear render cache for new note
this._cachedRenderedHTML = '';
this.noteContent = data.content; this.noteContent = data.content;
this.currentNoteName = notePath.split('/').pop().replace('.md', ''); this.currentNoteName = notePath.split('/').pop().replace('.md', '');
this.currentImage = ''; // Clear image viewer when loading a note this.currentImage = ''; // Clear image viewer when loading a note
@ -2707,6 +2914,8 @@ function noteApp() {
this.currentNote = ''; this.currentNote = '';
this.noteContent = ''; this.noteContent = '';
this.currentNoteName = ''; this.currentNoteName = '';
this._lastRenderedContent = ''; // Clear render cache
this._cachedRenderedHTML = '';
// Redirect to root // Redirect to root
window.history.replaceState({}, '', '/'); window.history.replaceState({}, '', '/');
} }
@ -2855,6 +3064,11 @@ function noteApp() {
get renderedMarkdown() { get renderedMarkdown() {
if (!this.noteContent) return '<p style="color: var(--text-tertiary);">Nothing to preview yet...</p>'; if (!this.noteContent) return '<p style="color: var(--text-tertiary);">Nothing to preview yet...</p>';
// Performance: Return cached HTML if content hasn't changed
if (this.noteContent === this._lastRenderedContent && this._cachedRenderedHTML) {
return this._cachedRenderedHTML;
}
// Strip YAML frontmatter from content before rendering // Strip YAML frontmatter from content before rendering
let contentToRender = this.noteContent; let contentToRender = this.noteContent;
if (contentToRender.trim().startsWith('---')) { if (contentToRender.trim().startsWith('---')) {
@ -2877,37 +3091,18 @@ function noteApp() {
// Convert Obsidian-style wikilinks: [[note]] or [[note|display text]] // Convert Obsidian-style wikilinks: [[note]] or [[note|display text]]
// Must be done before marked.parse() to avoid conflicts with markdown syntax // Must be done before marked.parse() to avoid conflicts with markdown syntax
const notes = this.notes; // Reference for closure const self = this; // Reference for closure
contentToRender = contentToRender.replace( contentToRender = contentToRender.replace(
/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g,
(match, target, displayText) => { (match, target, displayText) => {
const linkTarget = target.trim(); const linkTarget = target.trim();
const linkText = displayText ? displayText.trim() : linkTarget; const linkText = displayText ? displayText.trim() : linkTarget;
const linkTargetLower = linkTarget.toLowerCase();
// Check if note exists (by path or by name, case-insensitive) // Fast O(1) check using pre-built lookup maps
const noteExists = notes.some(n => { // Handle section anchors: extract base note path
const pathLower = n.path.toLowerCase(); const hashIndex = linkTarget.indexOf('#');
const nameLower = n.name.toLowerCase(); const basePath = hashIndex !== -1 ? linkTarget.substring(0, hashIndex) : linkTarget;
return ( const noteExists = basePath === '' || self.wikiLinkExists(basePath);
// Exact path match
n.path === linkTarget ||
n.path === linkTarget + '.md' ||
// Case-insensitive path match
pathLower === linkTargetLower ||
pathLower === linkTargetLower + '.md' ||
// Name match (with or without .md)
n.name === linkTarget ||
n.name === linkTarget + '.md' ||
nameLower === linkTargetLower ||
nameLower === linkTargetLower + '.md' ||
// Match by filename at end of path
n.path.endsWith('/' + linkTarget) ||
n.path.endsWith('/' + linkTarget + '.md') ||
pathLower.endsWith('/' + linkTargetLower) ||
pathLower.endsWith('/' + linkTargetLower + '.md')
);
});
// Escape special chars: href needs quote escaping, text needs HTML escaping // Escape special chars: href needs quote escaping, text needs HTML escaping
const safeHref = linkTarget.replace(/"/g, '%22'); const safeHref = linkTarget.replace(/"/g, '%22');
@ -2985,11 +3180,13 @@ function noteApp() {
html = tempDiv.innerHTML; html = tempDiv.innerHTML;
// Trigger MathJax rendering after DOM updates // Debounced MathJax rendering (avoid re-running on every keystroke)
this.typesetMath(); if (this._mathDebounceTimeout) clearTimeout(this._mathDebounceTimeout);
this._mathDebounceTimeout = setTimeout(() => this.typesetMath(), 300);
// Render Mermaid diagrams // Debounced Mermaid rendering
this.renderMermaid(); if (this._mermaidDebounceTimeout) clearTimeout(this._mermaidDebounceTimeout);
this._mermaidDebounceTimeout = setTimeout(() => this.renderMermaid(), 300);
// Apply syntax highlighting and add copy buttons to code blocks // Apply syntax highlighting and add copy buttons to code blocks
setTimeout(() => { setTimeout(() => {
@ -3013,6 +3210,10 @@ function noteApp() {
} }
}, 0); }, 0);
// Cache the result for performance
this._lastRenderedContent = this.noteContent;
this._cachedRenderedHTML = html;
return html; return html;
}, },
@ -4043,6 +4244,55 @@ function noteApp() {
}, 1500); }, 1500);
}, },
// Toggle Zen Mode (full immersive writing experience)
async toggleZenMode() {
if (!this.zenMode) {
// Entering Zen Mode
this.previousViewMode = this.viewMode;
this.viewMode = 'edit';
this.mobileSidebarOpen = false;
this.zenMode = true;
// Request fullscreen
try {
const elem = document.documentElement;
if (elem.requestFullscreen) {
await elem.requestFullscreen();
} else if (elem.webkitRequestFullscreen) {
await elem.webkitRequestFullscreen();
} else if (elem.msRequestFullscreen) {
await elem.msRequestFullscreen();
}
} catch (e) {
// Fullscreen not supported or denied, continue anyway
console.log('Fullscreen not available:', e);
}
// Focus editor after transition
setTimeout(() => {
const editor = document.getElementById('note-editor');
if (editor) editor.focus();
}, 300);
} else {
// Exiting Zen Mode
this.zenMode = false;
this.viewMode = this.previousViewMode;
// Exit fullscreen
try {
if (document.exitFullscreen) {
await document.exitFullscreen();
} else if (document.webkitExitFullscreen) {
await document.webkitExitFullscreen();
} else if (document.msExitFullscreen) {
await document.msExitFullscreen();
}
} catch (e) {
console.log('Exit fullscreen error:', e);
}
}
},
// Homepage folder navigation methods // Homepage folder navigation methods
goToHomepageFolder(folderPath) { goToHomepageFolder(folderPath) {
this.showGraph = false; // Close graph when navigating this.showGraph = false; // Close graph when navigating

View File

@ -470,6 +470,67 @@
background-color: var(--bg-primary); background-color: var(--bg-primary);
z-index: 0; z-index: 0;
} }
/* Zen Mode Styles */
.zen-mode-active {
background-color: var(--bg-primary);
}
.zen-mode-active .zen-hide {
display: none !important;
}
.zen-mode-active .zen-editor-container {
width: 100%;
margin: 0;
padding: 2rem 4rem;
height: 100vh;
}
.zen-mode-active .zen-editor-container .editor-wrapper {
background-color: var(--bg-primary);
}
.zen-mode-active .zen-editor-container .editor-textarea {
font-size: 18px;
line-height: 1.8;
}
.zen-mode-active .zen-editor-container .syntax-overlay {
font-size: 18px;
line-height: 1.8;
}
.zen-exit-button {
position: fixed;
bottom: 2rem;
right: 2rem;
padding: 0.75rem 1.25rem;
border-radius: 9999px;
background-color: var(--bg-tertiary);
color: var(--text-secondary);
border: 1px solid var(--border-primary);
cursor: pointer;
opacity: 0;
transition: opacity 0.3s ease, transform 0.2s ease;
z-index: 9999;
font-size: 0.875rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.zen-exit-button:hover {
opacity: 1 !important;
background-color: var(--bg-secondary);
transform: scale(1.05);
}
.zen-mode-active:hover .zen-exit-button {
opacity: 0.6;
}
/* Zen mode fade transition */
.zen-fade-enter {
animation: zenFadeIn 0.3s ease-out;
}
@keyframes zenFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
/* Syntax highlight colors using theme variables */ /* Syntax highlight colors using theme variables */
.syntax-overlay .md-heading { color: var(--accent-primary); font-weight: 600; } .syntax-overlay .md-heading { color: var(--accent-primary); font-weight: 600; }
.syntax-overlay .md-bold { color: var(--text-primary); font-weight: 700; } .syntax-overlay .md-bold { color: var(--text-primary); font-weight: 700; }
@ -1000,7 +1061,23 @@
} }
</style> </style>
</head> </head>
<body x-data="noteApp()" x-init="init()" style="background-color: var(--bg-primary);"> <body x-data="noteApp()" x-init="init()" style="background-color: var(--bg-primary);" :class="{'zen-mode-active': zenMode}">
<!-- Zen Mode Exit Button -->
<button
x-show="zenMode"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4"
x-transition:enter-end="opacity-60 translate-y-0"
@click="toggleZenMode()"
class="zen-exit-button"
title="Exit Zen Mode (Esc)"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
<span>Exit Zen</span>
</button>
<!-- Mobile Overlay --> <!-- Mobile Overlay -->
<div <div
@ -1014,7 +1091,7 @@
<!-- Sidebar with Icon Rail --> <!-- Sidebar with Icon Rail -->
<div <div
class="flex mobile-sidebar" class="flex mobile-sidebar zen-hide"
:class="{'mobile-sidebar-open': mobileSidebarOpen}" :class="{'mobile-sidebar-open': mobileSidebarOpen}"
:style="'width: ' + sidebarWidth + 'px; background-color: var(--bg-secondary); min-width: 200px; max-width: 600px;'" :style="'width: ' + sidebarWidth + 'px; background-color: var(--bg-secondary); min-width: 200px; max-width: 600px;'"
> >
@ -1138,6 +1215,62 @@
<!-- Folder Tree --> <!-- Folder Tree -->
<div class="flex-1 overflow-y-auto custom-scrollbar p-2"> <div class="flex-1 overflow-y-auto custom-scrollbar p-2">
<!-- Favorites Section -->
<div x-show="favoriteNotes.length > 0" class="mb-3">
<div
@click="favoritesExpanded = !favoritesExpanded"
class="flex items-center justify-between px-2 py-1 cursor-pointer rounded hover:bg-opacity-50 transition-colors"
style="color: var(--text-tertiary);"
@mouseenter="$el.style.backgroundColor = 'var(--bg-hover)'"
@mouseleave="$el.style.backgroundColor = 'transparent'"
>
<div class="flex items-center gap-1.5">
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24" style="color: var(--warning, #eab308);">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"></path>
</svg>
<span class="text-xs font-semibold uppercase tracking-wide">Favorites</span>
</div>
<svg
class="w-3 h-3 transition-transform"
:class="{'rotate-90': favoritesExpanded}"
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
</div>
<div x-show="favoritesExpanded" x-transition:enter="transition ease-out duration-150" x-transition:enter-start="opacity-0 -translate-y-1" x-transition:enter-end="opacity-100 translate-y-0" x-transition:leave="transition ease-in duration-100" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" class="mt-1">
<template x-for="fav in favoriteNotes" :key="fav.path">
<div
@click="loadNote(fav.path)"
class="flex items-center justify-between px-2 py-1.5 rounded cursor-pointer group"
:class="{'bg-opacity-50': currentNote === fav.path}"
:style="currentNote === fav.path ? 'background-color: var(--accent-primary); color: white;' : 'color: var(--text-secondary);'"
@mouseenter="if (currentNote !== fav.path) $el.style.backgroundColor = 'var(--bg-hover)'"
@mouseleave="if (currentNote !== fav.path) $el.style.backgroundColor = 'transparent'"
>
<div class="flex items-center gap-1.5 min-w-0 flex-1">
<svg class="w-3.5 h-3.5 flex-shrink-0" style="color: var(--warning, #eab308);" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"></path>
</svg>
<span class="text-xs truncate" x-text="fav.name"></span>
</div>
<button
@click.stop="toggleFavorite(fav.path)"
class="opacity-0 group-hover:opacity-100 p-0.5 rounded transition-opacity"
title="Remove from favorites"
@mouseenter="$el.style.backgroundColor = 'var(--bg-secondary)'"
@mouseleave="$el.style.backgroundColor = 'transparent'"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
</template>
</div>
</div>
<div class="text-xs px-2 py-1 mb-2" style="color: var(--text-tertiary);"> <div class="text-xs px-2 py-1 mb-2" style="color: var(--text-tertiary);">
<div class="flex items-center justify-between mb-1"> <div class="flex items-center justify-between mb-1">
<span>Folders & Notes</span> <span>Folders & Notes</span>
@ -1741,9 +1874,9 @@
<template x-if="currentNote || currentImage"> <template x-if="currentNote || currentImage">
<!-- Editor Area --> <!-- Editor Area -->
<div class="flex-1 flex flex-col overflow-hidden" style="background-color: var(--bg-primary);"> <div class="flex-1 flex flex-col overflow-hidden" :class="{'zen-editor-container': zenMode}" style="background-color: var(--bg-primary);">
<!-- Toolbar --> <!-- Toolbar -->
<div class="px-4 py-3 flex items-center justify-between mobile-toolbar" style="background-color: var(--bg-secondary); border-bottom: 1px solid var(--border-primary);"> <div class="px-4 py-3 flex items-center justify-between mobile-toolbar zen-hide" style="background-color: var(--bg-secondary); border-bottom: 1px solid var(--border-primary);">
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<!-- Mobile Menu Button --> <!-- Mobile Menu Button -->
<button <button
@ -1812,6 +1945,7 @@
</button> </button>
<span x-show="isSaving" class="text-xs" style="color: var(--text-secondary);">Saving...</span> <span x-show="isSaving" class="text-xs" style="color: var(--text-secondary);">Saving...</span>
<span x-show="!isSaving && lastSaved" class="text-xs" style="color: var(--success);">✓ Saved</span> <span x-show="!isSaving && lastSaved" class="text-xs" style="color: var(--success);">✓ Saved</span>
<span x-show="!isSaving && !lastSaved && lastEditedText" class="text-xs hidden md:inline" style="color: var(--text-tertiary);" x-text="'Edited ' + lastEditedText"></span>
</div> </div>
<!-- Demo Mode Warning Banner --> <!-- Demo Mode Warning Banner -->
@ -1860,6 +1994,24 @@
class="flex items-center rounded overflow-hidden" class="flex items-center rounded overflow-hidden"
style="background-color: var(--bg-tertiary); border: 1px solid var(--border-primary);" style="background-color: var(--bg-tertiary); border: 1px solid var(--border-primary);"
> >
<button
@click="toggleFavorite()"
class="p-2 transition"
:title="isFavorite(currentNote) ? 'Remove from favorites' : 'Add to favorites'"
:style="'color: ' + (isFavorite(currentNote) ? 'var(--warning, #eab308)' : 'var(--text-secondary)') + ';'"
@mouseenter="$el.style.backgroundColor = 'var(--bg-secondary)'"
@mouseleave="$el.style.backgroundColor = 'transparent'"
>
<!-- Filled star (favorited) -->
<svg x-show="isFavorite(currentNote)" class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"></path>
</svg>
<!-- Empty star (not favorited) -->
<svg x-show="!isFavorite(currentNote)" x-cloak class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"></path>
</svg>
</button>
<div style="width: 1px; height: 20px; background-color: var(--border-primary);"></div>
<button <button
@click="exportToHTML()" @click="exportToHTML()"
class="p-2 transition" class="p-2 transition"
@ -1890,6 +2042,20 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg> </svg>
</button> </button>
<div style="width: 1px; height: 20px; background-color: var(--border-primary);"></div>
<button
@click="toggleZenMode()"
class="p-2 transition"
style="color: var(--text-secondary);"
title="Zen Mode (Ctrl+Shift+Z)"
@mouseenter="$el.style.backgroundColor = 'var(--bg-secondary)'"
@mouseleave="$el.style.backgroundColor = 'transparent'"
>
<!-- Expand/Focus icon -->
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"></path>
</svg>
</button>
</div> </div>
</div> </div>
</div> </div>
@ -2082,7 +2248,7 @@
<!-- Stats Status Bar (only shows if plugin enabled) --> <!-- Stats Status Bar (only shows if plugin enabled) -->
<div x-show="statsPluginEnabled && currentNote && noteStats" <div x-show="statsPluginEnabled && currentNote && noteStats"
class="border-t" class="border-t zen-hide"
style="background-color: var(--bg-secondary); border-color: var(--border-primary);"> style="background-color: var(--bg-secondary); border-color: var(--border-primary);">
<!-- Collapsed State (default) --> <!-- Collapsed State (default) -->