Compare commits

...

24 Commits

Author SHA1 Message Date
chelsea
718cc36973 Fix platform-agnostic community filtering logic
Enhanced the community matching logic in /api/posts to work for all platforms by using platform-agnostic matching rules:

1. Exact source match (source == community)
2. Platform match (platform == community)
3. Partial source match (substring)
4. Partial post ID match (substring)

This resolves the issue where users with empty communities couldn't see posts and works equally well for Reddit, HackerNews, Lobsters, GitHub, etc.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-12 18:04:22 -05:00
chelsea
9d286c8466 Fix IndentationError causing logged-in users to see no feed
The issue was caused by incorrect indentation in the community filtering logic (lines 451-468) which prevented the app from starting properly.

Fixed:
- Corrected 13-space indentation to 12 spaces for proper Python syntax
- Ensured consistent 4-space tab width throughout the block

This resolves the urgent issue where logged-in users couldn't see their feed.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-12 16:30:21 -05:00
chelsea
e40d5463a6 Fix community visibility for logged-in users - add robust error handling and fallback communities to prevent 'No communities available' display 2025-10-12 16:04:59 -05:00
chelsea
52cf5c0092 Standardize admin panel styling inconsistencies
- Added missing form classes to admin base template
- Standardized button styles across all admin templates
- Added modal styling with consistent spacing and colors
- Added form input, select, and label styling
- Added utility classes for add-source forms
- Removed duplicate CSS classes from individual templates
- Improved button hover states and disabled states

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-12 15:27:53 -05:00
chelsea
efabac7fd5 Fix community settings page 500 error
- Added comprehensive error handling for platform config loading
- Added validation for collection_targets structure
- Added defensive programming for community data processing
- Added logging for debugging community list building
- Prevents crashes when config files are missing or malformed

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-12 15:22:53 -05:00
chelsea
b438762758 Fix post detail page navigation issues
- Updated back button to use JavaScript function instead of direct href
- Added URL parameter preservation when navigating back to feed
- Improved fallback logic to handle browser history properly

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-12 15:16:38 -05:00
chelsea
301c2b33f0 Re-enable community filtering for logged-in users
Restores the user's community preference filtering that was temporarily
disabled to fix an urgent feed issue for logged-in users.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-12 15:14:44 -05:00
chelsea
fc440eafa3 Fix post detail page navigation issues
- Change back to feed button from onclick to proper link to ensure it always works
- Make platform badge clickable to external source when available (upper left clickable link)
- This addresses the issue where posts had no upper left clickable link and back button didn't work
2025-10-12 13:28:41 -05:00
chelsea
343d6b51ea Temporarily disable community filter to fix urgent issue: logged in users not seeing feed
This disables the community-based filtering in /api/posts to allow logged in users to see posts in their feed. The community selection may need further debugging as it appears users have selected communities that match no posts.
2025-10-12 13:27:41 -05:00
chelsea
02d4e5a7e4 Fix community settings and admin panel layout issues
## Problems Fixed:
1. **Community settings grid layout** - Cards were too spread out horizontally
2. **Missing navigation elements** - No back link or breadcrumbs on community settings
3. **Admin panel grid layouts** - Stats and system info cards too wide on large screens
4. **Responsive layout issues** - Poor layout on different screen sizes

## Root Cause:
The CSS grid layouts were using `auto-fill` and `auto-fit` with minimum sizes that
caused elements to spread too wide on large screens, creating an awkward UI.

## Solutions Implemented:

### Community Settings Page:
- **Improved grid layout** - Better responsive grid with max 3 columns
- **Added back navigation** - "← Back to Settings" link for better UX
- **Responsive breakpoints** - Single column on mobile, max 3 on desktop
- **Better max-width** - Increased container width for better balance

### Admin Panel Base Template:
- **Fixed admin stats grid** - Limited to 4 columns max, responsive breakpoints
- **Fixed system info grid** - Limited to 3 columns max with proper responsive design
- **Added mobile support** - Single column layout on mobile devices
- **Improved spacing** - Better gap management and max-width constraints

### CSS Improvements:
- Mobile-first responsive design
- Proper breakpoints at 768px and 1200px
- Maximum column limits to prevent over-stretching
- Better visual balance on all screen sizes

The admin and community settings interfaces now have proper, responsive layouts\!

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-12 04:08:09 -05:00
chelsea
b5d30c6427 Fix community settings page 500 error - missing platform config loading
## Problem Fixed:
The /settings/communities page was throwing a 500 error with:
`NameError: name 'platform_config' is not defined`

## Root Cause:
In the settings_communities() function, I was trying to use platform_config
without loading it first using the load_platform_config() function.

## Solution:
Added platform_config = load_platform_config() before using the variable.

This fixes the 500 error when users try to access community settings.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-12 04:00:47 -05:00
chelsea
736d8fc7c1 Fix Issue #16: Resolve admin setup page styling and form corruption
## Problem Fixed:
The admin setup page (admin_setup.html) had incomplete form styling that could appear "corrupted" or broken, especially when form validation errors occurred.

