diff --git a/app.py b/app.py index afc3bb6..c503fa8 100644 --- a/app.py +++ b/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['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 app.config['AUTH0_DOMAIN'] = os.getenv('AUTH0_DOMAIN', '') app.config['AUTH0_CLIENT_ID'] = os.getenv('AUTH0_CLIENT_ID', '') @@ -215,10 +219,15 @@ def _validate_user_settings(settings_str): exp = settings['experience'] if isinstance(exp, dict): 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: if field in exp and isinstance(exp[field], bool): 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 return validated @@ -317,7 +326,9 @@ def index(): 'infinite_scroll': False, 'auto_refresh': 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) @@ -385,25 +396,50 @@ def api_posts(): community = request.args.get('community', '') platform = request.args.get('platform', '') 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' user_communities = [] + time_filter_enabled = False + time_filter_days = 7 if current_user.is_authenticated: try: user_settings = json.loads(current_user.settings) if current_user.settings else {} filterset_name = user_settings.get('filter_set', 'no_filter') 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: filterset_name = 'no_filter' 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 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 raw_posts = [] 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) if community and post_data.get('source', '').lower() != community.lower(): continue @@ -620,6 +656,210 @@ def api_content_timestamp(): 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/') +@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): """Build a hierarchical comment tree from flat comment list""" # Create lookup dict by UUID @@ -703,8 +943,16 @@ def serve_theme(filename): @app.route('/logo.png') def serve_logo(): - """Serve logo""" - return send_from_directory('.', 'logo.png') + """Serve configurable logo""" + 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/') def serve_static(filename): @@ -1148,15 +1396,27 @@ def settings_communities(): except: selected_communities = [] - # Available communities - available_communities = [ - {'id': 'programming', 'name': 'Programming', 'platform': 'reddit'}, - {'id': 'python', 'name': 'Python', 'platform': 'reddit'}, - {'id': 'technology', 'name': 'Technology', 'platform': 'reddit'}, - {'id': 'hackernews', 'name': 'Hacker News', 'platform': 'hackernews'}, - {'id': 'lobsters', 'name': 'Lobsters', 'platform': 'lobsters'}, - {'id': 'stackoverflow', 'name': 'Stack Overflow', 'platform': 'stackexchange'}, - ] + # Get available communities from platform config and collection targets + available_communities = [] + + # Get enabled communities from collection_targets (what's actually being crawled) + enabled_communities = set() + for target in platform_config.get('collection_targets', []): + enabled_communities.add((target['platform'], target['community'])) + + # 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', user=current_user, @@ -1228,7 +1488,9 @@ def settings_experience(): 'infinite_scroll': request.form.get('infinite_scroll') == 'on', 'auto_refresh': request.form.get('auto_refresh') == '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 @@ -1248,7 +1510,9 @@ def settings_experience(): 'infinite_scroll': False, 'auto_refresh': 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', @@ -1738,6 +2002,18 @@ def admin_polling_logs(source_id): 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 # ============================================================ diff --git a/migrate_bookmarks.py b/migrate_bookmarks.py new file mode 100644 index 0000000..bcb6352 --- /dev/null +++ b/migrate_bookmarks.py @@ -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() \ No newline at end of file diff --git a/models.py b/models.py index 48ad94f..4cd07d9 100644 --- a/models.py +++ b/models.py @@ -217,3 +217,32 @@ class PollLog(db.Model): def __repr__(self): return f'' + + +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'' diff --git a/themes/modern-card-ui/card-template.html b/themes/modern-card-ui/card-template.html index 2121975..646cad2 100644 --- a/themes/modern-card-ui/card-template.html +++ b/themes/modern-card-ui/card-template.html @@ -40,6 +40,10 @@ diff --git a/themes/modern-card-ui/index.html b/themes/modern-card-ui/index.html index f93e6cf..74d8789 100644 --- a/themes/modern-card-ui/index.html +++ b/themes/modern-card-ui/index.html @@ -228,6 +228,9 @@ 🎛️ Filters + + 📚 Bookmarks +