Add Note Sharing feature.

This commit is contained in:
2025-07-08 12:40:50 +02:00
committed by Jens Luedicke
parent d2ca2905fa
commit 66d65a6ed2
11 changed files with 1505 additions and 10 deletions

4
app.py
View File

@@ -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, CompanyInvitation
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, CompanyInvitation, Note, NoteFolder, NoteShare
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
@@ -23,6 +23,7 @@ from werkzeug.security import check_password_hash
from routes.notes import notes_bp
from routes.notes_download import notes_download_bp
from routes.notes_api import notes_api_bp
from routes.notes_public import notes_public_bp
from routes.tasks import tasks_bp, get_filtered_tasks_for_burndown
from routes.tasks_api import tasks_api_bp
from routes.sprints import sprints_bp
@@ -87,6 +88,7 @@ db.init_app(app)
app.register_blueprint(notes_bp)
app.register_blueprint(notes_download_bp)
app.register_blueprint(notes_api_bp)
app.register_blueprint(notes_public_bp)
app.register_blueprint(tasks_bp)
app.register_blueprint(tasks_api_bp)
app.register_blueprint(sprints_bp)

View File

@@ -0,0 +1,21 @@
-- Add note_share table for public note sharing functionality
CREATE TABLE IF NOT EXISTS note_share (
id SERIAL PRIMARY KEY,
note_id INTEGER NOT NULL REFERENCES note(id) ON DELETE CASCADE,
token VARCHAR(64) UNIQUE NOT NULL,
expires_at TIMESTAMP,
password_hash VARCHAR(255),
view_count INTEGER DEFAULT 0,
max_views INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by_id INTEGER NOT NULL REFERENCES "user"(id),
last_accessed_at TIMESTAMP
);
-- Create indexes for better performance
CREATE INDEX IF NOT EXISTS idx_note_share_token ON note_share(token);
CREATE INDEX IF NOT EXISTS idx_note_share_note_id ON note_share(note_id);
CREATE INDEX IF NOT EXISTS idx_note_share_created_by ON note_share(created_by_id);
-- Add comment
COMMENT ON TABLE note_share IS 'Public sharing links for notes with optional password protection and view limits';

View File