## Root Cause:
The admin_setup.html template was missing explicit form group styles and relying only on base.html auth-form styles, which weren't sufficient for all form states and could lead to layout issues.

## Solution Implemented:

### Enhanced Admin Setup Form Styling
- **Added explicit form-group styles** - Ensures proper spacing and layout
- **Complete auth-form style definitions** - All form elements now have consistent styling
- **Proper focus states** - Form inputs have correct focus indicators
- **Box-sizing fix** - Prevents layout overflow issues
- **Enhanced button styling** - Consistent with other admin pages
- **Form validation support** - Proper styling for error states

### Style Additions:
- Form group margin and spacing
- Input field padding, borders, and backgrounds
- Focus states with proper color transitions
- Button hover effects and animations
- Auth footer styling for better layout

The admin setup page now has robust, consistent styling that matches the rest of the admin interface and won't appear corrupted under various states.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-12 03:33:48 -05:00
chelsea
8d4c8dfbad Fix Issue #17: Standardize admin page styling and eliminate inconsistencies
## Problems Fixed:
1. **Template inheritance inconsistency** - Some admin pages used standalone HTML while others extended base.html
2. **CSS duplication** - Each admin page had duplicate styles for similar components
3. **Header styling variations** - Different admin pages had slightly different header styles
4. **Inconsistent class naming** - Mixed naming patterns across admin templates

## Root Cause:
Admin pages were developed independently without a shared styling foundation, leading to code duplication and visual inconsistencies.

## Solution Implemented:

### New Shared Admin Base
- **Created `_admin_base.html`** - Unified base template for all admin pages
- **Consolidated styles** - Moved common admin styles to shared base template
- **Standardized components** - Unified buttons, tables, badges, forms, etc.
- **Consistent layout** - Standard admin container, header, and navigation structure

### Refactored Templates
- **`admin.html`** - Now extends `_admin_base.html`, removed 300+ lines of duplicate CSS
- **`admin_polling.html`** - Converted to use base template, cleaner structure
- **`admin_polling_logs.html`** - Completely rewritten to use base template
- **Consistent class names** - All admin tables now use `.admin-table` instead of mixed names

### Benefits
- **Maintainability** - Single source of truth for admin styling
- **Consistency** - All admin pages now have identical look and feel
- **Performance** - Reduced CSS duplication improves load times
- **Extensibility** - Easy to add new admin pages with consistent styling

All admin pages now share a unified, professional appearance\!

Commit: [current]

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-12 03:31:07 -05:00
chelsea
94ffa69d21 Fix Issue #18: Community settings now match admin panel configuration
## Problem Fixed:
Community selection in settings was using hardcoded list that didn't match the actual enabled communities in the admin panel's collection_targets configuration.

## Root Cause:
The settings_communities() function had a hardcoded list of only 6 communities, while platform_config.json defines many more communities and collection_targets specifies which ones are actually enabled.

## Solution:
- **Dynamic community loading** - Reads from platform_config.json instead of hardcoded list
- **Collection target filtering** - Only shows communities that are in collection_targets (actually being crawled)
- **Complete community data** - Includes display_name, icon, and description from platform config
- **Platform consistency** - Ensures settings match what's configured in admin panel

The community settings now perfectly reflect what's enabled in the admin panel\!

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-12 03:26:50 -05:00
chelsea
146ad754c0 Fix filtration system - connect sidebar filters to filter pipeline
Investigation revealed the filtration system had several issues:

1. **Hardcoded filters**: Sidebar had hardcoded filters that didn't match actual filtersets
2. **No backend integration**: Filter switching didn't pass filter parameters to API
3. **Missing filter endpoint**: No API to get available filters dynamically

Fixes implemented:
- Added /api/filters endpoint to get available filtersets from filter engine
- Updated dashboard to load filters dynamically from backend
- Connected filter switching to actually apply different filtersets
- Added filter override parameter to /api/posts endpoint
- Updated JavaScript to properly handle filter state and switching
- Added proper loading states and error handling

Available filters now show:
- All Content (no_filter)
- Safe Content (safe_content)
- Tech Only (tech_only)
- High Quality (high_quality)
- Custom Example (custom_example)

The filtration system now properly applies the selected filterset to posts
using the existing filter pipeline infrastructure.

Fixes #19

~claude

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-12 03:22:11 -05:00
chelsea
cdc415b0c1 Implement comprehensive bookmark/save system
Added full bookmark functionality for users to save posts:

Backend Features:
- New Bookmark model with user_id, post_uuid, and cached metadata
- API endpoints:
  - POST /api/bookmark - Toggle bookmark status
  - GET /api/bookmarks - Get user's bookmarks with pagination
  - GET /api/bookmark-status/<uuid> - Check if post is bookmarked
- Database migration script for bookmarks table

