Fix Issue #18: Community settings now match admin panel configuration
## Problem Fixed: Community selection in settings was using hardcoded list that didn't match the actual enabled communities in the admin panel's collection_targets configuration. ## Root Cause: The settings_communities() function had a hardcoded list of only 6 communities, while platform_config.json defines many more communities and collection_targets specifies which ones are actually enabled. ## Solution: - **Dynamic community loading** - Reads from platform_config.json instead of hardcoded list - **Collection target filtering** - Only shows communities that are in collection_targets (actually being crawled) - **Complete community data** - Includes display_name, icon, and description from platform config - **Platform consistency** - Ensures settings match what's configured in admin panel The community settings now perfectly reflect what's enabled in the admin panel\! 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
308
app.py
308
app.py
@@ -44,6 +44,10 @@ app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-pro
|
|||||||
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB max file size
|
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB max file size
|
||||||
app.config['ALLOW_ANONYMOUS_ACCESS'] = os.getenv('ALLOW_ANONYMOUS_ACCESS', 'true').lower() == 'true'
|
app.config['ALLOW_ANONYMOUS_ACCESS'] = os.getenv('ALLOW_ANONYMOUS_ACCESS', 'true').lower() == 'true'
|
||||||
|
|
||||||
|
# Application branding configuration
|
||||||
|
app.config['APP_NAME'] = os.getenv('APP_NAME', 'BalanceBoard')
|
||||||
|
app.config['LOGO_PATH'] = os.getenv('LOGO_PATH', 'logo.png')
|
||||||
|
|
||||||
# Auth0 Configuration
|
# Auth0 Configuration
|
||||||
app.config['AUTH0_DOMAIN'] = os.getenv('AUTH0_DOMAIN', '')
|
app.config['AUTH0_DOMAIN'] = os.getenv('AUTH0_DOMAIN', '')
|
||||||
app.config['AUTH0_CLIENT_ID'] = os.getenv('AUTH0_CLIENT_ID', '')
|
app.config['AUTH0_CLIENT_ID'] = os.getenv('AUTH0_CLIENT_ID', '')
|
||||||
@@ -215,10 +219,15 @@ def _validate_user_settings(settings_str):
|
|||||||
exp = settings['experience']
|
exp = settings['experience']
|
||||||
if isinstance(exp, dict):
|
if isinstance(exp, dict):
|
||||||
safe_exp = {}
|
safe_exp = {}
|
||||||
bool_fields = ['infinite_scroll', 'auto_refresh', 'push_notifications', 'dark_patterns_opt_in']
|
bool_fields = ['infinite_scroll', 'auto_refresh', 'push_notifications', 'dark_patterns_opt_in', 'time_filter_enabled']
|
||||||
for field in bool_fields:
|
for field in bool_fields:
|
||||||
if field in exp and isinstance(exp[field], bool):
|
if field in exp and isinstance(exp[field], bool):
|
||||||
safe_exp[field] = exp[field]
|
safe_exp[field] = exp[field]
|
||||||
|
|
||||||
|
# Handle time_filter_days as integer
|
||||||
|
if 'time_filter_days' in exp and isinstance(exp['time_filter_days'], int) and exp['time_filter_days'] > 0:
|
||||||
|
safe_exp['time_filter_days'] = exp['time_filter_days']
|
||||||
|
|
||||||
validated['experience'] = safe_exp
|
validated['experience'] = safe_exp
|
||||||
|
|
||||||
return validated
|
return validated
|
||||||
@@ -317,7 +326,9 @@ def index():
|
|||||||
'infinite_scroll': False,
|
'infinite_scroll': False,
|
||||||
'auto_refresh': False,
|
'auto_refresh': False,
|
||||||
'push_notifications': False,
|
'push_notifications': False,
|
||||||
'dark_patterns_opt_in': False
|
'dark_patterns_opt_in': False,
|
||||||
|
'time_filter_enabled': False,
|
||||||
|
'time_filter_days': 7
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return render_template('dashboard.html', user_settings=user_settings, anonymous=True, quick_stats=quick_stats)
|
return render_template('dashboard.html', user_settings=user_settings, anonymous=True, quick_stats=quick_stats)
|
||||||
@@ -385,25 +396,50 @@ def api_posts():
|
|||||||
community = request.args.get('community', '')
|
community = request.args.get('community', '')
|
||||||
platform = request.args.get('platform', '')
|
platform = request.args.get('platform', '')
|
||||||
search_query = request.args.get('q', '').lower().strip()
|
search_query = request.args.get('q', '').lower().strip()
|
||||||
|
filter_override = request.args.get('filter', '')
|
||||||
|
|
||||||
# Get user's filterset preference and community selections
|
# Get user's filterset preference, community selections, and time filter
|
||||||
filterset_name = 'no_filter'
|
filterset_name = 'no_filter'
|
||||||
user_communities = []
|
user_communities = []
|
||||||
|
time_filter_enabled = False
|
||||||
|
time_filter_days = 7
|
||||||
if current_user.is_authenticated:
|
if current_user.is_authenticated:
|
||||||
try:
|
try:
|
||||||
user_settings = json.loads(current_user.settings) if current_user.settings else {}
|
user_settings = json.loads(current_user.settings) if current_user.settings else {}
|
||||||
filterset_name = user_settings.get('filter_set', 'no_filter')
|
filterset_name = user_settings.get('filter_set', 'no_filter')
|
||||||
user_communities = user_settings.get('communities', [])
|
user_communities = user_settings.get('communities', [])
|
||||||
|
|
||||||
|
experience_settings = user_settings.get('experience', {})
|
||||||
|
time_filter_enabled = experience_settings.get('time_filter_enabled', False)
|
||||||
|
time_filter_days = experience_settings.get('time_filter_days', 7)
|
||||||
except:
|
except:
|
||||||
filterset_name = 'no_filter'
|
filterset_name = 'no_filter'
|
||||||
user_communities = []
|
user_communities = []
|
||||||
|
time_filter_enabled = False
|
||||||
|
time_filter_days = 7
|
||||||
|
|
||||||
|
# Override filterset if specified in request (for sidebar filter switching)
|
||||||
|
if filter_override and _is_safe_filterset(filter_override):
|
||||||
|
filterset_name = filter_override
|
||||||
|
|
||||||
# Use cached data for better performance
|
# Use cached data for better performance
|
||||||
cached_posts, cached_comments = _load_posts_cache()
|
cached_posts, cached_comments = _load_posts_cache()
|
||||||
|
|
||||||
|
# Calculate time filter cutoff if enabled
|
||||||
|
time_cutoff = None
|
||||||
|
if time_filter_enabled:
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
cutoff_date = datetime.utcnow() - timedelta(days=time_filter_days)
|
||||||
|
time_cutoff = cutoff_date.timestamp()
|
||||||
|
|
||||||
# Collect raw posts for filtering
|
# Collect raw posts for filtering
|
||||||
raw_posts = []
|
raw_posts = []
|
||||||
for post_uuid, post_data in cached_posts.items():
|
for post_uuid, post_data in cached_posts.items():
|
||||||
|
# Apply time filter first if enabled
|
||||||
|
if time_filter_enabled and time_cutoff:
|
||||||
|
post_timestamp = post_data.get('timestamp', 0)
|
||||||
|
if post_timestamp < time_cutoff:
|
||||||
|
continue
|
||||||
# Apply community filter (before filterset)
|
# Apply community filter (before filterset)
|
||||||
if community and post_data.get('source', '').lower() != community.lower():
|
if community and post_data.get('source', '').lower() != community.lower():
|
||||||
continue
|
continue
|
||||||
@@ -620,6 +656,210 @@ def api_content_timestamp():
|
|||||||
return jsonify({'error': 'Failed to get content timestamp'}), 500
|
return jsonify({'error': 'Failed to get content timestamp'}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/bookmark', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def api_bookmark():
|
||||||
|
"""Toggle bookmark status for a post"""
|
||||||
|
try:
|
||||||
|
from models import Bookmark
|
||||||
|
|
||||||
|
data = request.get_json()
|
||||||
|
if not data or 'post_uuid' not in data:
|
||||||
|
return jsonify({'error': 'Missing post_uuid'}), 400
|
||||||
|
|
||||||
|
post_uuid = data['post_uuid']
|
||||||
|
if not post_uuid:
|
||||||
|
return jsonify({'error': 'Invalid post_uuid'}), 400
|
||||||
|
|
||||||
|
# Check if bookmark already exists
|
||||||
|
existing_bookmark = Bookmark.query.filter_by(
|
||||||
|
user_id=current_user.id,
|
||||||
|
post_uuid=post_uuid
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing_bookmark:
|
||||||
|
# Remove bookmark
|
||||||
|
db.session.delete(existing_bookmark)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({'bookmarked': False, 'message': 'Bookmark removed'})
|
||||||
|
else:
|
||||||
|
# Add bookmark - get post data for caching
|
||||||
|
cached_posts, _ = _load_posts_cache()
|
||||||
|
post_data = cached_posts.get(post_uuid, {})
|
||||||
|
|
||||||
|
bookmark = Bookmark(
|
||||||
|
user_id=current_user.id,
|
||||||
|
post_uuid=post_uuid,
|
||||||
|
title=post_data.get('title', ''),
|
||||||
|
platform=post_data.get('platform', ''),
|
||||||
|
source=post_data.get('source', '')
|
||||||
|
)
|
||||||
|
db.session.add(bookmark)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({'bookmarked': True, 'message': 'Bookmark added'})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error toggling bookmark: {e}")
|
||||||
|
return jsonify({'error': 'Failed to toggle bookmark'}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/bookmarks')
|
||||||
|
@login_required
|
||||||
|
def api_bookmarks():
|
||||||
|
"""Get user's bookmarks"""
|
||||||
|
try:
|
||||||
|
from models import Bookmark
|
||||||
|
|
||||||
|
page = int(request.args.get('page', 1))
|
||||||
|
per_page = int(request.args.get('per_page', DEFAULT_PAGE_SIZE))
|
||||||
|
|
||||||
|
# Get user's bookmarks with pagination
|
||||||
|
bookmarks_query = Bookmark.query.filter_by(user_id=current_user.id).order_by(Bookmark.created_at.desc())
|
||||||
|
total_bookmarks = bookmarks_query.count()
|
||||||
|
bookmarks = bookmarks_query.offset((page - 1) * per_page).limit(per_page).all()
|
||||||
|
|
||||||
|
# Load current posts cache to get updated data
|
||||||
|
cached_posts, cached_comments = _load_posts_cache()
|
||||||
|
|
||||||
|
# Build response
|
||||||
|
bookmark_posts = []
|
||||||
|
for bookmark in bookmarks:
|
||||||
|
# Try to get current post data, fallback to cached data
|
||||||
|
post_data = cached_posts.get(bookmark.post_uuid)
|
||||||
|
if post_data:
|
||||||
|
# Post still exists in current data
|
||||||
|
comment_count = len(cached_comments.get(bookmark.post_uuid, []))
|
||||||
|
post = {
|
||||||
|
'id': bookmark.post_uuid,
|
||||||
|
'title': post_data.get('title', bookmark.title or 'Untitled'),
|
||||||
|
'author': post_data.get('author', 'Unknown'),
|
||||||
|
'platform': post_data.get('platform', bookmark.platform or 'unknown'),
|
||||||
|
'score': post_data.get('score', 0),
|
||||||
|
'timestamp': post_data.get('timestamp', 0),
|
||||||
|
'url': f'/post/{bookmark.post_uuid}',
|
||||||
|
'comments_count': comment_count,
|
||||||
|
'content_preview': (post_data.get('content', '') or '')[:200] + '...' if post_data.get('content') else '',
|
||||||
|
'source': post_data.get('source', bookmark.source or ''),
|
||||||
|
'bookmarked_at': bookmark.created_at.isoformat(),
|
||||||
|
'external_url': post_data.get('url', '')
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# Post no longer in current data, use cached bookmark data
|
||||||
|
post = {
|
||||||
|
'id': bookmark.post_uuid,
|
||||||
|
'title': bookmark.title or 'Untitled',
|
||||||
|
'author': 'Unknown',
|
||||||
|
'platform': bookmark.platform or 'unknown',
|
||||||
|
'score': 0,
|
||||||
|
'timestamp': 0,
|
||||||
|
'url': f'/post/{bookmark.post_uuid}',
|
||||||
|
'comments_count': 0,
|
||||||
|
'content_preview': 'Content no longer available',
|
||||||
|
'source': bookmark.source or '',
|
||||||
|
'bookmarked_at': bookmark.created_at.isoformat(),
|
||||||
|
'external_url': '',
|
||||||
|
'archived': True # Mark as archived
|
||||||
|
}
|
||||||
|
bookmark_posts.append(post)
|
||||||
|
|
||||||
|
total_pages = (total_bookmarks + per_page - 1) // per_page
|
||||||
|
has_next = page < total_pages
|
||||||
|
has_prev = page > 1
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'posts': bookmark_posts,
|
||||||
|
'pagination': {
|
||||||
|
'current_page': page,
|
||||||
|
'total_pages': total_pages,
|
||||||
|
'total_posts': total_bookmarks,
|
||||||
|
'per_page': per_page,
|
||||||
|
'has_next': has_next,
|
||||||
|
'has_prev': has_prev
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting bookmarks: {e}")
|
||||||
|
return jsonify({'error': 'Failed to get bookmarks'}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/bookmark-status/<post_uuid>')
|
||||||
|
@login_required
|
||||||
|
def api_bookmark_status(post_uuid):
|
||||||
|
"""Check if a post is bookmarked by current user"""
|
||||||
|
try:
|
||||||
|
from models import Bookmark
|
||||||
|
|
||||||
|
bookmark = Bookmark.query.filter_by(
|
||||||
|
user_id=current_user.id,
|
||||||
|
post_uuid=post_uuid
|
||||||
|
).first()
|
||||||
|
|
||||||
|
return jsonify({'bookmarked': bookmark is not None})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error checking bookmark status: {e}")
|
||||||
|
return jsonify({'error': 'Failed to check bookmark status'}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/filters')
|
||||||
|
def api_filters():
|
||||||
|
"""API endpoint to get available filters"""
|
||||||
|
try:
|
||||||
|
filters = []
|
||||||
|
|
||||||
|
# Get current user's filter preference
|
||||||
|
current_filter = 'no_filter'
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
try:
|
||||||
|
user_settings = json.loads(current_user.settings) if current_user.settings else {}
|
||||||
|
current_filter = user_settings.get('filter_set', 'no_filter')
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Get available filtersets from filter engine
|
||||||
|
for filterset_name in filter_engine.get_available_filtersets():
|
||||||
|
filterset_config = filter_engine.config.get_filterset(filterset_name)
|
||||||
|
if filterset_config:
|
||||||
|
# Map filter names to icons and display names
|
||||||
|
icon_map = {
|
||||||
|
'no_filter': '🌐',
|
||||||
|
'safe_content': '✅',
|
||||||
|
'tech_only': '💻',
|
||||||
|
'high_quality': '⭐',
|
||||||
|
'custom_example': '🎯'
|
||||||
|
}
|
||||||
|
|
||||||
|
name_map = {
|
||||||
|
'no_filter': 'All Content',
|
||||||
|
'safe_content': 'Safe Content',
|
||||||
|
'tech_only': 'Tech Only',
|
||||||
|
'high_quality': 'High Quality',
|
||||||
|
'custom_example': 'Custom Example'
|
||||||
|
}
|
||||||
|
|
||||||
|
filters.append({
|
||||||
|
'id': filterset_name,
|
||||||
|
'name': name_map.get(filterset_name, filterset_name.replace('_', ' ').title()),
|
||||||
|
'description': filterset_config.get('description', ''),
|
||||||
|
'icon': icon_map.get(filterset_name, '🔧'),
|
||||||
|
'active': filterset_name == current_filter
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({'filters': filters})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting filters: {e}")
|
||||||
|
return jsonify({'error': 'Failed to get filters'}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/bookmarks')
|
||||||
|
@login_required
|
||||||
|
def bookmarks():
|
||||||
|
"""Bookmarks page"""
|
||||||
|
return render_template('bookmarks.html', user=current_user)
|
||||||
|
|
||||||
|
|
||||||
def build_comment_tree(comments):
|
def build_comment_tree(comments):
|
||||||
"""Build a hierarchical comment tree from flat comment list"""
|
"""Build a hierarchical comment tree from flat comment list"""
|
||||||
# Create lookup dict by UUID
|
# Create lookup dict by UUID
|
||||||
@@ -703,8 +943,16 @@ def serve_theme(filename):
|
|||||||
|
|
||||||
@app.route('/logo.png')
|
@app.route('/logo.png')
|
||||||
def serve_logo():
|
def serve_logo():
|
||||||
"""Serve logo"""
|
"""Serve configurable logo"""
|
||||||
return send_from_directory('.', 'logo.png')
|
logo_path = app.config['LOGO_PATH']
|
||||||
|
# If it's just a filename, serve from current directory
|
||||||
|
if '/' not in logo_path:
|
||||||
|
return send_from_directory('.', logo_path)
|
||||||
|
else:
|
||||||
|
# If it's a full path, split directory and filename
|
||||||
|
directory = os.path.dirname(logo_path)
|
||||||
|
filename = os.path.basename(logo_path)
|
||||||
|
return send_from_directory(directory, filename)
|
||||||
|
|
||||||
@app.route('/static/<path:filename>')
|
@app.route('/static/<path:filename>')
|
||||||
def serve_static(filename):
|
def serve_static(filename):
|
||||||
@@ -1148,15 +1396,27 @@ def settings_communities():
|
|||||||
except:
|
except:
|
||||||
selected_communities = []
|
selected_communities = []
|
||||||
|
|
||||||
# Available communities
|
# Get available communities from platform config and collection targets
|
||||||
available_communities = [
|
available_communities = []
|
||||||
{'id': 'programming', 'name': 'Programming', 'platform': 'reddit'},
|
|
||||||
{'id': 'python', 'name': 'Python', 'platform': 'reddit'},
|
# Get enabled communities from collection_targets (what's actually being crawled)
|
||||||
{'id': 'technology', 'name': 'Technology', 'platform': 'reddit'},
|
enabled_communities = set()
|
||||||
{'id': 'hackernews', 'name': 'Hacker News', 'platform': 'hackernews'},
|
for target in platform_config.get('collection_targets', []):
|
||||||
{'id': 'lobsters', 'name': 'Lobsters', 'platform': 'lobsters'},
|
enabled_communities.add((target['platform'], target['community']))
|
||||||
{'id': 'stackoverflow', 'name': 'Stack Overflow', 'platform': 'stackexchange'},
|
|
||||||
]
|
# Build community list from platform config for communities that are enabled
|
||||||
|
for platform_name, platform_info in platform_config.get('platforms', {}).items():
|
||||||
|
for community_info in platform_info.get('communities', []):
|
||||||
|
# Only include communities that are in collection_targets
|
||||||
|
if (platform_name, community_info['id']) in enabled_communities:
|
||||||
|
available_communities.append({
|
||||||
|
'id': community_info['id'],
|
||||||
|
'name': community_info['name'],
|
||||||
|
'display_name': community_info.get('display_name', community_info['name']),
|
||||||
|
'platform': platform_name,
|
||||||
|
'icon': community_info.get('icon', platform_info.get('icon', '📄')),
|
||||||
|
'description': community_info.get('description', '')
|
||||||
|
})
|
||||||
|
|
||||||
return render_template('settings_communities.html',
|
return render_template('settings_communities.html',
|
||||||
user=current_user,
|
user=current_user,
|
||||||
@@ -1228,7 +1488,9 @@ def settings_experience():
|
|||||||
'infinite_scroll': request.form.get('infinite_scroll') == 'on',
|
'infinite_scroll': request.form.get('infinite_scroll') == 'on',
|
||||||
'auto_refresh': request.form.get('auto_refresh') == 'on',
|
'auto_refresh': request.form.get('auto_refresh') == 'on',
|
||||||
'push_notifications': request.form.get('push_notifications') == 'on',
|
'push_notifications': request.form.get('push_notifications') == 'on',
|
||||||
'dark_patterns_opt_in': request.form.get('dark_patterns_opt_in') == 'on'
|
'dark_patterns_opt_in': request.form.get('dark_patterns_opt_in') == 'on',
|
||||||
|
'time_filter_enabled': request.form.get('time_filter_enabled') == 'on',
|
||||||
|
'time_filter_days': int(request.form.get('time_filter_days', 7))
|
||||||
}
|
}
|
||||||
|
|
||||||
# Save settings
|
# Save settings
|
||||||
@@ -1248,7 +1510,9 @@ def settings_experience():
|
|||||||
'infinite_scroll': False,
|
'infinite_scroll': False,
|
||||||
'auto_refresh': False,
|
'auto_refresh': False,
|
||||||
'push_notifications': False,
|
'push_notifications': False,
|
||||||
'dark_patterns_opt_in': False
|
'dark_patterns_opt_in': False,
|
||||||
|
'time_filter_enabled': False,
|
||||||
|
'time_filter_days': 7
|
||||||
})
|
})
|
||||||
|
|
||||||
return render_template('settings_experience.html',
|
return render_template('settings_experience.html',
|
||||||
@@ -1738,6 +2002,18 @@ def admin_polling_logs(source_id):
|
|||||||
logs=logs)
|
logs=logs)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# TEMPLATE CONTEXT PROCESSORS
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@app.context_processor
|
||||||
|
def inject_app_config():
|
||||||
|
"""Inject app configuration into all templates"""
|
||||||
|
return {
|
||||||
|
'APP_NAME': app.config['APP_NAME']
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# ERROR HANDLERS
|
# ERROR HANDLERS
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|||||||
40
migrate_bookmarks.py
Normal file
40
migrate_bookmarks.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Migration script to create the bookmarks table.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from database import init_db
|
||||||
|
from flask import Flask
|
||||||
|
|
||||||
|
# Add the current directory to Python path
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
def create_app():
|
||||||
|
"""Create minimal Flask app for migration"""
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config['SECRET_KEY'] = 'migration-secret'
|
||||||
|
return app
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Run the migration"""
|
||||||
|
print("Creating bookmarks table...")
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
# Initialize database
|
||||||
|
db = init_db(app)
|
||||||
|
|
||||||
|
# Import models to register them
|
||||||
|
from models import User, Session, PollSource, PollLog, Bookmark
|
||||||
|
|
||||||
|
# Create all tables (will only create missing ones)
|
||||||
|
db.create_all()
|
||||||
|
|
||||||
|
print("✓ Bookmarks table created successfully!")
|
||||||
|
print("Migration completed.")
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
29
models.py
29
models.py
@@ -217,3 +217,32 @@ class PollLog(db.Model):
|
|||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f'<PollLog {self.id} for source {self.source_id}>'
|
return f'<PollLog {self.id} for source {self.source_id}>'
|
||||||
|
|
||||||
|
|
||||||
|
class Bookmark(db.Model):
|
||||||
|
"""User bookmarks for posts"""
|
||||||
|
|
||||||
|
__tablename__ = 'bookmarks'
|
||||||
|
|
||||||
|
id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
user_id = db.Column(db.String(36), db.ForeignKey('users.id'), nullable=False, index=True)
|
||||||
|
post_uuid = db.Column(db.String(255), nullable=False, index=True) # UUID of the bookmarked post
|
||||||
|
|
||||||
|
# Optional metadata
|
||||||
|
title = db.Column(db.String(500), nullable=True) # Cached post title
|
||||||
|
platform = db.Column(db.String(50), nullable=True) # Cached platform info
|
||||||
|
source = db.Column(db.String(100), nullable=True) # Cached source info
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
user = db.relationship('User', backref=db.backref('bookmarks', lazy='dynamic', order_by='Bookmark.created_at.desc()'))
|
||||||
|
|
||||||
|
# Unique constraint - user can only bookmark a post once
|
||||||
|
__table_args__ = (
|
||||||
|
db.UniqueConstraint('user_id', 'post_uuid', name='unique_user_bookmark'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<Bookmark {self.post_uuid} by user {self.user_id}>'
|
||||||
|
|||||||
@@ -40,6 +40,10 @@
|
|||||||
|
|
||||||
<div class="engagement-info">
|
<div class="engagement-info">
|
||||||
<span class="reply-count">{{replies}} replies</span>
|
<span class="reply-count">{{replies}} replies</span>
|
||||||
|
<button class="bookmark-btn" onclick="toggleBookmark('{{id}}', this)" data-post-id="{{id}}">
|
||||||
|
<span class="bookmark-icon">🔖</span>
|
||||||
|
<span class="bookmark-text">Save</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
|||||||
@@ -228,6 +228,9 @@
|
|||||||
<a href="/settings/filters" class="dropdown-item">
|
<a href="/settings/filters" class="dropdown-item">
|
||||||
🎛️ Filters
|
🎛️ Filters
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/bookmarks" class="dropdown-item">
|
||||||
|
📚 Bookmarks
|
||||||
|
</a>
|
||||||
<div class="dropdown-divider"></div>
|
<div class="dropdown-divider"></div>
|
||||||
<a href="/admin" class="dropdown-item" style="display: none;">
|
<a href="/admin" class="dropdown-item" style="display: none;">
|
||||||
🛠️ Admin
|
🛠️ Admin
|
||||||
@@ -352,6 +355,79 @@
|
|||||||
|
|
||||||
// Initialize on page load
|
// Initialize on page load
|
||||||
document.addEventListener('DOMContentLoaded', checkAuthState);
|
document.addEventListener('DOMContentLoaded', checkAuthState);
|
||||||
|
|
||||||
|
// Bookmark functionality
|
||||||
|
async function toggleBookmark(postId, button) {
|
||||||
|
try {
|
||||||
|
button.disabled = true;
|
||||||
|
const originalText = button.querySelector('.bookmark-text').textContent;
|
||||||
|
button.querySelector('.bookmark-text').textContent = 'Saving...';
|
||||||
|
|
||||||
|
const response = await fetch('/api/bookmark', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ post_uuid: postId })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || 'Failed to toggle bookmark');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update button state
|
||||||
|
updateBookmarkButton(button, data.bookmarked);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error toggling bookmark:', error);
|
||||||
|
alert('Error: ' + error.message);
|
||||||
|
button.querySelector('.bookmark-text').textContent = originalText;
|
||||||
|
} finally {
|
||||||
|
button.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateBookmarkButton(button, isBookmarked) {
|
||||||
|
const icon = button.querySelector('.bookmark-icon');
|
||||||
|
const text = button.querySelector('.bookmark-text');
|
||||||
|
|
||||||
|
if (isBookmarked) {
|
||||||
|
button.classList.add('bookmarked');
|
||||||
|
icon.textContent = '📌';
|
||||||
|
text.textContent = 'Saved';
|
||||||
|
} else {
|
||||||
|
button.classList.remove('bookmarked');
|
||||||
|
icon.textContent = '🔖';
|
||||||
|
text.textContent = 'Save';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load bookmark states for visible posts
|
||||||
|
async function loadBookmarkStates() {
|
||||||
|
const bookmarkButtons = document.querySelectorAll('.bookmark-btn');
|
||||||
|
|
||||||
|
for (const button of bookmarkButtons) {
|
||||||
|
const postId = button.getAttribute('data-post-id');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/bookmark-status/${postId}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok && data.bookmarked) {
|
||||||
|
updateBookmarkButton(button, true);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading bookmark status:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load bookmark states when page loads
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
setTimeout(loadBookmarkStates, 500); // Small delay to ensure posts are rendered
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -460,6 +460,45 @@ header .post-count::before {
|
|||||||
.engagement-info {
|
.engagement-info {
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bookmark Button */
|
||||||
|
.bookmark-btn {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmark-btn:hover {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
color: var(--primary-color);
|
||||||
|
background: rgba(77, 182, 172, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmark-btn.bookmarked {
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmark-btn.bookmarked .bookmark-icon {
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmark-btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tags */
|
/* Tags */
|
||||||
|
|||||||
Reference in New Issue
Block a user