@@ -17,6 +17,7 @@ MIGRATION_STATE_FILE = '/data/postgres_migrations_state.json'
# List of PostgreSQL migrations in order
POSTGRES_MIGRATIONS = [
'postgres_only_migration.py', # Main migration from commit 4214e88 onward
'add_note_sharing.sql', # Add note sharing functionality
]
@@ -49,7 +50,22 @@ def run_migration(migration_file):
print(f"\n🔄 Running migration: {migration_file}")
try:
# Run the migration script
# Check if it's a SQL file
if migration_file.endswith('.sql'):
# Run SQL file using psql
import os
db_host = os.environ.get('POSTGRES_HOST', 'db')
db_name = os.environ.get('POSTGRES_DB', 'timetrack')
db_user = os.environ.get('POSTGRES_USER', 'timetrack')
result = subprocess.run(
['psql', '-h', db_host, '-U', db_user, '-d', db_name, '-f', script_path],
capture_output=True,
text=True,
env={**os.environ, 'PGPASSWORD': os.environ.get('POSTGRES_PASSWORD', 'timetrack')}
)
else:
# Run Python migration script
result = subprocess.run(
[sys.executable, script_path],
capture_output=True,

View File

@@ -27,6 +27,7 @@ from .dashboard import DashboardWidget, WidgetTemplate
from .work_config import WorkConfig
from .invitation import CompanyInvitation
from .note import Note, NoteVisibility, NoteLink, NoteFolder
from .note_share import NoteShare
# Make all models available at package level
__all__ = [
@@ -47,5 +48,5 @@ __all__ = [
'DashboardWidget', 'WidgetTemplate',
'WorkConfig',
'CompanyInvitation',
'Note', 'NoteVisibility', 'NoteLink', 'NoteFolder'
'Note', 'NoteVisibility', 'NoteLink', 'NoteFolder', 'NoteShare'
]

View File

@@ -5,7 +5,7 @@ Migrated from models_old.py to maintain consistency with the new modular structu
import enum
import re
from datetime import datetime
from datetime import datetime, timedelta
from sqlalchemy import UniqueConstraint
@@ -224,6 +224,44 @@ class Note(db.Model):
if 'pinned' in metadata:
self.is_pinned = bool(metadata['pinned'])
def create_share_link(self, expires_in_days=None, password=None, max_views=None, created_by=None):
"""Create a public share link for this note"""
from .note_share import NoteShare
from flask import g
share = NoteShare(
note_id=self.id,
created_by_id=created_by.id if created_by else g.user.id
)
# Set expiration
if expires_in_days:
share.expires_at = datetime.now() + timedelta(days=expires_in_days)
# Set password
if password:
share.set_password(password)
# Set view limit
if max_views:
share.max_views = max_views
db.session.add(share)
return share
def get_active_shares(self):
"""Get all active share links for this note"""
return [s for s in self.shares if s.is_valid()]
def get_all_shares(self):
"""Get all share links for this note"""
from models.note_share import NoteShare
return self.shares.order_by(NoteShare.created_at.desc()).all()
def has_active_shares(self):
"""Check if this note has any active share links"""
return any(s.is_valid() for s in self.shares)
class NoteLink(db.Model):
"""Links between notes for creating relationships"""

107
models/note_share.py Normal file
View File

@@ -0,0 +1,107 @@
from datetime import datetime, timedelta
from . import db
import secrets
import string
from werkzeug.security import generate_password_hash, check_password_hash
class NoteShare(db.Model):
"""Public sharing links for notes"""
__tablename__ = 'note_share'
id = db.Column(db.Integer, primary_key=True)
note_id = db.Column(db.Integer, db.ForeignKey('note.id', ondelete='CASCADE'), nullable=False)
token = db.Column(db.String(64), unique=True, nullable=False, index=True)
# Share settings
expires_at = db.Column(db.DateTime, nullable=True) # None means no expiry
password_hash = db.Column(db.String(255), nullable=True) # Optional password protection
view_count = db.Column(db.Integer, default=0)
max_views = db.Column(db.Integer, nullable=True) # Limit number of views
# Metadata
created_at = db.Column(db.DateTime, default=datetime.now)
created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
last_accessed_at = db.Column(db.DateTime, nullable=True)
# Relationships
note = db.relationship('Note', backref=db.backref('shares', cascade='all, delete-orphan', lazy='dynamic'))
created_by = db.relationship('User', foreign_keys=[created_by_id])
def __init__(self, **kwargs):
super(NoteShare, self).__init__(**kwargs)
if not self.token:
self.token = self.generate_token()
@staticmethod
def generate_token():
"""Generate a secure random token"""
alphabet = string.ascii_letters + string.digits
return ''.join(secrets.choice(alphabet) for _ in range(32))
def set_password(self, password):
"""Set password protection for the share"""
if password:
self.password_hash = generate_password_hash(password)
else:
self.password_hash = None
def check_password(self, password):
"""Check if the provided password is correct"""
if not self.password_hash:
return True
return check_password_hash(self.password_hash, password)
def is_valid(self):
"""Check if share link is still valid"""
# Check expiration
if self.expires_at and datetime.now() > self.expires_at:
return False
# Check view count
if self.max_views and self.view_count >= self.max_views:
return False
return True
def is_expired(self):
"""Check if the share has expired"""
if self.expires_at and datetime.now() > self.expires_at:
return True
return False
def is_view_limit_reached(self):
"""Check if view limit has been reached"""
if self.max_views and self.view_count >= self.max_views:
return True
return False
def record_access(self):
"""Record that the share was accessed"""
self.view_count += 1
self.last_accessed_at = datetime.now()
def get_share_url(self, _external=True):
"""Get the full URL for this share"""
from flask import url_for
return url_for('notes_public.view_shared_note',
token=self.token,
_external=_external)
def to_dict(self):
"""Convert share to dictionary for API responses"""
return {
'id': self.id,
'token': self.token,
'url': self.get_share_url(),
'expires_at': self.expires_at.isoformat() if self.expires_at else None,
'has_password': bool(self.password_hash),
'max_views': self.max_views,
'view_count': self.view_count,
'created_at': self.created_at.isoformat(),
'created_by': self.created_by.username,
'last_accessed_at': self.last_accessed_at.isoformat() if self.last_accessed_at else None,
'is_valid': self.is_valid(),
'is_expired': self.is_expired(),
'is_view_limit_reached': self.is_view_limit_reached()
}

View File

@@ -420,3 +420,141 @@ def unlink_notes(note_id):
'success': True,
'message': 'Link removed successfully'
})
@notes_api_bp.route('/<slug>/shares', methods=['POST'])
@login_required
@company_required
def create_note_share(slug):
"""Create a share link for a note"""
note = Note.query.filter_by(slug=slug, company_id=g.user.company_id).first()
if not note:
return jsonify({'success': False, 'error': 'Note not found'}), 404
# Check permissions - only editors can create shares
if not note.can_user_edit(g.user):
return jsonify({'success': False, 'error': 'Permission denied'}), 403
data = request.get_json()
try:
share = note.create_share_link(
expires_in_days=data.get('expires_in_days'),
password=data.get('password'),
max_views=data.get('max_views')
)
db.session.commit()
return jsonify({
'success': True,
'share': share.to_dict()
})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'error': str(e)}), 500
@notes_api_bp.route('/<slug>/shares', methods=['GET'])
@login_required
@company_required
def list_note_shares(slug):
"""List all share links for a note"""
note = Note.query.filter_by(slug=slug, company_id=g.user.company_id).first()
if not note:
return jsonify({'success': False, 'error': 'Note not found'}), 404
# Check permissions
if not note.can_user_view(g.user):
return jsonify({'success': False, 'error': 'Permission denied'}), 403
# Get all shares (not just active ones)
shares = note.get_all_shares()
return jsonify({
'success': True,
'shares': [s.to_dict() for s in shares]
})
@notes_api_bp.route('/shares/<int:share_id>', methods=['DELETE'])
@login_required
@company_required
def delete_note_share(share_id):
"""Delete a share link"""
from models import NoteShare
share = NoteShare.query.get(share_id)
if not share:
return jsonify({'success': False, 'error': 'Share not found'}), 404
# Check permissions
if share.note.company_id != g.user.company_id:
return jsonify({'success': False, 'error': 'Permission denied'}), 403
if not share.note.can_user_edit(g.user):
return jsonify({'success': False, 'error': 'Permission denied'}), 403
try:
db.session.delete(share)
db.session.commit()
return jsonify({
'success': True,
'message': 'Share link deleted successfully'
})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'error': str(e)}), 500
@notes_api_bp.route('/shares/<int:share_id>', methods=['PUT'])
@login_required
@company_required
def update_note_share(share_id):
"""Update a share link settings"""
from models import NoteShare
share = NoteShare.query.get(share_id)
if not share:
return jsonify({'success': False, 'error': 'Share not found'}), 404
# Check permissions
if share.note.company_id != g.user.company_id:
return jsonify({'success': False, 'error': 'Permission denied'}), 403
if not share.note.can_user_edit(g.user):
return jsonify({'success': False, 'error': 'Permission denied'}), 403
data = request.get_json()
try:
# Update expiration
if 'expires_in_days' in data:
if data['expires_in_days'] is None:
share.expires_at = None
else:
from datetime import datetime, timedelta
share.expires_at = datetime.now() + timedelta(days=data['expires_in_days'])
# Update password
if 'password' in data:
share.set_password(data['password'])
# Update view limit
if 'max_views' in data:
share.max_views = data['max_views']
db.session.commit()
return jsonify({
'success': True,
'share': share.to_dict()
})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'error': str(e)}), 500