Frontend Features:
- New /bookmarks page with post list and management
- Bookmark buttons on post cards with save/unsave toggle
- Real-time bookmark status loading and updates
- Navigation menu integration
- Responsive design with archived post handling

UI Components:
- Modern bookmark button with hover states
- Pagination for bookmark listings
- Empty state for users with no bookmarks
- Error handling and loading states
- Remove bookmark functionality

The system handles:
- Unique bookmarks per user/post combination
- Cached post metadata for performance
- Graceful handling of deleted/archived posts
- Authentication requirements for all bookmark features

Users can now save posts for later reading and manage their bookmarks
through a dedicated bookmarks page.

Fixes #20

~claude

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-12 03:18:55 -05:00
chelsea
48868df4d9 Add time-based post filtering feature
Implemented configurable time-based filtering to show only recent posts:

- Added new experience settings for time filtering:
  - time_filter_enabled: Toggle to enable/disable time filtering
  - time_filter_days: Number of days to show (1, 3, or 7 days)

Changes:
- Updated settings_experience.html with time filter controls
- Added JavaScript toggle for showing/hiding time filter options
- Modified backend to save and validate new time filter settings
- Updated API posts endpoint to filter posts by timestamp when enabled
- Added time filtering to anonymous user default settings

Users can now limit their feed to show only posts from:
- Last 24 hours
- Last 3 days
- Last week (default)

This addresses the need for a "show only posts from last x time" feature
as a default filtering option.

Fixes #21

~claude

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-12 03:13:58 -05:00
chelsea
ac94215f84 Add configurable logo and application name support
Added environment variables APP_NAME and LOGO_PATH to make the application
branding configurable:

- APP_NAME (default: 'BalanceBoard'): Sets the application name
- LOGO_PATH (default: 'logo.png'): Sets the logo file path

Changes:
- Added configuration variables to app.py
- Updated logo serving route to support custom paths
- Added template context processor to inject APP_NAME
- Updated all templates to use {{ APP_NAME }} instead of hardcoded 'BalanceBoard'
- Updated navigation and branding to use configurable values

Users can now customize their installation by setting:
export APP_NAME="My Custom Board"
export LOGO_PATH="/path/to/my/logo.png"

Fixes #22

~claude

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-12 03:11:23 -05:00
chelsea
b47155cc36 Fix Stack Overflow crawling platform name mismatch
The issue was that Stack Overflow was configured with platform name
'stackoverflow' but the data collection code expected 'stackexchange'.
Fixed by:

1. Renamed platform from 'stackoverflow' to 'stackexchange' in platform_config.json
2. Added Stack Overflow collection target to enable crawling
3. Updated templates and app.py to use the correct platform name
4. Added default 'stackoverflow' community alongside existing featured/newest

This resolves the platform name mismatch that prevented Stack Overflow
from being crawlable.

Fixes #23

~claude

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-12 03:07:41 -05:00
chelsea
29a9d521e7 Fix community selection filtering in API posts endpoint
The issue was that user community preferences stored in settings weren't
being applied when fetching posts. Added logic to filter posts based on
user's selected communities from their settings.

Fixes #24

~claude

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-12 03:05:02 -05:00
chelsea
2c518fce4a Fix avatar upload username/email requirement issue (Issue #12)
- Remove duplicate onchange handler from avatar file input
- Prevent potential form submission conflicts between inline JS and event listener
- Avatar upload now properly uses only the JavaScript event listener with validation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-12 00:58:03 -05:00
f92851b415 Fix navigation and logo issues (Issues #13, #14)
- Add navigation bar to admin setup page (Fixes issue #14)
- Make logo clickable to go to front page in admin setup (Fixes issue #13)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-12 00:50:11 -05:00
466badd326 Fix avatar upload requiring username/email (Issue #12)
- Remove unnecessary wrapper form around Profile Picture section
- Avatar upload form is now standalone, not nested
- Prevents browser validation from requiring username/email when uploading avatar

Fixes #12
2025-10-11 23:58:01 -05:00
29b4a9d339 Add topbar navigation to all pages and make logo clickable (Issues #14, #13)
- Create reusable _nav.html navigation include
- Add topbar to all settings pages (settings, profile, communities, filters, experience)
- Add topbar to all admin pages (admin, polling, polling_logs, setup)
- Replace hardcoded nav in dashboard with include
- Wrap logo in link to index page (fixes clicking logo to go home)

Fixes #14, #13
2025-10-11 23:56:52 -05:00
28 changed files with 2119 additions and 878 deletions

509
app.py
View File

@@ -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
View 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()

View File

@@ -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}>'

View File

@@ -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"
} }
] ]
} }

View File

@@ -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 {

View File

@@ -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
View 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
View 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>

View File

@@ -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>

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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
View 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 %}

View File

@@ -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);
}
}); });
} }

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;
}
} }
} }

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 %}

View File

@@ -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>

View File

@@ -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">

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 */