From 9113dc1a69ac87a1c38ca346ab8ad8ee963aadc7 Mon Sep 17 00:00:00 2001 From: Jens Luedicke Date: Sun, 6 Jul 2025 22:29:13 +0200 Subject: [PATCH] Store YAML frontmatter in notes. --- app.py | 26 +- frontmatter_utils.py | 70 +++ migrate_db.py | 67 +++ migrations/add_cascade_delete_note_links.sql | 20 + .../add_cascade_delete_note_links_sqlite.sql | 25 ++ models.py | 75 +++- requirements.txt | 1 + templates/note_editor.html | 237 ++++++++++- templates/note_mindmap.html | 401 ++++++++++++++++++ templates/note_view.html | 25 +- templates/notes_list.html | 22 +- 11 files changed, 946 insertions(+), 23 deletions(-) create mode 100644 frontmatter_utils.py create mode 100644 migrations/add_cascade_delete_note_links.sql create mode 100644 migrations/add_cascade_delete_note_links_sqlite.sql create mode 100644 templates/note_mindmap.html diff --git a/app.py b/app.py index d5d6013..1f1aa29 100644 --- a/app.py +++ b/app.py @@ -1794,6 +1794,9 @@ def create_note(): company_id=g.user.company_id ) + # Sync metadata from frontmatter if present + note.sync_from_frontmatter() + # Set team_id if visibility is Team if visibility == 'Team' and g.user.team_id: note.team_id = g.user.team_id @@ -1867,11 +1870,11 @@ def view_note(slug): incoming_links = [] for link in note.outgoing_links: - if link.target_note.can_user_view(g.user): + if link.target_note.can_user_view(g.user) and not link.target_note.is_archived: outgoing_links.append(link) for link in note.incoming_links: - if link.source_note.can_user_view(g.user): + if link.source_note.can_user_view(g.user) and not link.source_note.is_archived: incoming_links.append(link) # Get linkable notes for the modal @@ -1896,6 +1899,22 @@ def view_note(slug): can_edit=note.can_user_edit(g.user)) +@app.route('/notes//mindmap') +@login_required +@company_required +def view_note_mindmap(slug): + """View a note as a mind map""" + note = Note.query.filter_by(slug=slug, company_id=g.user.company_id).first_or_404() + + # Check permissions + if not note.can_user_view(g.user): + abort(403) + + return render_template('note_mindmap.html', + title=f"{note.title} - Mind Map", + note=note) + + @app.route('/notes//edit', methods=['GET', 'POST']) @login_required @company_required @@ -1938,6 +1957,9 @@ def edit_note(slug): note.folder = folder if folder else None note.tags = ','.join(tag_list) if tag_list else None + # Sync metadata from frontmatter if present + note.sync_from_frontmatter() + # Update team_id if visibility is Team if visibility == 'Team' and g.user.team_id: note.team_id = g.user.team_id diff --git a/frontmatter_utils.py b/frontmatter_utils.py new file mode 100644 index 0000000..b8aa906 --- /dev/null +++ b/frontmatter_utils.py @@ -0,0 +1,70 @@ +import yaml +import re +from datetime import datetime + +def parse_frontmatter(content): + """ + Parse YAML frontmatter from markdown content. + Returns a tuple of (metadata dict, content without frontmatter) + """ + if not content or not content.strip().startswith('---'): + return {}, content + + # Match frontmatter pattern + pattern = r'^---\s*\n(.*?)\n---\s*\n(.*)$' + match = re.match(pattern, content, re.DOTALL) + + if not match: + return {}, content + + try: + # Parse YAML frontmatter + metadata = yaml.safe_load(match.group(1)) or {} + content_body = match.group(2) + return metadata, content_body + except yaml.YAMLError: + # If YAML parsing fails, return original content + return {}, content + +def create_frontmatter(metadata): + """ + Create YAML frontmatter from metadata dict. + """ + if not metadata: + return "" + + # Filter out None values and empty strings + filtered_metadata = {k: v for k, v in metadata.items() if v is not None and v != ''} + + if not filtered_metadata: + return "" + + return f"---\n{yaml.dump(filtered_metadata, default_flow_style=False, sort_keys=False)}---\n\n" + +def update_frontmatter(content, metadata): + """ + Update or add frontmatter to content. + """ + _, body = parse_frontmatter(content) + frontmatter = create_frontmatter(metadata) + return frontmatter + body + +def extract_title_from_content(content): + """ + Extract title from content, checking frontmatter first, then first line. + """ + metadata, body = parse_frontmatter(content) + + # Check if title is in frontmatter + if metadata.get('title'): + return metadata['title'] + + # Otherwise extract from first line of body + lines = body.strip().split('\n') + for line in lines: + line = line.strip() + if line: + # Remove markdown headers if present + return re.sub(r'^#+\s*', '', line) + + return 'Untitled Note' \ No newline at end of file diff --git a/migrate_db.py b/migrate_db.py index d80a97a..5ba69d7 100644 --- a/migrate_db.py +++ b/migrate_db.py @@ -77,6 +77,7 @@ def run_all_migrations(db_path=None): migrate_dashboard_system(db_path) migrate_comment_system(db_path) migrate_notes_system(db_path) + update_note_link_cascade(db_path) # Run PostgreSQL-specific migrations if applicable if FLASK_AVAILABLE: @@ -1753,6 +1754,72 @@ def migrate_notes_system(db_file=None): conn.close() +def update_note_link_cascade(db_path): + """Update note_link table to ensure CASCADE delete is enabled.""" + print("Checking note_link cascade delete constraints...") + + try: + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + # Check if note_link table exists + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='note_link'") + if not cursor.fetchone(): + print("note_link table does not exist, skipping cascade update") + return + + # Check current foreign key constraints + cursor.execute("PRAGMA foreign_key_list(note_link)") + fk_info = cursor.fetchall() + + # Check if CASCADE is already set + has_cascade = any('CASCADE' in str(fk) for fk in fk_info) + + if not has_cascade: + print("Updating note_link table with CASCADE delete...") + + # SQLite doesn't support ALTER TABLE for foreign keys, so recreate the table + cursor.execute(""" + CREATE TABLE note_link_temp ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source_note_id INTEGER NOT NULL, + target_note_id INTEGER NOT NULL, + link_type VARCHAR(50) DEFAULT 'related', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_by_id INTEGER NOT NULL, + FOREIGN KEY (source_note_id) REFERENCES note(id) ON DELETE CASCADE, + FOREIGN KEY (target_note_id) REFERENCES note(id) ON DELETE CASCADE, + FOREIGN KEY (created_by_id) REFERENCES user(id), + UNIQUE(source_note_id, target_note_id) + ) + """) + + # Copy data + cursor.execute("INSERT INTO note_link_temp SELECT * FROM note_link") + + # Drop old table and rename new one + cursor.execute("DROP TABLE note_link") + cursor.execute("ALTER TABLE note_link_temp RENAME TO note_link") + + # Recreate indexes + cursor.execute("CREATE INDEX idx_note_link_source ON note_link(source_note_id)") + cursor.execute("CREATE INDEX idx_note_link_target ON note_link(target_note_id)") + + print("note_link table updated with CASCADE delete") + else: + print("note_link table already has CASCADE delete") + + conn.commit() + + except Exception as e: + print(f"Error updating note_link cascade: {e}") + if conn: + conn.rollback() + finally: + if conn: + conn.close() + + def main(): """Main function with command line interface.""" parser = argparse.ArgumentParser(description='TimeTrack Database Migration Tool') diff --git a/migrations/add_cascade_delete_note_links.sql b/migrations/add_cascade_delete_note_links.sql new file mode 100644 index 0000000..697aa16 --- /dev/null +++ b/migrations/add_cascade_delete_note_links.sql @@ -0,0 +1,20 @@ +-- Migration to add CASCADE delete to note_link foreign keys +-- This ensures that when a note is deleted, all links to/from it are also deleted + +-- For PostgreSQL +-- Drop existing foreign key constraints +ALTER TABLE note_link DROP CONSTRAINT IF EXISTS note_link_source_note_id_fkey; +ALTER TABLE note_link DROP CONSTRAINT IF EXISTS note_link_target_note_id_fkey; + +-- Add new foreign key constraints with CASCADE +ALTER TABLE note_link + ADD CONSTRAINT note_link_source_note_id_fkey + FOREIGN KEY (source_note_id) + REFERENCES note(id) + ON DELETE CASCADE; + +ALTER TABLE note_link + ADD CONSTRAINT note_link_target_note_id_fkey + FOREIGN KEY (target_note_id) + REFERENCES note(id) + ON DELETE CASCADE; \ No newline at end of file diff --git a/migrations/add_cascade_delete_note_links_sqlite.sql b/migrations/add_cascade_delete_note_links_sqlite.sql new file mode 100644 index 0000000..3816bfe --- /dev/null +++ b/migrations/add_cascade_delete_note_links_sqlite.sql @@ -0,0 +1,25 @@ +-- SQLite migration for cascade delete on note_link +-- SQLite doesn't support ALTER TABLE for foreign keys, so we need to recreate the table + +-- Create new table with CASCADE delete +CREATE TABLE note_link_new ( + id INTEGER PRIMARY KEY, + source_note_id INTEGER NOT NULL, + target_note_id INTEGER NOT NULL, + link_type VARCHAR(50) DEFAULT 'related', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + created_by_id INTEGER NOT NULL, + FOREIGN KEY (source_note_id) REFERENCES note(id) ON DELETE CASCADE, + FOREIGN KEY (target_note_id) REFERENCES note(id) ON DELETE CASCADE, + FOREIGN KEY (created_by_id) REFERENCES user(id), + UNIQUE(source_note_id, target_note_id) +); + +-- Copy data from old table +INSERT INTO note_link_new SELECT * FROM note_link; + +-- Drop old table +DROP TABLE note_link; + +-- Rename new table +ALTER TABLE note_link_new RENAME TO note_link; \ No newline at end of file diff --git a/models.py b/models.py index 61c232f..65fc813 100644 --- a/models.py +++ b/models.py @@ -1368,7 +1368,12 @@ class Note(db.Model): """Get a plain text preview of the note content""" # Strip markdown formatting for preview import re - text = self.content + from frontmatter_utils import parse_frontmatter + + # Extract body content without frontmatter + _, body = parse_frontmatter(self.content) + text = body + # Remove headers text = re.sub(r'^#+\s+', '', text, flags=re.MULTILINE) # Remove emphasis @@ -1390,30 +1395,84 @@ class Note(db.Model): """Render markdown content to HTML""" try: import markdown + from frontmatter_utils import parse_frontmatter + # Extract body content without frontmatter + _, body = parse_frontmatter(self.content) # Use extensions for better markdown support - html = markdown.markdown(self.content, extensions=['extra', 'codehilite', 'toc']) + html = markdown.markdown(body, extensions=['extra', 'codehilite', 'toc']) return html except ImportError: # Fallback if markdown not installed return f'
{self.content}
' + + def get_frontmatter(self): + """Get frontmatter metadata from content""" + from frontmatter_utils import parse_frontmatter + metadata, _ = parse_frontmatter(self.content) + return metadata + + def update_frontmatter(self): + """Update content with current metadata as frontmatter""" + from frontmatter_utils import update_frontmatter + metadata = { + 'title': self.title, + 'visibility': self.visibility.value.lower(), + 'folder': self.folder, + 'tags': self.get_tags_list() if self.tags else None, + 'project': self.project.code if self.project else None, + 'task_id': self.task_id, + 'pinned': self.is_pinned if self.is_pinned else None, + 'created': self.created_at.isoformat() if self.created_at else None, + 'updated': self.updated_at.isoformat() if self.updated_at else None, + 'author': self.created_by.username if self.created_by else None + } + # Remove None values + metadata = {k: v for k, v in metadata.items() if v is not None} + self.content = update_frontmatter(self.content, metadata) + + def sync_from_frontmatter(self): + """Update model fields from frontmatter in content""" + from frontmatter_utils import parse_frontmatter + metadata, _ = parse_frontmatter(self.content) + + if metadata: + # Update fields from frontmatter + if 'title' in metadata: + self.title = metadata['title'] + if 'visibility' in metadata: + try: + self.visibility = NoteVisibility[metadata['visibility'].upper()] + except KeyError: + pass + if 'folder' in metadata: + self.folder = metadata['folder'] + if 'tags' in metadata: + if isinstance(metadata['tags'], list): + self.set_tags_list(metadata['tags']) + elif isinstance(metadata['tags'], str): + self.tags = metadata['tags'] + if 'pinned' in metadata: + self.is_pinned = bool(metadata['pinned']) class NoteLink(db.Model): """Links between notes for creating relationships""" id = db.Column(db.Integer, primary_key=True) - # Source and target notes - source_note_id = db.Column(db.Integer, db.ForeignKey('note.id'), nullable=False) - target_note_id = db.Column(db.Integer, db.ForeignKey('note.id'), nullable=False) + # Source and target notes with cascade deletion + source_note_id = db.Column(db.Integer, db.ForeignKey('note.id', ondelete='CASCADE'), nullable=False) + target_note_id = db.Column(db.Integer, db.ForeignKey('note.id', ondelete='CASCADE'), nullable=False) # Link metadata link_type = db.Column(db.String(50), default='related') # related, parent, child, etc. created_at = db.Column(db.DateTime, default=datetime.now) created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) - # Relationships - source_note = db.relationship('Note', foreign_keys=[source_note_id], backref='outgoing_links') - target_note = db.relationship('Note', foreign_keys=[target_note_id], backref='incoming_links') + # Relationships with cascade deletion + source_note = db.relationship('Note', foreign_keys=[source_note_id], + backref=db.backref('outgoing_links', cascade='all, delete-orphan')) + target_note = db.relationship('Note', foreign_keys=[target_note_id], + backref=db.backref('incoming_links', cascade='all, delete-orphan')) created_by = db.relationship('User', foreign_keys=[created_by_id]) # Unique constraint to prevent duplicate links diff --git a/requirements.txt b/requirements.txt index 7df6e54..82c0062 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,3 +15,4 @@ xlsxwriter==3.1.2 Flask-Mail==0.9.1 psycopg2-binary==2.9.9 markdown==3.4.4 +PyYAML==6.0.1 diff --git a/templates/note_editor.html b/templates/note_editor.html index b3ded1c..6b02cec 100644 --- a/templates/note_editor.html +++ b/templates/note_editor.html @@ -89,6 +89,10 @@
+ + @@ -635,6 +639,25 @@ // Global Ace Editor instance let aceEditor; +// Toggle frontmatter visibility +function toggleFrontmatter() { + if (!aceEditor) return; + + const content = aceEditor.getValue(); + + if (content.trim().startsWith('---')) { + // Frontmatter exists, just move cursor to it + aceEditor.moveCursorTo(0, 0); + aceEditor.focus(); + } else { + // Add frontmatter + const newContent = updateContentFrontmatter(content); + aceEditor.setValue(newContent, -1); + aceEditor.moveCursorTo(0, 0); + aceEditor.focus(); + } +} + // Markdown toolbar functions for Ace Editor function insertMarkdown(before, after) { if (!aceEditor) return; @@ -666,14 +689,39 @@ function insertMarkdown(before, after) { syncContentAndUpdatePreview(); } -// Extract title from first line of content +// Extract title from content (frontmatter or first line) function extractTitleFromContent(content) { + // Check for frontmatter first + if (content.trim().startsWith('---')) { + const frontmatterMatch = content.match(/^---\s*\n([\s\S]*?)\n---/); + if (frontmatterMatch) { + const frontmatterContent = frontmatterMatch[1]; + const titleMatch = frontmatterContent.match(/title:\s*(.+)/); + if (titleMatch) { + // Remove quotes if present + return titleMatch[1].replace(/^["']|["']$/g, '').trim(); + } + } + } + + // Otherwise extract from first line const lines = content.split('\n'); let firstLine = ''; - // Find the first non-empty line - for (let line of lines) { - const trimmed = line.trim(); + // Skip frontmatter if present + let skipUntil = 0; + if (lines[0].trim() === '---') { + for (let i = 1; i < lines.length; i++) { + if (lines[i].trim() === '---') { + skipUntil = i + 1; + break; + } + } + } + + // Find the first non-empty line after frontmatter + for (let i = skipUntil; i < lines.length; i++) { + const trimmed = lines[i].trim(); if (trimmed) { // Remove markdown headers if present firstLine = trimmed.replace(/^#+\s*/, ''); @@ -685,14 +733,101 @@ function extractTitleFromContent(content) { return firstLine || 'Untitled Note'; } +// Update or create frontmatter in content +function updateContentFrontmatter(content) { + const settings = { + visibility: document.getElementById('visibility').value.toLowerCase(), + folder: document.getElementById('folder').value || undefined, + tags: document.getElementById('tags').value ? + document.getElementById('tags').value.split(',').map(t => t.trim()).filter(t => t) : undefined, + project: document.getElementById('project_id').selectedOptions[0]?.text.split(' - ')[0] || undefined, + task_id: parseInt(document.getElementById('task_id').value) || undefined, + pinned: false + }; + + // Remove undefined values + Object.keys(settings).forEach(key => settings[key] === undefined && delete settings[key]); + + // Parse existing frontmatter + let body = content; + let existingFrontmatter = {}; + + if (content.trim().startsWith('---')) { + const match = content.match(/^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/); + if (match) { + try { + // Simple YAML parsing for our use case + const yamlContent = match[1]; + yamlContent.split('\n').forEach(line => { + const colonIndex = line.indexOf(':'); + if (colonIndex > 0) { + const key = line.substring(0, colonIndex).trim(); + const value = line.substring(colonIndex + 1).trim(); + existingFrontmatter[key] = value.replace(/^["']|["']$/g, ''); + } + }); + body = match[2]; + } catch (e) { + console.error('Error parsing frontmatter:', e); + } + } + } + + // Merge settings with existing frontmatter + const frontmatter = { ...existingFrontmatter, ...settings }; + + // Add title from content or form field + const titleField = document.getElementById('title').value; + if (titleField && titleField !== 'Untitled Note') { + frontmatter.title = titleField; + } else { + frontmatter.title = extractTitleFromContent(body); + } + + // Build new frontmatter + let yamlContent = '---\n'; + Object.entries(frontmatter).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== '') { + if (Array.isArray(value)) { + yamlContent += `${key}:\n`; + value.forEach(item => { + yamlContent += ` - ${item}\n`; + }); + } else if (typeof value === 'string' && (value.includes(':') || value.includes('"'))) { + yamlContent += `${key}: "${value}"\n`; + } else { + yamlContent += `${key}: ${value}\n`; + } + } + }); + yamlContent += '---\n\n'; + + return yamlContent + body; +} + // Sync Ace Editor content with hidden textarea and update preview -function syncContentAndUpdatePreview() { +let frontmatterUpdateTimer; +function syncContentAndUpdatePreview(updateFrontmatter = true) { if (!aceEditor) return; - const content = aceEditor.getValue(); + let content = aceEditor.getValue(); + + // Only update frontmatter when settings change, not on every keystroke + if (updateFrontmatter) { + clearTimeout(frontmatterUpdateTimer); + frontmatterUpdateTimer = setTimeout(() => { + const newContent = updateContentFrontmatter(aceEditor.getValue()); + if (aceEditor.getValue() !== newContent) { + const currentPosition = aceEditor.getCursorPosition(); + aceEditor.setValue(newContent, -1); + aceEditor.moveCursorToPosition(currentPosition); + } + }, 2000); // Wait 2 seconds after typing stops + } + document.getElementById('content').value = content; - // Update title from first line + // Update title from content const title = extractTitleFromContent(content); document.getElementById('title').value = title; @@ -703,9 +838,61 @@ function syncContentAndUpdatePreview() { headerTitle.textContent = title ? (isEdit ? `Edit: ${title}` : title) : (isEdit ? 'Edit Note' : 'Create Note'); } + // Sync settings from frontmatter + syncSettingsFromFrontmatter(content); + updatePreview(); } +// Sync settings UI from frontmatter +function syncSettingsFromFrontmatter(content) { + if (!content.trim().startsWith('---')) return; + + const match = content.match(/^---\s*\n([\s\S]*?)\n---/); + if (!match) return; + + const yamlContent = match[1]; + const frontmatter = {}; + + // Simple YAML parsing + yamlContent.split('\n').forEach(line => { + const colonIndex = line.indexOf(':'); + if (colonIndex > 0) { + const key = line.substring(0, colonIndex).trim(); + let value = line.substring(colonIndex + 1).trim(); + + // Handle arrays (tags) + if (key === 'tags' && !value) { + // Multi-line array, skip for now + return; + } + + value = value.replace(/^["']|["']$/g, ''); + frontmatter[key] = value; + } + }); + + // Update UI elements + if (frontmatter.visibility) { + const visibilitySelect = document.getElementById('visibility'); + const capitalizedVisibility = frontmatter.visibility.charAt(0).toUpperCase() + frontmatter.visibility.slice(1); + for (let option of visibilitySelect.options) { + if (option.value === capitalizedVisibility) { + visibilitySelect.value = capitalizedVisibility; + break; + } + } + } + + if (frontmatter.folder !== undefined) { + document.getElementById('folder').value = frontmatter.folder; + } + + if (frontmatter.tags !== undefined) { + document.getElementById('tags').value = frontmatter.tags; + } +} + // Live preview update let previewTimer; function updatePreview() { @@ -747,7 +934,7 @@ function initializeAceEditor() { // Set theme (use github theme for light mode) aceEditor.setTheme("ace/theme/github"); - // Set markdown mode + // Set markdown mode (which includes YAML frontmatter highlighting) aceEditor.session.setMode("ace/mode/markdown"); // Configure editor options @@ -770,15 +957,43 @@ function initializeAceEditor() { const initialContent = document.getElementById('content').value; aceEditor.setValue(initialContent, -1); // -1 moves cursor to start - // If editing and has content, extract title + // If editing and has content, sync from frontmatter if (initialContent) { - const title = extractTitleFromContent(initialContent); + // If editing existing note without frontmatter, add it + if (!initialContent.trim().startsWith('---')) { + const newContent = updateContentFrontmatter(initialContent); + aceEditor.setValue(newContent, -1); + } + syncSettingsFromFrontmatter(aceEditor.getValue()); + const title = extractTitleFromContent(aceEditor.getValue()); document.getElementById('title').value = title; } // Listen for changes in Ace Editor aceEditor.on('change', function() { - syncContentAndUpdatePreview(); + syncContentAndUpdatePreview(false); // Don't update frontmatter on every keystroke + }); + + // If this is a new note, add initial frontmatter + if (!initialContent || initialContent.trim() === '') { + const newContent = updateContentFrontmatter('# New Note\n\nStart writing here...'); + aceEditor.setValue(newContent, -1); + syncContentAndUpdatePreview(false); + } + + // Listen for changes in settings to update frontmatter + ['visibility', 'folder', 'tags', 'project_id', 'task_id'].forEach(id => { + const element = document.getElementById(id); + if (element) { + element.addEventListener('change', function() { + // Force immediate frontmatter update when settings change + const content = updateContentFrontmatter(aceEditor.getValue()); + const currentPosition = aceEditor.getCursorPosition(); + aceEditor.setValue(content, -1); + aceEditor.moveCursorToPosition(currentPosition); + syncContentAndUpdatePreview(false); + }); + } }); // Handle form submission - ensure content is synced diff --git a/templates/note_mindmap.html b/templates/note_mindmap.html new file mode 100644 index 0000000..7ffbc3d --- /dev/null +++ b/templates/note_mindmap.html @@ -0,0 +1,401 @@ +{% extends "layout.html" %} + +{% block content %} +
+
+