191
routes/notes_public.py Normal file
View File

@@ -0,0 +1,191 @@
"""
Public routes for viewing shared notes without authentication
"""
from flask import Blueprint, render_template, abort, request, session, jsonify
from werkzeug.security import check_password_hash
from models import NoteShare, db
notes_public_bp = Blueprint('notes_public', __name__, url_prefix='/public/notes')
@notes_public_bp.route('/<token>')
def view_shared_note(token):
"""View a publicly shared note"""
# Find the share
share = NoteShare.query.filter_by(token=token).first()
if not share:
abort(404, "Share link not found")
# Check if share is valid
if not share.is_valid():
if share.is_expired():
abort(410, "This share link has expired")
elif share.is_view_limit_reached():
abort(410, "This share link has reached its view limit")
else:
abort(404, "This share link is no longer valid")
# Check password if required
if share.password_hash:
# Check if password was already verified in session
verified_shares = session.get('verified_shares', [])
if share.id not in verified_shares:
# For GET request, show password form
if request.method == 'GET':
return render_template('notes/share_password.html',
token=token,
note_title=share.note.title)
# Record access
share.record_access()
db.session.commit()
# Render the note (read-only view)
return render_template('notes/public_view.html',
note=share.note,
share=share)
@notes_public_bp.route('/<token>/verify', methods=['POST'])
def verify_share_password(token):
"""Verify password for a protected share"""
share = NoteShare.query.filter_by(token=token).first()
if not share:
abort(404, "Share link not found")
if not share.is_valid():
abort(410, "This share link is no longer valid")
password = request.form.get('password', '')
if share.check_password(password):
# Store verification in session
verified_shares = session.get('verified_shares', [])
if share.id not in verified_shares:
verified_shares.append(share.id)
session['verified_shares'] = verified_shares
# Redirect to the note view
return jsonify({'success': True, 'redirect': f'/public/notes/{token}'})
else:
return jsonify({'success': False, 'error': 'Invalid password'}), 401
@notes_public_bp.route('/<token>/download/<format>')
def download_shared_note(token, format):
"""Download a shared note in various formats"""
share = NoteShare.query.filter_by(token=token).first()
if not share:
abort(404, "Share link not found")
if not share.is_valid():
abort(410, "This share link is no longer valid")
# Check password protection
if share.password_hash:
verified_shares = session.get('verified_shares', [])
if share.id not in verified_shares:
abort(403, "Password verification required")
# Record access
share.record_access()
db.session.commit()
# Generate download based on format
from flask import Response, send_file
import markdown
import tempfile
import os
from datetime import datetime
note = share.note
if format == 'md':
# Markdown download
response = Response(note.content, mimetype='text/markdown')
response.headers['Content-Disposition'] = f'attachment; filename="{note.slug}.md"'
return response
elif format == 'html':
# HTML download
html_content = f"""<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>{note.title}</title>
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, sans-serif; line-height: 1.6; max-width: 800px; margin: 0 auto; padding: 2rem; }}
h1, h2, h3, h4, h5, h6 {{ margin-top: 2rem; margin-bottom: 1rem; }}
code {{ background: #f4f4f4; padding: 0.2rem 0.4rem; border-radius: 3px; }}
pre {{ background: #f4f4f4; padding: 1rem; border-radius: 5px; overflow-x: auto; }}
blockquote {{ border-left: 4px solid #ddd; margin-left: 0; padding-left: 1rem; color: #666; }}
</style>
</head>
<body>
<h1>{note.title}</h1>
<p><em>Created: {note.created_at.strftime('%B %d, %Y')}</em></p>
{note.render_html()}
</body>
</html>"""
response = Response(html_content, mimetype='text/html')
response.headers['Content-Disposition'] = f'attachment; filename="{note.slug}.html"'
return response
elif format == 'pdf':
# PDF download using weasyprint
try:
import weasyprint
# Generate HTML first
html_content = f"""<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>{note.title}</title>
<style>
@page {{ size: A4; margin: 2cm; }}
body {{ font-family: -apple-system, BlinkMacSystemFont, sans-serif; line-height: 1.6; }}
h1, h2, h3, h4, h5, h6 {{ margin-top: 1.5rem; margin-bottom: 0.75rem; page-break-after: avoid; }}
code {{ background: #f4f4f4; padding: 0.2rem 0.4rem; border-radius: 3px; font-size: 0.9em; }}
pre {{ background: #f4f4f4; padding: 1rem; border-radius: 5px; overflow-x: auto; page-break-inside: avoid; }}
blockquote {{ border-left: 4px solid #ddd; margin-left: 0; padding-left: 1rem; color: #666; }}
table {{ border-collapse: collapse; width: 100%; margin: 1rem 0; }}
th, td {{ border: 1px solid #ddd; padding: 0.5rem; text-align: left; }}
th {{ background: #f4f4f4; font-weight: bold; }}
img {{ max-width: 100%; height: auto; }}
</style>
</head>
<body>
<h1>{note.title}</h1>
<p><em>Created: {note.created_at.strftime('%B %d, %Y')}</em></p>
{note.render_html()}
</body>
</html>"""
# Create temporary file for PDF
temp_file = tempfile.NamedTemporaryFile(mode='wb', suffix='.pdf', delete=False)
# Generate PDF
weasyprint.HTML(string=html_content).write_pdf(temp_file.name)
temp_file.close()
# Send file
response = send_file(
temp_file.name,
mimetype='application/pdf',
as_attachment=True,
download_name=f'{note.slug}.pdf'
)
# Clean up temp file after sending
os.unlink(temp_file.name)
return response
except ImportError:
# If weasyprint is not installed, return error
abort(500, "PDF generation not available")
else:
abort(400, "Invalid format")

