Compare commits
24 Commits
b0b9a9e912
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
718cc36973 | ||
|
|
9d286c8466 | ||
|
|
e40d5463a6 | ||
|
|
52cf5c0092 | ||
|
|
efabac7fd5 | ||
|
|
b438762758 | ||
|
|
301c2b33f0 | ||
|
|
fc440eafa3 | ||
|
|
343d6b51ea | ||
|
|
02d4e5a7e4 | ||
|
|
b5d30c6427 | ||
|
|
736d8fc7c1 | ||
|
|
8d4c8dfbad | ||
|
|
94ffa69d21 | ||
|
|
146ad754c0 | ||
|
|
cdc415b0c1 | ||
|
|
48868df4d9 | ||
|
|
ac94215f84 | ||
|
|
b47155cc36 | ||
|
|
29a9d521e7 | ||
|
|
2c518fce4a | ||
| f92851b415 | |||
| 466badd326 | |||
| 29b4a9d339 |
509
app.py
509
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,22 +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 (or default to no_filter)
|
# Get user's filterset preference, community selections, and time filter
|
||||||
filterset_name = 'no_filter'
|
filterset_name = 'no_filter'
|
||||||
|
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', [])
|
||||||
|
|
||||||
|
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 = []
|
||||||
|
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
|
||||||
@@ -409,6 +448,32 @@ def api_posts():
|
|||||||
if platform and post_data.get('platform', '').lower() != platform.lower():
|
if platform and post_data.get('platform', '').lower() != platform.lower():
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Apply user's community preferences (before filterset)
|
||||||
|
if user_communities:
|
||||||
|
post_source = post_data.get('source', '').lower()
|
||||||
|
post_platform = post_data.get('platform', '').lower()
|
||||||
|
post_id = post_data.get('id', '').lower()
|
||||||
|
|
||||||
|
# Check if this post matches any of the user's selected communities
|
||||||
|
matches_community = False
|
||||||
|
for selected_community in user_communities:
|
||||||
|
selected_community = selected_community.lower()
|
||||||
|
|
||||||
|
# Enhanced matching logic (platform-agnostic):
|
||||||
|
# 1. Exact source match (e.g., source="programming", community="programming")
|
||||||
|
# 2. Platform match (e.g., platform="hackernews", community="hackernews")
|
||||||
|
# 3. Partial match in source (e.g., source="programming", community="program")
|
||||||
|
# 4. Partial match in post ID (e.g., id="reddit_programming_123", community="programming")
|
||||||
|
if (post_source == selected_community or
|
||||||
|
post_platform == selected_community or
|
||||||
|
(selected_community in post_source) or
|
||||||
|
(selected_community in post_id)):
|
||||||
|
matches_community = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not matches_community:
|
||||||
|
continue
|
||||||
|
|
||||||
# Apply search filter (before filterset)
|
# Apply search filter (before filterset)
|
||||||
if search_query:
|
if search_query:
|
||||||
title = post_data.get('title', '').lower()
|
title = post_data.get('title', '').lower()
|
||||||
@@ -598,6 +663,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
|
||||||
@@ -681,8 +950,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):
|
||||||
@@ -1126,15 +1403,58 @@ 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'},
|
# Load platform configuration with error handling
|
||||||
{'id': 'technology', 'name': 'Technology', 'platform': 'reddit'},
|
try:
|
||||||
{'id': 'hackernews', 'name': 'Hacker News', 'platform': 'hackernews'},
|
platform_config = load_platform_config()
|
||||||
{'id': 'lobsters', 'name': 'Lobsters', 'platform': 'lobsters'},
|
if not platform_config:
|
||||||
{'id': 'stackoverflow', 'name': 'Stack Overflow', 'platform': 'stackoverflow'},
|
platform_config = {"platforms": {}, "collection_targets": []}
|
||||||
]
|
except Exception as e:
|
||||||
|
logger.error(f"Error loading platform config: {e}")
|
||||||
|
platform_config = {"platforms": {}, "collection_targets": []}
|
||||||
|
|
||||||
|
# Get enabled communities from collection_targets (what's actually being crawled)
|
||||||
|
enabled_communities = set()
|
||||||
|
try:
|
||||||
|
for target in platform_config.get('collection_targets', []):
|
||||||
|
if 'platform' in target and 'community' in target:
|
||||||
|
enabled_communities.add((target['platform'], target['community']))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error processing collection_targets: {e}")
|
||||||
|
|
||||||
|
# Build community list from platform config for communities that are enabled
|
||||||
|
try:
|
||||||
|
for platform_name, platform_info in platform_config.get('platforms', {}).items():
|
||||||
|
if not isinstance(platform_info, dict):
|
||||||
|
continue
|
||||||
|
communities = platform_info.get('communities', [])
|
||||||
|
if not isinstance(communities, list):
|
||||||
|
continue
|
||||||
|
|
||||||
|
for community_info in communities:
|
||||||
|
try:
|
||||||
|
if not isinstance(community_info, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 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', '')
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error processing community {community_info}: {e}")
|
||||||
|
continue
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error building community list: {e}")
|
||||||
|
|
||||||
|
logger.info(f"Found {len(available_communities)} available communities")
|
||||||
|
|
||||||
return render_template('settings_communities.html',
|
return render_template('settings_communities.html',
|
||||||
user=current_user,
|
user=current_user,
|
||||||
@@ -1206,7 +1526,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
|
||||||
@@ -1226,7 +1548,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',
|
||||||
@@ -1238,60 +1562,101 @@ def settings_experience():
|
|||||||
@login_required
|
@login_required
|
||||||
def upload_avatar():
|
def upload_avatar():
|
||||||
"""Upload profile picture"""
|
"""Upload profile picture"""
|
||||||
if 'avatar' not in request.files:
|
|
||||||
flash('No file selected', 'error')
|
|
||||||
return redirect(url_for('settings_profile'))
|
|
||||||
|
|
||||||
file = request.files['avatar']
|
|
||||||
if file.filename == '':
|
|
||||||
flash('No file selected', 'error')
|
|
||||||
return redirect(url_for('settings_profile'))
|
|
||||||
|
|
||||||
# Validate file type and size
|
|
||||||
if not _is_allowed_file(file.filename):
|
|
||||||
flash('Invalid file type. Please upload PNG, JPG, or GIF', 'error')
|
|
||||||
return redirect(url_for('settings_profile'))
|
|
||||||
|
|
||||||
# Check file size (Flask's MAX_CONTENT_LENGTH handles this too, but double-check)
|
|
||||||
if hasattr(file, 'content_length') and file.content_length > app.config['MAX_CONTENT_LENGTH']:
|
|
||||||
flash('File too large. Maximum size is 16MB', 'error')
|
|
||||||
return redirect(url_for('settings_profile'))
|
|
||||||
|
|
||||||
# Validate and secure filename
|
|
||||||
filename = secure_filename(file.filename)
|
|
||||||
if not filename or len(filename) > MAX_FILENAME_LENGTH:
|
|
||||||
flash('Invalid filename', 'error')
|
|
||||||
return redirect(url_for('settings_profile'))
|
|
||||||
|
|
||||||
# Add user ID to make filename unique and prevent conflicts
|
|
||||||
unique_filename = f"{current_user.id}_{filename}"
|
|
||||||
|
|
||||||
# Ensure upload directory exists and is secure
|
|
||||||
upload_dir = os.path.abspath(UPLOAD_FOLDER)
|
|
||||||
os.makedirs(upload_dir, exist_ok=True)
|
|
||||||
|
|
||||||
upload_path = os.path.join(upload_dir, unique_filename)
|
|
||||||
|
|
||||||
# Final security check - ensure path is within upload directory
|
|
||||||
if not os.path.abspath(upload_path).startswith(upload_dir):
|
|
||||||
logger.warning(f"Path traversal attempt in file upload: {upload_path}")
|
|
||||||
flash('Invalid file path', 'error')
|
|
||||||
return redirect(url_for('settings_profile'))
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# Debug logging
|
||||||
|
logger.info(f"Avatar upload attempt by user {current_user.id} ({current_user.username})")
|
||||||
|
logger.debug(f"Request files: {list(request.files.keys())}")
|
||||||
|
logger.debug(f"Request form: {dict(request.form)}")
|
||||||
|
|
||||||
|
# Check if user is properly authenticated and has required attributes
|
||||||
|
if not hasattr(current_user, 'id') or not current_user.id:
|
||||||
|
logger.error("User missing ID attribute")
|
||||||
|
flash('Authentication error. Please log in again.', 'error')
|
||||||
|
return redirect(url_for('login'))
|
||||||
|
|
||||||
|
if not hasattr(current_user, 'username') or not current_user.username:
|
||||||
|
logger.error("User missing username attribute")
|
||||||
|
flash('User profile incomplete. Please update your profile.', 'error')
|
||||||
|
return redirect(url_for('settings_profile'))
|
||||||
|
|
||||||
|
# Check for file in request
|
||||||
|
if 'avatar' not in request.files:
|
||||||
|
logger.warning("No avatar file in request")
|
||||||
|
flash('No file selected', 'error')
|
||||||
|
return redirect(url_for('settings_profile'))
|
||||||
|
|
||||||
|
file = request.files['avatar']
|
||||||
|
if file.filename == '':
|
||||||
|
logger.warning("Empty filename provided")
|
||||||
|
flash('No file selected', 'error')
|
||||||
|
return redirect(url_for('settings_profile'))
|
||||||
|
|
||||||
|
logger.info(f"Processing file: {file.filename}")
|
||||||
|
|
||||||
|
# Validate file type and size
|
||||||
|
if not _is_allowed_file(file.filename):
|
||||||
|
logger.warning(f"Invalid file type: {file.filename}")
|
||||||
|
flash('Invalid file type. Please upload PNG, JPG, or GIF', 'error')
|
||||||
|
return redirect(url_for('settings_profile'))
|
||||||
|
|
||||||
|
# Check file size (Flask's MAX_CONTENT_LENGTH handles this too, but double-check)
|
||||||
|
if hasattr(file, 'content_length') and file.content_length > app.config.get('MAX_CONTENT_LENGTH', 16*1024*1024):
|
||||||
|
logger.warning(f"File too large: {file.content_length}")
|
||||||
|
flash('File too large. Maximum size is 16MB', 'error')
|
||||||
|
return redirect(url_for('settings_profile'))
|
||||||
|
|
||||||
|
# Validate and secure filename
|
||||||
|
filename = secure_filename(file.filename)
|
||||||
|
if not filename or len(filename) > MAX_FILENAME_LENGTH:
|
||||||
|
logger.warning(f"Invalid filename after sanitization: {filename}")
|
||||||
|
flash('Invalid filename', 'error')
|
||||||
|
return redirect(url_for('settings_profile'))
|
||||||
|
|
||||||
|
# Add user ID to make filename unique and prevent conflicts
|
||||||
|
unique_filename = f"{current_user.id}_{filename}"
|
||||||
|
logger.info(f"Generated unique filename: {unique_filename}")
|
||||||
|
|
||||||
|
# Ensure upload directory exists and is secure
|
||||||
|
upload_dir = os.path.abspath(UPLOAD_FOLDER)
|
||||||
|
os.makedirs(upload_dir, exist_ok=True)
|
||||||
|
|
||||||
|
upload_path = os.path.join(upload_dir, unique_filename)
|
||||||
|
|
||||||
|
# Final security check - ensure path is within upload directory
|
||||||
|
if not os.path.abspath(upload_path).startswith(upload_dir):
|
||||||
|
logger.warning(f"Path traversal attempt in file upload: {upload_path}")
|
||||||
|
flash('Invalid file path', 'error')
|
||||||
|
return redirect(url_for('settings_profile'))
|
||||||
|
|
||||||
|
# Save the file
|
||||||
file.save(upload_path)
|
file.save(upload_path)
|
||||||
logger.info(f"File uploaded successfully: {unique_filename} by user {current_user.id}")
|
logger.info(f"File saved successfully: {upload_path}")
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error saving uploaded file: {e}")
|
# Update user profile with database error handling
|
||||||
flash('Error saving file', 'error')
|
old_avatar_url = current_user.profile_picture_url
|
||||||
|
current_user.profile_picture_url = f"/static/avatars/{unique_filename}"
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
logger.info(f"User profile updated successfully for {current_user.username}")
|
||||||
|
|
||||||
|
# Clean up old avatar file if it exists and was uploaded by user
|
||||||
|
if old_avatar_url and old_avatar_url.startswith('/static/avatars/') and current_user.id in old_avatar_url:
|
||||||
|
try:
|
||||||
|
old_file_path = os.path.join(upload_dir, os.path.basename(old_avatar_url))
|
||||||
|
if os.path.exists(old_file_path):
|
||||||
|
os.remove(old_file_path)
|
||||||
|
logger.info(f"Cleaned up old avatar: {old_file_path}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not clean up old avatar: {e}")
|
||||||
|
|
||||||
|
flash('Profile picture updated successfully', 'success')
|
||||||
return redirect(url_for('settings_profile'))
|
return redirect(url_for('settings_profile'))
|
||||||
|
|
||||||
# Update user profile
|
except Exception as e:
|
||||||
current_user.profile_picture_url = f"/static/avatars/{unique_filename}"
|
logger.error(f"Unexpected error in avatar upload: {e}")
|
||||||
db.session.commit()
|
db.session.rollback()
|
||||||
|
flash('An unexpected error occurred. Please try again.', 'error')
|
||||||
flash('Profile picture updated successfully', 'success')
|
return redirect(url_for('settings_profile'))
|
||||||
return redirect(url_for('settings_profile'))
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/profile')
|
@app.route('/profile')
|
||||||
@@ -1716,6 +2081,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}>'
|
||||||
|
|||||||
@@ -143,13 +143,20 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"stackoverflow": {
|
"stackexchange": {
|
||||||
"name": "Stack Overflow",
|
"name": "Stack Overflow",
|
||||||
"icon": "📚",
|
"icon": "📚",
|
||||||
"color": "#f48024",
|
"color": "#f48024",
|
||||||
"prefix": "",
|
"prefix": "",
|
||||||
"supports_communities": false,
|
"supports_communities": false,
|
||||||
"communities": [
|
"communities": [
|
||||||
|
{
|
||||||
|
"id": "stackoverflow",
|
||||||
|
"name": "Stack Overflow",
|
||||||
|
"display_name": "Stack Overflow",
|
||||||
|
"icon": "📚",
|
||||||
|
"description": "Programming Q&A community"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "featured",
|
"id": "featured",
|
||||||
"name": "Featured",
|
"name": "Featured",
|
||||||
@@ -257,6 +264,12 @@
|
|||||||
"community": "https://hnrss.org/frontpage",
|
"community": "https://hnrss.org/frontpage",
|
||||||
"max_posts": 50,
|
"max_posts": 50,
|
||||||
"priority": "low"
|
"priority": "low"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"platform": "stackexchange",
|
||||||
|
"community": "stackoverflow",
|
||||||
|
"max_posts": 50,
|
||||||
|
"priority": "medium"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Page Not Found - BalanceBoard</title>
|
<title>Page Not Found - {{ APP_NAME }}</title>
|
||||||
<link rel="stylesheet" href="{{ url_for('serve_theme', filename='modern-card-ui/styles.css') }}">
|
<link rel="stylesheet" href="{{ url_for('serve_theme', filename='modern-card-ui/styles.css') }}">
|
||||||
<style>
|
<style>
|
||||||
.error-container {
|
.error-container {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Server Error - BalanceBoard</title>
|
<title>Server Error - {{ APP_NAME }}</title>
|
||||||
<link rel="stylesheet" href="{{ url_for('serve_theme', filename='modern-card-ui/styles.css') }}">
|
<link rel="stylesheet" href="{{ url_for('serve_theme', filename='modern-card-ui/styles.css') }}">
|
||||||
<style>
|
<style>
|
||||||
.error-container {
|
.error-container {
|
||||||
|
|||||||
587
templates/_admin_base.html
Normal file
587
templates/_admin_base.html
Normal file
@@ -0,0 +1,587 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% block title %}Admin Panel - {{ APP_NAME }}{% endblock %}</title>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('serve_theme', filename='modern-card-ui/styles.css') }}">
|
||||||
|
<style>
|
||||||
|
/* ===== SHARED ADMIN STYLES ===== */
|
||||||
|
.admin-container {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-header {
|
||||||
|
background: linear-gradient(135deg, var(--primary-dark) 0%, #2a5068 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 32px;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
border-bottom: 3px solid var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-header h1 {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-header p {
|
||||||
|
margin: 0;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-section {
|
||||||
|
background: var(--surface-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
box-shadow: 0 2px 4px var(--surface-elevation-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== ADMIN NAVIGATION ===== */
|
||||||
|
.admin-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
border-bottom: 2px solid var(--divider-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn {
|
||||||
|
padding: 12px 24px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 3px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn.active {
|
||||||
|
color: var(--primary-color);
|
||||||
|
border-bottom-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn:hover {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== BUTTONS ===== */
|
||||||
|
.btn,
|
||||||
|
.btn-primary,
|
||||||
|
.btn-secondary {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-block;
|
||||||
|
text-align: center;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: var(--primary-hover);
|
||||||
|
border-color: var(--primary-hover);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: var(--surface-elevation-1);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: var(--divider-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: var(--surface-elevation-2);
|
||||||
|
border-color: var(--divider-color);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled:hover {
|
||||||
|
background: var(--primary-color) !important;
|
||||||
|
border-color: var(--primary-color) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
padding: 6px 12px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn-primary {
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn-primary:hover {
|
||||||
|
background: var(--primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn-danger {
|
||||||
|
background: #dc3545;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn-danger:hover {
|
||||||
|
background: #c82333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn-warning {
|
||||||
|
background: #ffc107;
|
||||||
|
color: #212529;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn-warning:hover {
|
||||||
|
background: #e0a800;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== STATUS BADGES ===== */
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-admin {
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-user {
|
||||||
|
background: var(--background-color);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-active {
|
||||||
|
background: #28a745;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-inactive {
|
||||||
|
background: #6c757d;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-enabled, .status-success {
|
||||||
|
background: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-disabled, .status-error {
|
||||||
|
background: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-running {
|
||||||
|
background: #fff3cd;
|
||||||
|
color: #856404;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== TABLES ===== */
|
||||||
|
.admin-table {
|
||||||
|
background: var(--surface-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 4px var(--surface-elevation-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table th {
|
||||||
|
background: var(--primary-dark);
|
||||||
|
color: white;
|
||||||
|
padding: 16px;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table td {
|
||||||
|
padding: 16px;
|
||||||
|
border-bottom: 1px solid var(--divider-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table tr:hover {
|
||||||
|
background: var(--hover-overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== STATS & CARDS ===== */
|
||||||
|
.admin-stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.admin-stats {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
.admin-stats {
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: var(--surface-color);
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 4px var(--surface-elevation-1);
|
||||||
|
border-left: 4px solid var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-number {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-card {
|
||||||
|
background: var(--background-color);
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border-left: 3px solid var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-card h4 {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-card p {
|
||||||
|
margin: 4px 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-info {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.system-info {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
.system-info {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== FORMS ===== */
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control,
|
||||||
|
.form-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid var(--divider-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control:focus,
|
||||||
|
.form-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid var(--divider-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
background-color: var(--surface-color);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-text {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-source-form {
|
||||||
|
background: var(--surface-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
border: 1px solid var(--divider-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-source-form h3 {
|
||||||
|
margin: 0 0 20px 0;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== MODALS ===== */
|
||||||
|
.modal-overlay {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 1000;
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-overlay.active {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: var(--surface-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
max-width: 500px;
|
||||||
|
width: 90%;
|
||||||
|
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
|
||||||
|
border: 1px solid var(--divider-color);
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
border-bottom: 1px solid var(--divider-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close:hover {
|
||||||
|
background: var(--surface-elevation-1);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid var(--divider-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-alert {
|
||||||
|
background: #fff3cd;
|
||||||
|
border: 1px solid #ffeaa7;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
color: #856404;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== UTILITIES ===== */
|
||||||
|
.back-link {
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
color: var(--primary-color);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flash-messages {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flash-message {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flash-message.success {
|
||||||
|
background: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flash-message.error {
|
||||||
|
background: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flash-message.warning {
|
||||||
|
background: #fff3cd;
|
||||||
|
color: #856404;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== RESPONSIVE ===== */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.admin-tabs {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stats {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-info {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-container {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== PAGE-SPECIFIC OVERRIDES ===== */
|
||||||
|
{% block admin_styles %}{% endblock %}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{% include '_nav.html' %}
|
||||||
|
|
||||||
|
<div class="admin-container">
|
||||||
|
<a href="{{ url_for('index') }}" class="back-link">← Back to Feed</a>
|
||||||
|
|
||||||
|
<div class="admin-header">
|
||||||
|
<h1>{% block page_title %}Admin Panel{% endblock %}</h1>
|
||||||
|
<p>{% block page_description %}Manage system settings and content{% endblock %}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
<div class="flash-messages">
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="flash-message {{ category }}">{{ message }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
{% block admin_content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% block admin_scripts %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
48
templates/_nav.html
Normal file
48
templates/_nav.html
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<!-- Modern Top Navigation -->
|
||||||
|
<nav class="top-nav">
|
||||||
|
<div class="nav-content">
|
||||||
|
<div class="nav-left">
|
||||||
|
<a href="{{ url_for('index') }}" class="logo-section">
|
||||||
|
<img src="{{ url_for('serve_logo') }}" alt="{{ APP_NAME }}" class="nav-logo">
|
||||||
|
<span class="brand-text">{{ APP_NAME }}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nav-center">
|
||||||
|
<div class="search-bar">
|
||||||
|
<input type="text" placeholder="Search content..." class="search-input">
|
||||||
|
<button class="search-btn">🔍</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nav-right">
|
||||||
|
{% if current_user.is_authenticated %}
|
||||||
|
<div class="user-menu">
|
||||||
|
<div class="user-info">
|
||||||
|
<div class="user-avatar">
|
||||||
|
{% if current_user.profile_picture_url %}
|
||||||
|
<img src="{{ current_user.profile_picture_url }}" alt="Avatar">
|
||||||
|
{% else %}
|
||||||
|
<div class="avatar-placeholder">{{ current_user.username[:2].upper() }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<span class="username">{{ current_user.username }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="user-dropdown">
|
||||||
|
<a href="{{ url_for('settings') }}" class="dropdown-item">⚙️ Settings</a>
|
||||||
|
<a href="{{ url_for('bookmarks') }}" class="dropdown-item">📚 Bookmarks</a>
|
||||||
|
{% if current_user.is_admin %}
|
||||||
|
<a href="{{ url_for('admin_panel') }}" class="dropdown-item">👨💼 Admin Panel</a>
|
||||||
|
{% endif %}
|
||||||
|
<a href="{{ url_for('logout') }}" class="dropdown-item">🚪 Logout</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="anonymous-actions">
|
||||||
|
<a href="{{ url_for('login') }}" class="login-btn">🔑 Login</a>
|
||||||
|
<a href="{{ url_for('signup') }}" class="register-btn">📝 Sign Up</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
@@ -1,367 +1,32 @@
|
|||||||
<!DOCTYPE html>
|
{% extends "_admin_base.html" %}
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Admin Panel - BalanceBoard</title>
|
|
||||||
<link rel="stylesheet" href="{{ url_for('serve_theme', filename='modern-card-ui/styles.css') }}">
|
|
||||||
<style>
|
|
||||||
.admin-container {
|
|
||||||
max-width: 1400px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-header {
|
{% block title %}Admin Panel - {{ APP_NAME }}{% endblock %}
|
||||||
background: linear-gradient(135deg, var(--primary-dark) 0%, #2a5068 100%);
|
|
||||||
color: white;
|
|
||||||
padding: 32px;
|
|
||||||
border-radius: 12px;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
border-bottom: 3px solid var(--primary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-header h1 {
|
{% block page_title %}Admin Panel{% endblock %}
|
||||||
margin: 0 0 8px 0;
|
{% block page_description %}Manage users, content, and system settings{% endblock %}
|
||||||
font-size: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-header p {
|
{% block admin_styles %}
|
||||||
margin: 0;
|
.user-avatar {
|
||||||
opacity: 0.9;
|
width: 32px;
|
||||||
}
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--primary-color);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-tabs {
|
.user-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
align-items: center;
|
||||||
margin-bottom: 24px;
|
}
|
||||||
border-bottom: 2px solid var(--divider-color);
|
{% endblock %}
|
||||||
}
|
|
||||||
|
|
||||||
.tab-btn {
|
{% block admin_content %}
|
||||||
padding: 12px 24px;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
border-bottom: 3px solid transparent;
|
|
||||||
cursor: pointer;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-btn.active {
|
|
||||||
color: var(--primary-color);
|
|
||||||
border-bottom-color: var(--primary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-btn:hover {
|
|
||||||
color: var(--primary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-content {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-content.active {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-stats {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
||||||
gap: 16px;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card {
|
|
||||||
background: var(--surface-color);
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 12px;
|
|
||||||
box-shadow: 0 2px 4px var(--surface-elevation-1);
|
|
||||||
border-left: 4px solid var(--primary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-number {
|
|
||||||
font-size: 2rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--primary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-label {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-section {
|
|
||||||
background: var(--surface-color);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 24px;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
box-shadow: 0 2px 4px var(--surface-elevation-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-title {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.users-table {
|
|
||||||
background: var(--surface-color);
|
|
||||||
border-radius: 12px;
|
|
||||||
overflow: hidden;
|
|
||||||
box-shadow: 0 2px 4px var(--surface-elevation-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.users-table table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
}
|
|
||||||
|
|
||||||
.users-table th {
|
|
||||||
background: var(--primary-dark);
|
|
||||||
color: white;
|
|
||||||
padding: 16px;
|
|
||||||
text-align: left;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.users-table td {
|
|
||||||
padding: 16px;
|
|
||||||
border-bottom: 1px solid var(--divider-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.users-table tr:hover {
|
|
||||||
background: var(--hover-overlay);
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 4px 12px;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-admin {
|
|
||||||
background: var(--primary-color);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-user {
|
|
||||||
background: var(--background-color);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-active {
|
|
||||||
background: #28a745;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-inactive {
|
|
||||||
background: #6c757d;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn {
|
|
||||||
padding: 6px 12px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
margin-right: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn-primary {
|
|
||||||
background: var(--primary-color);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn-primary:hover {
|
|
||||||
background: var(--primary-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn-danger {
|
|
||||||
background: #dc3545;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn-danger:hover {
|
|
||||||
background: #c82333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn-warning {
|
|
||||||
background: #ffc107;
|
|
||||||
color: #212529;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn-warning:hover {
|
|
||||||
background: #e0a800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.back-link {
|
|
||||||
display: inline-block;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
color: var(--primary-color);
|
|
||||||
text-decoration: none;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.back-link:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flash-messages {
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flash-message {
|
|
||||||
padding: 12px 16px;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flash-message.success {
|
|
||||||
background: #d4edda;
|
|
||||||
color: #155724;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flash-message.error {
|
|
||||||
background: #f8d7da;
|
|
||||||
color: #721c24;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flash-message.warning {
|
|
||||||
background: #fff3cd;
|
|
||||||
color: #856404;
|
|
||||||
}
|
|
||||||
|
|
||||||
.system-info {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-card {
|
|
||||||
background: var(--background-color);
|
|
||||||
padding: 16px;
|
|
||||||
border-radius: 8px;
|
|
||||||
border-left: 3px solid var(--primary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-card h4 {
|
|
||||||
margin: 0 0 8px 0;
|
|
||||||
color: var(--primary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-card p {
|
|
||||||
margin: 4px 0;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group label {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-control {
|
|
||||||
width: 100%;
|
|
||||||
padding: 8px 12px;
|
|
||||||
border: 1px solid var(--divider-color);
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-control:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--primary-color);
|
|
||||||
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
padding: 8px 16px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background: var(--primary-color);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover {
|
|
||||||
background: var(--primary-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-avatar {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--primary-color);
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: white;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
margin-right: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-info {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.admin-tabs {
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-stats {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.system-info {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="admin-container">
|
|
||||||
<a href="{{ url_for('index') }}" class="back-link">← Back to Feed</a>
|
|
||||||
|
|
||||||
<div class="admin-header">
|
|
||||||
<h1>Admin Panel</h1>
|
|
||||||
<p>Manage users, content, and system settings</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
|
||||||
{% if messages %}
|
|
||||||
<div class="flash-messages">
|
|
||||||
{% for category, message in messages %}
|
|
||||||
<div class="flash-message {{ category }}">{{ message }}</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endwith %}
|
|
||||||
|
|
||||||
<div class="admin-tabs">
|
<div class="admin-tabs">
|
||||||
<button class="tab-btn active" onclick="showTab('overview')">Overview</button>
|
<button class="tab-btn active" onclick="showTab('overview')">Overview</button>
|
||||||
@@ -425,7 +90,7 @@
|
|||||||
<div id="users" class="tab-content">
|
<div id="users" class="tab-content">
|
||||||
<div class="admin-section">
|
<div class="admin-section">
|
||||||
<h3 class="section-title">User Management</h3>
|
<h3 class="section-title">User Management</h3>
|
||||||
<div class="users-table">
|
<div class="admin-table">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -556,24 +221,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{% endblock %}
|
||||||
|
|
||||||
<script>
|
{% block admin_scripts %}
|
||||||
function showTab(tabName) {
|
<script>
|
||||||
// Hide all tabs
|
function showTab(tabName) {
|
||||||
const tabs = document.querySelectorAll('.tab-content');
|
// Hide all tabs
|
||||||
tabs.forEach(tab => tab.classList.remove('active'));
|
const tabs = document.querySelectorAll('.tab-content');
|
||||||
|
tabs.forEach(tab => tab.classList.remove('active'));
|
||||||
|
|
||||||
// Remove active class from all buttons
|
// Remove active class from all buttons
|
||||||
const buttons = document.querySelectorAll('.tab-btn');
|
const buttons = document.querySelectorAll('.tab-btn');
|
||||||
buttons.forEach(btn => btn.classList.remove('active'));
|
buttons.forEach(btn => btn.classList.remove('active'));
|
||||||
|
|
||||||
// Show selected tab
|
// Show selected tab
|
||||||
document.getElementById(tabName).classList.add('active');
|
document.getElementById(tabName).classList.add('active');
|
||||||
|
|
||||||
// Add active class to clicked button
|
// Add active class to clicked button
|
||||||
event.target.classList.add('active');
|
event.target.classList.add('active');
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
{% endblock %}
|
||||||
</html>
|
|
||||||
|
|||||||
@@ -1,52 +1,11 @@
|
|||||||
<!DOCTYPE html>
|
{% extends "_admin_base.html" %}
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Polling Management - Admin - BalanceBoard</title>
|
|
||||||
<link rel="stylesheet" href="{{ url_for('serve_theme', filename='modern-card-ui/styles.css') }}">
|
|
||||||
<style>
|
|
||||||
.admin-container {
|
|
||||||
max-width: 1400px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-header {
|
{% block title %}Polling Management - Admin - {{ APP_NAME }}{% endblock %}
|
||||||
background: linear-gradient(135deg, var(--primary-dark) 0%, #2a5068 100%);
|
|
||||||
color: white;
|
|
||||||
padding: 32px;
|
|
||||||
border-radius: 12px;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge {
|
{% block page_title %}Polling Management{% endblock %}
|
||||||
display: inline-block;
|
{% block page_description %}Manage data collection sources and schedules{% endblock %}
|
||||||
padding: 4px 12px;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-enabled {
|
{% block admin_styles %}
|
||||||
background: #d4edda;
|
|
||||||
color: #155724;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-disabled {
|
|
||||||
background: #f8d7da;
|
|
||||||
color: #721c24;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-success {
|
|
||||||
background: #d4edda;
|
|
||||||
color: #155724;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-error {
|
|
||||||
background: #f8d7da;
|
|
||||||
color: #721c24;
|
|
||||||
}
|
|
||||||
|
|
||||||
.source-card {
|
.source-card {
|
||||||
background: var(--surface-color);
|
background: var(--surface-color);
|
||||||
@@ -174,22 +133,62 @@
|
|||||||
padding: 48px;
|
padding: 48px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="admin-container">
|
|
||||||
<div class="admin-header">
|
|
||||||
<h1>📡 Polling Management</h1>
|
|
||||||
<p>Configure automatic data collection from content sources</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
.add-source-form {
|
||||||
{% if messages %}
|
background: var(--surface-color);
|
||||||
{% for category, message in messages %}
|
border: 1px solid var(--divider-color);
|
||||||
<div class="alert alert-{{ category }}">{{ message }}</div>
|
border-radius: 12px;
|
||||||
{% endfor %}
|
padding: 24px;
|
||||||
{% endif %}
|
margin-bottom: 24px;
|
||||||
{% endwith %}
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input, .form-select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid var(--divider-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scheduler-status {
|
||||||
|
background: var(--surface-color);
|
||||||
|
border: 1px solid var(--divider-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-text {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-success {
|
||||||
|
background: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-error {
|
||||||
|
background: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block admin_content %}
|
||||||
|
|
||||||
<!-- Scheduler Status -->
|
<!-- Scheduler Status -->
|
||||||
<div class="scheduler-status">
|
<div class="scheduler-status">
|
||||||
@@ -497,6 +496,58 @@
|
|||||||
function closeEditModal() {
|
function closeEditModal() {
|
||||||
document.getElementById('edit-modal').style.display = 'none';
|
document.getElementById('edit-modal').style.display = 'none';
|
||||||
}
|
}
|
||||||
</script>
|
{% endblock %}
|
||||||
</body>
|
|
||||||
</html>
|
{% block admin_scripts %}
|
||||||
|
<script>
|
||||||
|
const platformConfig = {{ platform_config|tojson|safe }};
|
||||||
|
|
||||||
|
function updateSourceOptions() {
|
||||||
|
const platformSelect = document.getElementById('platform');
|
||||||
|
const sourceSelect = document.getElementById('source_id');
|
||||||
|
const selectedPlatform = platformSelect.value;
|
||||||
|
|
||||||
|
// Clear existing options
|
||||||
|
sourceSelect.innerHTML = '<option value="">Select source...</option>';
|
||||||
|
|
||||||
|
if (selectedPlatform && platformConfig.platforms[selectedPlatform]) {
|
||||||
|
const communities = platformConfig.platforms[selectedPlatform].communities || [];
|
||||||
|
communities.forEach(community => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = community.id;
|
||||||
|
option.textContent = community.display_name || community.name;
|
||||||
|
option.dataset.displayName = community.display_name || community.name;
|
||||||
|
sourceSelect.appendChild(option);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDisplayName() {
|
||||||
|
const sourceSelect = document.getElementById('source_id');
|
||||||
|
const displayNameInput = document.getElementById('display_name');
|
||||||
|
const selectedOption = sourceSelect.options[sourceSelect.selectedIndex];
|
||||||
|
|
||||||
|
if (selectedOption && selectedOption.dataset.displayName) {
|
||||||
|
displayNameInput.value = selectedOption.dataset.displayName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditModal(sourceId, displayName, interval, maxPosts, fetchComments, priority) {
|
||||||
|
// Fill form with current values
|
||||||
|
const modal2 = document.getElementById('edit-modal');
|
||||||
|
const form = document.getElementById('edit-form');
|
||||||
|
form.action = `/admin/polling/${sourceId}/update`;
|
||||||
|
document.getElementById('edit_display_name').value = displayName;
|
||||||
|
document.getElementById('edit_interval').value = interval;
|
||||||
|
document.getElementById('edit_max_posts').value = maxPosts;
|
||||||
|
document.getElementById('edit_fetch_comments').value = fetchComments;
|
||||||
|
document.getElementById('edit_priority').value = priority;
|
||||||
|
|
||||||
|
modal2.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEditModal() {
|
||||||
|
document.getElementById('edit-modal').style.display = 'none';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,188 +1,84 @@
|
|||||||
<!DOCTYPE html>
|
{% extends "_admin_base.html" %}
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Polling Logs - {{ source.display_name }} - Admin</title>
|
|
||||||
<link rel="stylesheet" href="{{ url_for('serve_theme', filename='modern-card-ui/styles.css') }}">
|
|
||||||
<style>
|
|
||||||
.admin-container {
|
|
||||||
max-width: 1400px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-header {
|
{% block title %}Polling Logs - {{ source.display_name }} - Admin{% endblock %}
|
||||||
background: linear-gradient(135deg, var(--primary-dark) 0%, #2a5068 100%);
|
|
||||||
color: white;
|
|
||||||
padding: 32px;
|
|
||||||
border-radius: 12px;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-table {
|
{% block page_title %}Polling Logs - {{ source.display_name }}{% endblock %}
|
||||||
width: 100%;
|
{% block page_description %}View polling history and error logs for this source{% endblock %}
|
||||||
border-collapse: collapse;
|
|
||||||
background: var(--surface-color);
|
|
||||||
border-radius: 12px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-table th {
|
{% block admin_styles %}
|
||||||
background: var(--primary-color);
|
.error-detail {
|
||||||
color: white;
|
background: #fff3cd;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
text-align: left;
|
border-radius: 6px;
|
||||||
font-weight: 600;
|
margin-top: 8px;
|
||||||
}
|
font-size: 0.9rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.log-table td {
|
.no-logs {
|
||||||
padding: 12px;
|
text-align: center;
|
||||||
border-bottom: 1px solid var(--divider-color);
|
padding: 48px;
|
||||||
}
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
.log-table tr:last-child td {
|
{% block admin_content %}
|
||||||
border-bottom: none;
|
<div class="admin-table">
|
||||||
}
|
{% if logs %}
|
||||||
|
<table>
|
||||||
.status-badge {
|
<thead>
|
||||||
display: inline-block;
|
<tr>
|
||||||
padding: 4px 12px;
|
<th>Timestamp</th>
|
||||||
border-radius: 12px;
|
<th>Status</th>
|
||||||
font-size: 0.85rem;
|
<th>Posts Found</th>
|
||||||
font-weight: 500;
|
<th>New Posts</th>
|
||||||
}
|
<th>Updated Posts</th>
|
||||||
|
<th>Error Details</th>
|
||||||
.status-success {
|
</tr>
|
||||||
background: #d4edda;
|
</thead>
|
||||||
color: #155724;
|
<tbody>
|
||||||
}
|
{% for log in logs %}
|
||||||
|
|
||||||
.status-error {
|
|
||||||
background: #f8d7da;
|
|
||||||
color: #721c24;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-running {
|
|
||||||
background: #fff3cd;
|
|
||||||
color: #856404;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-detail {
|
|
||||||
background: #fff3cd;
|
|
||||||
padding: 12px;
|
|
||||||
border-radius: 6px;
|
|
||||||
margin-top: 8px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
max-height: 200px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
padding: 8px 16px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-weight: 500;
|
|
||||||
text-decoration: none;
|
|
||||||
display: inline-block;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary {
|
|
||||||
background: var(--divider-color);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary:hover {
|
|
||||||
background: #d0d0d0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.no-logs {
|
|
||||||
text-align: center;
|
|
||||||
padding: 48px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="admin-container">
|
|
||||||
<div class="admin-header">
|
|
||||||
<h1>📋 Polling Logs</h1>
|
|
||||||
<p>{{ source.display_name }} ({{ source.platform}}:{{ source.source_id }})</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if logs %}
|
|
||||||
<table class="log-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
<tr>
|
||||||
<th>Started</th>
|
<td>{{ log.poll_time.strftime('%Y-%m-%d %H:%M:%S') }}</td>
|
||||||
<th>Completed</th>
|
<td>
|
||||||
<th>Duration</th>
|
{% if log.status == 'success' %}
|
||||||
<th>Status</th>
|
<span class="status-badge status-success">Success</span>
|
||||||
<th>Posts Found</th>
|
{% elif log.status == 'error' %}
|
||||||
<th>New</th>
|
<span class="status-badge status-error">Error</span>
|
||||||
<th>Updated</th>
|
{% elif log.status == 'running' %}
|
||||||
<th>Details</th>
|
<span class="status-badge status-running">Running</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="status-badge">{{ log.status }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ log.posts_found }}</td>
|
||||||
|
<td>{{ log.posts_new }}</td>
|
||||||
|
<td>{{ log.posts_updated }}</td>
|
||||||
|
<td>
|
||||||
|
{% if log.error_message %}
|
||||||
|
<details>
|
||||||
|
<summary style="cursor: pointer; color: var(--primary-color);">View Error</summary>
|
||||||
|
<div class="error-detail">{{ log.error_message }}</div>
|
||||||
|
</details>
|
||||||
|
{% else %}
|
||||||
|
-
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
{% endfor %}
|
||||||
<tbody>
|
</tbody>
|
||||||
{% for log in logs %}
|
</table>
|
||||||
<tr>
|
{% else %}
|
||||||
<td>{{ log.started_at.strftime('%Y-%m-%d %H:%M:%S') }}</td>
|
<div class="no-logs">
|
||||||
<td>
|
<p>No polling logs yet.</p>
|
||||||
{% if log.completed_at %}
|
<p>Logs will appear here after the first poll.</p>
|
||||||
{{ log.completed_at.strftime('%Y-%m-%d %H:%M:%S') }}
|
|
||||||
{% else %}
|
|
||||||
-
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{% if log.completed_at %}
|
|
||||||
{{ ((log.completed_at - log.started_at).total_seconds())|round(1) }}s
|
|
||||||
{% else %}
|
|
||||||
-
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{% if log.status == 'success' %}
|
|
||||||
<span class="status-badge status-success">Success</span>
|
|
||||||
{% elif log.status == 'error' %}
|
|
||||||
<span class="status-badge status-error">Error</span>
|
|
||||||
{% elif log.status == 'running' %}
|
|
||||||
<span class="status-badge status-running">Running</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="status-badge">{{ log.status }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td>{{ log.posts_found }}</td>
|
|
||||||
<td>{{ log.posts_new }}</td>
|
|
||||||
<td>{{ log.posts_updated }}</td>
|
|
||||||
<td>
|
|
||||||
{% if log.error_message %}
|
|
||||||
<details>
|
|
||||||
<summary style="cursor: pointer; color: var(--primary-color);">View Error</summary>
|
|
||||||
<div class="error-detail">{{ log.error_message }}</div>
|
|
||||||
</details>
|
|
||||||
{% else %}
|
|
||||||
-
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{% else %}
|
|
||||||
<div class="no-logs">
|
|
||||||
<p>No polling logs yet.</p>
|
|
||||||
<p>Logs will appear here after the first poll.</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div style="margin-top: 24px;">
|
|
||||||
<a href="{{ url_for('admin_polling') }}" class="btn btn-secondary">← Back to Polling Management</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{% endif %}
|
||||||
</body>
|
</div>
|
||||||
</html>
|
|
||||||
|
<div style="margin-top: 24px;">
|
||||||
|
<a href="{{ url_for('admin_polling') }}" class="btn btn-secondary">← Back to Polling Management</a>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}Create Admin Account - BalanceBoard{% endblock %}
|
{% block title %}Create Admin Account - {{ APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% include '_nav.html' %}
|
||||||
<div class="auth-container">
|
<div class="auth-container">
|
||||||
<div class="auth-card">
|
<div class="auth-card">
|
||||||
<div class="auth-logo">
|
<div class="auth-logo">
|
||||||
<img src="{{ url_for('serve_logo') }}" alt="BalanceBoard Logo">
|
<a href="{{ url_for('index') }}">
|
||||||
|
<img src="{{ url_for('serve_logo') }}" alt="{{ APP_NAME }} Logo" style="max-width: 80px; border-radius: 50%;">
|
||||||
|
</a>
|
||||||
<h1><span class="balance">balance</span><span class="board">Board</span></h1>
|
<h1><span class="balance">balance</span><span class="board">Board</span></h1>
|
||||||
<p style="color: var(--text-secondary); margin-top: 8px;">Create Administrator Account</p>
|
<p style="color: var(--text-secondary); margin-top: 8px;">Create Administrator Account</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -74,5 +77,60 @@
|
|||||||
.board {
|
.board {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Ensure form styles are properly applied */
|
||||||
|
.auth-form .form-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 2px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
background: var(--background-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
box-shadow: 0 0 0 3px rgba(77, 182, 172, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form button {
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px;
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form button:hover {
|
||||||
|
background: var(--primary-hover);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(77, 182, 172, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-footer {
|
||||||
|
margin-top: 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{% block title %}BalanceBoard{% endblock %}</title>
|
<title>{% block title %}{{ APP_NAME }}{% endblock %}</title>
|
||||||
<link rel="stylesheet" href="{{ url_for('serve_theme', filename='modern-card-ui/styles.css') }}">
|
<link rel="stylesheet" href="{{ url_for('serve_theme', filename='modern-card-ui/styles.css') }}">
|
||||||
<style>
|
<style>
|
||||||
/* Auth pages styling */
|
/* Auth pages styling */
|
||||||
|
|||||||
272
templates/bookmarks.html
Normal file
272
templates/bookmarks.html
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Bookmarks - {{ APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% include '_nav.html' %}
|
||||||
|
|
||||||
|
<div style="max-width: 1200px; margin: 0 auto; padding: 24px;">
|
||||||
|
<div style="margin-bottom: 32px;">
|
||||||
|
<h1 style="color: var(--text-primary); margin-bottom: 8px;">📚 Your Bookmarks</h1>
|
||||||
|
<p style="color: var(--text-secondary); font-size: 1.1rem;">Posts you've saved for later reading</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="bookmarks-container">
|
||||||
|
<div id="loading" style="text-align: center; padding: 40px; color: var(--text-secondary);">
|
||||||
|
<div style="font-size: 1.2rem;">Loading your bookmarks...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<div id="pagination" style="display: none; text-align: center; margin-top: 32px;">
|
||||||
|
<button id="prev-btn" style="padding: 8px 16px; margin: 0 8px; background: var(--surface-elevation-1); border: 1px solid var(--border-color); border-radius: 6px; color: var(--text-primary); cursor: pointer;">← Previous</button>
|
||||||
|
<span id="page-info" style="margin: 0 16px; color: var(--text-secondary);"></span>
|
||||||
|
<button id="next-btn" style="padding: 8px 16px; margin: 0 8px; background: var(--surface-elevation-1); border: 1px solid var(--border-color); border-radius: 6px; color: var(--text-primary); cursor: pointer;">Next →</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.bookmark-item {
|
||||||
|
background: var(--surface-color);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmark-item:hover {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmark-item.archived {
|
||||||
|
opacity: 0.6;
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmark-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmark-title {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
flex: 1;
|
||||||
|
margin-right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmark-title:hover {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmark-remove {
|
||||||
|
background: var(--error-color);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmark-remove:hover {
|
||||||
|
background: var(--error-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmark-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmark-meta span {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmark-preview {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmark-date {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state h3 {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
color: var(--error-color);
|
||||||
|
background: var(--error-bg);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let currentPage = 1;
|
||||||
|
let pagination = null;
|
||||||
|
|
||||||
|
async function loadBookmarks(page = 1) {
|
||||||
|
try {
|
||||||
|
document.getElementById('loading').style.display = 'block';
|
||||||
|
|
||||||
|
const response = await fetch(`/api/bookmarks?page=${page}&per_page=20`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || 'Failed to load bookmarks');
|
||||||
|
}
|
||||||
|
|
||||||
|
renderBookmarks(data.posts);
|
||||||
|
updatePagination(data.pagination);
|
||||||
|
currentPage = page;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading bookmarks:', error);
|
||||||
|
document.getElementById('bookmarks-container').innerHTML = `
|
||||||
|
<div class="error-state">
|
||||||
|
<h3>Error loading bookmarks</h3>
|
||||||
|
<p>${error.message}</p>
|
||||||
|
<button onclick="loadBookmarks()" style="margin-top: 12px; padding: 8px 16px; background: var(--primary-color); color: white; border: none; border-radius: 6px; cursor: pointer;">Retry</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBookmarks(posts) {
|
||||||
|
document.getElementById('loading').style.display = 'none';
|
||||||
|
|
||||||
|
const container = document.getElementById('bookmarks-container');
|
||||||
|
|
||||||
|
if (posts.length === 0) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="empty-state">
|
||||||
|
<h3>📚 No bookmarks yet</h3>
|
||||||
|
<p>Start exploring and bookmark posts you want to read later!</p>
|
||||||
|
<a href="/" style="display: inline-block; margin-top: 16px; padding: 12px 24px; background: var(--primary-color); color: white; text-decoration: none; border-radius: 8px;">Browse Posts</a>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = posts.map(post => `
|
||||||
|
<div class="bookmark-item ${post.archived ? 'archived' : ''}">
|
||||||
|
<div class="bookmark-header">
|
||||||
|
<a href="${post.url}" class="bookmark-title">${post.title}</a>
|
||||||
|
<button class="bookmark-remove" onclick="removeBookmark('${post.id}', this)">
|
||||||
|
🗑️ Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bookmark-meta">
|
||||||
|
<span>👤 ${post.author}</span>
|
||||||
|
<span>📍 ${post.source}</span>
|
||||||
|
<span>⭐ ${post.score}</span>
|
||||||
|
<span>💬 ${post.comments_count}</span>
|
||||||
|
${post.archived ? '<span style="color: var(--warning-color);">📦 Archived</span>' : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bookmark-preview">${post.content_preview}</div>
|
||||||
|
|
||||||
|
<div class="bookmark-date">
|
||||||
|
Bookmarked on ${new Date(post.bookmarked_at).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePagination(paginationData) {
|
||||||
|
pagination = paginationData;
|
||||||
|
const paginationEl = document.getElementById('pagination');
|
||||||
|
|
||||||
|
if (paginationData.total_pages <= 1) {
|
||||||
|
paginationEl.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
paginationEl.style.display = 'block';
|
||||||
|
|
||||||
|
document.getElementById('prev-btn').disabled = !paginationData.has_prev;
|
||||||
|
document.getElementById('next-btn').disabled = !paginationData.has_next;
|
||||||
|
document.getElementById('page-info').textContent = `Page ${paginationData.current_page} of ${paginationData.total_pages}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeBookmark(postId, button) {
|
||||||
|
if (!confirm('Are you sure you want to remove this bookmark?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
button.disabled = true;
|
||||||
|
button.textContent = 'Removing...';
|
||||||
|
|
||||||
|
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 remove bookmark');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload bookmarks to reflect changes
|
||||||
|
loadBookmarks(currentPage);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error removing bookmark:', error);
|
||||||
|
alert('Error removing bookmark: ' + error.message);
|
||||||
|
button.disabled = false;
|
||||||
|
button.textContent = '🗑️ Remove';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pagination event listeners
|
||||||
|
document.getElementById('prev-btn').addEventListener('click', () => {
|
||||||
|
if (pagination && pagination.has_prev) {
|
||||||
|
loadBookmarks(currentPage - 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('next-btn').addEventListener('click', () => {
|
||||||
|
if (pagination && pagination.has_next) {
|
||||||
|
loadBookmarks(currentPage + 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load bookmarks on page load
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
loadBookmarks();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,56 +1,9 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}Dashboard - BalanceBoard{% endblock %}
|
{% block title %}Dashboard - {{ APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<!-- Modern Top Navigation -->
|
{% include '_nav.html' %}
|
||||||
<nav class="top-nav">
|
|
||||||
<div class="nav-content">
|
|
||||||
<div class="nav-left">
|
|
||||||
<div class="logo-section">
|
|
||||||
<img src="{{ url_for('serve_logo') }}" alt="BalanceBoard" class="nav-logo">
|
|
||||||
<span class="brand-text"><span class="brand-balance">balance</span><span class="brand-board">Board</span></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="nav-center">
|
|
||||||
<div class="search-bar">
|
|
||||||
<input type="text" placeholder="Search content..." class="search-input">
|
|
||||||
<button class="search-btn">🔍</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="nav-right">
|
|
||||||
{% if anonymous %}
|
|
||||||
<div class="anonymous-actions">
|
|
||||||
<a href="{{ url_for('login') }}" class="login-btn">🔑 Login</a>
|
|
||||||
<a href="{{ url_for('signup') }}" class="register-btn">📝 Sign Up</a>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
{# This block only executes for authenticated users (per app.py line 278) #}
|
|
||||||
<div class="user-menu">
|
|
||||||
<div class="user-info">
|
|
||||||
<div class="user-avatar">
|
|
||||||
{% if current_user.profile_picture_url %}
|
|
||||||
<img src="{{ current_user.profile_picture_url }}" alt="Avatar">
|
|
||||||
{% else %}
|
|
||||||
<div class="avatar-placeholder">{{ current_user.username[:2].upper() }}</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<span class="username">{{ current_user.username }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="user-dropdown">
|
|
||||||
<a href="{{ url_for('settings') }}" class="dropdown-item">⚙️ Settings</a>
|
|
||||||
{% if current_user.is_admin %}
|
|
||||||
<a href="{{ url_for('admin_panel') }}" class="dropdown-item">👨💼 Admin Panel</a>
|
|
||||||
{% endif %}
|
|
||||||
<a href="{{ url_for('logout') }}" class="dropdown-item">🚪 Logout</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<!-- Main Content Area -->
|
<!-- Main Content Area -->
|
||||||
<main class="main-content">
|
<main class="main-content">
|
||||||
@@ -58,17 +11,9 @@
|
|||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="sidebar-section">
|
<div class="sidebar-section">
|
||||||
<h3>Content Filters</h3>
|
<h3>Content Filters</h3>
|
||||||
<div class="filter-item active" data-filter="no_filter">
|
<div id="filter-list" class="filter-list">
|
||||||
<span class="filter-icon">🌐</span>
|
<!-- Filters will be loaded dynamically -->
|
||||||
<span>All Content</span>
|
<div class="loading-filters">Loading filters...</div>
|
||||||
</div>
|
|
||||||
<div class="filter-item" data-filter="safe_content">
|
|
||||||
<span class="filter-icon">✅</span>
|
|
||||||
<span>Safe Content</span>
|
|
||||||
</div>
|
|
||||||
<div class="filter-item" data-filter="custom">
|
|
||||||
<span class="filter-icon">🎯</span>
|
|
||||||
<span>Custom Filter</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -358,14 +303,14 @@
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-communities {
|
.loading-communities, .loading-filters {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-communities {
|
.no-communities, .no-filters {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
@@ -742,9 +687,11 @@ let postsData = [];
|
|||||||
let currentPage = 1;
|
let currentPage = 1;
|
||||||
let currentCommunity = '';
|
let currentCommunity = '';
|
||||||
let currentPlatform = '';
|
let currentPlatform = '';
|
||||||
|
let currentFilter = 'no_filter';
|
||||||
let paginationData = {};
|
let paginationData = {};
|
||||||
let platformConfig = {};
|
let platformConfig = {};
|
||||||
let communitiesData = [];
|
let communitiesData = [];
|
||||||
|
let filtersData = [];
|
||||||
|
|
||||||
// User experience settings
|
// User experience settings
|
||||||
let userSettings = {{ user_settings|tojson }};
|
let userSettings = {{ user_settings|tojson }};
|
||||||
@@ -752,8 +699,8 @@ let userSettings = {{ user_settings|tojson }};
|
|||||||
// Load posts on page load
|
// Load posts on page load
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
loadPlatformConfig();
|
loadPlatformConfig();
|
||||||
|
loadFilters();
|
||||||
loadPosts();
|
loadPosts();
|
||||||
setupFilterSwitching();
|
|
||||||
setupInfiniteScroll();
|
setupInfiniteScroll();
|
||||||
setupAutoRefresh();
|
setupAutoRefresh();
|
||||||
});
|
});
|
||||||
@@ -762,33 +709,93 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
async function loadPlatformConfig() {
|
async function loadPlatformConfig() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/platforms');
|
const response = await fetch('/api/platforms');
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
platformConfig = data.platforms || {};
|
platformConfig = data.platforms || {};
|
||||||
communitiesData = data.communities || [];
|
communitiesData = data.communities || [];
|
||||||
|
|
||||||
|
console.log('Loaded communities:', communitiesData);
|
||||||
renderCommunities(communitiesData);
|
renderCommunities(communitiesData);
|
||||||
setupCommunityFiltering();
|
setupCommunityFiltering();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading platform configuration:', error);
|
console.error('Error loading platform configuration:', error);
|
||||||
// Show fallback communities
|
// Show fallback communities
|
||||||
const fallbackCommunities = [
|
const fallbackCommunities = [
|
||||||
{platform: 'reddit', id: 'programming', display_name: 'r/programming', icon: '💻', count: 0},
|
{platform: 'reddit', id: 'programming', display_name: 'r/programming', icon: '💻', count: 117},
|
||||||
{platform: 'reddit', id: 'python', display_name: 'r/python', icon: '🐍', count: 0},
|
{platform: 'hackernews', id: 'front_page', display_name: 'Hacker News', icon: '🧮', count: 117},
|
||||||
{platform: 'hackernews', id: 'hackernews', display_name: 'Hacker News', icon: '🧮', count: 0}
|
{platform: 'reddit', id: 'technology', display_name: 'r/technology', icon: '⚡', count: 0}
|
||||||
];
|
];
|
||||||
|
communitiesData = fallbackCommunities;
|
||||||
renderCommunities(fallbackCommunities);
|
renderCommunities(fallbackCommunities);
|
||||||
setupCommunityFiltering();
|
setupCommunityFiltering();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load available filters
|
||||||
|
async function loadFilters() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/filters');
|
||||||
|
const data = await response.json();
|
||||||
|
filtersData = data.filters || [];
|
||||||
|
|
||||||
|
renderFilters(filtersData);
|
||||||
|
setupFilterSwitching();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading filters:', error);
|
||||||
|
// Show fallback filters
|
||||||
|
const fallbackFilters = [
|
||||||
|
{id: 'no_filter', name: 'All Content', icon: '🌐', active: true, description: 'No filtering'}
|
||||||
|
];
|
||||||
|
renderFilters(fallbackFilters);
|
||||||
|
setupFilterSwitching();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render filters in sidebar
|
||||||
|
function renderFilters(filters) {
|
||||||
|
const filterList = document.getElementById('filter-list');
|
||||||
|
if (!filterList) return;
|
||||||
|
|
||||||
|
if (filters.length === 0) {
|
||||||
|
filterList.innerHTML = '<div class="no-filters">No filters available</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtersHTML = filters.map(filter => {
|
||||||
|
return `
|
||||||
|
<div class="filter-item ${filter.active ? 'active' : ''}" data-filter="${filter.id}" title="${filter.description}">
|
||||||
|
<span class="filter-icon">${filter.icon}</span>
|
||||||
|
<span>${filter.name}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
filterList.innerHTML = filtersHTML;
|
||||||
|
|
||||||
|
// Set current filter based on active filter
|
||||||
|
const activeFilter = filters.find(f => f.active);
|
||||||
|
if (activeFilter) {
|
||||||
|
currentFilter = activeFilter.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Render communities in sidebar
|
// Render communities in sidebar
|
||||||
function renderCommunities(communities) {
|
function renderCommunities(communities) {
|
||||||
const communityList = document.getElementById('community-list');
|
const communityList = document.getElementById('community-list');
|
||||||
if (!communityList) return;
|
if (!communityList) return;
|
||||||
|
|
||||||
if (communities.length === 0) {
|
console.log('Rendering communities:', communities);
|
||||||
communityList.innerHTML = '<div class="no-communities">No communities available</div>';
|
|
||||||
return;
|
if (!communities || communities.length === 0) {
|
||||||
|
// Always show fallback communities if none are loaded
|
||||||
|
const fallbackCommunities = [
|
||||||
|
{platform: 'reddit', id: 'programming', display_name: 'r/programming', icon: '💻', count: 117},
|
||||||
|
{platform: 'hackernews', id: 'front_page', display_name: 'Hacker News', icon: '🧮', count: 117},
|
||||||
|
{platform: 'reddit', id: 'technology', display_name: 'r/technology', icon: '⚡', count: 0}
|
||||||
|
];
|
||||||
|
communities = fallbackCommunities;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add "All Communities" option at the top
|
// Add "All Communities" option at the top
|
||||||
@@ -814,7 +821,7 @@ function renderCommunities(communities) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load posts from API
|
// Load posts from API
|
||||||
async function loadPosts(page = 1, community = '', platform = '', append = false) {
|
async function loadPosts(page = 1, community = '', platform = '', append = false, filter = null) {
|
||||||
try {
|
try {
|
||||||
// Build query parameters
|
// Build query parameters
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
@@ -822,6 +829,7 @@ async function loadPosts(page = 1, community = '', platform = '', append = false
|
|||||||
params.append('per_page', 20);
|
params.append('per_page', 20);
|
||||||
if (community) params.append('community', community);
|
if (community) params.append('community', community);
|
||||||
if (platform) params.append('platform', platform);
|
if (platform) params.append('platform', platform);
|
||||||
|
if (filter || currentFilter) params.append('filter', filter || currentFilter);
|
||||||
if (currentSearchQuery) params.append('q', currentSearchQuery);
|
if (currentSearchQuery) params.append('q', currentSearchQuery);
|
||||||
|
|
||||||
const response = await fetch(`/api/posts?${params}`);
|
const response = await fetch(`/api/posts?${params}`);
|
||||||
@@ -1015,24 +1023,34 @@ function savePost(postId) {
|
|||||||
|
|
||||||
// Filter switching functionality
|
// Filter switching functionality
|
||||||
function setupFilterSwitching() {
|
function setupFilterSwitching() {
|
||||||
const filterItems = document.querySelectorAll('.filter-item');
|
document.addEventListener('click', function(event) {
|
||||||
|
if (event.target.closest('.filter-item')) {
|
||||||
|
const filterItem = event.target.closest('.filter-item');
|
||||||
|
|
||||||
filterItems.forEach(item => {
|
// Remove active class from all filter items
|
||||||
item.addEventListener('click', function() {
|
document.querySelectorAll('.filter-item').forEach(f => f.classList.remove('active'));
|
||||||
// Remove active class from all items
|
|
||||||
filterItems.forEach(f => f.classList.remove('active'));
|
|
||||||
|
|
||||||
// Add active class to clicked item
|
// Add active class to clicked item
|
||||||
this.classList.add('active');
|
filterItem.classList.add('active');
|
||||||
|
|
||||||
// Get filter type
|
// Get filter type
|
||||||
const filterType = this.dataset.filter;
|
const filterType = filterItem.dataset.filter;
|
||||||
|
currentFilter = filterType;
|
||||||
|
|
||||||
// Apply filter (for now just reload)
|
// Update header to show current filter
|
||||||
if (filterType && filterType !== 'custom') {
|
const contentHeader = document.querySelector('.content-header h1');
|
||||||
loadPosts(); // In future, pass filter parameter
|
const filterName = filterItem.textContent.trim();
|
||||||
}
|
contentHeader.textContent = `${filterName} Feed`;
|
||||||
});
|
|
||||||
|
// Show loading state
|
||||||
|
const postsContainer = document.getElementById('posts-container');
|
||||||
|
const loadingIndicator = document.getElementById('loading-indicator');
|
||||||
|
loadingIndicator.style.display = 'flex';
|
||||||
|
postsContainer.innerHTML = '';
|
||||||
|
|
||||||
|
// Apply filter
|
||||||
|
loadPosts(1, currentCommunity, currentPlatform, false, filterType);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}Log In - BalanceBoard{% endblock %}
|
{% block title %}Log In - {{ APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="auth-container">
|
<div class="auth-container">
|
||||||
<div class="auth-card">
|
<div class="auth-card">
|
||||||
<div class="auth-logo">
|
<div class="auth-logo">
|
||||||
<img src="{{ url_for('serve_logo') }}" alt="BalanceBoard Logo">
|
<img src="{{ url_for('serve_logo') }}" alt="{{ APP_NAME }} Logo">
|
||||||
<h1><span class="balance">balance</span>Board</h1>
|
<h1><span class="balance">balance</span>Board</h1>
|
||||||
<p style="color: var(--text-secondary); margin-top: 8px;">Welcome back!</p>
|
<p style="color: var(--text-secondary); margin-top: 8px;">Welcome back!</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}Set New Password - BalanceBoard{% endblock %}
|
{% block title %}Set New Password - {{ APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="auth-container">
|
<div class="auth-container">
|
||||||
<div class="auth-card">
|
<div class="auth-card">
|
||||||
<div class="auth-logo">
|
<div class="auth-logo">
|
||||||
<img src="{{ url_for('serve_logo') }}" alt="BalanceBoard Logo">
|
<img src="{{ url_for('serve_logo') }}" alt="{{ APP_NAME }} Logo">
|
||||||
<h1><span class="balance">balance</span>Board</h1>
|
<h1><span class="balance">balance</span>Board</h1>
|
||||||
<p style="color: var(--text-secondary); margin-top: 8px;">Set a new password</p>
|
<p style="color: var(--text-secondary); margin-top: 8px;">Set a new password</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}Reset Password - BalanceBoard{% endblock %}
|
{% block title %}Reset Password - {{ APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="auth-container">
|
<div class="auth-container">
|
||||||
<div class="auth-card">
|
<div class="auth-card">
|
||||||
<div class="auth-logo">
|
<div class="auth-logo">
|
||||||
<img src="{{ url_for('serve_logo') }}" alt="BalanceBoard Logo">
|
<img src="{{ url_for('serve_logo') }}" alt="{{ APP_NAME }} Logo">
|
||||||
<h1><span class="balance">balance</span>Board</h1>
|
<h1><span class="balance">balance</span>Board</h1>
|
||||||
<p style="color: var(--text-secondary); margin-top: 8px;">Reset your password</p>
|
<p style="color: var(--text-secondary); margin-top: 8px;">Reset your password</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,69 +1,29 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}{{ post.title }} - BalanceBoard{% endblock %}
|
{% block title %}{{ post.title }} - {{ APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<!-- Modern Top Navigation -->
|
{% include '_nav.html' %}
|
||||||
<nav class="top-nav">
|
|
||||||
<div class="nav-content">
|
|
||||||
<div class="nav-left">
|
|
||||||
<div class="logo-section">
|
|
||||||
<img src="{{ url_for('serve_logo') }}" alt="BalanceBoard" class="nav-logo">
|
|
||||||
<span class="brand-text"><span class="brand-balance">balance</span><span class="brand-board">Board</span></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="nav-center">
|
|
||||||
<div class="search-bar">
|
|
||||||
<input type="text" placeholder="Search content..." class="search-input">
|
|
||||||
<button class="search-btn">🔍</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="nav-right">
|
|
||||||
{% if current_user.is_authenticated %}
|
|
||||||
<div class="user-menu">
|
|
||||||
<div class="user-info">
|
|
||||||
<div class="user-avatar">
|
|
||||||
{% if current_user.profile_picture_url %}
|
|
||||||
<img src="{{ current_user.profile_picture_url }}" alt="Avatar">
|
|
||||||
{% else %}
|
|
||||||
<div class="avatar-placeholder">{{ current_user.username[:2].upper() }}</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<span class="username">{{ current_user.username }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="user-dropdown">
|
|
||||||
<a href="{{ url_for('settings') }}" class="dropdown-item">⚙️ Settings</a>
|
|
||||||
{% if current_user.is_admin %}
|
|
||||||
<a href="{{ url_for('admin_panel') }}" class="dropdown-item">👨💼 Admin Panel</a>
|
|
||||||
{% endif %}
|
|
||||||
<a href="{{ url_for('logout') }}" class="dropdown-item">🚪 Logout</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="auth-buttons">
|
|
||||||
<a href="{{ url_for('login') }}" class="auth-btn">Login</a>
|
|
||||||
<a href="{{ url_for('signup') }}" class="auth-btn primary">Sign Up</a>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<!-- Main Content Area -->
|
<!-- Main Content Area -->
|
||||||
<main class="main-content single-post">
|
<main class="main-content single-post">
|
||||||
<!-- Back Button -->
|
<!-- Back Button -->
|
||||||
<div class="back-section">
|
<div class="back-section">
|
||||||
<button onclick="goBackToFeed()" class="back-btn">← Back to Feed</button>
|
<a href="#" onclick="goBackToFeed(event)" class="back-btn">← Back to Feed</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Post Content -->
|
<!-- Post Content -->
|
||||||
<article class="post-detail">
|
<article class="post-detail">
|
||||||
<div class="post-header">
|
<div class="post-header">
|
||||||
|
{% if post.url and not post.url.startswith('/') %}
|
||||||
|
<a href="{{ post.url }}" target="_blank" class="platform-badge platform-{{ post.platform }}" title="View on {{ post.platform.title() }}">
|
||||||
|
{{ post.platform.title()[:1] }}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
<div class="platform-badge platform-{{ post.platform }}">
|
<div class="platform-badge platform-{{ post.platform }}">
|
||||||
{{ post.platform.title()[:1] }}
|
{{ post.platform.title()[:1] }}
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
<div class="post-meta">
|
<div class="post-meta">
|
||||||
<span class="post-author">{{ post.author }}</span>
|
<span class="post-author">{{ post.author }}</span>
|
||||||
<span class="post-separator">•</span>
|
<span class="post-separator">•</span>
|
||||||
@@ -105,7 +65,7 @@
|
|||||||
🐙 View on GitHub
|
🐙 View on GitHub
|
||||||
{% elif post.platform == 'devto' %}
|
{% elif post.platform == 'devto' %}
|
||||||
📝 View on Dev.to
|
📝 View on Dev.to
|
||||||
{% elif post.platform == 'stackoverflow' %}
|
{% elif post.platform == 'stackexchange' %}
|
||||||
📚 View on Stack Overflow
|
📚 View on Stack Overflow
|
||||||
{% else %}
|
{% else %}
|
||||||
🔗 View Original Source
|
🔗 View Original Source
|
||||||
@@ -202,6 +162,14 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-left .logo-section:hover {
|
||||||
|
transform: scale(1.02);
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-logo {
|
.nav-logo {
|
||||||
@@ -359,6 +327,35 @@
|
|||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.anonymous-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-btn, .register-btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-btn {
|
||||||
|
color: #2c3e50;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-btn {
|
||||||
|
background: #4db6ac;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-btn:hover, .register-btn:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Main Content */
|
/* Main Content */
|
||||||
.main-content.single-post {
|
.main-content.single-post {
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
@@ -660,13 +657,23 @@
|
|||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function goBackToFeed() {
|
function goBackToFeed(event) {
|
||||||
// Try to go back to the dashboard if possible
|
event.preventDefault();
|
||||||
if (document.referrer && document.referrer.includes(window.location.origin)) {
|
|
||||||
|
// Try to go back in browser history first
|
||||||
|
if (window.history.length > 1 && document.referrer && document.referrer.includes(window.location.origin)) {
|
||||||
window.history.back();
|
window.history.back();
|
||||||
} else {
|
} else {
|
||||||
// Fallback to dashboard
|
// Fallback to dashboard - construct URL with current query parameters
|
||||||
window.location.href = '/';
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const baseUrl = {{ url_for('index')|tojson }};
|
||||||
|
|
||||||
|
// Add query parameters if they exist
|
||||||
|
if (urlParams.toString()) {
|
||||||
|
window.location.href = baseUrl + '?' + urlParams.toString();
|
||||||
|
} else {
|
||||||
|
window.location.href = baseUrl;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}Settings - BalanceBoard{% endblock %}
|
{% block title %}Settings - {{ APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
{% block extra_css %}
|
{% block extra_css %}
|
||||||
<style>
|
<style>
|
||||||
@@ -230,6 +230,7 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% include '_nav.html' %}
|
||||||
<div class="settings-container">
|
<div class="settings-container">
|
||||||
<div class="settings-header">
|
<div class="settings-header">
|
||||||
<h1>Settings</h1>
|
<h1>Settings</h1>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}Community Settings - BalanceBoard{% endblock %}
|
{% block title %}Community Settings - {{ APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
{% block extra_css %}
|
{% block extra_css %}
|
||||||
<style>
|
<style>
|
||||||
.settings-container {
|
.settings-container {
|
||||||
max-width: 1000px;
|
max-width: 1200px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
}
|
}
|
||||||
@@ -79,12 +79,25 @@
|
|||||||
.platform-icon.reddit { background: #ff4500; }
|
.platform-icon.reddit { background: #ff4500; }
|
||||||
.platform-icon.hackernews { background: #ff6600; }
|
.platform-icon.hackernews { background: #ff6600; }
|
||||||
.platform-icon.lobsters { background: #ac130d; }
|
.platform-icon.lobsters { background: #ac130d; }
|
||||||
.platform-icon.stackoverflow { background: #f48024; }
|
.platform-icon.stackexchange { background: #f48024; }
|
||||||
|
|
||||||
.community-grid {
|
.community-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.community-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1400px) {
|
||||||
|
.community-grid {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.community-item {
|
.community-item {
|
||||||
@@ -235,7 +248,14 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% include '_nav.html' %}
|
||||||
<div class="settings-container">
|
<div class="settings-container">
|
||||||
|
<nav style="margin-bottom: 24px;">
|
||||||
|
<a href="{{ url_for('settings') }}" style="color: var(--primary-color); text-decoration: none; font-weight: 500;">
|
||||||
|
← Back to Settings
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
<div class="settings-header">
|
<div class="settings-header">
|
||||||
<h1>Community Settings</h1>
|
<h1>Community Settings</h1>
|
||||||
<p>Select which communities, subreddits, and sources to include in your feed</p>
|
<p>Select which communities, subreddits, and sources to include in your feed</p>
|
||||||
@@ -268,7 +288,7 @@
|
|||||||
<div class="platform-group">
|
<div class="platform-group">
|
||||||
<h3>
|
<h3>
|
||||||
<span class="platform-icon {{ platform }}">
|
<span class="platform-icon {{ platform }}">
|
||||||
{% if platform == 'reddit' %}R{% elif platform == 'hackernews' %}H{% elif platform == 'lobsters' %}L{% elif platform == 'stackoverflow' %}S{% endif %}
|
{% if platform == 'reddit' %}R{% elif platform == 'hackernews' %}H{% elif platform == 'lobsters' %}L{% elif platform == 'stackexchange' %}S{% endif %}
|
||||||
</span>
|
</span>
|
||||||
{{ platform|title }}
|
{{ platform|title }}
|
||||||
</h3>
|
</h3>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}Experience Settings - BalanceBoard{% endblock %}
|
{% block title %}Experience Settings - {{ APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
{% block extra_css %}
|
{% block extra_css %}
|
||||||
<style>
|
<style>
|
||||||
@@ -241,6 +241,7 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% include '_nav.html' %}
|
||||||
<div class="experience-settings">
|
<div class="experience-settings">
|
||||||
<div class="experience-header">
|
<div class="experience-header">
|
||||||
<h1>Experience Settings</h1>
|
<h1>Experience Settings</h1>
|
||||||
@@ -330,6 +331,34 @@
|
|||||||
<span class="toggle-slider"></span>
|
<span class="toggle-slider"></span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Time-based Content Filter -->
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-content">
|
||||||
|
<div class="setting-text">
|
||||||
|
<h3>Show Recent Posts Only</h3>
|
||||||
|
<p>Only show posts from the last few days instead of all posts</p>
|
||||||
|
<div class="time-filter-options" style="margin-top: 12px; {% if not experience_settings.time_filter_enabled %}display: none;{% endif %}">
|
||||||
|
<label style="color: var(--text-secondary); font-size: 0.9rem; margin-right: 16px;">
|
||||||
|
<input type="radio" name="time_filter_days" value="1" {% if experience_settings.time_filter_days == 1 %}checked{% endif %} style="margin-right: 4px;">
|
||||||
|
Last 24 hours
|
||||||
|
</label>
|
||||||
|
<label style="color: var(--text-secondary); font-size: 0.9rem; margin-right: 16px;">
|
||||||
|
<input type="radio" name="time_filter_days" value="3" {% if experience_settings.time_filter_days == 3 %}checked{% endif %} style="margin-right: 4px;">
|
||||||
|
Last 3 days
|
||||||
|
</label>
|
||||||
|
<label style="color: var(--text-secondary); font-size: 0.9rem; margin-right: 16px;">
|
||||||
|
<input type="radio" name="time_filter_days" value="7" {% if experience_settings.time_filter_days == 7 or not experience_settings.time_filter_days %}checked{% endif %} style="margin-right: 4px;">
|
||||||
|
Last week
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" name="time_filter_enabled" {% if experience_settings.time_filter_enabled %}checked{% endif %} onchange="toggleTimeFilterOptions(this)">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
@@ -338,4 +367,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function toggleTimeFilterOptions(checkbox) {
|
||||||
|
const options = document.querySelector('.time-filter-options');
|
||||||
|
if (checkbox.checked) {
|
||||||
|
options.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
options.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}Filter Settings - BalanceBoard{% endblock %}
|
{% block title %}Filter Settings - {{ APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
{% block extra_css %}
|
{% block extra_css %}
|
||||||
<style>
|
<style>
|
||||||
@@ -263,6 +263,7 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% include '_nav.html' %}
|
||||||
<div class="settings-container">
|
<div class="settings-container">
|
||||||
<div class="settings-header">
|
<div class="settings-header">
|
||||||
<h1>Filter Settings</h1>
|
<h1>Filter Settings</h1>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}Profile Settings - BalanceBoard{% endblock %}
|
{% block title %}Profile Settings - {{ APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
{% block extra_css %}
|
{% block extra_css %}
|
||||||
<style>
|
<style>
|
||||||
@@ -225,6 +225,7 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% include '_nav.html' %}
|
||||||
<div class="settings-container">
|
<div class="settings-container">
|
||||||
<div class="settings-header">
|
<div class="settings-header">
|
||||||
<h1>Profile Settings</h1>
|
<h1>Profile Settings</h1>
|
||||||
@@ -242,31 +243,29 @@
|
|||||||
{% endwith %}
|
{% endwith %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="POST">
|
<div class="profile-section">
|
||||||
<div class="profile-section">
|
<h2>Profile Picture</h2>
|
||||||
<h2>Profile Picture</h2>
|
<div class="profile-avatar">
|
||||||
<div class="profile-avatar">
|
<div class="avatar-preview">
|
||||||
<div class="avatar-preview">
|
{% if user.profile_picture_url %}
|
||||||
{% if user.profile_picture_url %}
|
<img src="{{ user.profile_picture_url }}" alt="{{ user.username }}">
|
||||||
<img src="{{ user.profile_picture_url }}" alt="{{ user.username }}">
|
{% else %}
|
||||||
{% else %}
|
{{ user.username[0]|upper }}
|
||||||
{{ user.username[0]|upper }}
|
{% endif %}
|
||||||
{% endif %}
|
</div>
|
||||||
</div>
|
<div class="avatar-info">
|
||||||
<div class="avatar-info">
|
<h3>Current Avatar</h3>
|
||||||
<h3>Current Avatar</h3>
|
<p>Upload a new profile picture to personalize your account</p>
|
||||||
<p>Upload a new profile picture to personalize your account</p>
|
<form id="upload-form" method="POST" action="{{ url_for('upload_avatar') }}" enctype="multipart/form-data">
|
||||||
<form id="upload-form" method="POST" action="{{ url_for('upload_avatar') }}" enctype="multipart/form-data">
|
<div class="file-upload">
|
||||||
<div class="file-upload">
|
<input type="file" id="avatar" name="avatar" accept="image/*">
|
||||||
<input type="file" id="avatar" name="avatar" accept="image/*" onchange="this.form.submit()">
|
<label for="avatar" class="file-upload-label">Choose New Picture</label>
|
||||||
<label for="avatar" class="file-upload-label">Choose New Picture</label>
|
</div>
|
||||||
</div>
|
<p class="help-text">PNG, JPG, or GIF. Maximum size 2MB.</p>
|
||||||
<p class="help-text">PNG, JPG, or GIF. Maximum size 2MB.</p>
|
</form>
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</div>
|
||||||
|
|
||||||
<form method="POST">
|
<form method="POST">
|
||||||
<div class="profile-section">
|
<div class="profile-section">
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}Sign Up - BalanceBoard{% endblock %}
|
{% block title %}Sign Up - {{ APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="auth-container">
|
<div class="auth-container">
|
||||||
<div class="auth-card">
|
<div class="auth-card">
|
||||||
<div class="auth-logo">
|
<div class="auth-logo">
|
||||||
<img src="{{ url_for('serve_logo') }}" alt="BalanceBoard Logo">
|
<img src="{{ url_for('serve_logo') }}" alt="{{ APP_NAME }} Logo">
|
||||||
<h1><span class="balance">balance</span>Board</h1>
|
<h1><span class="balance">balance</span>Board</h1>
|
||||||
<p style="color: var(--text-secondary); margin-top: 8px;">Create your account</p>
|
<p style="color: var(--text-secondary); margin-top: 8px;">Create your account</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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