{{ note.title }} - Mind Map View

+
+ + + + Back to Note + +
+
+ +
+ +
+
+ + + + + + +{% endblock %} \ No newline at end of file diff --git a/templates/note_view.html b/templates/note_view.html index d6f2b86..2d33e0a 100644 --- a/templates/note_view.html +++ b/templates/note_view.html @@ -15,10 +15,24 @@ {% if note.updated_at > note.created_at %} ยท Updated {{ note.updated_at|format_date }} {% endif %} + {% if note.is_pinned %} + ๐Ÿ“Œ Pinned + {% endif %}
+ + + + + + + + + + Mind Map + {% if note.can_user_edit(g.user) %} Edit
Tags: - {% for tag in note.tags %} + {% for tag in note.get_tags_list() %} {{ tag }} {% endfor %} @@ -429,6 +443,15 @@ font-style: italic; } +.pin-badge { + background: #fff3cd; + color: #856404; + padding: 0.2rem 0.5rem; + border-radius: 4px; + font-size: 0.85rem; + font-weight: 500; +} + /* Modal styles */ .modal-content { max-width: 500px; diff --git a/templates/notes_list.html b/templates/notes_list.html index dc09961..5a59bbb 100644 --- a/templates/notes_list.html +++ b/templates/notes_list.html @@ -248,6 +248,16 @@ + + + + + + + + + + {% if note.can_user_edit(g.user) %} @@ -328,6 +338,16 @@ + + + + + + + + + + {% if note.can_user_edit(g.user) %} @@ -1006,7 +1026,7 @@ } .column-actions { - width: 120px; + width: 160px; } .note-actions {