View File

@@ -63,6 +63,10 @@
Mind Map
</a>
{% if note.can_user_edit(g.user) %}
<button type="button" class="btn btn-secondary" onclick="showShareModal()">
<span class="icon">🔗</span>
Share
</button>
<a href="{{ url_for('notes.edit_note', slug=note.slug) }}" class="btn btn-primary">
<span class="icon">✏️</span>
Edit
@@ -962,4 +966,504 @@ window.onclick = function(event) {
{% endif %}
</script>
<!-- Share Modal -->
<div id="share-modal" class="modal" style="display: none;">
<div class="modal-overlay" onclick="hideShareModal()"></div>
<div class="modal-content modal-large">
<div class="modal-header">
<h3 class="modal-title">Share Note</h3>
<button type="button" class="modal-close" onclick="hideShareModal()">&times;</button>
</div>
<div class="modal-body">
<!-- Create New Share Form -->
<div class="share-create-section">
<h4>Create New Share Link</h4>
<form id="create-share-form" class="modern-form">
<div class="form-row">
<div class="form-group">
<label for="expires_in_days" class="form-label">Expiration</label>
<select id="expires_in_days" name="expires_in_days" class="form-control">
<option value="">Never expires</option>
<option value="1">1 day</option>
<option value="7" selected>7 days</option>
<option value="30">30 days</option>
<option value="90">90 days</option>
</select>
</div>
<div class="form-group">
<label for="max_views" class="form-label">View Limit</label>
<input type="number"
id="max_views"
name="max_views"
class="form-control"
min="1"
placeholder="Unlimited">
</div>
</div>
<div class="form-group">
<label for="password" class="form-label">Password Protection (Optional)</label>
<input type="password"
id="password"
name="password"
class="form-control"
placeholder="Leave empty for no password">
</div>
<button type="submit" class="btn btn-primary">
<span class="icon"></span>
Create Share Link
</button>
</form>
</div>
<!-- Existing Shares List -->
<div class="shares-list-section">
<h4>Active Share Links</h4>
<div id="shares-list" class="shares-list">
<div class="loading">Loading shares...</div>
</div>
</div>
</div>
</div>
</div>
<style>
/* Share Modal Styles */
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1000;
align-items: center;
justify-content: center;
}
.modal-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
}
.modal-content {
position: relative;
background: white;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
max-height: 90vh;
overflow-y: auto;
margin: 2rem;
}
.modal-large {
max-width: 800px;
width: 100%;
}
.modal-header {
padding: 1.5rem;
border-bottom: 1px solid #e5e7eb;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-title {
font-size: 1.5rem;
font-weight: 600;
margin: 0;
color: #1f2937;
}
.modal-close {
background: none;
border: none;
font-size: 1.5rem;
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: #6b7280;
border-radius: 6px;
transition: all 0.2s;
}
.modal-close:hover {
background: #f3f4f6;
color: #1f2937;
}
.modal-body {
padding: 1.5rem;
}
.share-create-section {
background: #f8f9fa;
border-radius: 12px;
padding: 1.5rem;
margin-bottom: 2rem;
}
.shares-list-section h4 {
margin-bottom: 1rem;
}
.shares-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.share-item {
background: white;
border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 1.5rem;
display: flex;
justify-content: space-between;
align-items: flex-start;
transition: all 0.2s;
gap: 1rem;
}
.share-item:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.share-item.expired {
opacity: 0.6;
background: #f8f9fa;
}
.share-info {
flex: 1;
min-width: 0;
}
.share-url {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.share-url input {
flex: 1;
padding: 0.5rem;
border: 1px solid #e5e7eb;
border-radius: 6px;
font-family: monospace;
font-size: 0.875rem;
min-width: 0;
}
.share-meta {
display: flex;
flex-wrap: wrap;
gap: 1rem;
font-size: 0.875rem;
color: #6b7280;
}
.share-meta-item {
display: flex;
align-items: center;
gap: 0.25rem;
}
.share-actions {
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
.btn-sm {
padding: 0.5rem 1rem;
font-size: 0.875rem;
border-radius: 6px;
white-space: nowrap;
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.btn-sm.btn-secondary {
background: white;
color: #667eea;
border: 2px solid #e5e7eb;
}
.btn-sm.btn-secondary:hover {
border-color: #667eea;
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.15);
}
.btn-sm.btn-danger {
background: white;
color: #dc2626;
border: 2px solid #fee2e2;
}
.btn-sm.btn-danger:hover {
background: #dc2626;
color: white;
border-color: #dc2626;
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(220, 38, 38, 0.2);
}
.copy-feedback {
position: fixed;
bottom: 2rem;
left: 50%;
transform: translateX(-50%);
background: #10b981;
color: white;
padding: 0.75rem 1.5rem;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
opacity: 0;
transition: opacity 0.3s;
}
.copy-feedback.show {
opacity: 1;
}
.empty-state {
text-align: center;
padding: 2rem;
color: #6b7280;
}
/* Form styles in modal */
.form-row {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
}
.form-row .form-group {
flex: 1;
}
@media (max-width: 640px) {
.form-row {
flex-direction: column;
}
.share-item {
flex-direction: column;
align-items: stretch;
}
.share-actions {
margin-top: 1rem;
justify-content: flex-end;
}
}
</style>
<script>
let shareModal = null;
function showShareModal() {
shareModal = document.getElementById('share-modal');
shareModal.style.display = 'flex';
loadShares();
}
function hideShareModal() {
if (shareModal) {
shareModal.style.display = 'none';
}
}
async function loadShares() {
const sharesList = document.getElementById('shares-list');
sharesList.innerHTML = '<div class="loading">Loading shares...</div>';
try {
const response = await fetch(`/api/notes/{{ note.slug }}/shares`);
const data = await response.json();
if (data.success) {
if (data.shares.length === 0) {
sharesList.innerHTML = '<div class="empty-state">No share links created yet.</div>';
} else {
sharesList.innerHTML = data.shares.map(share => createShareItem(share)).join('');
}
} else {
sharesList.innerHTML = '<div class="error">Failed to load shares.</div>';
}
} catch (error) {
sharesList.innerHTML = '<div class="error">Failed to load shares.</div>';
}
}
function createShareItem(share) {
const isExpired = share.is_expired;
const isLimitReached = share.is_view_limit_reached;
const isInvalid = !share.is_valid;
return `
<div class="share-item ${isInvalid ? 'expired' : ''}">
<div class="share-info">
<div class="share-url">
<input type="text"
value="${share.url}"
readonly
id="share-url-${share.id}"
${isInvalid ? 'disabled' : ''}>
${!isInvalid ? `
<button class="btn btn-sm btn-secondary" onclick="copyShareUrl(${share.id})">
<span class="icon">📋</span>
Copy
</button>
` : ''}
</div>
<div class="share-meta">
<div class="share-meta-item">
<span class="icon">👁️</span>
<span>${share.view_count}${share.max_views ? `/${share.max_views}` : ''} views</span>
</div>
${share.expires_at ? `
<div class="share-meta-item">
<span class="icon">⏰</span>
<span>${isExpired ? 'Expired' : 'Expires'} ${new Date(share.expires_at).toLocaleDateString()}</span>
</div>
` : ''}
${share.has_password ? `
<div class="share-meta-item">
<span class="icon">🔒</span>
<span>Password protected</span>
</div>
` : ''}
<div class="share-meta-item">
<span class="icon">👤</span>
<span>Created by ${share.created_by}</span>
</div>
</div>
</div>
<div class="share-actions">
<button class="btn btn-sm btn-danger" onclick="deleteShare(${share.id})">
<span class="icon">🗑️</span>
Delete
</button>
</div>
</div>
`;
}
function copyShareUrl(shareId) {
const input = document.getElementById(`share-url-${shareId}`);
input.select();
document.execCommand('copy');
// Show feedback
showCopyFeedback();
}
function showCopyFeedback() {
let feedback = document.getElementById('copy-feedback');
if (!feedback) {
feedback = document.createElement('div');
feedback.id = 'copy-feedback';
feedback.className = 'copy-feedback';
feedback.textContent = 'Link copied to clipboard!';
document.body.appendChild(feedback);
}
feedback.classList.add('show');
setTimeout(() => {
feedback.classList.remove('show');
}, 2000);
}
async function deleteShare(shareId) {
if (!confirm('Are you sure you want to delete this share link?')) {
return;
}
try {
const response = await fetch(`/api/notes/shares/${shareId}`, {
method: 'DELETE'
});
const data = await response.json();
if (response.ok) {
loadShares();
} else {
alert('Failed to delete share: ' + (data.error || 'Unknown error'));
}
} catch (error) {
alert('Failed to delete share');
}
}
// Create share form handler
document.getElementById('create-share-form').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const data = {};
// Convert form data to object
const expiresInDays = formData.get('expires_in_days');
if (expiresInDays) {
data.expires_in_days = parseInt(expiresInDays);
}
const maxViews = formData.get('max_views');
if (maxViews) {
data.max_views = parseInt(maxViews);
}
const password = formData.get('password');
if (password) {
data.password = password;
}
try {
const response = await fetch(`/api/notes/{{ note.slug }}/shares`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
const result = await response.json();
if (result.success) {
e.target.reset();
loadShares();
// Auto-copy the new share URL
setTimeout(() => {
const input = document.getElementById(`share-url-${result.share.id}`);
if (input) {
input.select();
document.execCommand('copy');
showCopyFeedback();
}
}, 100);
} else {
alert('Failed to create share: ' + (result.error || 'Unknown error'));
}
} catch (error) {
alert('Failed to create share');
}
});
</script>
{% endblock %}

View File

@@ -0,0 +1,283 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ note.title }}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css">
<style>
body {
background: #f9fafb;
margin: 0;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
}
.public-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 2rem 0;
margin-bottom: 2rem;
}
.header-content {
max-width: 900px;
margin: 0 auto;
padding: 0 2rem;
}
.note-title {
font-size: 2rem;
font-weight: 700;
margin: 0 0 0.5rem 0;
}
.note-meta {
font-size: 0.875rem;
opacity: 0.9;
}
.content-container {
max-width: 900px;
margin: 0 auto;
padding: 0 2rem 2rem;
}
.content-card {
background: white;
border-radius: 16px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
padding: 3rem;
margin-bottom: 2rem;
}
.markdown-content {
line-height: 1.8;
color: #333;
}
.markdown-content h1,
.markdown-content h2,
.markdown-content h3,
.markdown-content h4,
.markdown-content h5,
.markdown-content h6 {
margin-top: 2rem;
margin-bottom: 1rem;
font-weight: 600;
}
.markdown-content h1 { font-size: 2rem; }
.markdown-content h2 { font-size: 1.75rem; }
.markdown-content h3 { font-size: 1.5rem; }
.markdown-content h4 { font-size: 1.25rem; }
.markdown-content h5 { font-size: 1.125rem; }
.markdown-content h6 { font-size: 1rem; }
.markdown-content p {
margin-bottom: 1rem;
}
.markdown-content ul,
.markdown-content ol {
margin-bottom: 1rem;
padding-left: 2rem;
}
.markdown-content li {
margin-bottom: 0.5rem;
}
.markdown-content code {
background: #f3f4f6;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-size: 0.875em;
}
.markdown-content pre {
background: #f3f4f6;
padding: 1rem;
border-radius: 0.5rem;
overflow-x: auto;
margin-bottom: 1rem;
}
.markdown-content pre code {
background: none;
padding: 0;
}
.markdown-content blockquote {
border-left: 4px solid #e5e7eb;
padding-left: 1rem;
margin-left: 0;
margin-bottom: 1rem;
color: #6b7280;
}
.markdown-content table {
width: 100%;
border-collapse: collapse;
margin-bottom: 1rem;
}
.markdown-content th,
.markdown-content td {
border: 1px solid #e5e7eb;
padding: 0.75rem;
text-align: left;
}
.markdown-content th {
background: #f9fafb;
font-weight: 600;
}
.markdown-content img {
max-width: 100%;
height: auto;
border-radius: 0.5rem;
}
.markdown-content a {
color: #667eea;
text-decoration: none;
}
.markdown-content a:hover {
text-decoration: underline;
}
.share-info {
background: #f3f4f6;
border-radius: 12px;
padding: 1.5rem;
margin-bottom: 2rem;
font-size: 0.875rem;
color: #6b7280;
}
.share-info-item {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.share-info-item:last-child {
margin-bottom: 0;
}
.download-buttons {
display: flex;
gap: 1rem;
flex-wrap: wrap;
margin-top: 2rem;
padding-top: 2rem;
border-top: 1px solid #e5e7eb;
}
.btn {
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-weight: 600;
transition: all 0.3s ease;
border: 2px solid transparent;
display: inline-flex;
align-items: center;
gap: 0.5rem;
text-decoration: none;
cursor: pointer;
}
.btn-secondary {
background: white;
color: #667eea;
border-color: #e5e7eb;
}
.btn-secondary:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
border-color: #667eea;
}
.footer {
text-align: center;
padding: 2rem;
color: #6b7280;
font-size: 0.875rem;
}
.footer a {
color: #667eea;
text-decoration: none;
}
</style>
</head>
<body>
<div class="public-header">
<div class="header-content">
<h1 class="note-title">{{ note.title }}</h1>
<div class="note-meta">
Shared by {{ note.created_by.username }} •
Created {{ note.created_at|format_date }}
{% if note.updated_at > note.created_at %}
• Updated {{ note.updated_at|format_date }}
{% endif %}
</div>
</div>
</div>
<div class="content-container">
{% if share %}
<div class="share-info">
<div class="share-info-item">
<span>👁️</span>
<span>Views: {{ share.view_count }}{% if share.max_views %} / {{ share.max_views }}{% endif %}</span>
</div>
{% if share.expires_at %}
<div class="share-info-item">
<span></span>
<span>Expires: {{ share.expires_at|format_datetime }}</span>
</div>
{% endif %}
</div>
{% endif %}
<div class="content-card">
<div class="markdown-content">
{{ note.render_html()|safe }}
</div>
<div class="download-buttons">
<a href="{{ url_for('notes_public.download_shared_note', token=share.token, format='md') }}"
class="btn btn-secondary">
<span>📄</span>
Download as Markdown
</a>
<a href="{{ url_for('notes_public.download_shared_note', token=share.token, format='html') }}"
class="btn btn-secondary">
<span>🌐</span>
Download as HTML
</a>
<a href="{{ url_for('notes_public.download_shared_note', token=share.token, format='pdf') }}"
class="btn btn-secondary">
<span>📑</span>
Download as PDF
</a>
</div>
</div>
</div>
<div class="footer">
<p>Powered by TimeTrack Notes</p>
</div>
<!-- Syntax highlighting -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/autoloader/prism-autoloader.min.js"></script>
</body>
</html>

