Add Note Sharing feature.
This commit is contained in:
4
app.py
4
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, 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)
|
||||
|
||||
21
migrations/add_note_sharing.sql
Normal file
21
migrations/add_note_sharing.sql
Normal 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';
|
||||
@@ -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,12 +50,27 @@ def run_migration(migration_file):
|
||||
print(f"\n🔄 Running migration: {migration_file}")
|
||||
|
||||
try:
|
||||
# Run the migration script
|
||||
result = subprocess.run(
|
||||
[sys.executable, script_path],
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
# 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,
|
||||
text=True
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
print(f"✅ {migration_file} completed successfully")
|
||||
|
||||
@@ -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'
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -223,6 +223,44 @@ class Note(db.Model):
|
||||
self.tags = metadata['tags']
|
||||
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):
|
||||
|
||||
107
models/note_share.py
Normal file
107
models/note_share.py
Normal 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()
|
||||
}
|
||||
@@ -419,4 +419,142 @@ def unlink_notes(note_id):
|
||||
return jsonify({
|
||||
'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
191
routes/notes_public.py
Normal 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")
|
||||
@@ -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()">×</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 %}
|
||||
283
templates/notes/public_view.html
Normal file
283
templates/notes/public_view.html
Normal 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>
|
||||
194
templates/notes/share_password.html
Normal file
194
templates/notes/share_password.html
Normal 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>
|
||||
Reference in New Issue
Block a user