Add initial version of Markdown Notes feature.
This commit is contained in:
468
app.py
468
app.py
@@ -1,5 +1,5 @@
|
||||
from flask import Flask, render_template, request, redirect, url_for, jsonify, flash, session, g, Response, send_file, abort
|
||||
from models import db, TimeEntry, WorkConfig, User, SystemSettings, Team, Role, Project, Company, CompanyWorkConfig, CompanySettings, UserPreferences, WorkRegion, AccountType, ProjectCategory, Task, SubTask, TaskStatus, TaskPriority, TaskDependency, Sprint, SprintStatus, Announcement, SystemEvent, WidgetType, UserDashboard, DashboardWidget, WidgetTemplate, Comment, CommentVisibility, BrandingSettings
|
||||
from models import db, TimeEntry, WorkConfig, User, SystemSettings, Team, Role, Project, Company, CompanyWorkConfig, CompanySettings, UserPreferences, WorkRegion, AccountType, ProjectCategory, Task, SubTask, TaskStatus, TaskPriority, TaskDependency, Sprint, SprintStatus, Announcement, SystemEvent, WidgetType, UserDashboard, DashboardWidget, WidgetTemplate, Comment, CommentVisibility, BrandingSettings, Note, NoteLink, NoteVisibility
|
||||
from data_formatting import (
|
||||
format_duration, prepare_export_data, prepare_team_hours_export_data,
|
||||
format_table_data, format_graph_data, format_team_data, format_burndown_data
|
||||
@@ -1606,6 +1606,404 @@ def contact():
|
||||
# redacted
|
||||
return render_template('contact.html', title='Contact')
|
||||
|
||||
|
||||
# Notes Management Routes
|
||||
@app.route('/notes')
|
||||
@login_required
|
||||
@company_required
|
||||
def notes_list():
|
||||
"""List all notes accessible to the user"""
|
||||
# Get filter parameters
|
||||
visibility_filter = request.args.get('visibility', 'all')
|
||||
tag_filter = request.args.get('tag')
|
||||
search_query = request.args.get('search', request.args.get('q'))
|
||||
|
||||
# Base query - all notes in user's company
|
||||
query = Note.query.filter_by(company_id=g.user.company_id, is_archived=False)
|
||||
|
||||
# Apply visibility filter
|
||||
if visibility_filter == 'private':
|
||||
query = query.filter_by(created_by_id=g.user.id, visibility=NoteVisibility.PRIVATE)
|
||||
elif visibility_filter == 'team':
|
||||
query = query.filter_by(visibility=NoteVisibility.TEAM)
|
||||
if g.user.role not in [Role.ADMIN, Role.SYSTEM_ADMIN]:
|
||||
query = query.filter_by(team_id=g.user.team_id)
|
||||
elif visibility_filter == 'company':
|
||||
query = query.filter_by(visibility=NoteVisibility.COMPANY)
|
||||
else: # 'all' - show all accessible notes
|
||||
# Complex filter for visibility
|
||||
from sqlalchemy import or_, and_
|
||||
conditions = [
|
||||
# User's own notes
|
||||
Note.created_by_id == g.user.id,
|
||||
# Company-wide notes
|
||||
Note.visibility == NoteVisibility.COMPANY,
|
||||
# Team notes if user is in the team
|
||||
and_(
|
||||
Note.visibility == NoteVisibility.TEAM,
|
||||
Note.team_id == g.user.team_id
|
||||
)
|
||||
]
|
||||
# Admins can see all team notes
|
||||
if g.user.role in [Role.ADMIN, Role.SYSTEM_ADMIN]:
|
||||
conditions.append(Note.visibility == NoteVisibility.TEAM)
|
||||
|
||||
query = query.filter(or_(*conditions))
|
||||
|
||||
# Apply tag filter
|
||||
if tag_filter:
|
||||
query = query.filter(Note.tags.like(f'%{tag_filter}%'))
|
||||
|
||||
# Apply search
|
||||
if search_query:
|
||||
query = query.filter(
|
||||
db.or_(
|
||||
Note.title.ilike(f'%{search_query}%'),
|
||||
Note.content.ilike(f'%{search_query}%'),
|
||||
Note.tags.ilike(f'%{search_query}%')
|
||||
)
|
||||
)
|
||||
|
||||
# Order by pinned first, then by updated date
|
||||
notes = query.order_by(Note.is_pinned.desc(), Note.updated_at.desc()).all()
|
||||
|
||||
# Get all unique tags for filter dropdown
|
||||
all_tags = set()
|
||||
for note in Note.query.filter_by(company_id=g.user.company_id, is_archived=False).all():
|
||||
all_tags.update(note.get_tags_list())
|
||||
|
||||
# Get projects for filter
|
||||
projects = Project.query.filter_by(company_id=g.user.company_id, is_active=True).all()
|
||||
|
||||
return render_template('notes_list.html',
|
||||
title='Notes',
|
||||
notes=notes,
|
||||
visibility_filter=visibility_filter,
|
||||
tag_filter=tag_filter,
|
||||
search_query=search_query,
|
||||
all_tags=sorted(list(all_tags)),
|
||||
projects=projects,
|
||||
NoteVisibility=NoteVisibility)
|
||||
|
||||
|
||||
@app.route('/notes/new', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@company_required
|
||||
def create_note():
|
||||
"""Create a new note"""
|
||||
if request.method == 'POST':
|
||||
title = request.form.get('title', '').strip()
|
||||
content = request.form.get('content', '').strip()
|
||||
visibility = request.form.get('visibility', 'Private')
|
||||
tags = request.form.get('tags', '').strip()
|
||||
project_id = request.form.get('project_id')
|
||||
task_id = request.form.get('task_id')
|
||||
|
||||
# Validate
|
||||
if not title:
|
||||
flash('Title is required', 'error')
|
||||
return redirect(url_for('create_note'))
|
||||
|
||||
if not content:
|
||||
flash('Content is required', 'error')
|
||||
return redirect(url_for('create_note'))
|
||||
|
||||
try:
|
||||
# Parse tags
|
||||
tag_list = []
|
||||
if tags:
|
||||
tag_list = [tag.strip() for tag in tags.split(',') if tag.strip()]
|
||||
|
||||
# Create note
|
||||
note = Note(
|
||||
title=title,
|
||||
content=content,
|
||||
visibility=NoteVisibility[visibility.upper()], # Convert to uppercase for enum access
|
||||
tags=','.join(tag_list) if tag_list else None,
|
||||
created_by_id=g.user.id,
|
||||
company_id=g.user.company_id
|
||||
)
|
||||
|
||||
# Set team_id if visibility is Team
|
||||
if visibility == 'Team' and g.user.team_id:
|
||||
note.team_id = g.user.team_id
|
||||
|
||||
# Set optional associations
|
||||
if project_id:
|
||||
project = Project.query.filter_by(id=project_id, company_id=g.user.company_id).first()
|
||||
if project:
|
||||
note.project_id = project.id
|
||||
|
||||
if task_id:
|
||||
task = Task.query.filter_by(id=task_id).first()
|
||||
if task and task.project.company_id == g.user.company_id:
|
||||
note.task_id = task.id
|
||||
|
||||
# Generate slug
|
||||
note.slug = note.generate_slug()
|
||||
|
||||
db.session.add(note)
|
||||
db.session.commit()
|
||||
|
||||
flash('Note created successfully', 'success')
|
||||
return redirect(url_for('view_note', slug=note.slug))
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger.error(f"Error creating note: {str(e)}")
|
||||
flash('Error creating note', 'error')
|
||||
return redirect(url_for('create_note'))
|
||||
|
||||
# GET request - show form
|
||||
projects = Project.query.filter_by(company_id=g.user.company_id, is_active=True).all()
|
||||
tasks = []
|
||||
|
||||
return render_template('note_editor.html',
|
||||
title='New Note',
|
||||
note=None,
|
||||
projects=projects,
|
||||
tasks=tasks,
|
||||
NoteVisibility=NoteVisibility)
|
||||
|
||||
|
||||
@app.route('/notes/<slug>')
|
||||
@login_required
|
||||
@company_required
|
||||
def view_note(slug):
|
||||
"""View a specific note"""
|
||||
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)
|
||||
|
||||
# Get linked notes
|
||||
outgoing_links = []
|
||||
incoming_links = []
|
||||
|
||||
for link in note.outgoing_links:
|
||||
if link.target_note.can_user_view(g.user):
|
||||
outgoing_links.append(link)
|
||||
|
||||
for link in note.incoming_links:
|
||||
if link.source_note.can_user_view(g.user):
|
||||
incoming_links.append(link)
|
||||
|
||||
# Get linkable notes for the modal
|
||||
linkable_notes = []
|
||||
if note.can_user_edit(g.user):
|
||||
# Get all notes the user can view
|
||||
all_notes = Note.query.filter_by(company_id=g.user.company_id, is_archived=False).all()
|
||||
for n in all_notes:
|
||||
if n.id != note.id and n.can_user_view(g.user):
|
||||
# Check if not already linked
|
||||
already_linked = any(link.target_note_id == n.id for link in note.outgoing_links)
|
||||
already_linked = already_linked or any(link.source_note_id == n.id for link in note.incoming_links)
|
||||
if not already_linked:
|
||||
linkable_notes.append(n)
|
||||
|
||||
return render_template('note_view.html',
|
||||
title=note.title,
|
||||
note=note,
|
||||
outgoing_links=outgoing_links,
|
||||
incoming_links=incoming_links,
|
||||
linkable_notes=linkable_notes,
|
||||
can_edit=note.can_user_edit(g.user))
|
||||
|
||||
|
||||
@app.route('/notes/<slug>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@company_required
|
||||
def edit_note(slug):
|
||||
"""Edit an existing note"""
|
||||
note = Note.query.filter_by(slug=slug, company_id=g.user.company_id).first_or_404()
|
||||
|
||||
# Check permissions
|
||||
if not note.can_user_edit(g.user):
|
||||
abort(403)
|
||||
|
||||
if request.method == 'POST':
|
||||
title = request.form.get('title', '').strip()
|
||||
content = request.form.get('content', '').strip()
|
||||
visibility = request.form.get('visibility', 'Private')
|
||||
tags = request.form.get('tags', '').strip()
|
||||
project_id = request.form.get('project_id')
|
||||
task_id = request.form.get('task_id')
|
||||
|
||||
# Validate
|
||||
if not title:
|
||||
flash('Title is required', 'error')
|
||||
return redirect(url_for('edit_note', slug=slug))
|
||||
|
||||
if not content:
|
||||
flash('Content is required', 'error')
|
||||
return redirect(url_for('edit_note', slug=slug))
|
||||
|
||||
try:
|
||||
# Parse tags
|
||||
tag_list = []
|
||||
if tags:
|
||||
tag_list = [tag.strip() for tag in tags.split(',') if tag.strip()]
|
||||
|
||||
# Update note
|
||||
note.title = title
|
||||
note.content = content
|
||||
note.visibility = NoteVisibility[visibility.upper()] # Convert to uppercase for enum access
|
||||
note.tags = ','.join(tag_list) if tag_list else None
|
||||
|
||||
# Update team_id if visibility is Team
|
||||
if visibility == 'Team' and g.user.team_id:
|
||||
note.team_id = g.user.team_id
|
||||
else:
|
||||
note.team_id = None
|
||||
|
||||
# Update optional associations
|
||||
if project_id:
|
||||
project = Project.query.filter_by(id=project_id, company_id=g.user.company_id).first()
|
||||
note.project_id = project.id if project else None
|
||||
else:
|
||||
note.project_id = None
|
||||
|
||||
if task_id:
|
||||
task = Task.query.filter_by(id=task_id).first()
|
||||
if task and task.project.company_id == g.user.company_id:
|
||||
note.task_id = task.id
|
||||
else:
|
||||
note.task_id = None
|
||||
else:
|
||||
note.task_id = None
|
||||
|
||||
# Regenerate slug if title changed
|
||||
new_slug = note.generate_slug()
|
||||
if new_slug != note.slug:
|
||||
note.slug = new_slug
|
||||
|
||||
db.session.commit()
|
||||
|
||||
flash('Note updated successfully', 'success')
|
||||
return redirect(url_for('view_note', slug=note.slug))
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger.error(f"Error updating note: {str(e)}")
|
||||
flash('Error updating note', 'error')
|
||||
return redirect(url_for('edit_note', slug=slug))
|
||||
|
||||
# GET request - show form
|
||||
projects = Project.query.filter_by(company_id=g.user.company_id, is_active=True).all()
|
||||
tasks = []
|
||||
if note.project_id:
|
||||
tasks = Task.query.filter_by(project_id=note.project_id).all()
|
||||
|
||||
return render_template('note_editor.html',
|
||||
title=f'Edit: {note.title}',
|
||||
note=note,
|
||||
projects=projects,
|
||||
tasks=tasks,
|
||||
NoteVisibility=NoteVisibility)
|
||||
|
||||
|
||||
@app.route('/notes/<slug>/delete', methods=['POST'])
|
||||
@login_required
|
||||
@company_required
|
||||
def delete_note(slug):
|
||||
"""Delete (archive) a note"""
|
||||
note = Note.query.filter_by(slug=slug, company_id=g.user.company_id).first_or_404()
|
||||
|
||||
# Check permissions
|
||||
if not note.can_user_edit(g.user):
|
||||
abort(403)
|
||||
|
||||
try:
|
||||
# Soft delete
|
||||
note.is_archived = True
|
||||
note.archived_at = datetime.now()
|
||||
db.session.commit()
|
||||
|
||||
flash('Note deleted successfully', 'success')
|
||||
return redirect(url_for('notes_list'))
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger.error(f"Error deleting note: {str(e)}")
|
||||
flash('Error deleting note', 'error')
|
||||
return redirect(url_for('view_note', slug=slug))
|
||||
|
||||
|
||||
@app.route('/api/notes/<int:note_id>/link', methods=['POST'])
|
||||
@login_required
|
||||
@company_required
|
||||
def link_notes(note_id):
|
||||
"""Create a link between two notes"""
|
||||
source_note = Note.query.filter_by(id=note_id, company_id=g.user.company_id).first_or_404()
|
||||
|
||||
# Check permissions
|
||||
if not source_note.can_user_edit(g.user):
|
||||
return jsonify({'success': False, 'message': 'Permission denied'}), 403
|
||||
|
||||
data = request.get_json()
|
||||
target_note_id = data.get('target_note_id')
|
||||
link_type = data.get('link_type', 'related')
|
||||
|
||||
if not target_note_id:
|
||||
return jsonify({'success': False, 'message': 'Target note ID required'}), 400
|
||||
|
||||
target_note = Note.query.filter_by(id=target_note_id, company_id=g.user.company_id).first()
|
||||
if not target_note:
|
||||
return jsonify({'success': False, 'message': 'Target note not found'}), 404
|
||||
|
||||
if not target_note.can_user_view(g.user):
|
||||
return jsonify({'success': False, 'message': 'Cannot link to this note'}), 403
|
||||
|
||||
try:
|
||||
# Check if link already exists (in either direction)
|
||||
existing_link = NoteLink.query.filter(
|
||||
db.or_(
|
||||
db.and_(
|
||||
NoteLink.source_note_id == note_id,
|
||||
NoteLink.target_note_id == target_note_id
|
||||
),
|
||||
db.and_(
|
||||
NoteLink.source_note_id == target_note_id,
|
||||
NoteLink.target_note_id == note_id
|
||||
)
|
||||
)
|
||||
).first()
|
||||
|
||||
if existing_link:
|
||||
return jsonify({'success': False, 'message': 'Link already exists between these notes'}), 400
|
||||
|
||||
# Create link
|
||||
link = NoteLink(
|
||||
source_note_id=note_id,
|
||||
target_note_id=target_note_id,
|
||||
link_type=link_type,
|
||||
created_by_id=g.user.id
|
||||
)
|
||||
|
||||
db.session.add(link)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Notes linked successfully',
|
||||
'link': {
|
||||
'id': link.id,
|
||||
'source_note_id': link.source_note_id,
|
||||
'target_note_id': link.target_note_id,
|
||||
'target_note': {
|
||||
'id': target_note.id,
|
||||
'title': target_note.title,
|
||||
'slug': target_note.slug
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger.error(f"Error linking notes: {str(e)}")
|
||||
return jsonify({'success': False, 'message': f'Error linking notes: {str(e)}'}), 500
|
||||
|
||||
# We can keep this route as a redirect to home for backward compatibility
|
||||
@app.route('/timetrack')
|
||||
@login_required
|
||||
@@ -5941,6 +6339,74 @@ def search_sprints():
|
||||
logger.error(f"Error in search_sprints: {str(e)}")
|
||||
return jsonify({'success': False, 'message': str(e)})
|
||||
|
||||
# Markdown rendering API
|
||||
@app.route('/api/render-markdown', methods=['POST'])
|
||||
@login_required
|
||||
def render_markdown():
|
||||
"""Render markdown content to HTML for preview"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
content = data.get('content', '')
|
||||
|
||||
if not content:
|
||||
return jsonify({'html': ''})
|
||||
|
||||
# Import markdown here to avoid issues if not installed
|
||||
import markdown
|
||||
html = markdown.markdown(content, extensions=['extra', 'codehilite', 'toc'])
|
||||
|
||||
return jsonify({'html': html})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error rendering markdown: {str(e)}")
|
||||
return jsonify({'html': '<p>Error rendering markdown</p>'})
|
||||
|
||||
# Note link deletion endpoint
|
||||
@app.route('/api/notes/<int:note_id>/link', methods=['DELETE'])
|
||||
@login_required
|
||||
@company_required
|
||||
def unlink_notes(note_id):
|
||||
"""Remove a link between two notes"""
|
||||
try:
|
||||
note = Note.query.filter_by(id=note_id, company_id=g.user.company_id).first()
|
||||
|
||||
if not note:
|
||||
return jsonify({'success': False, 'message': 'Note not found'})
|
||||
|
||||
if not note.can_user_edit(g.user):
|
||||
return jsonify({'success': False, 'message': 'Permission denied'})
|
||||
|
||||
data = request.get_json()
|
||||
target_note_id = data.get('target_note_id')
|
||||
|
||||
if not target_note_id:
|
||||
return jsonify({'success': False, 'message': 'Target note ID required'})
|
||||
|
||||
# Find and remove the link
|
||||
link = NoteLink.query.filter_by(
|
||||
source_note_id=note_id,
|
||||
target_note_id=target_note_id
|
||||
).first()
|
||||
|
||||
if not link:
|
||||
# Try reverse direction
|
||||
link = NoteLink.query.filter_by(
|
||||
source_note_id=target_note_id,
|
||||
target_note_id=note_id
|
||||
).first()
|
||||
|
||||
if link:
|
||||
db.session.delete(link)
|
||||
db.session.commit()
|
||||
return jsonify({'success': True, 'message': 'Link removed successfully'})
|
||||
else:
|
||||
return jsonify({'success': False, 'message': 'Link not found'})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger.error(f"Error removing note link: {str(e)}")
|
||||
return jsonify({'success': False, 'message': str(e)})
|
||||
|
||||
if __name__ == '__main__':
|
||||
port = int(os.environ.get('PORT', 5000))
|
||||
app.run(debug=True, host='0.0.0.0', port=port)
|
||||
157
migrate_db.py
157
migrate_db.py
@@ -76,6 +76,7 @@ def run_all_migrations(db_path=None):
|
||||
migrate_system_events(db_path)
|
||||
migrate_dashboard_system(db_path)
|
||||
migrate_comment_system(db_path)
|
||||
migrate_notes_system(db_path)
|
||||
|
||||
# Run PostgreSQL-specific migrations if applicable
|
||||
if FLASK_AVAILABLE:
|
||||
@@ -1272,6 +1273,74 @@ def migrate_postgresql_schema():
|
||||
"""))
|
||||
db.session.commit()
|
||||
|
||||
# Check if note table exists
|
||||
result = db.session.execute(text("""
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_name = 'note'
|
||||
"""))
|
||||
|
||||
if not result.fetchone():
|
||||
print("Creating note and note_link tables...")
|
||||
|
||||
# Create NoteVisibility enum type
|
||||
db.session.execute(text("""
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE notevisibility AS ENUM ('Private', 'Team', 'Company');
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
"""))
|
||||
|
||||
db.session.execute(text("""
|
||||
CREATE TABLE note (
|
||||
id SERIAL PRIMARY KEY,
|
||||
title VARCHAR(200) NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
slug VARCHAR(100) NOT NULL,
|
||||
visibility notevisibility NOT NULL DEFAULT 'Private',
|
||||
company_id INTEGER NOT NULL,
|
||||
created_by_id INTEGER NOT NULL,
|
||||
project_id INTEGER,
|
||||
task_id INTEGER,
|
||||
tags TEXT[],
|
||||
is_archived BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (company_id) REFERENCES company (id),
|
||||
FOREIGN KEY (created_by_id) REFERENCES "user" (id),
|
||||
FOREIGN KEY (project_id) REFERENCES project (id),
|
||||
FOREIGN KEY (task_id) REFERENCES task (id)
|
||||
)
|
||||
"""))
|
||||
|
||||
# Create note_link table
|
||||
db.session.execute(text("""
|
||||
CREATE TABLE note_link (
|
||||
source_note_id INTEGER NOT NULL,
|
||||
target_note_id INTEGER NOT NULL,
|
||||
link_type VARCHAR(50) DEFAULT 'related',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (source_note_id, target_note_id),
|
||||
FOREIGN KEY (source_note_id) REFERENCES note (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (target_note_id) REFERENCES note (id) ON DELETE CASCADE
|
||||
)
|
||||
"""))
|
||||
|
||||
# Create indexes
|
||||
db.session.execute(text("CREATE INDEX idx_note_company ON note(company_id)"))
|
||||
db.session.execute(text("CREATE INDEX idx_note_created_by ON note(created_by_id)"))
|
||||
db.session.execute(text("CREATE INDEX idx_note_project ON note(project_id)"))
|
||||
db.session.execute(text("CREATE INDEX idx_note_task ON note(task_id)"))
|
||||
db.session.execute(text("CREATE INDEX idx_note_slug ON note(company_id, slug)"))
|
||||
db.session.execute(text("CREATE INDEX idx_note_visibility ON note(visibility)"))
|
||||
db.session.execute(text("CREATE INDEX idx_note_archived ON note(archived)"))
|
||||
db.session.execute(text("CREATE INDEX idx_note_created_at ON note(created_at DESC)"))
|
||||
db.session.execute(text("CREATE INDEX idx_note_link_source ON note_link(source_note_id)"))
|
||||
db.session.execute(text("CREATE INDEX idx_note_link_target ON note_link(target_note_id)"))
|
||||
|
||||
db.session.commit()
|
||||
|
||||
print("PostgreSQL schema migration completed successfully!")
|
||||
|
||||
except Exception as e:
|
||||
@@ -1482,6 +1551,94 @@ def migrate_comment_system(db_file=None):
|
||||
conn.close()
|
||||
|
||||
|
||||
def migrate_notes_system(db_file=None):
|
||||
"""Migrate to add Notes system with markdown support."""
|
||||
db_path = get_db_path(db_file)
|
||||
|
||||
print(f"Migrating Notes system in {db_path}...")
|
||||
|
||||
if not os.path.exists(db_path):
|
||||
print(f"Database file {db_path} does not exist. Run basic migration first.")
|
||||
return False
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
# Check if note table already exists
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='note'")
|
||||
if cursor.fetchone():
|
||||
print("Note table already exists. Skipping migration.")
|
||||
return True
|
||||
|
||||
print("Creating Notes system tables...")
|
||||
|
||||
# Create note table
|
||||
cursor.execute("""
|
||||
CREATE TABLE note (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title VARCHAR(200) NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
slug VARCHAR(100) NOT NULL,
|
||||
visibility VARCHAR(20) NOT NULL DEFAULT 'Private',
|
||||
company_id INTEGER NOT NULL,
|
||||
created_by_id INTEGER NOT NULL,
|
||||
project_id INTEGER,
|
||||
task_id INTEGER,
|
||||
tags TEXT,
|
||||
archived BOOLEAN DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (company_id) REFERENCES company (id),
|
||||
FOREIGN KEY (created_by_id) REFERENCES user (id),
|
||||
FOREIGN KEY (project_id) REFERENCES project (id),
|
||||
FOREIGN KEY (task_id) REFERENCES task (id)
|
||||
)
|
||||
""")
|
||||
|
||||
# Create note_link table for linking notes
|
||||
cursor.execute("""
|
||||
CREATE TABLE note_link (
|
||||
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)
|
||||
)
|
||||
""")
|
||||
|
||||
# Create indexes for better performance
|
||||
cursor.execute("CREATE INDEX idx_note_company ON note(company_id)")
|
||||
cursor.execute("CREATE INDEX idx_note_created_by ON note(created_by_id)")
|
||||
cursor.execute("CREATE INDEX idx_note_project ON note(project_id)")
|
||||
cursor.execute("CREATE INDEX idx_note_task ON note(task_id)")
|
||||
cursor.execute("CREATE INDEX idx_note_slug ON note(company_id, slug)")
|
||||
cursor.execute("CREATE INDEX idx_note_visibility ON note(visibility)")
|
||||
cursor.execute("CREATE INDEX idx_note_archived ON note(archived)")
|
||||
cursor.execute("CREATE INDEX idx_note_created_at ON note(created_at DESC)")
|
||||
|
||||
# Create indexes for note links
|
||||
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)")
|
||||
|
||||
conn.commit()
|
||||
print("Notes system migration completed successfully!")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error during Notes system migration: {e}")
|
||||
conn.rollback()
|
||||
return False
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def main():
|
||||
"""Main function with command line interface."""
|
||||
parser = argparse.ArgumentParser(description='TimeTrack Database Migration Tool')
|
||||
|
||||
178
models.py
178
models.py
@@ -1241,4 +1241,180 @@ class WidgetTemplate(db.Model):
|
||||
user_level = role_hierarchy.get(user.role, 0)
|
||||
required_level = role_hierarchy.get(self.required_role, 0)
|
||||
|
||||
return user_level >= required_level
|
||||
return user_level >= required_level
|
||||
|
||||
|
||||
# Note Sharing Visibility
|
||||
class NoteVisibility(enum.Enum):
|
||||
PRIVATE = "Private"
|
||||
TEAM = "Team"
|
||||
COMPANY = "Company"
|
||||
|
||||
|
||||
class Note(db.Model):
|
||||
"""Markdown notes with sharing capabilities"""
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
title = db.Column(db.String(200), nullable=False)
|
||||
content = db.Column(db.Text, nullable=False) # Markdown content
|
||||
slug = db.Column(db.String(100), nullable=False) # URL-friendly identifier
|
||||
|
||||
# Visibility and sharing
|
||||
visibility = db.Column(db.Enum(NoteVisibility), nullable=False, default=NoteVisibility.PRIVATE)
|
||||
|
||||
# Metadata
|
||||
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
# Associations
|
||||
created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False)
|
||||
|
||||
# Optional associations
|
||||
team_id = db.Column(db.Integer, db.ForeignKey('team.id'), nullable=True) # For team-specific notes
|
||||
project_id = db.Column(db.Integer, db.ForeignKey('project.id'), nullable=True) # Link to project
|
||||
task_id = db.Column(db.Integer, db.ForeignKey('task.id'), nullable=True) # Link to task
|
||||
|
||||
# Tags for organization
|
||||
tags = db.Column(db.String(500)) # Comma-separated tags
|
||||
|
||||
# Pin important notes
|
||||
is_pinned = db.Column(db.Boolean, default=False)
|
||||
|
||||
# Soft delete
|
||||
is_archived = db.Column(db.Boolean, default=False)
|
||||
archived_at = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
# Relationships
|
||||
created_by = db.relationship('User', foreign_keys=[created_by_id], backref='notes')
|
||||
company = db.relationship('Company', backref='notes')
|
||||
team = db.relationship('Team', backref='notes')
|
||||
project = db.relationship('Project', backref='notes')
|
||||
task = db.relationship('Task', backref='notes')
|
||||
|
||||
# Unique constraint on slug per company
|
||||
__table_args__ = (db.UniqueConstraint('company_id', 'slug', name='uq_note_slug_per_company'),)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Note {self.title}>'
|
||||
|
||||
def generate_slug(self):
|
||||
"""Generate URL-friendly slug from title"""
|
||||
import re
|
||||
# Remove special characters and convert to lowercase
|
||||
slug = re.sub(r'[^\w\s-]', '', self.title.lower())
|
||||
# Replace spaces with hyphens
|
||||
slug = re.sub(r'[-\s]+', '-', slug)
|
||||
# Remove leading/trailing hyphens
|
||||
slug = slug.strip('-')
|
||||
|
||||
# Ensure uniqueness within company
|
||||
base_slug = slug
|
||||
counter = 1
|
||||
while Note.query.filter_by(company_id=self.company_id, slug=slug).filter(Note.id != self.id).first():
|
||||
slug = f"{base_slug}-{counter}"
|
||||
counter += 1
|
||||
|
||||
return slug
|
||||
|
||||
def can_user_view(self, user):
|
||||
"""Check if user can view this note"""
|
||||
# Creator can always view
|
||||
if user.id == self.created_by_id:
|
||||
return True
|
||||
|
||||
# Check company match
|
||||
if user.company_id != self.company_id:
|
||||
return False
|
||||
|
||||
# Check visibility
|
||||
if self.visibility == NoteVisibility.COMPANY:
|
||||
return True
|
||||
elif self.visibility == NoteVisibility.TEAM:
|
||||
# Check if user is in the same team
|
||||
if self.team_id and user.team_id == self.team_id:
|
||||
return True
|
||||
# Admins can view all team notes
|
||||
if user.role in [Role.ADMIN, Role.SYSTEM_ADMIN]:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def can_user_edit(self, user):
|
||||
"""Check if user can edit this note"""
|
||||
# Creator can always edit
|
||||
if user.id == self.created_by_id:
|
||||
return True
|
||||
|
||||
# Admins can edit company notes
|
||||
if user.role in [Role.ADMIN, Role.SYSTEM_ADMIN] and user.company_id == self.company_id:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def get_tags_list(self):
|
||||
"""Get tags as a list"""
|
||||
if not self.tags:
|
||||
return []
|
||||
return [tag.strip() for tag in self.tags.split(',') if tag.strip()]
|
||||
|
||||
def set_tags_list(self, tags_list):
|
||||
"""Set tags from a list"""
|
||||
self.tags = ','.join(tags_list) if tags_list else None
|
||||
|
||||
def get_preview(self, length=200):
|
||||
"""Get a plain text preview of the note content"""
|
||||
# Strip markdown formatting for preview
|
||||
import re
|
||||
text = self.content
|
||||
# Remove headers
|
||||
text = re.sub(r'^#+\s+', '', text, flags=re.MULTILINE)
|
||||
# Remove emphasis
|
||||
text = re.sub(r'\*{1,2}([^\*]+)\*{1,2}', r'\1', text)
|
||||
text = re.sub(r'_{1,2}([^_]+)_{1,2}', r'\1', text)
|
||||
# Remove links
|
||||
text = re.sub(r'\[([^\]]+)\]\([^\)]+\)', r'\1', text)
|
||||
# Remove code blocks
|
||||
text = re.sub(r'```[^`]*```', '', text, flags=re.DOTALL)
|
||||
text = re.sub(r'`([^`]+)`', r'\1', text)
|
||||
# Clean up whitespace
|
||||
text = ' '.join(text.split())
|
||||
|
||||
if len(text) > length:
|
||||
return text[:length] + '...'
|
||||
return text
|
||||
|
||||
def render_html(self):
|
||||
"""Render markdown content to HTML"""
|
||||
try:
|
||||
import markdown
|
||||
# Use extensions for better markdown support
|
||||
html = markdown.markdown(self.content, extensions=['extra', 'codehilite', 'toc'])
|
||||
return html
|
||||
except ImportError:
|
||||
# Fallback if markdown not installed
|
||||
return f'<pre>{self.content}</pre>'
|
||||
|
||||
|
||||
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)
|
||||
|
||||
# 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')
|
||||
created_by = db.relationship('User', foreign_keys=[created_by_id])
|
||||
|
||||
# Unique constraint to prevent duplicate links
|
||||
__table_args__ = (db.UniqueConstraint('source_note_id', 'target_note_id', name='uq_note_link'),)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<NoteLink {self.source_note_id} -> {self.target_note_id}>'
|
||||
@@ -14,3 +14,4 @@ pandas==1.5.3
|
||||
xlsxwriter==3.1.2
|
||||
Flask-Mail==0.9.1
|
||||
psycopg2-binary==2.9.9
|
||||
markdown==3.4.4
|
||||
|
||||
23
static/js/ace/ace.js
Normal file
23
static/js/ace/ace.js
Normal file
File diff suppressed because one or more lines are too long
8
static/js/ace/ext-language_tools.js
Normal file
8
static/js/ace/ext-language_tools.js
Normal file
File diff suppressed because one or more lines are too long
8
static/js/ace/mode-markdown.js
Normal file
8
static/js/ace/mode-markdown.js
Normal file
File diff suppressed because one or more lines are too long
8
static/js/ace/theme-github.js
Normal file
8
static/js/ace/theme-github.js
Normal file
@@ -0,0 +1,8 @@
|
||||
define("ace/theme/github-css",["require","exports","module"],function(e,t,n){n.exports='/* CSS style content from github\'s default pygments highlighter template.\n Cursor and selection styles from textmate.css. */\n.ace-github .ace_gutter {\n background: #e8e8e8;\n color: #AAA;\n}\n\n.ace-github {\n background: #fff;\n color: #000;\n}\n\n.ace-github .ace_keyword {\n font-weight: bold;\n}\n\n.ace-github .ace_string {\n color: #D14;\n}\n\n.ace-github .ace_variable.ace_class {\n color: teal;\n}\n\n.ace-github .ace_constant.ace_numeric {\n color: #099;\n}\n\n.ace-github .ace_constant.ace_buildin {\n color: #0086B3;\n}\n\n.ace-github .ace_support.ace_function {\n color: #0086B3;\n}\n\n.ace-github .ace_comment {\n color: #998;\n font-style: italic;\n}\n\n.ace-github .ace_variable.ace_language {\n color: #0086B3;\n}\n\n.ace-github .ace_paren {\n font-weight: bold;\n}\n\n.ace-github .ace_boolean {\n font-weight: bold;\n}\n\n.ace-github .ace_string.ace_regexp {\n color: #009926;\n font-weight: normal;\n}\n\n.ace-github .ace_variable.ace_instance {\n color: teal;\n}\n\n.ace-github .ace_constant.ace_language {\n font-weight: bold;\n}\n\n.ace-github .ace_cursor {\n color: black;\n}\n\n.ace-github.ace_focus .ace_marker-layer .ace_active-line {\n background: rgb(255, 255, 204);\n}\n.ace-github .ace_marker-layer .ace_active-line {\n background: rgb(245, 245, 245);\n}\n\n.ace-github .ace_marker-layer .ace_selection {\n background: rgb(181, 213, 255);\n}\n\n.ace-github.ace_multiselect .ace_selection.ace_start {\n box-shadow: 0 0 3px 0px white;\n}\n/* bold keywords cause cursor issues for some fonts */\n/* this disables bold style for editor and keeps for static highlighter */\n.ace-github.ace_nobold .ace_line > span {\n font-weight: normal !important;\n}\n\n.ace-github .ace_marker-layer .ace_step {\n background: rgb(252, 255, 0);\n}\n\n.ace-github .ace_marker-layer .ace_stack {\n background: rgb(164, 229, 101);\n}\n\n.ace-github .ace_marker-layer .ace_bracket {\n margin: -1px 0 0 -1px;\n border: 1px solid rgb(192, 192, 192);\n}\n\n.ace-github .ace_gutter-active-line {\n background-color : rgba(0, 0, 0, 0.07);\n}\n\n.ace-github .ace_marker-layer .ace_selected-word {\n background: rgb(250, 250, 255);\n border: 1px solid rgb(200, 200, 250);\n}\n\n.ace-github .ace_invisible {\n color: #BFBFBF\n}\n\n.ace-github .ace_print-margin {\n width: 1px;\n background: #e8e8e8;\n}\n\n.ace-github .ace_indent-guide {\n background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAACCAYAAACZgbYnAAAAE0lEQVQImWP4////f4bLly//BwAmVgd1/w11/gAAAABJRU5ErkJggg==") right repeat-y;\n}\n\n.ace-github .ace_indent-guide-active {\n background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAACCAYAAACZgbYnAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAIGNIUk0AAHolAACAgwAA+f8AAIDpAAB1MAAA6mAAADqYAAAXb5JfxUYAAAAZSURBVHjaYvj///9/hivKyv8BAAAA//8DACLqBhbvk+/eAAAAAElFTkSuQmCC") right repeat-y;\n}\n'}),define("ace/theme/github",["require","exports","module","ace/theme/github-css","ace/lib/dom"],function(e,t,n){t.isDark=!1,t.cssClass="ace-github",t.cssText=e("./github-css");var r=e("../lib/dom");r.importCssString(t.cssText,t.cssClass,!1)}); (function() {
|
||||
window.require(["ace/theme/github"], function(m) {
|
||||
if (typeof module == "object" && typeof exports == "object" && module) {
|
||||
module.exports = m;
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
8
static/js/ace/theme-monokai.js
Normal file
8
static/js/ace/theme-monokai.js
Normal file
@@ -0,0 +1,8 @@
|
||||
define("ace/theme/monokai-css",["require","exports","module"],function(e,t,n){n.exports=".ace-monokai .ace_gutter {\n background: #2F3129;\n color: #8F908A\n}\n\n.ace-monokai .ace_print-margin {\n width: 1px;\n background: #555651\n}\n\n.ace-monokai {\n background-color: #272822;\n color: #F8F8F2\n}\n\n.ace-monokai .ace_cursor {\n color: #F8F8F0\n}\n\n.ace-monokai .ace_marker-layer .ace_selection {\n background: #49483E\n}\n\n.ace-monokai.ace_multiselect .ace_selection.ace_start {\n box-shadow: 0 0 3px 0px #272822;\n}\n\n.ace-monokai .ace_marker-layer .ace_step {\n background: rgb(102, 82, 0)\n}\n\n.ace-monokai .ace_marker-layer .ace_bracket {\n margin: -1px 0 0 -1px;\n border: 1px solid #49483E\n}\n\n.ace-monokai .ace_marker-layer .ace_active-line {\n background: #202020\n}\n\n.ace-monokai .ace_gutter-active-line {\n background-color: #272727\n}\n\n.ace-monokai .ace_marker-layer .ace_selected-word {\n border: 1px solid #49483E\n}\n\n.ace-monokai .ace_invisible {\n color: #52524d\n}\n\n.ace-monokai .ace_entity.ace_name.ace_tag,\n.ace-monokai .ace_keyword,\n.ace-monokai .ace_meta.ace_tag,\n.ace-monokai .ace_storage {\n color: #F92672\n}\n\n.ace-monokai .ace_punctuation,\n.ace-monokai .ace_punctuation.ace_tag {\n color: #fff\n}\n\n.ace-monokai .ace_constant.ace_character,\n.ace-monokai .ace_constant.ace_language,\n.ace-monokai .ace_constant.ace_numeric,\n.ace-monokai .ace_constant.ace_other {\n color: #AE81FF\n}\n\n.ace-monokai .ace_invalid {\n color: #F8F8F0;\n background-color: #F92672\n}\n\n.ace-monokai .ace_invalid.ace_deprecated {\n color: #F8F8F0;\n background-color: #AE81FF\n}\n\n.ace-monokai .ace_support.ace_constant,\n.ace-monokai .ace_support.ace_function {\n color: #66D9EF\n}\n\n.ace-monokai .ace_fold {\n background-color: #A6E22E;\n border-color: #F8F8F2\n}\n\n.ace-monokai .ace_storage.ace_type,\n.ace-monokai .ace_support.ace_class,\n.ace-monokai .ace_support.ace_type {\n font-style: italic;\n color: #66D9EF\n}\n\n.ace-monokai .ace_entity.ace_name.ace_function,\n.ace-monokai .ace_entity.ace_other,\n.ace-monokai .ace_entity.ace_other.ace_attribute-name,\n.ace-monokai .ace_variable {\n color: #A6E22E\n}\n\n.ace-monokai .ace_variable.ace_parameter {\n font-style: italic;\n color: #FD971F\n}\n\n.ace-monokai .ace_string {\n color: #E6DB74\n}\n\n.ace-monokai .ace_comment {\n color: #75715E\n}\n\n.ace-monokai .ace_indent-guide {\n background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAACCAYAAACZgbYnAAAAEklEQVQImWPQ0FD0ZXBzd/wPAAjVAoxeSgNeAAAAAElFTkSuQmCC) right repeat-y\n}\n\n.ace-monokai .ace_indent-guide-active {\n background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAACCAYAAACZgbYnAAAAEklEQVQIW2PQ1dX9zzBz5sz/ABCcBFFentLlAAAAAElFTkSuQmCC) right repeat-y;\n}\n"}),define("ace/theme/monokai",["require","exports","module","ace/theme/monokai-css","ace/lib/dom"],function(e,t,n){t.isDark=!0,t.cssClass="ace-monokai",t.cssText=e("./monokai-css");var r=e("../lib/dom");r.importCssString(t.cssText,t.cssClass,!1)}); (function() {
|
||||
window.require(["ace/theme/monokai"], function(m) {
|
||||
if (typeof module == "object" && typeof exports == "object" && module) {
|
||||
module.exports = m;
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -132,6 +132,7 @@
|
||||
<li><a href="{{ url_for('dashboard') }}" data-tooltip="Dashboard"><i class="nav-icon">📊</i><span class="nav-text">Dashboard</span></a></li>
|
||||
<li><a href="{{ url_for('unified_task_management') }}" data-tooltip="Task Management"><i class="nav-icon">📋</i><span class="nav-text">Task Management</span></a></li>
|
||||
<li><a href="{{ url_for('sprint_management') }}" data-tooltip="Sprint Management"><i class="nav-icon">🏃♂️</i><span class="nav-text">Sprints</span></a></li>
|
||||
<li><a href="{{ url_for('notes_list') }}" data-tooltip="Notes"><i class="nav-icon">📝</i><span class="nav-text">Notes</span></a></li>
|
||||
<li><a href="{{ url_for('analytics') }}" data-tooltip="Time Analytics"><i class="nav-icon">📊</i><span class="nav-text">Analytics</span></a></li>
|
||||
|
||||
<!-- Role-based menu items -->
|
||||
|
||||
600
templates/note_editor.html
Normal file
600
templates/note_editor.html
Normal file
@@ -0,0 +1,600 @@
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="timetrack-container note-editor-container">
|
||||
<div class="editor-header">
|
||||
<h2>{% if note %}Edit Note{% else %}Create Note{% endif %}</h2>
|
||||
<div class="editor-actions">
|
||||
<a href="{{ url_for('notes_list') }}" class="btn btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="POST" id="note-form">
|
||||
<div class="editor-layout">
|
||||
<!-- Editor Panel -->
|
||||
<div class="editor-panel">
|
||||
<div class="form-group">
|
||||
<label for="title">Title</label>
|
||||
<input type="text" id="title" name="title" class="form-control"
|
||||
value="{{ note.title if note else '' }}" required autofocus>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="visibility">Visibility</label>
|
||||
<select id="visibility" name="visibility" class="form-control" required>
|
||||
<option value="Private" {% if not note or note.visibility.value == 'Private' %}selected{% endif %}>
|
||||
🔒 Private - Only you can see this
|
||||
</option>
|
||||
<option value="Team" {% if note and note.visibility.value == 'Team' %}selected{% endif %}>
|
||||
👥 Team - Your team members can see this
|
||||
</option>
|
||||
<option value="Company" {% if note and note.visibility.value == 'Company' %}selected{% endif %}>
|
||||
🏢 Company - Everyone in your company can see this
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="project_id">Project (Optional)</label>
|
||||
<select id="project_id" name="project_id" class="form-control">
|
||||
<option value="">No project</option>
|
||||
{% for project in projects %}
|
||||
<option value="{{ project.id }}" {% if note and note.project_id == project.id %}selected{% endif %}>
|
||||
{{ project.code }} - {{ project.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="task_id">Task (Optional)</label>
|
||||
<select id="task_id" name="task_id" class="form-control">
|
||||
<option value="">No task</option>
|
||||
{% for task in tasks %}
|
||||
<option value="{{ task.id }}" {% if note and note.task_id == task.id %}selected{% endif %}>
|
||||
#{{ task.id }} - {{ task.title }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="tags">Tags (comma-separated)</label>
|
||||
<input type="text" id="tags" name="tags" class="form-control"
|
||||
placeholder="documentation, meeting-notes, technical"
|
||||
value="{{ ', '.join(note.tags) if note and note.tags else '' }}">
|
||||
</div>
|
||||
|
||||
<div class="form-group editor-group">
|
||||
<label for="content">Content (Markdown)</label>
|
||||
<div class="editor-toolbar">
|
||||
<button type="button" class="toolbar-btn toolbar-bold" onclick="insertMarkdown('**', '**')" title="Bold">
|
||||
<b>B</b>
|
||||
</button>
|
||||
<button type="button" class="toolbar-btn toolbar-italic" onclick="insertMarkdown('*', '*')" title="Italic">
|
||||
<i>I</i>
|
||||
</button>
|
||||
<button type="button" class="toolbar-btn toolbar-code" onclick="insertMarkdown('`', '`')" title="Inline Code">
|
||||
</>
|
||||
</button>
|
||||
<button type="button" class="toolbar-btn toolbar-code-block" onclick="insertMarkdown('```\\n', '\\n```')" title="Code Block">
|
||||
[ ]
|
||||
</button>
|
||||
<span class="toolbar-separator"></span>
|
||||
<button type="button" class="toolbar-btn" onclick="insertMarkdown('# ', '')" title="Heading 1">
|
||||
H1
|
||||
</button>
|
||||
<button type="button" class="toolbar-btn" onclick="insertMarkdown('## ', '')" title="Heading 2">
|
||||
H2
|
||||
</button>
|
||||
<button type="button" class="toolbar-btn" onclick="insertMarkdown('### ', '')" title="Heading 3">
|
||||
H3
|
||||
</button>
|
||||
<span class="toolbar-separator"></span>
|
||||
<button type="button" class="toolbar-btn" onclick="insertMarkdown('- ', '')" title="Bullet List">
|
||||
≡
|
||||
</button>
|
||||
<button type="button" class="toolbar-btn" onclick="insertMarkdown('1. ', '')" title="Numbered List">
|
||||
≣
|
||||
</button>
|
||||
<button type="button" class="toolbar-btn" onclick="insertMarkdown('> ', '')" title="Quote">
|
||||
❞
|
||||
</button>
|
||||
<span class="toolbar-separator"></span>
|
||||
<button type="button" class="toolbar-btn" onclick="insertMarkdown('[', '](url)')" title="Link">
|
||||
🔗
|
||||
</button>
|
||||
<button type="button" class="toolbar-btn" onclick="insertMarkdown('')" title="Image">
|
||||
🖼
|
||||
</button>
|
||||
<button type="button" class="toolbar-btn" onclick="insertMarkdown('---\\n', '')" title="Horizontal Rule">
|
||||
—
|
||||
</button>
|
||||
</div>
|
||||
<div id="ace-editor" class="ace-editor-container">{{ note.content if note else '' }}</div>
|
||||
<textarea id="content" name="content" class="form-control markdown-editor"
|
||||
style="display: none;" required>{{ note.content if note else '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
{% if note %}Update Note{% else %}Create Note{% endif %}
|
||||
</button>
|
||||
<a href="{{ url_for('notes_list') }}" class="btn btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preview Panel -->
|
||||
<div class="preview-panel">
|
||||
<h3>Preview</h3>
|
||||
<div id="preview-content" class="markdown-content">
|
||||
<p class="preview-placeholder">Start typing to see the preview...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% if note and note.linked_notes %}
|
||||
<div class="linked-notes-section">
|
||||
<h3>Linked Notes</h3>
|
||||
<div class="linked-notes-list">
|
||||
{% for link in note.linked_notes %}
|
||||
<div class="linked-note-item">
|
||||
<a href="{{ url_for('view_note', slug=link.target_note.slug) }}">
|
||||
{{ link.target_note.title }}
|
||||
</a>
|
||||
<span class="link-type">{{ link.link_type }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Note editor specific styles */
|
||||
.note-editor-container {
|
||||
max-width: none !important;
|
||||
width: 100% !important;
|
||||
padding: 1rem !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.editor-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.editor-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 2rem;
|
||||
min-height: 600px;
|
||||
}
|
||||
|
||||
.editor-panel, .preview-panel {
|
||||
background: white;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.preview-panel {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.preview-panel h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 2px solid #e9ecef;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
outline: none;
|
||||
border-color: #4CAF50;
|
||||
box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.1);
|
||||
}
|
||||
|
||||
.editor-toolbar {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
padding: 0.5rem;
|
||||
background: #f8f9fa;
|
||||
border: 2px solid #e9ecef;
|
||||
border-bottom: none;
|
||||
border-radius: 6px 6px 0 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.toolbar-btn {
|
||||
padding: 0.5rem 0.75rem;
|
||||
min-width: 40px;
|
||||
border: 1px solid #dee2e6;
|
||||
background: white;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
transition: all 0.2s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.toolbar-btn:hover {
|
||||
background: #e9ecef;
|
||||
border-color: #adb5bd;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.toolbar-btn:active {
|
||||
background: #dee2e6;
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
/* Special styling for specific buttons */
|
||||
.toolbar-bold {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.toolbar-italic {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.toolbar-code, .toolbar-code-block {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
/* Ensure button content is visible */
|
||||
.toolbar-btn b, .toolbar-btn i {
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.toolbar-separator {
|
||||
width: 1px;
|
||||
background: #dee2e6;
|
||||
margin: 0 0.5rem;
|
||||
}
|
||||
|
||||
.markdown-editor {
|
||||
font-family: 'Monaco', 'Consolas', 'Courier New', monospace;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.5;
|
||||
border-radius: 0 0 6px 6px;
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
}
|
||||
|
||||
.ace-editor-container {
|
||||
width: 100%;
|
||||
height: 500px;
|
||||
border: 2px solid #e9ecef;
|
||||
border-radius: 0 0 6px 6px;
|
||||
border-top: none;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.markdown-content {
|
||||
padding: 1rem;
|
||||
min-height: 400px;
|
||||
max-height: 700px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.markdown-content h1, .markdown-content h2, .markdown-content h3,
|
||||
.markdown-content h4, .markdown-content h5, .markdown-content h6 {
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.markdown-content h1 { font-size: 2rem; }
|
||||
.markdown-content h2 { font-size: 1.5rem; }
|
||||
.markdown-content h3 { font-size: 1.25rem; }
|
||||
|
||||
.markdown-content p {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.markdown-content code {
|
||||
background: #e9ecef;
|
||||
padding: 0.2rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.markdown-content pre {
|
||||
background: #f8f9fa;
|
||||
padding: 1rem;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.markdown-content pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.markdown-content blockquote {
|
||||
border-left: 4px solid #dee2e6;
|
||||
padding-left: 1rem;
|
||||
margin: 1rem 0;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.markdown-content ul, .markdown-content ol {
|
||||
margin-bottom: 1rem;
|
||||
padding-left: 2rem;
|
||||
}
|
||||
|
||||
.markdown-content table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.markdown-content th, .markdown-content td {
|
||||
border: 1px solid #dee2e6;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.markdown-content th {
|
||||
background: #f8f9fa;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.preview-placeholder {
|
||||
color: #999;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.linked-notes-section {
|
||||
margin-top: 2rem;
|
||||
padding: 1.5rem;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.linked-notes-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.linked-note-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem 1rem;
|
||||
background: white;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.link-type {
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 1024px) {
|
||||
.editor-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.preview-panel {
|
||||
order: -1;
|
||||
min-height: 300px;
|
||||
max-height: 400px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Load Ace Editor -->
|
||||
<script src="{{ url_for('static', filename='js/ace/ace.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/ace/mode-markdown.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/ace/theme-github.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/ace/theme-monokai.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/ace/ext-language_tools.js') }}"></script>
|
||||
|
||||
<script>
|
||||
// Global Ace Editor instance
|
||||
let aceEditor;
|
||||
|
||||
// Markdown toolbar functions for Ace Editor
|
||||
function insertMarkdown(before, after) {
|
||||
if (!aceEditor) return;
|
||||
|
||||
const session = aceEditor.getSession();
|
||||
const selection = aceEditor.getSelection();
|
||||
const range = selection.getRange();
|
||||
const selectedText = session.getTextRange(range);
|
||||
|
||||
if (selectedText) {
|
||||
// Replace selected text
|
||||
session.replace(range, before + selectedText + after);
|
||||
|
||||
// Select the text between the markdown markers
|
||||
const newRange = selection.getRange();
|
||||
newRange.setStart(newRange.start.row, newRange.start.column + before.length);
|
||||
newRange.setEnd(newRange.start.row, newRange.start.column + selectedText.length);
|
||||
selection.setRange(newRange);
|
||||
} else {
|
||||
// Insert at cursor position
|
||||
const position = aceEditor.getCursorPosition();
|
||||
session.insert(position, before + after);
|
||||
|
||||
// Move cursor between the markers
|
||||
aceEditor.moveCursorTo(position.row, position.column + before.length);
|
||||
}
|
||||
|
||||
aceEditor.focus();
|
||||
syncContentAndUpdatePreview();
|
||||
}
|
||||
|
||||
// Sync Ace Editor content with hidden textarea and update preview
|
||||
function syncContentAndUpdatePreview() {
|
||||
if (!aceEditor) return;
|
||||
|
||||
const content = aceEditor.getValue();
|
||||
document.getElementById('content').value = content;
|
||||
updatePreview();
|
||||
}
|
||||
|
||||
// Live preview update
|
||||
let previewTimer;
|
||||
function updatePreview() {
|
||||
clearTimeout(previewTimer);
|
||||
previewTimer = setTimeout(() => {
|
||||
const content = document.getElementById('content').value;
|
||||
const preview = document.getElementById('preview-content');
|
||||
|
||||
if (content.trim() === '') {
|
||||
preview.innerHTML = '<p class="preview-placeholder">Start typing to see the preview...</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Send content to server for markdown rendering
|
||||
fetch('/api/render-markdown', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ content: content })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.html) {
|
||||
preview.innerHTML = data.html;
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error rendering markdown:', error);
|
||||
});
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// Initialize Ace Editor
|
||||
function initializeAceEditor() {
|
||||
// Create Ace Editor instance
|
||||
aceEditor = ace.edit("ace-editor");
|
||||
|
||||
// Set theme (use github theme for light mode)
|
||||
aceEditor.setTheme("ace/theme/github");
|
||||
|
||||
// Set markdown mode
|
||||
aceEditor.session.setMode("ace/mode/markdown");
|
||||
|
||||
// Configure editor options
|
||||
aceEditor.setOptions({
|
||||
fontSize: "14px",
|
||||
showPrintMargin: false,
|
||||
showGutter: true,
|
||||
highlightActiveLine: true,
|
||||
enableBasicAutocompletion: true,
|
||||
enableLiveAutocompletion: true,
|
||||
enableSnippets: true,
|
||||
tabSize: 2,
|
||||
useSoftTabs: true,
|
||||
wrap: true,
|
||||
showInvisibles: false,
|
||||
scrollPastEnd: 0.5
|
||||
});
|
||||
|
||||
// Set initial content from hidden textarea
|
||||
const initialContent = document.getElementById('content').value;
|
||||
aceEditor.setValue(initialContent, -1); // -1 moves cursor to start
|
||||
|
||||
// Listen for changes in Ace Editor
|
||||
aceEditor.on('change', function() {
|
||||
syncContentAndUpdatePreview();
|
||||
});
|
||||
|
||||
// Handle form submission - ensure content is synced
|
||||
document.getElementById('note-form').addEventListener('submit', function(e) {
|
||||
syncContentAndUpdatePreview();
|
||||
const submitBtn = this.querySelector('button[type="submit"]');
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Saving...';
|
||||
});
|
||||
|
||||
// Set focus to title field initially
|
||||
document.getElementById('title').focus();
|
||||
}
|
||||
|
||||
// Initialize when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Initialize Ace Editor
|
||||
initializeAceEditor();
|
||||
|
||||
// Initial preview
|
||||
updatePreview();
|
||||
|
||||
// Add keyboard shortcuts
|
||||
if (aceEditor) {
|
||||
aceEditor.commands.addCommand({
|
||||
name: 'bold',
|
||||
bindKey: {win: 'Ctrl-B', mac: 'Command-B'},
|
||||
exec: function() { insertMarkdown('**', '**'); }
|
||||
});
|
||||
|
||||
aceEditor.commands.addCommand({
|
||||
name: 'italic',
|
||||
bindKey: {win: 'Ctrl-I', mac: 'Command-I'},
|
||||
exec: function() { insertMarkdown('*', '*'); }
|
||||
});
|
||||
|
||||
aceEditor.commands.addCommand({
|
||||
name: 'link',
|
||||
bindKey: {win: 'Ctrl-K', mac: 'Command-K'},
|
||||
exec: function() { insertMarkdown('[', '](url)'); }
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
566
templates/note_view.html
Normal file
566
templates/note_view.html
Normal file
@@ -0,0 +1,566 @@
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="timetrack-container note-view-container">
|
||||
<div class="note-header">
|
||||
<div class="note-title-section">
|
||||
<h1>{{ note.title }}</h1>
|
||||
<div class="note-meta">
|
||||
<span class="visibility-badge visibility-{{ note.visibility.value.lower() }}">
|
||||
{% if note.visibility.value == 'Private' %}🔒{% elif note.visibility.value == 'Team' %}👥{% else %}🏢{% endif %}
|
||||
{{ note.visibility.value }}
|
||||
</span>
|
||||
<span class="author">By {{ note.created_by.username }}</span>
|
||||
<span class="date">Created {{ note.created_at|format_date }}</span>
|
||||
{% if note.updated_at > note.created_at %}
|
||||
<span class="date">· Updated {{ note.updated_at|format_date }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="note-actions">
|
||||
{% if note.can_user_edit(g.user) %}
|
||||
<a href="{{ url_for('edit_note', slug=note.slug) }}" class="btn btn-primary">Edit</a>
|
||||
<form method="POST" action="{{ url_for('delete_note', slug=note.slug) }}" style="display: inline;"
|
||||
onsubmit="return confirm('Are you sure you want to delete this note?')">
|
||||
<button type="submit" class="btn btn-danger">Delete</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('notes_list') }}" class="btn btn-secondary">Back to Notes</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="note-associations">
|
||||
{% if note.project %}
|
||||
<div class="association-item">
|
||||
<span class="association-label">Project:</span>
|
||||
<span class="association-value">
|
||||
<a href="{{ url_for('manage_project_tasks', project_id=note.project.id) }}">
|
||||
{{ note.project.code }} - {{ note.project.name }}
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if note.task %}
|
||||
<div class="association-item">
|
||||
<span class="association-label">Task:</span>
|
||||
<span class="association-value">
|
||||
<a href="{{ url_for('view_task', task_id=note.task.id) }}">
|
||||
#{{ note.task.id }} - {{ note.task.title }}
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if note.tags %}
|
||||
<div class="association-item">
|
||||
<span class="association-label">Tags:</span>
|
||||
<span class="association-value">
|
||||
{% for tag in note.tags %}
|
||||
<a href="{{ url_for('notes_list', tag=tag) }}" class="tag-badge">{{ tag }}</a>
|
||||
{% endfor %}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="note-content markdown-content">
|
||||
{{ note.render_html()|safe }}
|
||||
</div>
|
||||
|
||||
{% if note.linked_notes or note.can_user_edit(g.user) %}
|
||||
<div class="linked-notes-section">
|
||||
<div class="section-header">
|
||||
<h3>Linked Notes</h3>
|
||||
{% if note.can_user_edit(g.user) %}
|
||||
<button id="add-link-btn" class="btn btn-sm btn-success">Add Link</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if outgoing_links or incoming_links %}
|
||||
<div class="linked-notes-grid">
|
||||
{% for link in outgoing_links %}
|
||||
<div class="linked-note-card">
|
||||
<div class="linked-note-header">
|
||||
<h4><a href="{{ url_for('view_note', slug=link.target_note.slug) }}">{{ link.target_note.title }}</a></h4>
|
||||
<span class="link-type">→ {{ link.link_type }}</span>
|
||||
</div>
|
||||
<div class="linked-note-preview">
|
||||
{{ link.target_note.get_preview()|safe }}
|
||||
</div>
|
||||
{% if note.can_user_edit(g.user) %}
|
||||
<button class="remove-link-btn" data-target-id="{{ link.target_note_id }}">Remove Link</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% for link in incoming_links %}
|
||||
<div class="linked-note-card">
|
||||
<div class="linked-note-header">
|
||||
<h4><a href="{{ url_for('view_note', slug=link.source_note.slug) }}">{{ link.source_note.title }}</a></h4>
|
||||
<span class="link-type">← {{ link.link_type }}</span>
|
||||
</div>
|
||||
<div class="linked-note-preview">
|
||||
{{ link.source_note.get_preview()|safe }}
|
||||
</div>
|
||||
{% if note.can_user_edit(g.user) %}
|
||||
<button class="remove-link-btn" data-target-id="{{ link.source_note_id }}">Remove Link</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="no-links">No linked notes yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Add Link Modal -->
|
||||
{% if note.can_user_edit(g.user) %}
|
||||
<div id="link-modal" class="modal" style="display: none;">
|
||||
<div class="modal-content">
|
||||
<span class="close">×</span>
|
||||
<h3>Link to Another Note</h3>
|
||||
<form id="link-form">
|
||||
<div class="form-group">
|
||||
<label for="target-note">Select Note:</label>
|
||||
<select id="target-note" name="target_note_id" required>
|
||||
<option value="">Choose a note...</option>
|
||||
{% for other_note in linkable_notes %}
|
||||
{% if other_note.id != note.id %}
|
||||
<option value="{{ other_note.id }}">
|
||||
{{ other_note.title }}
|
||||
({{ other_note.visibility.value }})
|
||||
</option>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="link-type">Link Type:</label>
|
||||
<select id="link-type" name="link_type">
|
||||
<option value="related">Related</option>
|
||||
<option value="references">References</option>
|
||||
<option value="parent">Parent</option>
|
||||
<option value="child">Child</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="submit" class="btn btn-primary">Add Link</button>
|
||||
<button type="button" class="btn btn-secondary" id="cancel-link">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<style>
|
||||
/* Note view specific styles */
|
||||
.note-view-container {
|
||||
max-width: 900px !important;
|
||||
margin: 0 auto !important;
|
||||
padding: 2rem !important;
|
||||
}
|
||||
|
||||
.note-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1.5rem;
|
||||
border-bottom: 2px solid #e9ecef;
|
||||
}
|
||||
|
||||
.note-title-section h1 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 2.5rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.note-meta {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.9rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.visibility-badge {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.visibility-private {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.visibility-team {
|
||||
background: #d1ecf1;
|
||||
color: #0c5460;
|
||||
}
|
||||
|
||||
.visibility-company {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.note-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.note-actions form {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.note-associations {
|
||||
background: #f8f9fa;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.association-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.association-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.association-label {
|
||||
font-weight: 500;
|
||||
margin-right: 1rem;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.association-value {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tag-badge {
|
||||
background: #e9ecef;
|
||||
color: #495057;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
text-decoration: none;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.tag-badge:hover {
|
||||
background: #dee2e6;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.note-content {
|
||||
background: white;
|
||||
padding: 2rem;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 8px;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.markdown-content {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.markdown-content h1, .markdown-content h2, .markdown-content h3,
|
||||
.markdown-content h4, .markdown-content h5, .markdown-content h6 {
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.markdown-content h1:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.markdown-content p {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.markdown-content code {
|
||||
background: #e9ecef;
|
||||
padding: 0.2rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.markdown-content pre {
|
||||
background: #f8f9fa;
|
||||
padding: 1rem;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.markdown-content pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.markdown-content blockquote {
|
||||
border-left: 4px solid #dee2e6;
|
||||
padding-left: 1rem;
|
||||
margin: 1rem 0;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.markdown-content ul, .markdown-content ol {
|
||||
margin-bottom: 1rem;
|
||||
padding-left: 2rem;
|
||||
}
|
||||
|
||||
.markdown-content table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.markdown-content th, .markdown-content td {
|
||||
border: 1px solid #dee2e6;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.markdown-content th {
|
||||
background: #f8f9fa;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.linked-notes-section {
|
||||
margin-top: 3rem;
|
||||
padding: 2rem;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.section-header h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.linked-notes-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.linked-note-card {
|
||||
background: white;
|
||||
padding: 1rem;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 6px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.linked-note-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.linked-note-header h4 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.linked-note-header a {
|
||||
color: #333;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.linked-note-header a:hover {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.link-type {
|
||||
font-size: 0.8rem;
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.linked-note-preview {
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.remove-link-btn {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.linked-note-card:hover .remove-link-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.no-links {
|
||||
color: #999;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Modal styles */
|
||||
.modal-content {
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 768px) {
|
||||
.note-header {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.note-title-section h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.note-meta {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.linked-notes-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
{% if note.can_user_edit(g.user) %}
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const modal = document.getElementById('link-modal');
|
||||
const addLinkBtn = document.getElementById('add-link-btn');
|
||||
const closeBtn = modal.querySelector('.close');
|
||||
const cancelBtn = document.getElementById('cancel-link');
|
||||
const linkForm = document.getElementById('link-form');
|
||||
|
||||
// Open modal
|
||||
addLinkBtn.addEventListener('click', function() {
|
||||
modal.style.display = 'block';
|
||||
});
|
||||
|
||||
// Close modal
|
||||
closeBtn.addEventListener('click', function() {
|
||||
modal.style.display = 'none';
|
||||
});
|
||||
|
||||
cancelBtn.addEventListener('click', function() {
|
||||
modal.style.display = 'none';
|
||||
});
|
||||
|
||||
window.addEventListener('click', function(event) {
|
||||
if (event.target === modal) {
|
||||
modal.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// Submit link form
|
||||
linkForm.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const targetNoteId = document.getElementById('target-note').value;
|
||||
const linkType = document.getElementById('link-type').value;
|
||||
|
||||
fetch(`/api/notes/{{ note.id }}/link`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
target_note_id: parseInt(targetNoteId),
|
||||
link_type: linkType
|
||||
})
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
return response.json().then(data => {
|
||||
throw new Error(data.message || 'Server error');
|
||||
});
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Error: ' + data.message);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('Error: ' + error.message);
|
||||
});
|
||||
});
|
||||
|
||||
// Remove link buttons
|
||||
document.querySelectorAll('.remove-link-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
const targetId = this.getAttribute('data-target-id');
|
||||
|
||||
if (confirm('Are you sure you want to remove this link?')) {
|
||||
fetch(`/api/notes/{{ note.id }}/link`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
target_note_id: parseInt(targetId)
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Error: ' + data.message);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('An error occurred while removing the link');
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
{% endif %}
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
371
templates/notes_list.html
Normal file
371
templates/notes_list.html
Normal file
@@ -0,0 +1,371 @@
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="timetrack-container notes-list-container">
|
||||
<div class="admin-header">
|
||||
<h2>Notes</h2>
|
||||
<div class="admin-actions">
|
||||
<a href="{{ url_for('create_note') }}" class="btn btn-md btn-success">Create New Note</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Section -->
|
||||
<div class="notes-filter-section">
|
||||
<form method="GET" action="{{ url_for('notes_list') }}" class="filter-form">
|
||||
<div class="filter-row">
|
||||
<div class="filter-group">
|
||||
<label for="visibility">Visibility:</label>
|
||||
<select name="visibility" id="visibility" onchange="this.form.submit()">
|
||||
<option value="">All Notes</option>
|
||||
<option value="private" {% if request.args.get('visibility') == 'private' %}selected{% endif %}>Private</option>
|
||||
<option value="team" {% if request.args.get('visibility') == 'team' %}selected{% endif %}>Team</option>
|
||||
<option value="company" {% if request.args.get('visibility') == 'company' %}selected{% endif %}>Company</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label for="tag">Tag:</label>
|
||||
<select name="tag" id="tag" onchange="this.form.submit()">
|
||||
<option value="">All Tags</option>
|
||||
{% for tag in all_tags %}
|
||||
<option value="{{ tag }}" {% if request.args.get('tag') == tag %}selected{% endif %}>{{ tag }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label for="project_id">Project:</label>
|
||||
<select name="project_id" id="project_id" onchange="this.form.submit()">
|
||||
<option value="">All Projects</option>
|
||||
{% for project in projects %}
|
||||
<option value="{{ project.id }}" {% if request.args.get('project_id')|int == project.id %}selected{% endif %}>
|
||||
{{ project.code }} - {{ project.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group search-group">
|
||||
<label for="search">Search:</label>
|
||||
<input type="text" name="search" id="search" placeholder="Search notes..."
|
||||
value="{{ request.args.get('search', '') }}" class="search-input">
|
||||
<button type="submit" class="btn btn-sm btn-primary">Search</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if notes %}
|
||||
<div class="notes-grid">
|
||||
{% for note in notes %}
|
||||
<div class="note-card">
|
||||
<div class="note-header">
|
||||
<h3 class="note-title">
|
||||
<a href="{{ url_for('view_note', slug=note.slug) }}">{{ note.title }}</a>
|
||||
</h3>
|
||||
<div class="note-meta">
|
||||
<span class="visibility-badge visibility-{{ note.visibility.value.lower() }}">
|
||||
{% if note.visibility.value == 'Private' %}🔒{% elif note.visibility.value == 'Team' %}👥{% else %}🏢{% endif %}
|
||||
{{ note.visibility.value }}
|
||||
</span>
|
||||
{% if note.updated_at > note.created_at %}
|
||||
<span class="note-date">Updated {{ note.updated_at|format_date }}</span>
|
||||
{% else %}
|
||||
<span class="note-date">Created {{ note.created_at|format_date }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="note-preview">
|
||||
{{ note.get_preview()|safe }}
|
||||
</div>
|
||||
|
||||
<div class="note-footer">
|
||||
<div class="note-tags">
|
||||
{% if note.tags %}
|
||||
{% for tag in note.tags %}
|
||||
<a href="{{ url_for('notes_list', tag=tag) }}" class="tag-badge">{{ tag }}</a>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="note-associations">
|
||||
{% if note.project %}
|
||||
<span class="association-badge project">
|
||||
📁 {{ note.project.code }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if note.task %}
|
||||
<span class="association-badge task">
|
||||
✓ Task #{{ note.task.id }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if note.outgoing_links or note.incoming_links %}
|
||||
<span class="association-badge links">
|
||||
🔗 {{ (note.outgoing_links|length + note.incoming_links|length) }} link{% if (note.outgoing_links|length + note.incoming_links|length) != 1 %}s{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="note-actions">
|
||||
<a href="{{ url_for('view_note', slug=note.slug) }}" class="btn btn-sm btn-primary">View</a>
|
||||
{% if note.can_user_edit(g.user) %}
|
||||
<a href="{{ url_for('edit_note', slug=note.slug) }}" class="btn btn-sm btn-info">Edit</a>
|
||||
<form method="POST" action="{{ url_for('delete_note', slug=note.slug) }}" style="display: inline;"
|
||||
onsubmit="return confirm('Are you sure you want to delete this note?')">
|
||||
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
|
||||
{% else %}
|
||||
<div class="no-data">
|
||||
<p>No notes found. <a href="{{ url_for('create_note') }}">Create your first note</a>.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Notes list specific styles */
|
||||
.notes-list-container {
|
||||
max-width: none !important;
|
||||
width: 100% !important;
|
||||
padding: 1rem !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.notes-filter-section {
|
||||
background: #f8f9fa;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.filter-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.filter-group select,
|
||||
.filter-group input {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 2px solid #e9ecef;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.search-group {
|
||||
flex: 2;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.notes-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.note-card {
|
||||
background: white;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
transition: box-shadow 0.2s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.note-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.note-header {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.note-title {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.note-title a {
|
||||
color: #333;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.note-title a:hover {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.note-meta {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.visibility-badge {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.visibility-private {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.visibility-team {
|
||||
background: #d1ecf1;
|
||||
color: #0c5460;
|
||||
}
|
||||
|
||||
.visibility-company {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.note-preview {
|
||||
flex: 1;
|
||||
margin-bottom: 1rem;
|
||||
color: #555;
|
||||
line-height: 1.6;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.note-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.note-tags {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tag-badge {
|
||||
background: #e9ecef;
|
||||
color: #495057;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
text-decoration: none;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.tag-badge:hover {
|
||||
background: #dee2e6;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.note-associations {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.association-badge {
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.association-badge.project {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.association-badge.task {
|
||||
background: #cce5ff;
|
||||
color: #004085;
|
||||
}
|
||||
|
||||
.association-badge.links {
|
||||
background: #f5c6cb;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.note-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.note-actions form {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.page-info {
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 768px) {
|
||||
.filter-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.search-group {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.notes-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user