View File

@@ -0,0 +1,194 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Password Protected Note - {{ note_title }}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<style>
body {
background: #f5f5f5;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
margin: 0;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
}
.password-container {
background: white;
border-radius: 16px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
padding: 3rem;
max-width: 400px;
width: 100%;
text-align: center;
}
.lock-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
h1 {
font-size: 1.5rem;
margin-bottom: 0.5rem;
color: #1f2937;
}
.note-title {
color: #6b7280;
margin-bottom: 2rem;
font-size: 1rem;
}
.form-group {
margin-bottom: 1.5rem;
text-align: left;
}
.form-label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
color: #374151;
}
.form-control {
width: 100%;
padding: 0.75rem;
border: 1px solid #e5e7eb;
border-radius: 8px;
font-size: 1rem;
transition: all 0.2s;
}
.form-control:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.btn {
width: 100%;
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-weight: 600;
transition: all 0.3s ease;
border: none;
cursor: pointer;
font-size: 1rem;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.3);
}
.error-message {
background: #fee;
color: #dc2626;
padding: 0.75rem;
border-radius: 8px;
margin-bottom: 1rem;
font-size: 0.875rem;
}
.spinner {
display: none;
width: 20px;
height: 20px;
border: 2px solid #f3f3f3;
border-top: 2px solid #667eea;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
</head>
<body>
<div class="password-container">
<div class="lock-icon">🔒</div>
<h1>Password Protected Note</h1>
<p class="note-title">{{ note_title }}</p>
<form id="password-form">
<div id="error-container"></div>
<div class="form-group">
<label for="password" class="form-label">Enter Password</label>
<input type="password"
id="password"
name="password"
class="form-control"
required
autofocus
placeholder="Enter the password to view this note">
</div>
<button type="submit" class="btn btn-primary" id="submit-btn">
<span id="btn-text">Unlock Note</span>
<div class="spinner" id="spinner"></div>
</button>
</form>
</div>
<script>
document.getElementById('password-form').addEventListener('submit', async (e) => {
e.preventDefault();
const password = document.getElementById('password').value;
const submitBtn = document.getElementById('submit-btn');
const btnText = document.getElementById('btn-text');
const spinner = document.getElementById('spinner');
const errorContainer = document.getElementById('error-container');
// Clear previous errors
errorContainer.innerHTML = '';
// Show loading state
submitBtn.disabled = true;
btnText.style.display = 'none';
spinner.style.display = 'block';
try {
const response = await fetch(`/public/notes/{{ token }}/verify`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `password=${encodeURIComponent(password)}`
});
const data = await response.json();
if (data.success) {
window.location.href = data.redirect;
} else {
errorContainer.innerHTML = '<div class="error-message">Invalid password. Please try again.</div>';
submitBtn.disabled = false;
btnText.style.display = 'inline';
spinner.style.display = 'none';
}
} catch (error) {
errorContainer.innerHTML = '<div class="error-message">An error occurred. Please try again.</div>';
submitBtn.disabled = false;
btnText.style.display = 'inline';
spinner.style.display = 'none';
}
});
</script>
</body>
</html>