Compare commits

...

25 Commits

Author SHA1 Message Date
chelsea
9e5f27316e worked on hackernews schema converter 2025-10-12 21:16:15 -05:00
chelsea
1a6ad08079 fix(data): Correct post source field before saving to fix community filtering 2025-10-12 20:53:15 -05:00
chelsea
1a999ab00b additional debugging to find user_communinties value 2025-10-12 20:38:26 -05:00
chelsea
72b453d6dd additional debugging added to api_post() 2025-10-12 20:19:03 -05:00
chelsea
ea24102053 refactored api_posts() in app.py and added some debugging to trace issue 28 2025-10-12 19:42:01 -05:00
fecafc15ee fixed file permission error causing reboot loop
d
2025-10-12 23:40:33,603 - apscheduler.scheduler - INFO - Scheduler started
2025-10-12 23:40:33,605 - polling_service - INFO - Polling scheduler started
2025-10-12 23:40:33,605 - apscheduler.scheduler - INFO - Added job "Check and poll sources" to job store "default"
2025-10-12 23:40:33,606 - polling_service - INFO - Poll checker job scheduled
2025-10-12 23:40:33,610 - filter_pipeline.config - INFO - Loaded filter config from filter_config.json
2025-10-12 23:40:33,610 - filter_pipeline.config - INFO - Loaded 5 filtersets from filtersets.json
Traceback (most recent call last):
  File "<frozen runpy>", line 198, in _run_module_as_main
  File "<frozen runpy>", line 88, in _run_code
  File "/usr/local/lib/python3.12/site-packages/flask/__main__.py", line 3, in <module>
    main()
  File "/usr/local/lib/python3.12/site-packages/flask/cli.py", line 1131, in main
    cli.main()
  File "/usr/local/lib/python3.12/site-packages/click/core.py", line 1383, in main
    rv = self.invoke(ctx)
         ^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.12/site-packages/click/core.py", line 1850, in invoke
    return _process_result(sub_ctx.command.invoke(sub_ctx))
                           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.12/site-packages/click/core.py", line 1246, in invoke
    return ctx.invoke(self.callback, **ctx.params)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.12/site-packages/click/core.py", line 814, in invoke
    return callback(*args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.12/site-packages/click/decorators.py", line 93, in new_func
    return ctx.invoke(f, obj, *args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.12/site-packages/click/core.py", line 814, in invoke
    return callback(*args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.12/site-packages/flask/cli.py", line 979, in run_command
    raise e from None
  File "/usr/local/lib/python3.12/site-packages/flask/cli.py", line 963, in run_command
    app: WSGIApplication = info.load_app()  # pyright: ignore
                           ^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.12/site-packages/flask/cli.py", line 349, in load_app
    app = locate_app(import_name, name)
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.12/site-packages/flask/cli.py", line 245, in locate_app
    __import__(module_name)
  File "/app/app.py", line 91, in <module>
✓ Database tables created
    filter_engine = FilterEngine.get_instance()
                    ^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/app/filter_pipeline/engine.py", line 55, in get_instance
    cls._instance = cls()
                    ^^^^^
  File "/app/filter_pipeline/engine.py", line 43, in __init__
    self.cache = FilterCache(self.config.get_cache_dir())
                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/app/filter_pipeline/cache.py", line 28, in __init__
    self.cache_dir.mkdir(parents=True, exist_ok=True)
  File "/usr/local/lib/python3.12/pathlib.py", line 1311, in mkdir
    os.mkdir(self, mode)
PermissionError: [Errno 13] Permission denied: 'data/filter_cach
2025-10-12 23:43:42 +00: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
b0b9a9e912 Fix Auth0 500 error when not configured (Issue #5)
- Add check for AUTH0 credentials before attempting login
- Show friendly error message if Auth0 not configured
- Hide Auth0 button on login page when not configured
- Add try/catch for auth0.authorize_redirect() failures

Fixes #5
2025-10-11 23:40:48 -05:00
6a1834bbd2 Fix quick stats to show dynamic data (Issue #7)
- Add calculate_quick_stats() to get real-time post counts
- Calculate posts from last 24 hours instead of hardcoded value
- Pass quick_stats to dashboard template
- Update template to display dynamic posts_today count

Fixes #7
2025-10-11 23:38:06 -05:00
63fa44ed2c Fix comment tree display (Issue #10)
- Add build_comment_tree() to organize comments hierarchically
- Create recursive Jinja macro to render nested comments
- Add visual styling with left border and indentation
- Comments now display as threaded tree structure

Fixes #10
2025-10-11 23:36:27 -05:00
b84ebce8f1 Fix /settings/filters 500 error - change filter_sets from list to dict
Template expects dictionary but route was passing a list, causing:
jinja2.exceptions.UndefinedError: 'list object' has no attribute 'no_filter'

Changed to build dictionary mapping filter names to full configs.

Fixes #9
2025-10-11 23:15:29 -05:00
b87fb829ca Add comprehensive filter pipeline documentation
Documentation includes:
- Architecture overview (3-level caching)
- Pipeline stages description
- Configuration guide
- Usage examples (user & developer)
- AI integration setup
- Performance benchmarks
- Monitoring and troubleshooting
- Plugin system guide
- Built-in filtersets documentation

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 23:00:34 -05:00
32 changed files with 2253 additions and 825 deletions

368
FILTER_PIPELINE.md Normal file
View File

@@ -0,0 +1,368 @@
# Filter Pipeline Documentation
## Overview
BalanceBoard's Filter Pipeline is a plugin-based content filtering system that provides intelligent categorization, moderation, and ranking of posts using AI-powered analysis with aggressive caching for cost efficiency.
## Architecture
### Three-Level Caching System
1. **Level 1: Memory Cache** (5-minute TTL)
- In-memory cache for fast repeated access
- Cleared on application restart
2. **Level 2: AI Analysis Cache** (Permanent, content-hash based)
- Stores AI results (categorization, moderation, quality scores)
- Keyed by SHA-256 hash of content
- Never expires - same content always returns cached results
- **Huge cost savings**: Never re-analyze the same content
3. **Level 3: Filterset Results Cache** (24-hour TTL)
- Stores final filter results per filterset
- Invalidated when filterset definition changes
- Enables instant filterset switching
### Pipeline Stages
Posts flow through 4 sequential stages:
```
Raw Post
1. Categorizer (AI: topic detection, tags)
2. Moderator (AI: safety, quality, sentiment)
3. Filter (Rules: apply filterset conditions)
4. Ranker (Score: quality + recency + source + engagement)
Filtered & Scored Post
```
#### Stage 1: Categorizer
- **Purpose**: Detect topics and assign categories
- **AI Model**: Llama 70B (cheap model)
- **Caching**: Permanent (by content hash)
- **Output**: Categories, category scores, tags
#### Stage 2: Moderator
- **Purpose**: Safety and quality analysis
- **AI Model**: Llama 70B (cheap model)
- **Caching**: Permanent (by content hash)
- **Metrics**:
- Violence score (0.0-1.0)
- Sexual content score (0.0-1.0)
- Hate speech score (0.0-1.0)
- Harassment score (0.0-1.0)
- Quality score (0.0-1.0)
- Sentiment (positive/neutral/negative)
#### Stage 3: Filter
- **Purpose**: Apply filterset rules
- **AI**: None (fast rule evaluation)
- **Rules Supported**:
- `equals`, `not_equals`
- `in`, `not_in`
- `min`, `max`
- `includes_any`, `excludes`
#### Stage 4: Ranker
- **Purpose**: Calculate relevance scores
- **Scoring Factors**:
- Quality (30%): From Moderator stage
- Recency (25%): Age-based decay
- Source Tier (25%): Platform reputation
- Engagement (20%): Upvotes + comments
## Configuration
### filter_config.json
```json
{
"ai": {
"enabled": false,
"openrouter_key_file": "openrouter_key.txt",
"models": {
"cheap": "meta-llama/llama-3.3-70b-instruct",
"smart": "meta-llama/llama-3.3-70b-instruct"
},
"parallel_workers": 10,
"timeout_seconds": 60
},
"cache": {
"enabled": true,
"ai_cache_dir": "data/filter_cache",
"filterset_cache_ttl_hours": 24
},
"pipeline": {
"default_stages": ["categorizer", "moderator", "filter", "ranker"],
"batch_size": 50,
"enable_parallel": true
}
}
```
### filtersets.json
Each filterset defines filtering rules:
```json
{
"safe_content": {
"description": "Filter for safe, family-friendly content",
"post_rules": {
"moderation.flags.is_safe": {"equals": true},
"moderation.content_safety.violence": {"max": 0.3},
"moderation.content_safety.sexual_content": {"max": 0.2},
"moderation.content_safety.hate_speech": {"max": 0.1}
},
"comment_rules": {
"moderation.flags.is_safe": {"equals": true}
}
}
}
```
## Usage
### User Perspective
1. Navigate to **Settings → Filters**
2. Select a filterset from the dropdown
3. Save preferences
4. Feed automatically applies your filterset
5. Posts sorted by relevance score (highest first)
### Developer Perspective
```python
from filter_pipeline import FilterEngine
# Get singleton instance
engine = FilterEngine.get_instance()
# Apply filterset to posts
filtered_posts = engine.apply_filterset(
posts=raw_posts,
filterset_name='safe_content',
use_cache=True
)
# Access filter metadata
for post in filtered_posts:
score = post['_filter_score'] # 0.0-1.0
categories = post['_filter_categories'] # ['technology', 'programming']
tags = post['_filter_tags'] # ['reddit', 'python']
```
## AI Integration
### Enabling AI
1. **Get OpenRouter API Key**:
- Sign up at https://openrouter.ai
- Generate API key
2. **Configure**:
```bash
echo "your-api-key-here" > openrouter_key.txt
```
3. **Enable in config**:
```json
{
"ai": {
"enabled": true
}
}
```
4. **Restart application**
### Cost Efficiency
- **Model**: Llama 70B only (~$0.0003/1K tokens)
- **Caching**: Permanent AI result cache
- **Estimate**: ~$0.001 per post (first time), $0 (cached)
- **10,000 posts**: ~$10 first time, ~$0 cached
## Performance
### Benchmarks
- **With Cache Hit**: < 10ms per post
- **With Cache Miss (AI)**: ~500ms per post
- **Parallel Processing**: 10 workers (configurable)
- **Typical Feed Load**: 100 posts in < 1 second (cached)
### Cache Hit Rates
After initial processing:
- **AI Cache**: ~95% hit rate (content rarely changes)
- **Filterset Cache**: ~80% hit rate (depends on TTL)
- **Memory Cache**: ~60% hit rate (5min TTL)
## Monitoring
### Cache Statistics
```python
stats = filter_engine.get_cache_stats()
# {
# 'memory_cache_size': 150,
# 'ai_cache_size': 5000,
# 'filterset_cache_size': 8,
# 'ai_cache_dir': '/app/data/filter_cache',
# 'filterset_cache_dir': '/app/data/filter_cache/filtersets'
# }
```
### Logs
Filter pipeline logs to `app.log`:
```
INFO - FilterEngine initialized with 5 filtersets
DEBUG - Categorizer: Cache hit for a3f5c2e8...
DEBUG - Moderator: Analyzed b7d9e1f3... (quality: 0.75)
DEBUG - Filter: Post passed filterset 'safe_content'
DEBUG - Ranker: Post score=0.82 (q:0.75, r:0.90, s:0.70, e:0.85)
```
## Filtersets
### no_filter
- **Description**: No filtering - all content passes
- **Use Case**: Default, unfiltered feed
- **Rules**: None
- **AI**: Disabled
### safe_content
- **Description**: Family-friendly content only
- **Use Case**: Safe browsing
- **Rules**:
- Violence < 0.3
- Sexual content < 0.2
- Hate speech < 0.1
- **AI**: Required
### tech_only
- **Description**: Technology and programming content
- **Use Case**: Tech professionals
- **Rules**:
- Platform: hackernews, reddit, lobsters, stackoverflow
- Topics: technology, programming, software (confidence > 0.5)
- **AI**: Required
### high_quality
- **Description**: High quality posts only
- **Use Case**: Curated feed
- **Rules**:
- Score ≥ 10
- Quality ≥ 0.6
- Readability grade ≤ 14
- **AI**: Required
## Plugin System
### Creating Custom Plugins
```python
from filter_pipeline.plugins import BaseFilterPlugin
class MyCustomPlugin(BaseFilterPlugin):
def get_name(self) -> str:
return "MyCustomFilter"
def should_filter(self, post: dict, context: dict = None) -> bool:
# Return True to filter OUT (reject) post
title = post.get('title', '').lower()
return 'spam' in title
def score(self, post: dict, context: dict = None) -> float:
# Return score 0.0-1.0
return 0.5
```
### Built-in Plugins
- **KeywordFilterPlugin**: Blocklist/allowlist filtering
- **QualityFilterPlugin**: Length, caps, clickbait detection
## Troubleshooting
### Issue: AI Not Working
**Check**:
1. `filter_config.json`: `"enabled": true`
2. OpenRouter API key file exists
3. Logs for API errors
**Solution**:
```bash
# Test API key
curl -H "Authorization: Bearer $(cat openrouter_key.txt)" \
https://openrouter.ai/api/v1/models
```
### Issue: Posts Not Filtered
**Check**:
1. User has filterset selected in settings
2. Filterset exists in filtersets.json
3. Posts match filter rules
**Solution**:
```python
# Check user settings
user_settings = json.loads(current_user.settings)
print(user_settings.get('filter_set')) # Should not be 'no_filter'
```
### Issue: Slow Performance
**Check**:
1. Cache enabled in config
2. Cache hit rates
3. Parallel processing enabled
**Solution**:
```json
{
"cache": {"enabled": true},
"pipeline": {"enable_parallel": true, "parallel_workers": 10}
}
```
## Future Enhancements
- [ ] Database persistence for FilterResults
- [ ] Filter statistics dashboard
- [ ] Custom user-defined filtersets
- [ ] A/B testing different filter configurations
- [ ] Real-time filter updates without restart
- [ ] Multi-language support
- [ ] Advanced ML models for categorization
## Contributing
When adding new filtersets:
1. Define in `filtersets.json`
2. Test with sample posts
3. Document rules and use case
4. Consider AI requirements
When adding new stages:
1. Extend `BaseStage`
2. Implement `process()` method
3. Use caching where appropriate
4. Add to `pipeline_config.json`
## License
AGPL-3.0 with commercial licensing option (see LICENSE file)

478
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['ALLOW_ANONYMOUS_ACCESS'] = os.getenv('ALLOW_ANONYMOUS_ACCESS', 'true').lower() == 'true'
# Application branding configuration
app.config['APP_NAME'] = os.getenv('APP_NAME', 'BalanceBoard')
app.config['LOGO_PATH'] = os.getenv('LOGO_PATH', 'logo.png')
# Auth0 Configuration
app.config['AUTH0_DOMAIN'] = os.getenv('AUTH0_DOMAIN', '')
app.config['AUTH0_CLIENT_ID'] = os.getenv('AUTH0_CLIENT_ID', '')
@@ -215,10 +219,15 @@ def _validate_user_settings(settings_str):
exp = settings['experience']
if isinstance(exp, dict):
safe_exp = {}
bool_fields = ['infinite_scroll', 'auto_refresh', 'push_notifications', 'dark_patterns_opt_in']
bool_fields = ['infinite_scroll', 'auto_refresh', 'push_notifications', 'dark_patterns_opt_in', 'time_filter_enabled']
for field in bool_fields:
if field in exp and isinstance(exp[field], bool):
safe_exp[field] = exp[field]
# Handle time_filter_days as integer
if 'time_filter_days' in exp and isinstance(exp['time_filter_days'], int) and exp['time_filter_days'] > 0:
safe_exp['time_filter_days'] = exp['time_filter_days']
validated['experience'] = safe_exp
return validated
@@ -271,9 +280,32 @@ def check_first_user():
pass
def calculate_quick_stats():
"""Calculate quick stats for dashboard"""
from datetime import datetime, timedelta
cached_posts, _ = _load_posts_cache()
# Calculate posts from today (last 24 hours)
now = datetime.utcnow()
today_start = now - timedelta(hours=24)
today_timestamp = today_start.timestamp()
posts_today = sum(1 for post in cached_posts.values()
if post.get('timestamp', 0) >= today_timestamp)
return {
'posts_today': posts_today,
'total_posts': len(cached_posts)
}
@app.route('/')
def index():
"""Serve the main feed page"""
# Calculate stats
quick_stats = calculate_quick_stats()
if current_user.is_authenticated:
# Load user settings
try:
@@ -282,7 +314,7 @@ def index():
logger.warning(f"Invalid user settings JSON for user {current_user.id}: {e}")
user_settings = {}
return render_template('dashboard.html', user_settings=user_settings)
return render_template('dashboard.html', user_settings=user_settings, quick_stats=quick_stats)
else:
# Check if anonymous access is allowed
if app.config.get('ALLOW_ANONYMOUS_ACCESS', False):
@@ -294,10 +326,12 @@ def index():
'infinite_scroll': False,
'auto_refresh': False,
'push_notifications': False,
'dark_patterns_opt_in': False
'dark_patterns_opt_in': False,
'time_filter_enabled': False,
'time_filter_days': 7
}
}
return render_template('dashboard.html', user_settings=user_settings, anonymous=True)
return render_template('dashboard.html', user_settings=user_settings, anonymous=True, quick_stats=quick_stats)
else:
# Redirect non-authenticated users to login
return redirect(url_for('login'))
@@ -362,44 +396,103 @@ def api_posts():
community = request.args.get('community', '')
platform = request.args.get('platform', '')
search_query = request.args.get('q', '').lower().strip()
filter_override = request.args.get('filter', '')
# Get user's filterset preference (or default to no_filter)
# Get user's filterset preference, community selections, and time filter
filterset_name = 'no_filter'
user_communities = []
time_filter_enabled = False
time_filter_days = 7
if current_user.is_authenticated:
try:
user_settings = json.loads(current_user.settings) if current_user.settings else {}
filterset_name = user_settings.get('filter_set', 'no_filter')
user_communities = user_settings.get('communities', [])
experience_settings = user_settings.get('experience', {})
time_filter_enabled = experience_settings.get('time_filter_enabled', False)
time_filter_days = experience_settings.get('time_filter_days', 7)
except:
filterset_name = 'no_filter'
user_communities = []
time_filter_enabled = False
time_filter_days = 7
# Override filterset if specified in request (for sidebar filter switching)
if filter_override and _is_safe_filterset(filter_override):
filterset_name = filter_override
# Use cached data for better performance
cached_posts, cached_comments = _load_posts_cache()
# Collect raw posts for filtering
raw_posts = []
for post_uuid, post_data in cached_posts.items():
# Apply community filter (before filterset)
# 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()
# ====================================================================
# START OF REFACTORED SECTION
# ====================================================================
def _post_should_be_included(post_data):
"""Check if a post passes all pre-filterset criteria."""
# Apply time filter
if time_filter_enabled and time_cutoff:
if post_data.get('timestamp', 0) < time_cutoff:
return False
# Apply community filter
if community and post_data.get('source', '').lower() != community.lower():
continue
return False
# Apply platform filter (before filterset)
# Apply platform filter
if platform and post_data.get('platform', '').lower() != platform.lower():
continue
return False
# Apply search filter (before filterset)
# Apply user's community preferences
if user_communities:
post_source = post_data.get('source', '').lower()
post_platform = post_data.get('platform', '').lower()
if not any(
post_source == c or post_platform == c or c in post_source
for c in user_communities
):
# ====================================================================
# MODIFICATION: Add logging here
# ====================================================================
logger.error(
f"Post filtered out for user {current_user.id if current_user.is_authenticated else 'anonymous'}: "
f"Community mismatch. Platform='{post_platform}', Source='{post_source}', "
f"User Communities={user_communities}"
)
# ====================================================================
return False
# Apply search filter
if search_query:
title = post_data.get('title', '').lower()
content = post_data.get('content', '').lower()
author = post_data.get('author', '').lower()
source = post_data.get('source', '').lower()
if not (search_query in title or
search_query in content or
search_query in author or
search_query in source):
continue
return False
return True
raw_posts.append(post_data)
# Collect raw posts using a clean, declarative list comprehension
raw_posts = [
post_data for post_data in cached_posts.values()
if _post_should_be_included(post_data)
]
# ====================================================================
# END OF REFACTORED SECTION
# ====================================================================
# Apply filterset using FilterEngine
filtered_posts = filter_engine.apply_filterset(raw_posts, filterset_name, use_cache=True)
@@ -575,35 +668,266 @@ def api_content_timestamp():
return jsonify({'error': 'Failed to get content timestamp'}), 500
@app.route('/api/bookmark', methods=['POST'])
@login_required
def api_bookmark():
"""Toggle bookmark status for a post"""
try:
from models import Bookmark
data = request.get_json()
if not data or 'post_uuid' not in data:
return jsonify({'error': 'Missing post_uuid'}), 400
post_uuid = data['post_uuid']
if not post_uuid:
return jsonify({'error': 'Invalid post_uuid'}), 400
# Check if bookmark already exists
existing_bookmark = Bookmark.query.filter_by(
user_id=current_user.id,
post_uuid=post_uuid
).first()
if existing_bookmark:
# Remove bookmark
db.session.delete(existing_bookmark)
db.session.commit()
return jsonify({'bookmarked': False, 'message': 'Bookmark removed'})
else:
# Add bookmark - get post data for caching
cached_posts, _ = _load_posts_cache()
post_data = cached_posts.get(post_uuid, {})
bookmark = Bookmark(
user_id=current_user.id,
post_uuid=post_uuid,
title=post_data.get('title', ''),
platform=post_data.get('platform', ''),
source=post_data.get('source', '')
)
db.session.add(bookmark)
db.session.commit()
return jsonify({'bookmarked': True, 'message': 'Bookmark added'})
except Exception as e:
logger.error(f"Error toggling bookmark: {e}")
return jsonify({'error': 'Failed to toggle bookmark'}), 500
@app.route('/api/bookmarks')
@login_required
def api_bookmarks():
"""Get user's bookmarks"""
try:
from models import Bookmark
page = int(request.args.get('page', 1))
per_page = int(request.args.get('per_page', DEFAULT_PAGE_SIZE))
# Get user's bookmarks with pagination
bookmarks_query = Bookmark.query.filter_by(user_id=current_user.id).order_by(Bookmark.created_at.desc())
total_bookmarks = bookmarks_query.count()
bookmarks = bookmarks_query.offset((page - 1) * per_page).limit(per_page).all()
# Load current posts cache to get updated data
cached_posts, cached_comments = _load_posts_cache()
# Build response
bookmark_posts = []
for bookmark in bookmarks:
# Try to get current post data, fallback to cached data
post_data = cached_posts.get(bookmark.post_uuid)
if post_data:
# Post still exists in current data
comment_count = len(cached_comments.get(bookmark.post_uuid, []))
post = {
'id': bookmark.post_uuid,
'title': post_data.get('title', bookmark.title or 'Untitled'),
'author': post_data.get('author', 'Unknown'),
'platform': post_data.get('platform', bookmark.platform or 'unknown'),
'score': post_data.get('score', 0),
'timestamp': post_data.get('timestamp', 0),
'url': f'/post/{bookmark.post_uuid}',
'comments_count': comment_count,
'content_preview': (post_data.get('content', '') or '')[:200] + '...' if post_data.get('content') else '',
'source': post_data.get('source', bookmark.source or ''),
'bookmarked_at': bookmark.created_at.isoformat(),
'external_url': post_data.get('url', '')
}
else:
# Post no longer in current data, use cached bookmark data
post = {
'id': bookmark.post_uuid,
'title': bookmark.title or 'Untitled',
'author': 'Unknown',
'platform': bookmark.platform or 'unknown',
'score': 0,
'timestamp': 0,
'url': f'/post/{bookmark.post_uuid}',
'comments_count': 0,
'content_preview': 'Content no longer available',
'source': bookmark.source or '',
'bookmarked_at': bookmark.created_at.isoformat(),
'external_url': '',
'archived': True # Mark as archived
}
bookmark_posts.append(post)
total_pages = (total_bookmarks + per_page - 1) // per_page
has_next = page < total_pages
has_prev = page > 1
return jsonify({
'posts': bookmark_posts,
'pagination': {
'current_page': page,
'total_pages': total_pages,
'total_posts': total_bookmarks,
'per_page': per_page,
'has_next': has_next,
'has_prev': has_prev
}
})
except Exception as e:
logger.error(f"Error getting bookmarks: {e}")
return jsonify({'error': 'Failed to get bookmarks'}), 500
@app.route('/api/bookmark-status/<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):
"""Build a hierarchical comment tree from flat comment list"""
# Create lookup dict by UUID
comment_dict = {c['uuid']: {**c, 'replies': []} for c in comments}
# Build tree structure
root_comments = []
for comment in comments:
parent_uuid = comment.get('parent_comment_uuid')
if parent_uuid and parent_uuid in comment_dict:
# Add as reply to parent
comment_dict[parent_uuid]['replies'].append(comment_dict[comment['uuid']])
else:
# Top-level comment
root_comments.append(comment_dict[comment['uuid']])
# Sort at each level by timestamp
def sort_tree(comments_list):
comments_list.sort(key=lambda x: x.get('timestamp', 0))
for comment in comments_list:
if comment.get('replies'):
sort_tree(comment['replies'])
sort_tree(root_comments)
return root_comments
@app.route('/post/<post_id>')
def post_detail(post_id):
"""Serve individual post detail page with modern theme"""
try:
# Load platform configuration
platform_config = load_platform_config()
# Use cached data for better performance
cached_posts, cached_comments = _load_posts_cache()
# Get post data from cache
post_data = cached_posts.get(post_id)
if not post_data:
return render_template('404.html'), 404
# Add source display name
post_data['source_display'] = get_display_name_for_source(
post_data.get('platform', ''),
post_data.get('source', ''),
platform_config
)
# Get comments from cache
comments = cached_comments.get(post_id, [])
logger.info(f"Loading post {post_id}: found {len(comments)} comments")
# Sort comments by timestamp
comments.sort(key=lambda x: x.get('timestamp', 0))
# Get comments from cache
comments_flat = cached_comments.get(post_id, [])
logger.info(f"Loading post {post_id}: found {len(comments_flat)} comments")
# Build comment tree
comments = build_comment_tree(comments_flat)
# Load user settings if authenticated
user_settings = {}
if current_user.is_authenticated:
@@ -611,9 +935,9 @@ def post_detail(post_id):
user_settings = json.loads(current_user.settings) if current_user.settings else {}
except:
user_settings = {}
return render_template('post_detail.html', post=post_data, comments=comments, user_settings=user_settings)
except Exception as e:
print(f"Error loading post {post_id}: {e}")
return render_template('404.html'), 404
@@ -631,8 +955,16 @@ def serve_theme(filename):
@app.route('/logo.png')
def serve_logo():
"""Serve logo"""
return send_from_directory('.', 'logo.png')
"""Serve configurable logo"""
logo_path = app.config['LOGO_PATH']
# If it's just a filename, serve from current directory
if '/' not in logo_path:
return send_from_directory('.', logo_path)
else:
# If it's a full path, split directory and filename
directory = os.path.dirname(logo_path)
filename = os.path.basename(logo_path)
return send_from_directory(directory, filename)
@app.route('/static/<path:filename>')
def serve_static(filename):
@@ -654,6 +986,9 @@ def login():
if current_user.is_authenticated:
return redirect(url_for('index'))
# Check if Auth0 is configured
auth0_configured = bool(app.config.get('AUTH0_DOMAIN') and app.config.get('AUTH0_CLIENT_ID'))
if request.method == 'POST':
username = request.form.get('username')
password = request.form.get('password')
@@ -661,7 +996,7 @@ def login():
if not user_service:
flash('User service not available', 'error')
return render_template('login.html')
return render_template('login.html', auth0_configured=auth0_configured)
user = user_service.authenticate(username, password)
@@ -675,7 +1010,7 @@ def login():
else:
flash('Invalid username or password', 'error')
return render_template('login.html')
return render_template('login.html', auth0_configured=auth0_configured)
@app.route('/password-reset-request', methods=['GET', 'POST'])
@@ -754,8 +1089,18 @@ def password_reset(token):
@app.route('/auth0/login')
def auth0_login():
"""Redirect to Auth0 for authentication"""
redirect_uri = url_for('auth0_callback', _external=True)
return auth0.authorize_redirect(redirect_uri)
# Check if Auth0 is configured
if not app.config.get('AUTH0_DOMAIN') or not app.config.get('AUTH0_CLIENT_ID'):
flash('Auth0 authentication is not configured. Please use email/password login or contact the administrator.', 'error')
return redirect(url_for('login'))
try:
redirect_uri = url_for('auth0_callback', _external=True)
return auth0.authorize_redirect(redirect_uri)
except Exception as e:
logger.error(f"Auth0 login error: {e}")
flash('Auth0 authentication failed. Please use email/password login.', 'error')
return redirect(url_for('login'))
@app.route('/auth0/callback')
@@ -1063,15 +1408,30 @@ def settings_communities():
except:
selected_communities = []
# Available communities
available_communities = [
{'id': 'programming', 'name': 'Programming', 'platform': 'reddit'},
{'id': 'python', 'name': 'Python', 'platform': 'reddit'},
{'id': 'technology', 'name': 'Technology', 'platform': 'reddit'},
{'id': 'hackernews', 'name': 'Hacker News', 'platform': 'hackernews'},
{'id': 'lobsters', 'name': 'Lobsters', 'platform': 'lobsters'},
{'id': 'stackoverflow', 'name': 'Stack Overflow', 'platform': 'stackoverflow'},
]
# Get available communities from platform config and collection targets
available_communities = []
# Load platform configuration
platform_config = load_platform_config()
# Get enabled communities from collection_targets (what's actually being crawled)
enabled_communities = set()
for target in platform_config.get('collection_targets', []):
enabled_communities.add((target['platform'], target['community']))
# Build community list from platform config for communities that are enabled
for platform_name, platform_info in platform_config.get('platforms', {}).items():
for community_info in platform_info.get('communities', []):
# Only include communities that are in collection_targets
if (platform_name, community_info['id']) in enabled_communities:
available_communities.append({
'id': community_info['id'],
'name': community_info['name'],
'display_name': community_info.get('display_name', community_info['name']),
'platform': platform_name,
'icon': community_info.get('icon', platform_info.get('icon', '📄')),
'description': community_info.get('description', '')
})
return render_template('settings_communities.html',
user=current_user,
@@ -1116,18 +1476,14 @@ def settings_filters():
current_filter = user_settings.get('filter_set', 'no_filter')
# Load available filter sets from FilterEngine
filter_sets_list = []
# Load available filter sets from FilterEngine as a dictionary
filter_sets = {}
for filterset_name in filter_engine.get_available_filtersets():
description = filter_engine.get_filterset_description(filterset_name)
filter_sets_list.append({
'name': filterset_name,
'description': description or f'{filterset_name} filter'
})
filter_sets[filterset_name] = filter_engine.config.get_filterset(filterset_name)
return render_template('settings_filters.html',
user=current_user,
filter_sets=filter_sets_list,
filter_sets=filter_sets,
current_filter=current_filter)
@@ -1147,7 +1503,9 @@ def settings_experience():
'infinite_scroll': request.form.get('infinite_scroll') == 'on',
'auto_refresh': request.form.get('auto_refresh') == 'on',
'push_notifications': request.form.get('push_notifications') == 'on',
'dark_patterns_opt_in': request.form.get('dark_patterns_opt_in') == 'on'
'dark_patterns_opt_in': request.form.get('dark_patterns_opt_in') == 'on',
'time_filter_enabled': request.form.get('time_filter_enabled') == 'on',
'time_filter_days': int(request.form.get('time_filter_days', 7))
}
# Save settings
@@ -1167,7 +1525,9 @@ def settings_experience():
'infinite_scroll': False,
'auto_refresh': False,
'push_notifications': False,
'dark_patterns_opt_in': False
'dark_patterns_opt_in': False,
'time_filter_enabled': False,
'time_filter_days': 7
})
return render_template('settings_experience.html',
@@ -1657,6 +2017,18 @@ def admin_polling_logs(source_id):
logs=logs)
# ============================================================
# TEMPLATE CONTEXT PROCESSORS
# ============================================================
@app.context_processor
def inject_app_config():
"""Inject app configuration into all templates"""
return {
'APP_NAME': app.config['APP_NAME']
}
# ============================================================
# ERROR HANDLERS
# ============================================================

View File

@@ -211,6 +211,12 @@ def collect_platform(platform: str, community: str, start_date: str, end_date: s
if post_id in index:
continue
# ====================================================================
# FIX: Correct the post's source field BEFORE saving
# ====================================================================
post['source'] = community if community else platform
# ====================================================================
# Save post
post_uuid = save_post(post, platform, index, dirs)
added_count += 1

View File

@@ -292,8 +292,10 @@ class data_methods():
'meta': {'is_self': post.get('is_self', False)}
}
# In data_methods.converters.hackernews_to_schema()
@staticmethod
def hackernews_to_schema(raw):
def hackernews_to_schema(raw, community='front_page'): # Add community parameter
if not raw or raw.get('type') != 'story':
return None
return {
@@ -306,7 +308,11 @@ class data_methods():
'replies': raw.get('descendants', 0),
'url': raw.get('url', f"https://news.ycombinator.com/item?id={raw.get('id')}"),
'content': raw.get('text', ''),
'source': 'hackernews',
# ====================================================================
# FIX: Use the community parameter for the source
# ====================================================================
'source': community,
# ====================================================================
'tags': ['hackernews'],
'meta': {}
}
@@ -681,7 +687,7 @@ class data_methods():
stories.append(data_methods.utils.http_get_json(story_url))
# Convert and filter
posts = [data_methods.converters.hackernews_to_schema(s) for s in stories]
posts = [data_methods.converters.hackernews_to_schema(s, community) for s in stories]
return data_methods.utils.filter_by_date_range(posts, start_date, end_date)
@staticmethod

View File

@@ -48,8 +48,9 @@ services:
AUTH0_CLIENT_SECRET: ${AUTH0_CLIENT_SECRET:-}
AUTH0_AUDIENCE: ${AUTH0_AUDIENCE:-}
volumes:
# Persistent data storage
- ./data:/app/data
# Application-managed data (using a named volume)
- app_data:/app/data
# User-editable content (using bind mounts)
- ./static:/app/static
- ./backups:/app/backups
- ./active_html:/app/active_html
@@ -71,4 +72,5 @@ networks:
driver: bridge
volumes:
postgres_data:
postgres_data:
app_data: # <-- New named volume declared here

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):
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",
"icon": "📚",
"color": "#f48024",
"prefix": "",
"supports_communities": false,
"communities": [
{
"id": "stackoverflow",
"name": "Stack Overflow",
"display_name": "Stack Overflow",
"icon": "📚",
"description": "Programming Q&A community"
},
{
"id": "featured",
"name": "Featured",
@@ -257,6 +264,12 @@
"community": "https://hnrss.org/frontpage",
"max_posts": 50,
"priority": "low"
},
{
"platform": "stackexchange",
"community": "stackoverflow",
"max_posts": 50,
"priority": "medium"
}
]
}

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<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') }}">
<style>
.error-container {

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<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') }}">
<style>
.error-container {

409
templates/_admin_base.html Normal file
View File

@@ -0,0 +1,409 @@
<!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 {
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;
}
.btn-primary {
background: var(--primary-color);
color: white;
}
.btn-primary:hover {
background: var(--primary-hover);
}
.btn-secondary {
background: var(--surface-elevation-1);
color: var(--text-primary);
border: 1px solid var(--divider-color);
}
.btn-secondary:hover {
background: var(--surface-elevation-2);
}
.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(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;
}
.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;
}
/* ===== FORMS ===== */
.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);
}
/* ===== 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>
<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;
}
{% extends "_admin_base.html" %}
.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);
}
{% block title %}Admin Panel - {{ APP_NAME }}{% endblock %}
.admin-header h1 {
margin: 0 0 8px 0;
font-size: 2rem;
}
{% block page_title %}Admin Panel{% endblock %}
{% block page_description %}Manage users, content, and system settings{% endblock %}
.admin-header p {
margin: 0;
opacity: 0.9;
}
{% block admin_styles %}
.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;
}
.admin-tabs {
display: flex;
gap: 8px;
margin-bottom: 24px;
border-bottom: 2px solid var(--divider-color);
}
.user-info {
display: flex;
align-items: center;
}
{% endblock %}
.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;
}
.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 %}
{% block admin_content %}
<div class="admin-tabs">
<button class="tab-btn active" onclick="showTab('overview')">Overview</button>
@@ -425,7 +90,7 @@
<div id="users" class="tab-content">
<div class="admin-section">
<h3 class="section-title">User Management</h3>
<div class="users-table">
<div class="admin-table">
<table>
<thead>
<tr>
@@ -556,24 +221,24 @@
</div>
</div>
</div>
</div>
{% endblock %}
<script>
function showTab(tabName) {
// Hide all tabs
const tabs = document.querySelectorAll('.tab-content');
tabs.forEach(tab => tab.classList.remove('active'));
// Remove active class from all buttons
const buttons = document.querySelectorAll('.tab-btn');
buttons.forEach(btn => btn.classList.remove('active'));
// Show selected tab
document.getElementById(tabName).classList.add('active');
// Add active class to clicked button
event.target.classList.add('active');
}
</script>
</body>
</html>
{% block admin_scripts %}
<script>
function showTab(tabName) {
// Hide all tabs
const tabs = document.querySelectorAll('.tab-content');
tabs.forEach(tab => tab.classList.remove('active'));
// Remove active class from all buttons
const buttons = document.querySelectorAll('.tab-btn');
buttons.forEach(btn => btn.classList.remove('active'));
// Show selected tab
document.getElementById(tabName).classList.add('active');
// Add active class to clicked button
event.target.classList.add('active');
}
</script>
{% endblock %}

View File

@@ -1,52 +1,11 @@
<!DOCTYPE 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;
}
{% extends "_admin_base.html" %}
.admin-header {
background: linear-gradient(135deg, var(--primary-dark) 0%, #2a5068 100%);
color: white;
padding: 32px;
border-radius: 12px;
margin-bottom: 24px;
}
{% block title %}Polling Management - Admin - {{ APP_NAME }}{% endblock %}
.status-badge {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
font-size: 0.85rem;
font-weight: 500;
}
{% block page_title %}Polling Management{% endblock %}
{% block page_description %}Manage data collection sources and schedules{% endblock %}
.status-enabled {
background: #d4edda;
color: #155724;
}
.status-disabled {
background: #f8d7da;
color: #721c24;
}
.status-success {
background: #d4edda;
color: #155724;
}
.status-error {
background: #f8d7da;
color: #721c24;
}
{% block admin_styles %}
.source-card {
background: var(--surface-color);
@@ -174,22 +133,62 @@
padding: 48px;
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) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
.add-source-form {
background: var(--surface-color);
border: 1px solid var(--divider-color);
border-radius: 12px;
padding: 24px;
margin-bottom: 24px;
}
.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 -->
<div class="scheduler-status">
@@ -497,6 +496,58 @@
function closeEditModal() {
document.getElementById('edit-modal').style.display = 'none';
}
</script>
</body>
</html>
{% endblock %}
{% 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>
<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;
}
{% extends "_admin_base.html" %}
.admin-header {
background: linear-gradient(135deg, var(--primary-dark) 0%, #2a5068 100%);
color: white;
padding: 32px;
border-radius: 12px;
margin-bottom: 24px;
}
{% block title %}Polling Logs - {{ source.display_name }} - Admin{% endblock %}
.log-table {
width: 100%;
border-collapse: collapse;
background: var(--surface-color);
border-radius: 12px;
overflow: hidden;
}
{% block page_title %}Polling Logs - {{ source.display_name }}{% endblock %}
{% block page_description %}View polling history and error logs for this source{% endblock %}
.log-table th {
background: var(--primary-color);
color: white;
padding: 12px;
text-align: left;
font-weight: 600;
}
{% block admin_styles %}
.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;
}
.log-table td {
padding: 12px;
border-bottom: 1px solid var(--divider-color);
}
.no-logs {
text-align: center;
padding: 48px;
color: var(--text-secondary);
}
{% endblock %}
.log-table tr:last-child td {
border-bottom: none;
}
.status-badge {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
font-size: 0.85rem;
font-weight: 500;
}
.status-success {
background: #d4edda;
color: #155724;
}
.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>
{% block admin_content %}
<div class="admin-table">
{% if logs %}
<table>
<thead>
<tr>
<th>Timestamp</th>
<th>Status</th>
<th>Posts Found</th>
<th>New Posts</th>
<th>Updated Posts</th>
<th>Error Details</th>
</tr>
</thead>
<tbody>
{% for log in logs %}
<tr>
<th>Started</th>
<th>Completed</th>
<th>Duration</th>
<th>Status</th>
<th>Posts Found</th>
<th>New</th>
<th>Updated</th>
<th>Details</th>
<td>{{ log.poll_time.strftime('%Y-%m-%d %H:%M:%S') }}</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>
</thead>
<tbody>
{% for log in logs %}
<tr>
<td>{{ log.started_at.strftime('%Y-%m-%d %H:%M:%S') }}</td>
<td>
{% if log.completed_at %}
{{ 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>
{% 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>
</div>
</body>
</html>
{% endif %}
</div>
<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" %}
{% block title %}Create Admin Account - BalanceBoard{% endblock %}
{% block title %}Create Admin Account - {{ APP_NAME }}{% endblock %}
{% block content %}
{% include '_nav.html' %}
<div class="auth-container">
<div class="auth-card">
<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>
<p style="color: var(--text-secondary); margin-top: 8px;">Create Administrator Account</p>
</div>
@@ -74,5 +77,60 @@
.board {
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>
{% endblock %}

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<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') }}">
<style>
/* 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" %}
{% block title %}Dashboard - BalanceBoard{% endblock %}
{% block title %}Dashboard - {{ APP_NAME }}{% endblock %}
{% block content %}
<!-- Modern Top Navigation -->
<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>
{% include '_nav.html' %}
<!-- Main Content Area -->
<main class="main-content">
@@ -58,17 +11,9 @@
<aside class="sidebar">
<div class="sidebar-section">
<h3>Content Filters</h3>
<div class="filter-item active" data-filter="no_filter">
<span class="filter-icon">🌐</span>
<span>All Content</span>
</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 id="filter-list" class="filter-list">
<!-- Filters will be loaded dynamically -->
<div class="loading-filters">Loading filters...</div>
</div>
</div>
@@ -84,7 +29,7 @@
<h3>Quick Stats</h3>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-number">156</div>
<div class="stat-number">{{ quick_stats.posts_today if quick_stats else 0 }}</div>
<div class="stat-label">Posts Today</div>
</div>
<div class="stat-card">
@@ -358,14 +303,14 @@
font-weight: 500;
}
.loading-communities {
.loading-communities, .loading-filters {
text-align: center;
color: #64748b;
font-style: italic;
padding: 20px;
}
.no-communities {
.no-communities, .no-filters {
text-align: center;
color: #64748b;
font-style: italic;
@@ -742,9 +687,11 @@ let postsData = [];
let currentPage = 1;
let currentCommunity = '';
let currentPlatform = '';
let currentFilter = 'no_filter';
let paginationData = {};
let platformConfig = {};
let communitiesData = [];
let filtersData = [];
// User experience settings
let userSettings = {{ user_settings|tojson }};
@@ -752,8 +699,8 @@ let userSettings = {{ user_settings|tojson }};
// Load posts on page load
document.addEventListener('DOMContentLoaded', function() {
loadPlatformConfig();
loadFilters();
loadPosts();
setupFilterSwitching();
setupInfiniteScroll();
setupAutoRefresh();
});
@@ -781,6 +728,54 @@ async function loadPlatformConfig() {
}
}
// 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
function renderCommunities(communities) {
const communityList = document.getElementById('community-list');
@@ -814,7 +809,7 @@ function renderCommunities(communities) {
}
// 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 {
// Build query parameters
const params = new URLSearchParams();
@@ -822,6 +817,7 @@ async function loadPosts(page = 1, community = '', platform = '', append = false
params.append('per_page', 20);
if (community) params.append('community', community);
if (platform) params.append('platform', platform);
if (filter || currentFilter) params.append('filter', filter || currentFilter);
if (currentSearchQuery) params.append('q', currentSearchQuery);
const response = await fetch(`/api/posts?${params}`);
@@ -1015,24 +1011,34 @@ function savePost(postId) {
// Filter switching functionality
function setupFilterSwitching() {
const filterItems = document.querySelectorAll('.filter-item');
filterItems.forEach(item => {
item.addEventListener('click', function() {
// Remove active class from all items
filterItems.forEach(f => f.classList.remove('active'));
document.addEventListener('click', function(event) {
if (event.target.closest('.filter-item')) {
const filterItem = event.target.closest('.filter-item');
// Remove active class from all filter items
document.querySelectorAll('.filter-item').forEach(f => f.classList.remove('active'));
// Add active class to clicked item
this.classList.add('active');
filterItem.classList.add('active');
// Get filter type
const filterType = this.dataset.filter;
const filterType = filterItem.dataset.filter;
currentFilter = filterType;
// Apply filter (for now just reload)
if (filterType && filterType !== 'custom') {
loadPosts(); // In future, pass filter parameter
}
});
// Update header to show current filter
const contentHeader = document.querySelector('.content-header h1');
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" %}
{% block title %}Log In - BalanceBoard{% endblock %}
{% block title %}Log In - {{ APP_NAME }}{% endblock %}
{% block content %}
<div class="auth-container">
<div class="auth-card">
<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>
<p style="color: var(--text-secondary); margin-top: 8px;">Welcome back!</p>
</div>
@@ -48,6 +48,7 @@
<span>or</span>
</div>
{% if auth0_configured %}
<div class="social-auth-buttons">
<a href="{{ url_for('auth0_login') }}" class="social-btn auth0-btn">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
@@ -56,6 +57,7 @@
Continue with Auth0
</a>
</div>
{% endif %}
<div class="auth-footer">
<p>Don't have an account? <a href="{{ url_for('signup') }}">Sign up</a></p>

View File

@@ -1,12 +1,12 @@
{% extends "base.html" %}
{% block title %}Set New Password - BalanceBoard{% endblock %}
{% block title %}Set New Password - {{ APP_NAME }}{% endblock %}
{% block content %}
<div class="auth-container">
<div class="auth-card">
<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>
<p style="color: var(--text-secondary); margin-top: 8px;">Set a new password</p>
</div>

View File

@@ -1,12 +1,12 @@
{% extends "base.html" %}
{% block title %}Reset Password - BalanceBoard{% endblock %}
{% block title %}Reset Password - {{ APP_NAME }}{% endblock %}
{% block content %}
<div class="auth-container">
<div class="auth-card">
<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>
<p style="color: var(--text-secondary); margin-top: 8px;">Reset your password</p>
</div>

View File

@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block title %}{{ post.title }} - BalanceBoard{% endblock %}
{% block title %}{{ post.title }} - {{ APP_NAME }}{% endblock %}
{% block content %}
<!-- Modern Top Navigation -->
@@ -8,8 +8,8 @@
<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>
<img src="{{ url_for('serve_logo') }}" alt="{{ APP_NAME }}" class="nav-logo">
<span class="brand-text">{{ APP_NAME }}</span>
</div>
</div>
@@ -105,7 +105,7 @@
🐙 View on GitHub
{% elif post.platform == 'devto' %}
📝 View on Dev.to
{% elif post.platform == 'stackoverflow' %}
{% elif post.platform == 'stackexchange' %}
📚 View on Stack Overflow
{% else %}
🔗 View Original Source
@@ -135,25 +135,37 @@
<!-- Comments Section -->
<section class="comments-section">
<h2>Comments ({{ comments|length }})</h2>
{% macro render_comment(comment, depth=0) %}
<div class="comment" style="margin-left: {{ depth * 24 }}px;">
<div class="comment-header">
<span class="comment-author">{{ comment.author }}</span>
<span class="comment-separator"></span>
<span class="comment-time">{{ moment(comment.timestamp).fromNow() if moment else 'Recently' }}</span>
</div>
<div class="comment-content">
{{ comment.content | safe | nl2br }}
</div>
<div class="comment-footer">
<div class="comment-score">
<span>▲ {{ comment.score or 0 }}</span>
</div>
</div>
{% if comment.replies %}
<div class="comment-replies">
{% for reply in comment.replies %}
{{ render_comment(reply, depth + 1) }}
{% endfor %}
</div>
{% endif %}
</div>
{% endmacro %}
{% if comments %}
<div class="comments-list">
{% for comment in comments %}
<div class="comment">
<div class="comment-header">
<span class="comment-author">{{ comment.author }}</span>
<span class="comment-separator"></span>
<span class="comment-time">{{ moment(comment.timestamp).fromNow() if moment else 'Recently' }}</span>
</div>
<div class="comment-content">
{{ comment.content | safe | nl2br }}
</div>
<div class="comment-footer">
<div class="comment-score">
<span>▲ {{ comment.score or 0 }}</span>
</div>
</div>
</div>
{{ render_comment(comment) }}
{% endfor %}
</div>
{% else %}
@@ -554,12 +566,24 @@
.comment {
padding: 20px 0;
border-bottom: 1px solid #f1f5f9;
position: relative;
}
.comment:last-child {
border-bottom: none;
}
/* Threaded comment styling */
.comment[style*="margin-left"] {
padding-left: 16px;
border-left: 2px solid #e2e8f0;
border-bottom: none;
}
.comment-replies {
margin-top: 8px;
}
.comment-header {
display: flex;
align-items: center;

View File

@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block title %}Settings - BalanceBoard{% endblock %}
{% block title %}Settings - {{ APP_NAME }}{% endblock %}
{% block extra_css %}
<style>
@@ -230,6 +230,7 @@
{% endblock %}
{% block content %}
{% include '_nav.html' %}
<div class="settings-container">
<div class="settings-header">
<h1>Settings</h1>

View File

@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block title %}Community Settings - BalanceBoard{% endblock %}
{% block title %}Community Settings - {{ APP_NAME }}{% endblock %}
{% block extra_css %}
<style>
@@ -79,7 +79,7 @@
.platform-icon.reddit { background: #ff4500; }
.platform-icon.hackernews { background: #ff6600; }
.platform-icon.lobsters { background: #ac130d; }
.platform-icon.stackoverflow { background: #f48024; }
.platform-icon.stackexchange { background: #f48024; }
.community-grid {
display: grid;
@@ -235,6 +235,7 @@
{% endblock %}
{% block content %}
{% include '_nav.html' %}
<div class="settings-container">
<div class="settings-header">
<h1>Community Settings</h1>
@@ -268,7 +269,7 @@
<div class="platform-group">
<h3>
<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>
{{ platform|title }}
</h3>

View File

@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block title %}Experience Settings - BalanceBoard{% endblock %}
{% block title %}Experience Settings - {{ APP_NAME }}{% endblock %}
{% block extra_css %}
<style>
@@ -241,6 +241,7 @@
{% endblock %}
{% block content %}
{% include '_nav.html' %}
<div class="experience-settings">
<div class="experience-header">
<h1>Experience Settings</h1>
@@ -330,6 +331,34 @@
<span class="toggle-slider"></span>
</label>
</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 class="form-actions">
@@ -338,4 +367,15 @@
</div>
</form>
</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 %}

View File

@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block title %}Filter Settings - BalanceBoard{% endblock %}
{% block title %}Filter Settings - {{ APP_NAME }}{% endblock %}
{% block extra_css %}
<style>
@@ -263,6 +263,7 @@
{% endblock %}
{% block content %}
{% include '_nav.html' %}
<div class="settings-container">
<div class="settings-header">
<h1>Filter Settings</h1>

View File

@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block title %}Profile Settings - BalanceBoard{% endblock %}
{% block title %}Profile Settings - {{ APP_NAME }}{% endblock %}
{% block extra_css %}
<style>
@@ -225,6 +225,7 @@
{% endblock %}
{% block content %}
{% include '_nav.html' %}
<div class="settings-container">
<div class="settings-header">
<h1>Profile Settings</h1>
@@ -242,31 +243,29 @@
{% endwith %}
</div>
<form method="POST">
<div class="profile-section">
<h2>Profile Picture</h2>
<div class="profile-avatar">
<div class="avatar-preview">
{% if user.profile_picture_url %}
<img src="{{ user.profile_picture_url }}" alt="{{ user.username }}">
{% else %}
{{ user.username[0]|upper }}
{% endif %}
</div>
<div class="avatar-info">
<h3>Current Avatar</h3>
<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">
<div class="file-upload">
<input type="file" id="avatar" name="avatar" accept="image/*" onchange="this.form.submit()">
<label for="avatar" class="file-upload-label">Choose New Picture</label>
</div>
<p class="help-text">PNG, JPG, or GIF. Maximum size 2MB.</p>
</form>
</div>
<div class="profile-section">
<h2>Profile Picture</h2>
<div class="profile-avatar">
<div class="avatar-preview">
{% if user.profile_picture_url %}
<img src="{{ user.profile_picture_url }}" alt="{{ user.username }}">
{% else %}
{{ user.username[0]|upper }}
{% endif %}
</div>
<div class="avatar-info">
<h3>Current Avatar</h3>
<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">
<div class="file-upload">
<input type="file" id="avatar" name="avatar" accept="image/*">
<label for="avatar" class="file-upload-label">Choose New Picture</label>
</div>
<p class="help-text">PNG, JPG, or GIF. Maximum size 2MB.</p>
</form>
</div>
</div>
</form>
</div>
<form method="POST">
<div class="profile-section">

View File

@@ -1,12 +1,12 @@
{% extends "base.html" %}
{% block title %}Sign Up - BalanceBoard{% endblock %}
{% block title %}Sign Up - {{ APP_NAME }}{% endblock %}
{% block content %}
<div class="auth-container">
<div class="auth-card">
<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>
<p style="color: var(--text-secondary); margin-top: 8px;">Create your account</p>
</div>

View File

@@ -40,6 +40,10 @@
<div class="engagement-info">
<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>
</footer>

View File

@@ -228,6 +228,9 @@
<a href="/settings/filters" class="dropdown-item">
🎛️ Filters
</a>
<a href="/bookmarks" class="dropdown-item">
📚 Bookmarks
</a>
<div class="dropdown-divider"></div>
<a href="/admin" class="dropdown-item" style="display: none;">
🛠️ Admin
@@ -352,6 +355,79 @@
// Initialize on page load
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>
</body>
</html>

View File

@@ -460,6 +460,45 @@ header .post-count::before {
.engagement-info {
font-size: 0.9rem;
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 */