Compare commits

..

46 Commits

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

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

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

🤖 Generated with Claude Code

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## Solutions Implemented:

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

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

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

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

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

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

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

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

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

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

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

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

## Solution Implemented:

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

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

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

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

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

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

## Solution Implemented:

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

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

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

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

Commit: [current]

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

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

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

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

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

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

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

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

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

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

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

Fixes #19

~claude

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

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

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

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

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

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

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

Fixes #20

~claude

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

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

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

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

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

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

Fixes #21

~claude

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

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

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

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

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

Fixes #22

~claude

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

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

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

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

Fixes #23

~claude

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

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

Fixes #24

~claude

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

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

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

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

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

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

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

Fixes #14, #13
2025-10-11 23:56:52 -05:00
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
8c1e055a05 Integrate FilterEngine with app.py (Phase 4)
Complete integration of filter pipeline with web application:

App.py Integration:
- Initialize FilterEngine singleton at startup
- Update /api/posts endpoint to use FilterEngine.apply_filterset()
- Apply user's filterset preference from settings
- Sort posts by filter_score (highest first), then timestamp
- Add filter metadata to post responses (filter_score, categories, tags)

Settings Page Updates:
- Dynamically load available filtersets from FilterEngine
- Show filterset descriptions in settings UI
- Validate filterset selection against FilterEngine

Security:
- Update _is_safe_filterset() to use FilterEngine's list
- Dynamic ALLOWED_FILTERSETS from filtersets.json

User Experience:
- Posts automatically filtered based on user preferences
- Quality/relevance scores affect post ordering
- Transparent filter metadata available in API

Caching:
- FilterEngine uses 3-level cache for efficiency
- Cache reused across page loads (5min TTL)
- AI results cached permanently

Next Steps:
- Polling service integration
- Database model for persistent results
- UI for cache stats and filter debugging

Related to filtering engine implementation

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 22:57:18 -05:00
a3ea1e9bdb Add filter pipeline stages and plugins (Phase 2 & 3)
Implements complete content filtering pipeline with AI-powered analysis:

Phase 2 - Pipeline Stages:
- CategorizerStage: AI topic detection with content-hash caching
- ModeratorStage: Safety/quality analysis (violence, hate speech, quality scores)
- FilterStage: Fast rule-based filtering from filtersets.json
- RankerStage: Multi-factor scoring (quality, recency, source tier, engagement)

Phase 3 - Filter Plugins:
- KeywordFilterPlugin: Blocklist/allowlist keyword filtering
- QualityFilterPlugin: Quality metrics (length, caps, clickbait detection)

AI Client:
- OpenRouterClient: Llama 70B integration with retry logic
- Methods: categorize(), moderate(), score_quality(), analyze_sentiment()
- Content-hash based caching for cost efficiency

Pipeline Flow:
Raw Post → Categorizer → Moderator → Filter → Ranker → Scored Post

Key Features:
- All AI results cached permanently by content hash
- Parallel processing support (10 workers)
- Fallback modes when AI disabled
- Comprehensive scoring breakdown
- Plugin architecture for extensibility

Related to filtering engine implementation

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 22:54:38 -05:00
94e12041ec Add filter pipeline core infrastructure (Phase 1)
Implements plugin-based content filtering system with multi-level caching:

Core Components:
- FilterEngine: Main orchestrator for content filtering
- FilterCache: 3-level caching (memory, AI results, filterset results)
- FilterConfig: Configuration loader for filter_config.json & filtersets.json
- FilterResult & AIAnalysisResult: Data models for filter results

Architecture:
- BaseStage: Abstract class for pipeline stages
- BaseFilterPlugin: Abstract class for filter plugins
- Multi-threaded parallel processing support
- Content-hash based AI result caching (cost savings)
- Filterset result caching (fast filterset switching)

Configuration:
- filter_config.json: AI models, caching, parallel workers
- Using only Llama 70B for cost efficiency
- Compatible with existing filtersets.json

Integration:
- apply_filterset() API compatible with user preferences
- process_batch() for batch post processing
- Lazy-loaded stages to avoid import errors when AI disabled

Related to issue #8 (filtering engine implementation)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 22:46:10 -05:00
07df6d8f0a Fix 500 error: Change register route to signup
Fixed BuildError caused by incorrect endpoint name in anonymous mode.
The route is called 'signup' not 'register' in app.py line 878.

Error was:
werkzeug.routing.exceptions.BuildError: Could not build url for endpoint 'register'

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 22:26:35 -05:00
3ab3b04643 Clean up redundant authentication checks in dashboard template
Removed redundant current_user.is_authenticated checks in the else block
of the navigation menu. The else block only executes for authenticated
users per app.py logic (line 278 vs 293), so the nested checks were
dead code that created confusion.

Changes:
- Removed defensive checks for unauthenticated users in authenticated block
- Added clarifying comment about when else block executes
- Simplified template logic for better maintainability
- Removed dead code paths (Anonymous User label, ? avatar)

Addresses concerns raised in Issue #2 about confusing template logic.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 22:08:42 -05:00
236ec2abbe Fix anonymous access 500 error and add environment variable control
- Fixed dashboard.html template error accessing current_user.username for anonymous users
- Added ALLOW_ANONYMOUS_ACCESS environment variable with default true
- Enhanced index route logic to properly check config before allowing anonymous access
- Added proper environment variable to docker-compose.yml
- Anonymous access now works without 500 server errors

Fixes issue #2 completely - anonymous access is now functional

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 21:44:13 -05:00
c7bd634ad6 Add search functionality (Issue #3)
Backend changes:
- Added search query parameter (q) to /api/posts endpoint
- Search filters posts by title, content, author, and source
- Case-insensitive search with substring matching

Frontend changes:
- Made search bar functional with Enter key and click support
- Added performSearch() function to trigger searches
- Added Clear Search button that appears during active search
- Search results update feed title to show query
- Integrated search with existing pagination and filtering
- Preserves anonymous/authenticated feed title when clearing search

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 19:47:04 -05:00
5d6da930df Investigate comments loading issue (Issue #4)
- Added debug logging to post_detail route to track comment loading
- Created migration script for poll source fields (max_posts, fetch_comments, priority)
- Migration adds default values to ensure comments are fetched

The issue may be that existing poll sources in database dont have fetch_comments field.
Migration needs to be run on server with database access.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 19:43:55 -05:00
c47d1ede7e Add AGPL-3.0 dual license with commercial option
Added LICENSE file with dual licensing:
- Primary license: AGPL-3.0 for open source use
- Commercial licenses available from copyright holder
- AGPL-3.0 licenses are irrevocable (cannot be revoked)
- Commercial licensing rights reserved only for commercial use cases
- Clarifies that non-commercial use is governed by AGPL-3.0

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 19:39:44 -05:00
f220694735 Add comprehensive README documentation
Created README.md with complete project documentation:
- Project overview and feature list
- Quick start guide for local development
- Docker deployment instructions
- Configuration details for platforms and polling
- Architecture overview and data flow
- API endpoint documentation
- Project structure and file organization
- Guide for adding new platforms
- Contributing guidelines

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 19:02:47 -05:00
918586d6b1 Add deployment and issue management documentation
Created DEPLOYMENT.md with comprehensive instructions for:
- Making commits with proper formatting
- Commenting on Gitea issues via API
- Database migration procedures
- Docker build and deployment workflow
- Common troubleshooting steps
- Checklist for each commit cycle

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 19:01:41 -05:00
51911f2c48 Add password reset mechanism (Issue #1)
- Added reset_token and reset_token_expiry fields to User model
- Implemented generate_reset_token(), verify_reset_token(), and clear_reset_token() methods
- Created password reset request form (/password-reset-request)
- Created password reset form (/password-reset/<token>)
- Added "Forgot password?" link to login page
- Reset tokens expire after 1 hour for security
- Created migration script to add new database columns
- Reset links are logged (would be emailed in production)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 18:46:18 -05:00
a1d8c9d373 Implement anonymous account mode (Issue #2)
- Modified index route to allow browsing without login
- Set default user_settings for anonymous users with safe defaults
- Added anonymous flag to dashboard template
- Updated navigation to show Login/Sign Up buttons for anonymous users
- Changed feed header to "Public Feed" for anonymous browsing
- Hide Customize button for anonymous users

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 18:42:28 -05:00
36bb905f99 Add edit modal and diverse polling settings UI
- Add Edit button for each poll source
- Modal dialog for editing all source settings
- Add max_posts, fetch_comments, priority fields to add form
- Display source settings in source list
- JavaScript modal management for editing

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 18:36:53 -05:00
f477a074a2 Add poll source editing and expanded settings
- Update admin_polling_add to accept max_posts, fetch_comments, priority
- Enhance admin_polling_update to modify all configurable fields
- Support editing display_name, interval, max_posts, fetch_comments, priority

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 18:36:43 -05:00
99d51fe14a Use source-specific polling settings in collection
- Read max_posts from source.max_posts (fallback to 100)
- Read fetch_comments from source settings
- Allows customizing collection per source

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 18:36:32 -05:00
5d3b01926c Add polling configuration fields to PollSource model
- Add max_posts field (default 100)
- Add fetch_comments boolean (default true)
- Add priority field (low/medium/high, default medium)
- Enables per-source control of collection settings

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 18:36:17 -05:00
53 changed files with 5991 additions and 937 deletions

129
DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,129 @@
# Deployment and Issue Management Instructions
## Making Changes and Committing
### 1. Make Code Changes
Edit the necessary files to implement your feature or fix.
### 2. Commit Your Changes
Always use descriptive commit messages with the Claude Code format:
```bash
git add <files>
git commit -m "$(cat <<'EOF'
Brief title of the change
Detailed description of what was changed and why.
- Bullet points for key features
- More details as needed
- Reference issue numbers (e.g., Issue #1)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
EOF
)"
```
### 3. Push to Remote
```bash
git push origin main
```
## Commenting on Issues
After implementing a fix or feature that addresses a GitHub/Gitea issue, comment on the issue to document the work:
### Comment on Issue #1 Example
```bash
curl -X POST -u "the_bot:4152aOP!" \
-H "Content-Type: application/json" \
-d "{\"body\":\"Implemented [feature description] in commit [commit-hash].\\n\\nFeatures include:\\n- Feature 1\\n- Feature 2\\n- Feature 3\"}" \
https://git.scorpi.us/api/v1/repos/chelsea/balanceboard/issues/1/comments
```
### General Template
```bash
curl -X POST -u "the_bot:4152aOP!" \
-H "Content-Type: application/json" \
-d "{\"body\":\"Your comment here with \\n for newlines\"}" \
https://git.scorpi.us/api/v1/repos/chelsea/balanceboard/issues/ISSUE_NUMBER/comments
```
**Important Notes:**
- Use `the_bot` as the username with password `4152aOP!`
- Escape quotes in JSON with `\"`
- Use `\\n` for newlines in the body
- Remove apostrophes or escape them carefully to avoid shell parsing issues
- Replace `ISSUE_NUMBER` with the actual issue number
## Database Migrations
When you add new database fields:
1. **Update the Model** (in `models.py`)
2. **Create a Migration Script** (e.g., `migrate_password_reset.py`)
3. **Run the Migration** before deploying:
```bash
python3 migrate_password_reset.py
```
4. **Test Locally** to ensure the migration works
5. **Deploy** to production and run the migration there too
## Docker Deployment
### Build and Push
```bash
# Build the image
docker build -t git.scorpi.us/chelsea/balanceboard:latest .
# Push to registry
docker push git.scorpi.us/chelsea/balanceboard:latest
```
### Deploy on Server
```bash
# SSH to server
ssh user@reddit.scorpi.us
# Pull latest image
cd /path/to/balanceboard
docker-compose pull
# Restart services
docker-compose down
docker-compose up -d
# Check logs
docker-compose logs -f balanceboard
```
## Common Issues
### Container Won't Start
- Check logs: `docker-compose logs balanceboard`
- Verify file permissions in `data/` directory
- Ensure all required files are in git (filtersets.json, themes/, static/, etc.)
### Database Migration Errors
- Back up database first
- Run migration manually: `python3 migrate_*.py`
- Check if columns already exist before re-running
### 404 Errors for Static Files
- Ensure files are committed to git
- Rebuild Docker image after adding files
- Check volume mounts in docker-compose.yml
## Checklist for Each Commit
- [ ] Make code changes
- [ ] Test locally
- [ ] Run database migrations if needed
- [ ] Commit with descriptive message
- [ ] Push to git remote
- [ ] Comment on related issues
- [ ] Build and push Docker image (if needed)
- [ ] Deploy to server (if needed)
- [ ] Verify deployment works
- [ ] Update this README if process changes

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)

40
LICENSE Normal file
View File

@@ -0,0 +1,40 @@
BalanceBoard License
Copyright (c) 2025 Chelsea. All rights reserved.
This software is dual-licensed:
1. OPEN SOURCE LICENSE (AGPL-3.0)
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
2. COMMERCIAL LICENSE
The copyright holder reserves the right to offer separate commercial licenses
to any party for commercial use cases where the AGPL-3.0 terms are not suitable.
Commercial licenses, once granted, are irrevocable except for breach of the
commercial license terms themselves.
For commercial licensing inquiries, contact: chelsea.lee.woodruff@gmail.com
CLARIFICATIONS:
- This dual-licensing only applies to commercial use rights
- Non-commercial use is governed solely by AGPL-3.0
- The copyright holder does not reserve the right to revoke AGPL-3.0 licenses
- Once granted under AGPL-3.0, your rights under that license cannot be revoked
- The copyright holder only reserves the right to offer additional commercial licenses
Full text of AGPL-3.0: https://www.gnu.org/licenses/agpl-3.0.txt

229
README.md Normal file
View File

@@ -0,0 +1,229 @@
# BalanceBoard
A Reddit-style content aggregator that collects posts from multiple platforms (Reddit, Hacker News, RSS feeds) and presents them in a unified, customizable feed.
## Features
- **Multi-Platform Support**: Collect content from Reddit, Hacker News, and RSS feeds
- **Automated Polling**: Background service polls sources at configurable intervals
- **User Authentication**: Local accounts with bcrypt password hashing and Auth0 OAuth support
- **Anonymous Browsing**: Browse public feed without creating an account
- **Password Reset**: Secure token-based password reset mechanism
- **Customizable Feeds**: Filter and customize content based on your preferences
- **Admin Panel**: Manage polling sources, view logs, and configure the system
- **Modern UI**: Card-based interface with clean, responsive design
## Quick Start
### Prerequisites
- Python 3.12+
- PostgreSQL database
- Docker (for containerized deployment)
### Local Development
1. **Clone the repository**
```bash
git clone https://git.scorpi.us/chelsea/balanceboard.git
cd balanceboard
```
2. **Set up environment**
```bash
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
```
3. **Configure environment variables**
```bash
cp .env.example .env
# Edit .env with your database credentials and settings
```
4. **Initialize the database**
```bash
python3 -c "from app import app, db; app.app_context().push(); db.create_all()"
```
5. **Run migrations (if needed)**
```bash
python3 migrate_password_reset.py
```
6. **Start the application**
```bash
python3 app.py
```
7. **Access the application**
- Open browser to `http://localhost:5000`
- Create an account or browse anonymously
### Docker Deployment
1. **Build the image**
```bash
docker build -t git.scorpi.us/chelsea/balanceboard:latest .
```
2. **Push to registry**
```bash
docker push git.scorpi.us/chelsea/balanceboard:latest
```
3. **Deploy with docker-compose**
```bash
docker-compose up -d
```
See [DEPLOYMENT.md](DEPLOYMENT.md) for detailed deployment instructions.
## Configuration
### Platform Sources
Configure available platforms and communities in `platform_config.json`:
```json
{
"reddit": {
"name": "Reddit",
"communities": [
{
"id": "programming",
"name": "r/programming",
"description": "Computer programming"
}
]
}
}
```
### Polling Configuration
Admins can configure polling sources via the Admin Panel:
- **Platform**: reddit, hackernews, or rss
- **Source ID**: Subreddit name, or RSS feed URL
- **Poll Interval**: How often to check for new content (in minutes)
- **Max Posts**: Maximum posts to collect per poll
- **Fetch Comments**: Whether to collect comments
- **Priority**: low, medium, or high
### Environment Variables
Key environment variables (see `.env.example`):
- `DATABASE_URL`: PostgreSQL connection string
- `SECRET_KEY`: Flask secret key for sessions
- `AUTH0_DOMAIN`: Auth0 domain (if using OAuth)
- `AUTH0_CLIENT_ID`: Auth0 client ID
- `AUTH0_CLIENT_SECRET`: Auth0 client secret
## Architecture
### Components
- **Flask Web Server** (`app.py`): Main application server
- **Polling Service** (`polling_service.py`): Background scheduler for data collection
- **Data Collection** (`data_collection.py`, `data_collection_lib.py`): Platform-specific data fetchers
- **Database Models** (`models.py`): SQLAlchemy ORM models
- **User Service** (`user_service.py`): User authentication and management
### Database Schema
- **users**: User accounts with authentication
- **poll_sources**: Configured polling sources
- **poll_logs**: History of polling activities
- **user_sessions**: Active user sessions
### Data Flow
1. Polling service checks enabled sources at configured intervals
2. Data collection fetchers retrieve posts from platforms
3. Posts are normalized to a common schema and stored in `data/posts/`
4. Web interface displays posts from the feed
5. Users can filter, customize, and interact with content
## API Endpoints
### Public Routes
- `GET /`: Main feed (anonymous or authenticated)
- `GET /login`: Login page
- `POST /login`: Authenticate user
- `GET /register`: Registration page
- `POST /register`: Create new account
- `GET /password-reset-request`: Request password reset
- `POST /password-reset-request`: Send reset link
- `GET /password-reset/<token>`: Reset password form
- `POST /password-reset/<token>`: Update password
### Authenticated Routes
- `GET /settings`: User settings
- `GET /logout`: Log out
### Admin Routes
- `GET /admin`: Admin panel
- `GET /admin/polling`: Manage polling sources
- `POST /admin/polling/add`: Add new source
- `POST /admin/polling/update`: Update source settings
- `POST /admin/polling/poll`: Manually trigger poll
## Development
### Project Structure
```
balanceboard/
├── app.py # Main Flask application
├── polling_service.py # Background polling service
├── data_collection.py # Data collection orchestration
├── data_collection_lib.py # Platform-specific fetchers
├── models.py # Database models
├── user_service.py # User management
├── database.py # Database setup
├── platform_config.json # Platform configurations
├── filtersets.json # Content filter definitions
├── templates/ # Jinja2 templates
├── static/ # Static assets (CSS, JS)
├── themes/ # UI themes
├── data/ # Data storage
│ ├── posts/ # Collected posts
│ ├── comments/ # Collected comments
│ └── moderation/ # Moderation data
├── requirements.txt # Python dependencies
├── Dockerfile # Docker image definition
├── docker-compose.yml # Docker composition
├── README.md # This file
└── DEPLOYMENT.md # Deployment instructions
```
### Adding a New Platform
1. Add platform config to `platform_config.json`
2. Implement fetcher in `data_collection_lib.py`:
- `fetchers.getPlatformData()`
- `converters.platform_to_schema()`
- `builders.build_platform_url()`
3. Update routing in `getData()` function
4. Test data collection
5. Add to available sources in admin panel
## Contributing
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Test thoroughly
5. Submit a pull request
See [DEPLOYMENT.md](DEPLOYMENT.md) for commit and issue management guidelines.
## License
This is a personal project by Chelsea. All rights reserved.
## Support
For issues and feature requests, please use the issue tracker at:
https://git.scorpi.us/chelsea/balanceboard/issues

814
app.py

File diff suppressed because it is too large Load Diff

View File

@@ -40,6 +40,7 @@ services:
FLASK_ENV: production FLASK_ENV: production
DEBUG: "False" DEBUG: "False"
SECRET_KEY: ${SECRET_KEY:-change-this-secret-key-in-production} SECRET_KEY: ${SECRET_KEY:-change-this-secret-key-in-production}
ALLOW_ANONYMOUS_ACCESS: ${ALLOW_ANONYMOUS_ACCESS:-true}
# Auth0 configuration (optional) # Auth0 configuration (optional)
AUTH0_DOMAIN: ${AUTH0_DOMAIN:-} AUTH0_DOMAIN: ${AUTH0_DOMAIN:-}

27
filter_config.json Normal file
View File

@@ -0,0 +1,27 @@
{
"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,
"note": "Using only Llama 70B for cost efficiency"
},
"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
},
"output": {
"filtered_dir": "data/filtered",
"save_rejected": false
}
}

View File

@@ -0,0 +1,10 @@
"""
Filter Pipeline Package
Content filtering, categorization, and ranking system for BalanceBoard.
"""
from .engine import FilterEngine
from .models import FilterResult, ProcessingStatus
__all__ = ['FilterEngine', 'FilterResult', 'ProcessingStatus']
__version__ = '1.0.0'

View File

@@ -0,0 +1,326 @@
"""
AI Client
OpenRouter API client for content analysis (Llama 70B only).
"""
import requests
import logging
import time
from typing import Optional, Dict, Any
logger = logging.getLogger(__name__)
class OpenRouterClient:
"""
OpenRouter API client for AI-powered content analysis.
Uses only Llama 70B for cost efficiency.
"""
def __init__(self, api_key: str, model: str = 'meta-llama/llama-3.3-70b-instruct'):
"""
Initialize OpenRouter client.
Args:
api_key: OpenRouter API key
model: Model to use (default: Llama 70B)
"""
self.api_key = api_key
self.model = model
self.base_url = 'https://openrouter.ai/api/v1/chat/completions'
self.timeout = 60
self.max_retries = 3
self.retry_delay = 2 # seconds
def call_model(
self,
prompt: str,
max_tokens: int = 500,
temperature: float = 0.7,
system_prompt: Optional[str] = None
) -> str:
"""
Call AI model with prompt.
Args:
prompt: User prompt
max_tokens: Maximum tokens in response
temperature: Sampling temperature (0.0-1.0)
system_prompt: Optional system prompt
Returns:
Model response text
Raises:
Exception if API call fails after retries
"""
messages = []
if system_prompt:
messages.append({'role': 'system', 'content': system_prompt})
messages.append({'role': 'user', 'content': prompt})
payload = {
'model': self.model,
'messages': messages,
'max_tokens': max_tokens,
'temperature': temperature
}
headers = {
'Authorization': f'Bearer {self.api_key}',
'Content-Type': 'application/json',
'HTTP-Referer': 'https://github.com/balanceboard',
'X-Title': 'BalanceBoard Filter Pipeline'
}
# Retry loop
last_error = None
for attempt in range(self.max_retries):
try:
response = requests.post(
self.base_url,
headers=headers,
json=payload,
timeout=self.timeout
)
response.raise_for_status()
data = response.json()
# Extract response text
result = data['choices'][0]['message']['content'].strip()
logger.debug(f"AI call successful (attempt {attempt + 1})")
return result
except requests.exceptions.RequestException as e:
last_error = e
logger.warning(f"AI call failed (attempt {attempt + 1}/{self.max_retries}): {e}")
if attempt < self.max_retries - 1:
time.sleep(self.retry_delay * (attempt + 1)) # Exponential backoff
continue
# All retries failed
error_msg = f"AI call failed after {self.max_retries} attempts: {last_error}"
logger.error(error_msg)
raise Exception(error_msg)
def categorize(self, title: str, content: str, categories: list) -> Dict[str, Any]:
"""
Categorize content into predefined categories.
Args:
title: Post title
content: Post content/description
categories: List of valid category names
Returns:
Dict with 'category' and 'confidence' keys
"""
category_list = ', '.join(categories)
prompt = f"""Classify this content into ONE of these categories: {category_list}
Title: {title}
Content: {content[:500]}
Respond in this EXACT format:
CATEGORY: [category name]
CONFIDENCE: [0.0-1.0]"""
try:
response = self.call_model(prompt, max_tokens=20, temperature=0.3)
# Parse response
lines = response.strip().split('\n')
category = None
confidence = 0.5
for line in lines:
if line.startswith('CATEGORY:'):
category = line.split(':', 1)[1].strip().lower()
elif line.startswith('CONFIDENCE:'):
try:
confidence = float(line.split(':', 1)[1].strip())
except:
confidence = 0.5
# Validate category
if category not in [c.lower() for c in categories]:
category = categories[0].lower() # Default to first category
return {
'category': category,
'confidence': confidence
}
except Exception as e:
logger.error(f"Categorization failed: {e}")
return {
'category': categories[0].lower(),
'confidence': 0.0
}
def moderate(self, title: str, content: str) -> Dict[str, Any]:
"""
Perform content moderation (safety analysis).
Args:
title: Post title
content: Post content/description
Returns:
Dict with moderation flags and scores
"""
prompt = f"""Analyze this content for safety issues.
Title: {title}
Content: {content[:500]}
Respond in this EXACT format:
VIOLENCE: [0.0-1.0]
SEXUAL: [0.0-1.0]
HATE_SPEECH: [0.0-1.0]
HARASSMENT: [0.0-1.0]
IS_SAFE: [YES/NO]"""
try:
response = self.call_model(prompt, max_tokens=50, temperature=0.3)
# Parse response
moderation = {
'violence': 0.0,
'sexual_content': 0.0,
'hate_speech': 0.0,
'harassment': 0.0,
'is_safe': True
}
lines = response.strip().split('\n')
for line in lines:
if ':' not in line:
continue
key, value = line.split(':', 1)
key = key.strip().lower()
value = value.strip()
if key == 'violence':
moderation['violence'] = float(value)
elif key == 'sexual':
moderation['sexual_content'] = float(value)
elif key == 'hate_speech':
moderation['hate_speech'] = float(value)
elif key == 'harassment':
moderation['harassment'] = float(value)
elif key == 'is_safe':
moderation['is_safe'] = value.upper() == 'YES'
return moderation
except Exception as e:
logger.error(f"Moderation failed: {e}")
return {
'violence': 0.0,
'sexual_content': 0.0,
'hate_speech': 0.0,
'harassment': 0.0,
'is_safe': True
}
def score_quality(self, title: str, content: str) -> float:
"""
Score content quality (0.0-1.0).
Args:
title: Post title
content: Post content/description
Returns:
Quality score (0.0-1.0)
"""
prompt = f"""Rate this content's quality on a scale of 0.0 to 1.0.
Consider:
- Clarity and informativeness
- Proper grammar and formatting
- Lack of clickbait or sensationalism
- Factual tone
Title: {title}
Content: {content[:500]}
Respond with ONLY a number between 0.0 and 1.0 (e.g., 0.7)"""
try:
response = self.call_model(prompt, max_tokens=10, temperature=0.3)
# Extract number
score = float(response.strip())
score = max(0.0, min(1.0, score)) # Clamp to 0-1
return score
except Exception as e:
logger.error(f"Quality scoring failed: {e}")
return 0.5 # Default neutral score
def analyze_sentiment(self, title: str, content: str) -> Dict[str, Any]:
"""
Analyze sentiment of content.
Args:
title: Post title
content: Post content/description
Returns:
Dict with 'sentiment' (positive/neutral/negative) and 'score'
"""
prompt = f"""Analyze the sentiment of this content.
Title: {title}
Content: {content[:500]}
Respond in this EXACT format:
SENTIMENT: [positive/neutral/negative]
SCORE: [-1.0 to 1.0]"""
try:
response = self.call_model(prompt, max_tokens=20, temperature=0.3)
# Parse response
sentiment = 'neutral'
score = 0.0
lines = response.strip().split('\n')
for line in lines:
if ':' not in line:
continue
key, value = line.split(':', 1)
key = key.strip().lower()
value = value.strip()
if key == 'sentiment':
sentiment = value.lower()
elif key == 'score':
try:
score = float(value)
score = max(-1.0, min(1.0, score))
except:
score = 0.0
return {
'sentiment': sentiment,
'score': score
}
except Exception as e:
logger.error(f"Sentiment analysis failed: {e}")
return {
'sentiment': 'neutral',
'score': 0.0
}

259
filter_pipeline/cache.py Normal file
View File

@@ -0,0 +1,259 @@
"""
Multi-Level Caching System
Implements 3-tier caching for filter pipeline efficiency.
"""
import json
import hashlib
import os
import logging
from pathlib import Path
from typing import Optional, Dict, Any
from datetime import datetime, timedelta
from .models import AIAnalysisResult, FilterResult
logger = logging.getLogger(__name__)
class FilterCache:
"""
Three-level caching system:
Level 1: In-memory cache (fastest, TTL-based)
Level 2: AI analysis cache (persistent, content-hash based)
Level 3: Filterset result cache (persistent, filterset version based)
"""
def __init__(self, cache_dir: str = 'data/filter_cache'):
self.cache_dir = Path(cache_dir)
self.cache_dir.mkdir(parents=True, exist_ok=True)
# Level 1: In-memory cache
self.memory_cache: Dict[str, tuple[Any, datetime]] = {}
self.memory_ttl = timedelta(minutes=5)
# Level 2: AI analysis cache directory
self.ai_cache_dir = self.cache_dir / 'ai_analysis'
self.ai_cache_dir.mkdir(exist_ok=True)
# Level 3: Filterset result cache directory
self.filterset_cache_dir = self.cache_dir / 'filtersets'
self.filterset_cache_dir.mkdir(exist_ok=True)
# ===== Level 1: Memory Cache =====
def get_memory(self, key: str) -> Optional[Any]:
"""Get from memory cache if not expired"""
if key in self.memory_cache:
value, timestamp = self.memory_cache[key]
if datetime.now() - timestamp < self.memory_ttl:
return value
else:
# Expired, remove
del self.memory_cache[key]
return None
def set_memory(self, key: str, value: Any):
"""Store in memory cache"""
self.memory_cache[key] = (value, datetime.now())
def clear_memory(self):
"""Clear all memory cache"""
self.memory_cache.clear()
# ===== Level 2: AI Analysis Cache (Persistent) =====
@staticmethod
def compute_content_hash(title: str, content: str) -> str:
"""Compute SHA-256 hash of content for caching"""
text = f"{title}\n{content}".encode('utf-8')
return hashlib.sha256(text).hexdigest()
def get_ai_analysis(self, content_hash: str) -> Optional[AIAnalysisResult]:
"""
Get AI analysis result from cache.
Args:
content_hash: SHA-256 hash of content
Returns:
AIAnalysisResult if cached, None otherwise
"""
# Check memory first
mem_key = f"ai_{content_hash}"
cached = self.get_memory(mem_key)
if cached:
return cached
# Check disk
cache_file = self.ai_cache_dir / f"{content_hash}.json"
if cache_file.exists():
try:
with open(cache_file, 'r') as f:
data = json.load(f)
result = AIAnalysisResult.from_dict(data)
# Store in memory for faster access
self.set_memory(mem_key, result)
logger.debug(f"AI analysis cache hit for {content_hash[:8]}...")
return result
except Exception as e:
logger.error(f"Error loading AI cache {content_hash}: {e}")
return None
return None
def set_ai_analysis(self, content_hash: str, result: AIAnalysisResult):
"""
Store AI analysis result in cache (persistent).
Args:
content_hash: SHA-256 hash of content
result: AIAnalysisResult to cache
"""
# Store in memory
mem_key = f"ai_{content_hash}"
self.set_memory(mem_key, result)
# Store on disk (persistent)
cache_file = self.ai_cache_dir / f"{content_hash}.json"
try:
with open(cache_file, 'w') as f:
json.dump(result.to_dict(), f, indent=2)
logger.debug(f"Cached AI analysis for {content_hash[:8]}...")
except Exception as e:
logger.error(f"Error saving AI cache {content_hash}: {e}")
# ===== Level 3: Filterset Result Cache =====
def _get_filterset_version(self, filterset_name: str, filtersets_config: Dict) -> str:
"""Get version hash of filterset definition for cache invalidation"""
filterset_def = filtersets_config.get(filterset_name, {})
# Include version field if present, otherwise hash the entire definition
if 'version' in filterset_def:
return str(filterset_def['version'])
# Compute hash of filterset definition
definition_json = json.dumps(filterset_def, sort_keys=True)
return hashlib.md5(definition_json.encode()).hexdigest()[:8]
def get_filterset_results(
self,
filterset_name: str,
filterset_version: str,
max_age_hours: int = 24
) -> Optional[Dict[str, FilterResult]]:
"""
Get cached filterset results.
Args:
filterset_name: Name of filterset
filterset_version: Version hash of filterset definition
max_age_hours: Maximum age of cache in hours
Returns:
Dict mapping post_uuid to FilterResult, or None if cache invalid
"""
cache_file = self.filterset_cache_dir / f"{filterset_name}_{filterset_version}.json"
if not cache_file.exists():
return None
# Check age
try:
file_age = datetime.now() - datetime.fromtimestamp(cache_file.stat().st_mtime)
if file_age > timedelta(hours=max_age_hours):
logger.debug(f"Filterset cache expired for {filterset_name}")
return None
# Load cache
with open(cache_file, 'r') as f:
data = json.load(f)
# Deserialize FilterResults
results = {
uuid: FilterResult.from_dict(result_data)
for uuid, result_data in data.items()
}
logger.info(f"Filterset cache hit for {filterset_name} ({len(results)} results)")
return results
except Exception as e:
logger.error(f"Error loading filterset cache {filterset_name}: {e}")
return None
def set_filterset_results(
self,
filterset_name: str,
filterset_version: str,
results: Dict[str, FilterResult]
):
"""
Store filterset results in cache.
Args:
filterset_name: Name of filterset
filterset_version: Version hash of filterset definition
results: Dict mapping post_uuid to FilterResult
"""
cache_file = self.filterset_cache_dir / f"{filterset_name}_{filterset_version}.json"
try:
# Serialize FilterResults
data = {
uuid: result.to_dict()
for uuid, result in results.items()
}
with open(cache_file, 'w') as f:
json.dump(data, f, indent=2)
logger.info(f"Cached {len(results)} filterset results for {filterset_name}")
except Exception as e:
logger.error(f"Error saving filterset cache {filterset_name}: {e}")
def invalidate_filterset(self, filterset_name: str):
"""
Invalidate all caches for a filterset (when definition changes).
Args:
filterset_name: Name of filterset to invalidate
"""
pattern = f"{filterset_name}_*.json"
for cache_file in self.filterset_cache_dir.glob(pattern):
try:
cache_file.unlink()
logger.info(f"Invalidated filterset cache: {cache_file.name}")
except Exception as e:
logger.error(f"Error invalidating cache {cache_file}: {e}")
# ===== Utility Methods =====
def get_cache_stats(self) -> Dict[str, Any]:
"""Get cache statistics"""
ai_cache_count = len(list(self.ai_cache_dir.glob('*.json')))
filterset_cache_count = len(list(self.filterset_cache_dir.glob('*.json')))
memory_cache_count = len(self.memory_cache)
return {
'memory_cache_size': memory_cache_count,
'ai_cache_size': ai_cache_count,
'filterset_cache_size': filterset_cache_count,
'ai_cache_dir': str(self.ai_cache_dir),
'filterset_cache_dir': str(self.filterset_cache_dir)
}
def clear_all(self):
"""Clear all caches (use with caution!)"""
self.clear_memory()
# Clear AI cache
for cache_file in self.ai_cache_dir.glob('*.json'):
cache_file.unlink()
# Clear filterset cache
for cache_file in self.filterset_cache_dir.glob('*.json'):
cache_file.unlink()
logger.warning("All filter caches cleared")

206
filter_pipeline/config.py Normal file
View File

@@ -0,0 +1,206 @@
"""
Configuration Loader
Loads and validates filter pipeline configuration.
"""
import json
import os
import logging
from pathlib import Path
from typing import Dict, List, Any, Optional
logger = logging.getLogger(__name__)
class FilterConfig:
"""Configuration for filter pipeline"""
def __init__(
self,
config_file: str = 'filter_config.json',
filtersets_file: str = 'filtersets.json'
):
self.config_file = Path(config_file)
self.filtersets_file = Path(filtersets_file)
# Load configurations
self.config = self._load_config()
self.filtersets = self._load_filtersets()
def _load_config(self) -> Dict:
"""Load filter_config.json"""
if not self.config_file.exists():
logger.warning(f"{self.config_file} not found, using defaults")
return self._get_default_config()
try:
with open(self.config_file, 'r') as f:
config = json.load(f)
logger.info(f"Loaded filter config from {self.config_file}")
return config
except Exception as e:
logger.error(f"Error loading {self.config_file}: {e}")
return self._get_default_config()
def _load_filtersets(self) -> Dict:
"""Load filtersets.json"""
if not self.filtersets_file.exists():
logger.error(f"{self.filtersets_file} not found!")
return {}
try:
with open(self.filtersets_file, 'r') as f:
filtersets = json.load(f)
logger.info(f"Loaded {len(filtersets)} filtersets from {self.filtersets_file}")
return filtersets
except Exception as e:
logger.error(f"Error loading {self.filtersets_file}: {e}")
return {}
@staticmethod
def _get_default_config() -> Dict:
"""Get default configuration"""
return {
'ai': {
'enabled': False, # Disabled by default until API key is configured
'openrouter_key_file': 'openrouter_key.txt',
'models': {
'cheap': 'meta-llama/llama-3.3-70b-instruct',
'smart': 'anthropic/claude-3.5-sonnet'
},
'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
},
'output': {
'filtered_dir': 'data/filtered',
'save_rejected': False # Don't save posts that fail filters
}
}
# ===== AI Configuration =====
def is_ai_enabled(self) -> bool:
"""Check if AI processing is enabled"""
return self.config.get('ai', {}).get('enabled', False)
def get_openrouter_key(self) -> Optional[str]:
"""Get OpenRouter API key"""
# Try environment variable first
key = os.getenv('OPENROUTER_API_KEY')
if key:
return key
# Try key file
key_file = self.config.get('ai', {}).get('openrouter_key_file')
if key_file and Path(key_file).exists():
try:
with open(key_file, 'r') as f:
return f.read().strip()
except Exception as e:
logger.error(f"Error reading API key from {key_file}: {e}")
return None
def get_ai_model(self, model_type: str = 'cheap') -> str:
"""Get AI model name for a given type (cheap/smart)"""
models = self.config.get('ai', {}).get('models', {})
return models.get(model_type, 'meta-llama/llama-3.3-70b-instruct')
def get_parallel_workers(self) -> int:
"""Get number of parallel workers for AI processing"""
return self.config.get('ai', {}).get('parallel_workers', 10)
# ===== Cache Configuration =====
def is_cache_enabled(self) -> bool:
"""Check if caching is enabled"""
return self.config.get('cache', {}).get('enabled', True)
def get_cache_dir(self) -> str:
"""Get cache directory path"""
return self.config.get('cache', {}).get('ai_cache_dir', 'data/filter_cache')
def get_cache_ttl_hours(self) -> int:
"""Get filterset cache TTL in hours"""
return self.config.get('cache', {}).get('filterset_cache_ttl_hours', 24)
# ===== Pipeline Configuration =====
def get_default_stages(self) -> List[str]:
"""Get default pipeline stages"""
return self.config.get('pipeline', {}).get('default_stages', [
'categorizer', 'moderator', 'filter', 'ranker'
])
def get_batch_size(self) -> int:
"""Get batch processing size"""
return self.config.get('pipeline', {}).get('batch_size', 50)
def is_parallel_enabled(self) -> bool:
"""Check if parallel processing is enabled"""
return self.config.get('pipeline', {}).get('enable_parallel', True)
# ===== Filterset Methods =====
def get_filterset(self, name: str) -> Optional[Dict]:
"""Get filterset configuration by name"""
return self.filtersets.get(name)
def get_filterset_names(self) -> List[str]:
"""Get list of available filterset names"""
return list(self.filtersets.keys())
def get_filterset_version(self, name: str) -> Optional[str]:
"""Get version of filterset (for cache invalidation)"""
filterset = self.get_filterset(name)
if not filterset:
return None
# Use explicit version if present
if 'version' in filterset:
return str(filterset['version'])
# Otherwise compute hash of definition
import hashlib
definition_json = json.dumps(filterset, sort_keys=True)
return hashlib.md5(definition_json.encode()).hexdigest()[:8]
# ===== Output Configuration =====
def get_filtered_dir(self) -> str:
"""Get directory for filtered posts"""
return self.config.get('output', {}).get('filtered_dir', 'data/filtered')
def should_save_rejected(self) -> bool:
"""Check if rejected posts should be saved"""
return self.config.get('output', {}).get('save_rejected', False)
# ===== Utility Methods =====
def reload(self):
"""Reload configurations from disk"""
self.config = self._load_config()
self.filtersets = self._load_filtersets()
logger.info("Configuration reloaded")
def get_config_summary(self) -> Dict[str, Any]:
"""Get summary of configuration"""
return {
'ai_enabled': self.is_ai_enabled(),
'cache_enabled': self.is_cache_enabled(),
'parallel_enabled': self.is_parallel_enabled(),
'num_filtersets': len(self.filtersets),
'filterset_names': self.get_filterset_names(),
'default_stages': self.get_default_stages(),
'batch_size': self.get_batch_size()
}

376
filter_pipeline/engine.py Normal file
View File

@@ -0,0 +1,376 @@
"""
Filter Engine
Main orchestrator for content filtering pipeline.
"""
import logging
import traceback
from typing import List, Dict, Any, Optional
from datetime import datetime
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor, as_completed
from .config import FilterConfig
from .cache import FilterCache
from .models import FilterResult, ProcessingStatus, AIAnalysisResult
logger = logging.getLogger(__name__)
class FilterEngine:
"""
Main filter pipeline orchestrator.
Coordinates multi-stage content filtering with intelligent caching.
Compatible with user preferences and filterset selections.
"""
_instance = None
def __init__(
self,
config_file: str = 'filter_config.json',
filtersets_file: str = 'filtersets.json'
):
"""
Initialize filter engine.
Args:
config_file: Path to filter_config.json
filtersets_file: Path to filtersets.json
"""
self.config = FilterConfig(config_file, filtersets_file)
self.cache = FilterCache(self.config.get_cache_dir())
# Lazy-loaded stages (will be imported when AI is enabled)
self._stages = None
logger.info("FilterEngine initialized")
logger.info(f"Configuration: {self.config.get_config_summary()}")
@classmethod
def get_instance(cls) -> 'FilterEngine':
"""Get singleton instance of FilterEngine"""
if cls._instance is None:
cls._instance = cls()
return cls._instance
def _init_stages(self):
"""Initialize pipeline stages (lazy loading)"""
if self._stages is not None:
return
from .stages.categorizer import CategorizerStage
from .stages.moderator import ModeratorStage
from .stages.filter import FilterStage
from .stages.ranker import RankerStage
# Initialize stages based on configuration
self._stages = {
'categorizer': CategorizerStage(self.config, self.cache),
'moderator': ModeratorStage(self.config, self.cache),
'filter': FilterStage(self.config, self.cache),
'ranker': RankerStage(self.config, self.cache)
}
logger.info(f"Initialized {len(self._stages)} pipeline stages")
def apply_filterset(
self,
posts: List[Dict[str, Any]],
filterset_name: str = 'no_filter',
use_cache: bool = True
) -> List[Dict[str, Any]]:
"""
Apply filterset to posts (compatible with user preferences).
This is the main public API used by app.py when loading user feeds.
Args:
posts: List of post dictionaries
filterset_name: Name of filterset from user settings (e.g., 'safe_content')
use_cache: Whether to use cached results
Returns:
List of posts that passed the filter, with score and metadata added
"""
if not posts:
return []
# Validate filterset exists
filterset = self.config.get_filterset(filterset_name)
if not filterset:
logger.warning(f"Filterset '{filterset_name}' not found, using 'no_filter'")
filterset_name = 'no_filter'
logger.info(f"Applying filterset '{filterset_name}' to {len(posts)} posts")
# Check if we have cached filterset results
if use_cache and self.config.is_cache_enabled():
filterset_version = self.config.get_filterset_version(filterset_name)
cached_results = self.cache.get_filterset_results(
filterset_name,
filterset_version,
self.config.get_cache_ttl_hours()
)
if cached_results:
# Filter posts using cached results
filtered_posts = []
for post in posts:
post_uuid = post.get('uuid')
if post_uuid in cached_results:
result = cached_results[post_uuid]
if result.passed:
# Add filter metadata to post
post['_filter_score'] = result.score
post['_filter_categories'] = result.categories
post['_filter_tags'] = result.tags
filtered_posts.append(post)
logger.info(f"Cache hit: {len(filtered_posts)}/{len(posts)} posts passed filter")
return filtered_posts
# Cache miss or disabled - process posts through pipeline
results = self.process_batch(posts, filterset_name)
# Save to filterset cache
if self.config.is_cache_enabled():
filterset_version = self.config.get_filterset_version(filterset_name)
results_dict = {r.post_uuid: r for r in results}
self.cache.set_filterset_results(filterset_name, filterset_version, results_dict)
# Build filtered post list
filtered_posts = []
results_by_uuid = {r.post_uuid: r for r in results}
for post in posts:
post_uuid = post.get('uuid')
result = results_by_uuid.get(post_uuid)
if result and result.passed:
# Add filter metadata to post
post['_filter_score'] = result.score
post['_filter_categories'] = result.categories
post['_filter_tags'] = result.tags
filtered_posts.append(post)
logger.info(f"Processed: {len(filtered_posts)}/{len(posts)} posts passed filter")
return filtered_posts
def process_batch(
self,
posts: List[Dict[str, Any]],
filterset_name: str = 'no_filter'
) -> List[FilterResult]:
"""
Process batch of posts through pipeline.
Args:
posts: List of post dictionaries
filterset_name: Name of filterset to apply
Returns:
List of FilterResults for each post
"""
if not posts:
return []
# Special case: no_filter passes everything with default scores
if filterset_name == 'no_filter':
return self._process_no_filter(posts)
# Initialize stages if needed
if self.config.is_ai_enabled():
self._init_stages()
# If AI is disabled but filterset requires it, fall back to no_filter
if not self.config.is_ai_enabled() and filterset_name != 'no_filter':
logger.warning(f"AI disabled but '{filterset_name}' requires AI - falling back to 'no_filter'")
return self._process_no_filter(posts)
# Get pipeline stages for this filterset
stage_names = self._get_stages_for_filterset(filterset_name)
# Process posts (parallel or sequential based on config)
if self.config.is_parallel_enabled():
results = self._process_batch_parallel(posts, filterset_name, stage_names)
else:
results = self._process_batch_sequential(posts, filterset_name, stage_names)
return results
def _process_no_filter(self, posts: List[Dict[str, Any]]) -> List[FilterResult]:
"""Process posts with no_filter (all pass with default scores)"""
results = []
for post in posts:
result = FilterResult(
post_uuid=post.get('uuid', ''),
passed=True,
score=0.5, # Neutral score
categories=[],
tags=[],
filterset_name='no_filter',
processed_at=datetime.now(),
status=ProcessingStatus.COMPLETED
)
results.append(result)
return results
def _get_stages_for_filterset(self, filterset_name: str) -> List[str]:
"""Get pipeline stages to run for a filterset"""
filterset = self.config.get_filterset(filterset_name)
# Check if filterset specifies custom stages
if filterset and 'pipeline_stages' in filterset:
return filterset['pipeline_stages']
# Use default stages
return self.config.get_default_stages()
def _process_batch_parallel(
self,
posts: List[Dict[str, Any]],
filterset_name: str,
stage_names: List[str]
) -> List[FilterResult]:
"""Process posts in parallel"""
results = [None] * len(posts)
workers = self.config.get_parallel_workers()
def process_single_post(idx_post):
idx, post = idx_post
try:
result = self._process_single_post(post, filterset_name, stage_names)
return idx, result
except Exception as e:
logger.error(f"Error processing post {idx}: {e}")
logger.error(traceback.format_exc())
# Return failed result
return idx, FilterResult(
post_uuid=post.get('uuid', ''),
passed=False,
score=0.0,
filterset_name=filterset_name,
processed_at=datetime.now(),
status=ProcessingStatus.FAILED,
error=str(e)
)
with ThreadPoolExecutor(max_workers=workers) as executor:
futures = {executor.submit(process_single_post, (i, post)): i
for i, post in enumerate(posts)}
for future in as_completed(futures):
idx, result = future.result()
results[idx] = result
return results
def _process_batch_sequential(
self,
posts: List[Dict[str, Any]],
filterset_name: str,
stage_names: List[str]
) -> List[FilterResult]:
"""Process posts sequentially"""
results = []
for post in posts:
try:
result = self._process_single_post(post, filterset_name, stage_names)
results.append(result)
except Exception as e:
logger.error(f"Error processing post: {e}")
results.append(FilterResult(
post_uuid=post.get('uuid', ''),
passed=False,
score=0.0,
filterset_name=filterset_name,
processed_at=datetime.now(),
status=ProcessingStatus.FAILED,
error=str(e)
))
return results
def _process_single_post(
self,
post: Dict[str, Any],
filterset_name: str,
stage_names: List[str]
) -> FilterResult:
"""
Process single post through pipeline stages.
Stages are run sequentially: Categorizer → Moderator → Filter → Ranker
"""
# Initialize result
result = FilterResult(
post_uuid=post.get('uuid', ''),
passed=True, # Start as passed, stages can reject
score=0.5, # Default score
filterset_name=filterset_name,
processed_at=datetime.now(),
status=ProcessingStatus.PROCESSING
)
# Run each stage
for stage_name in stage_names:
if stage_name not in self._stages:
logger.warning(f"Stage '{stage_name}' not found, skipping")
continue
stage = self._stages[stage_name]
if not stage.is_enabled():
logger.debug(f"Stage '{stage_name}' disabled, skipping")
continue
# Process through stage
try:
result = stage.process(post, result)
# If post was rejected by this stage, stop processing
if not result.passed:
logger.debug(f"Post {post.get('uuid', '')} rejected by {stage_name}")
break
except Exception as e:
logger.error(f"Error in stage '{stage_name}': {e}")
result.status = ProcessingStatus.FAILED
result.error = f"{stage_name}: {str(e)}"
result.passed = False
break
# Mark as completed if not failed
if result.status != ProcessingStatus.FAILED:
result.status = ProcessingStatus.COMPLETED
return result
# ===== Utility Methods =====
def get_available_filtersets(self) -> List[str]:
"""Get list of available filterset names (for user settings UI)"""
return self.config.get_filterset_names()
def get_filterset_description(self, name: str) -> Optional[str]:
"""Get description of a filterset (for user settings UI)"""
filterset = self.config.get_filterset(name)
return filterset.get('description') if filterset else None
def invalidate_filterset_cache(self, filterset_name: str):
"""Invalidate cache for a filterset (when definition changes)"""
self.cache.invalidate_filterset(filterset_name)
logger.info(f"Invalidated cache for filterset '{filterset_name}'")
def get_cache_stats(self) -> Dict[str, Any]:
"""Get cache statistics"""
return self.cache.get_cache_stats()
def reload_config(self):
"""Reload configuration from disk"""
self.config.reload()
self._stages = None # Force re-initialization of stages
logger.info("Configuration reloaded")

121
filter_pipeline/models.py Normal file
View File

@@ -0,0 +1,121 @@
"""
Filter Pipeline Models
Data models for filter results and processing status.
"""
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Any
from datetime import datetime
from enum import Enum
class ProcessingStatus(Enum):
"""Status of content processing"""
PENDING = 'pending'
PROCESSING = 'processing'
COMPLETED = 'completed'
FAILED = 'failed'
CACHED = 'cached'
@dataclass
class FilterResult:
"""
Result of filtering pipeline for a single post.
Attributes:
post_uuid: Unique identifier for the post
passed: Whether post passed the filter
score: Relevance/quality score (0.0-1.0)
categories: Detected categories/topics
tags: Additional tags applied
moderation_data: Safety and quality analysis results
filterset_name: Name of filterset applied
cache_key: Content hash for caching
processed_at: Timestamp of processing
status: Processing status
error: Error message if failed
"""
post_uuid: str
passed: bool
score: float
categories: List[str] = field(default_factory=list)
tags: List[str] = field(default_factory=list)
moderation_data: Dict[str, Any] = field(default_factory=dict)
filterset_name: str = 'no_filter'
cache_key: Optional[str] = None
processed_at: Optional[datetime] = None
status: ProcessingStatus = ProcessingStatus.PENDING
error: Optional[str] = None
# Detailed scoring breakdown
score_breakdown: Dict[str, float] = field(default_factory=dict)
def to_dict(self) -> Dict:
"""Convert to dictionary for JSON serialization"""
return {
'post_uuid': self.post_uuid,
'passed': self.passed,
'score': self.score,
'categories': self.categories,
'tags': self.tags,
'moderation_data': self.moderation_data,
'filterset_name': self.filterset_name,
'cache_key': self.cache_key,
'processed_at': self.processed_at.isoformat() if self.processed_at else None,
'status': self.status.value if isinstance(self.status, ProcessingStatus) else self.status,
'error': self.error,
'score_breakdown': self.score_breakdown
}
@classmethod
def from_dict(cls, data: Dict) -> 'FilterResult':
"""Create from dictionary"""
# Handle datetime deserialization
if data.get('processed_at') and isinstance(data['processed_at'], str):
data['processed_at'] = datetime.fromisoformat(data['processed_at'])
# Handle enum deserialization
if data.get('status') and isinstance(data['status'], str):
data['status'] = ProcessingStatus(data['status'])
return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__})
@dataclass
class AIAnalysisResult:
"""
Result of AI analysis (categorization, moderation, etc).
Cached separately from FilterResult for reuse across filtersets.
"""
content_hash: str
categories: List[str] = field(default_factory=list)
category_scores: Dict[str, float] = field(default_factory=dict)
moderation: Dict[str, Any] = field(default_factory=dict)
quality_score: float = 0.5
sentiment: Optional[str] = None
sentiment_score: float = 0.0
analyzed_at: Optional[datetime] = None
model_used: Optional[str] = None
def to_dict(self) -> Dict:
"""Convert to dictionary for JSON serialization"""
return {
'content_hash': self.content_hash,
'categories': self.categories,
'category_scores': self.category_scores,
'moderation': self.moderation,
'quality_score': self.quality_score,
'sentiment': self.sentiment,
'sentiment_score': self.sentiment_score,
'analyzed_at': self.analyzed_at.isoformat() if self.analyzed_at else None,
'model_used': self.model_used
}
@classmethod
def from_dict(cls, data: Dict) -> 'AIAnalysisResult':
"""Create from dictionary"""
if data.get('analyzed_at') and isinstance(data['analyzed_at'], str):
data['analyzed_at'] = datetime.fromisoformat(data['analyzed_at'])
return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__})

View File

@@ -0,0 +1,10 @@
"""
Filter Plugins
Pluggable filters for content filtering.
"""
from .base import BaseFilterPlugin
from .keyword import KeywordFilterPlugin
from .quality import QualityFilterPlugin
__all__ = ['BaseFilterPlugin', 'KeywordFilterPlugin', 'QualityFilterPlugin']

View File

@@ -0,0 +1,66 @@
"""
Base Filter Plugin
Abstract base class for all filter plugins.
"""
from abc import ABC, abstractmethod
from typing import Dict, Any, Optional
class BaseFilterPlugin(ABC):
"""
Abstract base class for filter plugins.
Plugins can be used within stages to implement specific filtering logic.
Examples: keyword filtering, AI-based filtering, quality scoring, etc.
"""
def __init__(self, config: Dict[str, Any]):
"""
Initialize plugin.
Args:
config: Plugin configuration dictionary
"""
self.config = config
self.enabled = config.get('enabled', True)
@abstractmethod
def should_filter(self, post: Dict[str, Any], context: Optional[Dict] = None) -> bool:
"""
Determine if post should be filtered OUT.
Args:
post: Post data dictionary
context: Optional context from previous stages
Returns:
True if post should be filtered OUT (rejected), False to keep it
"""
pass
@abstractmethod
def score(self, post: Dict[str, Any], context: Optional[Dict] = None) -> float:
"""
Calculate relevance/quality score for post.
Args:
post: Post data dictionary
context: Optional context from previous stages
Returns:
Score from 0.0 (lowest) to 1.0 (highest)
"""
pass
@abstractmethod
def get_name(self) -> str:
"""Get plugin name for logging"""
pass
def is_enabled(self) -> bool:
"""Check if plugin is enabled"""
return self.enabled
def __repr__(self) -> str:
return f"<{self.get_name()} enabled={self.enabled}>"

View File

@@ -0,0 +1,95 @@
"""
Keyword Filter Plugin
Simple keyword-based filtering.
"""
import logging
from typing import Dict, Any, Optional, List
from .base import BaseFilterPlugin
logger = logging.getLogger(__name__)
class KeywordFilterPlugin(BaseFilterPlugin):
"""
Filter posts based on keyword matching.
Supports:
- Blocklist: Reject posts containing blocked keywords
- Allowlist: Only allow posts containing allowed keywords
- Case-insensitive matching
"""
def __init__(self, config: Dict[str, Any]):
super().__init__(config)
self.blocklist = [k.lower() for k in config.get('blocklist', [])]
self.allowlist = [k.lower() for k in config.get('allowlist', [])]
self.check_title = config.get('check_title', True)
self.check_content = config.get('check_content', True)
def get_name(self) -> str:
return "KeywordFilter"
def should_filter(self, post: Dict[str, Any], context: Optional[Dict] = None) -> bool:
"""
Check if post should be filtered out based on keywords.
Returns:
True if post contains blocked keywords or missing allowed keywords
"""
text = self._get_text(post)
# Check blocklist
if self.blocklist:
for keyword in self.blocklist:
if keyword in text:
logger.debug(f"KeywordFilter: Blocked keyword '{keyword}' found")
return True
# Check allowlist (if specified, at least one keyword must be present)
if self.allowlist:
found = any(keyword in text for keyword in self.allowlist)
if not found:
logger.debug("KeywordFilter: No allowed keywords found")
return True
return False
def score(self, post: Dict[str, Any], context: Optional[Dict] = None) -> float:
"""
Score based on keyword presence.
Returns:
1.0 if allowlist keywords present, 0.5 neutral, 0.0 if blocklist keywords present
"""
text = self._get_text(post)
# Check blocklist
if self.blocklist:
for keyword in self.blocklist:
if keyword in text:
return 0.0
# Check allowlist
if self.allowlist:
matches = sum(1 for keyword in self.allowlist if keyword in text)
if matches > 0:
return min(1.0, 0.5 + (matches * 0.1))
return 0.5 # Neutral
def _get_text(self, post: Dict[str, Any]) -> str:
"""Get searchable text from post"""
text_parts = []
if self.check_title:
title = post.get('title', '')
text_parts.append(title)
if self.check_content:
content = post.get('content', '')
text_parts.append(content)
return ' '.join(text_parts).lower()

View File

@@ -0,0 +1,128 @@
"""
Quality Filter Plugin
Filter based on quality metrics (readability, length, etc).
"""
import logging
import re
from typing import Dict, Any, Optional
from .base import BaseFilterPlugin
logger = logging.getLogger(__name__)
class QualityFilterPlugin(BaseFilterPlugin):
"""
Filter posts based on quality metrics.
Metrics:
- Title length (too short or too long)
- Content length
- Excessive caps (SHOUTING)
- Excessive punctuation (!!!)
- Clickbait patterns
"""
def __init__(self, config: Dict[str, Any]):
super().__init__(config)
self.min_title_length = config.get('min_title_length', 10)
self.max_title_length = config.get('max_title_length', 300)
self.min_content_length = config.get('min_content_length', 0)
self.max_caps_ratio = config.get('max_caps_ratio', 0.5)
self.max_exclamation_marks = config.get('max_exclamation_marks', 3)
# Clickbait patterns
self.clickbait_patterns = [
r'you won\'t believe',
r'shocking',
r'doctors hate',
r'this one trick',
r'number \d+ will',
r'what happened next'
]
def get_name(self) -> str:
return "QualityFilter"
def should_filter(self, post: Dict[str, Any], context: Optional[Dict] = None) -> bool:
"""
Check if post should be filtered based on quality.
Returns:
True if post fails quality checks
"""
title = post.get('title', '')
content = post.get('content', '')
# Check title length
if len(title) < self.min_title_length:
logger.debug(f"QualityFilter: Title too short ({len(title)} chars)")
return True
if len(title) > self.max_title_length:
logger.debug(f"QualityFilter: Title too long ({len(title)} chars)")
return True
# Check content length (if specified)
if self.min_content_length > 0 and len(content) < self.min_content_length:
logger.debug(f"QualityFilter: Content too short ({len(content)} chars)")
return True
# Check excessive caps
if len(title) > 0:
caps_ratio = sum(1 for c in title if c.isupper()) / len(title)
if caps_ratio > self.max_caps_ratio and len(title) > 10:
logger.debug(f"QualityFilter: Excessive caps ({caps_ratio:.1%})")
return True
# Check excessive exclamation marks
exclamations = title.count('!')
if exclamations > self.max_exclamation_marks:
logger.debug(f"QualityFilter: Excessive exclamations ({exclamations})")
return True
# Check clickbait patterns
title_lower = title.lower()
for pattern in self.clickbait_patterns:
if re.search(pattern, title_lower):
logger.debug(f"QualityFilter: Clickbait pattern detected: {pattern}")
return True
return False
def score(self, post: Dict[str, Any], context: Optional[Dict] = None) -> float:
"""
Score post quality.
Returns:
Quality score 0.0-1.0
"""
title = post.get('title', '')
content = post.get('content', '')
score = 1.0
# Penalize for short title
if len(title) < 20:
score -= 0.1
# Penalize for excessive caps
if len(title) > 0:
caps_ratio = sum(1 for c in title if c.isupper()) / len(title)
if caps_ratio > 0.3:
score -= (caps_ratio - 0.3) * 0.5
# Penalize for exclamation marks
exclamations = title.count('!')
if exclamations > 0:
score -= exclamations * 0.05
# Bonus for longer content
if len(content) > 500:
score += 0.1
elif len(content) > 200:
score += 0.05
return max(0.0, min(1.0, score))

View File

@@ -0,0 +1,12 @@
"""
Pipeline Stages
Sequential processing stages for content filtering.
"""
from .base_stage import BaseStage
from .categorizer import CategorizerStage
from .moderator import ModeratorStage
from .filter import FilterStage
from .ranker import RankerStage
__all__ = ['BaseStage', 'CategorizerStage', 'ModeratorStage', 'FilterStage', 'RankerStage']

View File

@@ -0,0 +1,62 @@
"""
Base Stage
Abstract base class for all pipeline stages.
"""
from abc import ABC, abstractmethod
from typing import List, Dict, Any
from ..models import FilterResult
class BaseStage(ABC):
"""
Abstract base class for pipeline stages.
Each stage processes posts sequentially and can modify FilterResults.
Stages are executed in order: Categorizer → Moderator → Filter → Ranker
"""
def __init__(self, config: Dict[str, Any], cache: Any):
"""
Initialize stage.
Args:
config: Configuration dictionary for this stage
cache: FilterCache instance
"""
self.config = config
self.cache = cache
self.enabled = config.get('enabled', True)
@abstractmethod
def process(
self,
post: Dict[str, Any],
result: FilterResult
) -> FilterResult:
"""
Process a single post and update its FilterResult.
Args:
post: Post data dictionary
result: Current FilterResult for this post
Returns:
Updated FilterResult
Raises:
Exception if processing fails
"""
pass
@abstractmethod
def get_name(self) -> str:
"""Get stage name for logging"""
pass
def is_enabled(self) -> bool:
"""Check if stage is enabled"""
return self.enabled
def __repr__(self) -> str:
return f"<{self.get_name()} enabled={self.enabled}>"

View File

@@ -0,0 +1,167 @@
"""
Categorizer Stage
Detect topics and categories using AI (cached by content hash).
"""
import logging
from typing import Dict, Any
from datetime import datetime
from .base_stage import BaseStage
from ..models import FilterResult, AIAnalysisResult
from ..cache import FilterCache
from ..ai_client import OpenRouterClient
logger = logging.getLogger(__name__)
class CategorizerStage(BaseStage):
"""
Stage 1: Categorize content and extract tags.
Uses AI to detect topics/categories with content-hash based caching.
"""
def __init__(self, config, cache: FilterCache):
super().__init__(config, cache)
# Initialize AI client if enabled
self.ai_client = None
if config.is_ai_enabled():
api_key = config.get_openrouter_key()
if api_key:
model = config.get_ai_model('cheap') # Use cheap model
self.ai_client = OpenRouterClient(api_key, model)
logger.info("Categorizer: AI client initialized")
else:
logger.warning("Categorizer: AI enabled but no API key found")
# Default categories
self.default_categories = [
'technology', 'programming', 'science', 'news',
'politics', 'business', 'entertainment', 'sports', 'other'
]
def get_name(self) -> str:
return "Categorizer"
def process(self, post: Dict[str, Any], result: FilterResult) -> FilterResult:
"""
Categorize post and add tags.
Args:
post: Post data
result: Current FilterResult
Returns:
Updated FilterResult with categories and tags
"""
title = post.get('title', '')
content = post.get('content', '')
# Compute content hash for caching
content_hash = self.cache.compute_content_hash(title, content)
result.cache_key = content_hash
# Try to get cached AI analysis
cached_analysis = self.cache.get_ai_analysis(content_hash)
if cached_analysis:
# Use cached categorization
result.categories = cached_analysis.categories
result.tags.extend(self._extract_platform_tags(post))
logger.debug(f"Categorizer: Cache hit for {content_hash[:8]}...")
return result
# No cache, need to categorize
if self.ai_client:
categories, category_scores = self._categorize_with_ai(title, content)
else:
# Fallback: Use platform/source as category
categories, category_scores = self._categorize_fallback(post)
# Store in AI analysis result for caching
ai_analysis = AIAnalysisResult(
content_hash=content_hash,
categories=categories,
category_scores=category_scores,
analyzed_at=datetime.now(),
model_used=self.ai_client.model if self.ai_client else 'fallback'
)
# Cache AI analysis
self.cache.set_ai_analysis(content_hash, ai_analysis)
# Update result
result.categories = categories
result.tags.extend(self._extract_platform_tags(post))
logger.debug(f"Categorizer: Analyzed {content_hash[:8]}... -> {categories}")
return result
def _categorize_with_ai(self, title: str, content: str) -> tuple:
"""
Categorize using AI.
Returns:
(categories list, category_scores dict)
"""
try:
response = self.ai_client.categorize(title, content, self.default_categories)
category = response.get('category', 'other')
confidence = response.get('confidence', 0.5)
categories = [category]
category_scores = {category: confidence}
return categories, category_scores
except Exception as e:
logger.error(f"AI categorization failed: {e}")
return ['other'], {'other': 0.0}
def _categorize_fallback(self, post: Dict[str, Any]) -> tuple:
"""
Fallback categorization using platform/source.
Returns:
(categories list, category_scores dict)
"""
# Use source as category
source = post.get('source', '').lower()
# Map common sources to categories
category_map = {
'programming': 'programming',
'python': 'programming',
'javascript': 'programming',
'technology': 'technology',
'science': 'science',
'politics': 'politics',
'worldnews': 'news',
'news': 'news'
}
category = category_map.get(source, 'other')
return [category], {category: 0.5}
def _extract_platform_tags(self, post: Dict[str, Any]) -> list:
"""Extract tags from platform, source, etc."""
tags = []
platform = post.get('platform', '')
if platform:
tags.append(platform)
source = post.get('source', '')
if source:
tags.append(source)
# Extract existing tags
existing_tags = post.get('tags', [])
if existing_tags:
tags.extend(existing_tags)
return list(set(tags)) # Remove duplicates

View File

@@ -0,0 +1,171 @@
"""
Filter Stage
Apply filterset rules to posts (no AI needed - fast rule evaluation).
"""
import logging
from typing import Dict, Any, List
from .base_stage import BaseStage
from ..models import FilterResult
logger = logging.getLogger(__name__)
class FilterStage(BaseStage):
"""
Stage 3: Apply filterset rules.
Evaluates filter conditions from filtersets.json without AI.
Fast rule-based filtering.
"""
def get_name(self) -> str:
return "Filter"
def process(self, post: Dict[str, Any], result: FilterResult) -> FilterResult:
"""
Apply filterset rules to post.
Args:
post: Post data
result: Current FilterResult
Returns:
Updated FilterResult (may be rejected)
"""
# Get filterset configuration
filterset = self.config.get_filterset(result.filterset_name)
if not filterset:
logger.warning(f"Filterset '{result.filterset_name}' not found")
return result
# Apply post rules
post_rules = filterset.get('post_rules', {})
if not self._evaluate_rules(post, result, post_rules):
result.passed = False
logger.debug(f"Filter: Post {post.get('uuid', '')} rejected by filterset rules")
return result
# Post passed all rules
logger.debug(f"Filter: Post {post.get('uuid', '')} passed filterset '{result.filterset_name}'")
return result
def _evaluate_rules(
self,
post: Dict[str, Any],
result: FilterResult,
rules: Dict[str, Any]
) -> bool:
"""
Evaluate all rules for a post.
Returns:
True if post passes all rules, False otherwise
"""
for field, condition in rules.items():
if not self._evaluate_condition(post, result, field, condition):
logger.debug(f"Filter: Failed condition '{field}': {condition}")
return False
return True
def _evaluate_condition(
self,
post: Dict[str, Any],
result: FilterResult,
field: str,
condition: Any
) -> bool:
"""
Evaluate a single condition.
Supported conditions:
- {"equals": value}
- {"not_equals": value}
- {"in": [values]}
- {"not_in": [values]}
- {"min": value}
- {"max": value}
- {"includes_any": [values]}
- {"excludes": [values]}
Args:
post: Post data
result: FilterResult with moderation data
field: Field path (e.g., "score", "moderation.flags.is_safe")
condition: Condition dict
Returns:
True if condition passes
"""
# Get field value
value = self._get_field_value(post, result, field)
# Evaluate condition
if isinstance(condition, dict):
for op, expected in condition.items():
if op == 'equals':
if value != expected:
return False
elif op == 'not_equals':
if value == expected:
return False
elif op == 'in':
if value not in expected:
return False
elif op == 'not_in':
if value in expected:
return False
elif op == 'min':
if value < expected:
return False
elif op == 'max':
if value > expected:
return False
elif op == 'includes_any':
# Check if any expected value is in the field (for lists)
if not isinstance(value, list):
return False
if not any(item in value for item in expected):
return False
elif op == 'excludes':
# Check that none of the excluded values are present
if isinstance(value, list):
if any(item in expected for item in value):
return False
elif value in expected:
return False
else:
logger.warning(f"Unknown condition operator: {op}")
return True
def _get_field_value(self, post: Dict[str, Any], result: FilterResult, field: str):
"""
Get field value from post or result.
Supports nested fields like "moderation.flags.is_safe"
"""
parts = field.split('.')
# Check if field is in moderation data
if parts[0] == 'moderation' and result.moderation_data:
value = result.moderation_data
for part in parts[1:]:
if isinstance(value, dict):
value = value.get(part)
else:
return None
return value
# Check post data
value = post
for part in parts:
if isinstance(value, dict):
value = value.get(part)
else:
return None
return value

View File

@@ -0,0 +1,153 @@
"""
Moderator Stage
Safety and quality analysis using AI (cached by content hash).
"""
import logging
from typing import Dict, Any
from datetime import datetime
from .base_stage import BaseStage
from ..models import FilterResult, AIAnalysisResult
from ..cache import FilterCache
from ..ai_client import OpenRouterClient
logger = logging.getLogger(__name__)
class ModeratorStage(BaseStage):
"""
Stage 2: Content moderation and quality analysis.
Uses AI to analyze safety, quality, and sentiment with content-hash based caching.
"""
def __init__(self, config, cache: FilterCache):
super().__init__(config, cache)
# Initialize AI client if enabled
self.ai_client = None
if config.is_ai_enabled():
api_key = config.get_openrouter_key()
if api_key:
model = config.get_ai_model('cheap') # Use cheap model
self.ai_client = OpenRouterClient(api_key, model)
logger.info("Moderator: AI client initialized")
else:
logger.warning("Moderator: AI enabled but no API key found")
def get_name(self) -> str:
return "Moderator"
def process(self, post: Dict[str, Any], result: FilterResult) -> FilterResult:
"""
Moderate post for safety and quality.
Args:
post: Post data
result: Current FilterResult
Returns:
Updated FilterResult with moderation data
"""
title = post.get('title', '')
content = post.get('content', '')
# Use existing cache key from Categorizer
content_hash = result.cache_key or self.cache.compute_content_hash(title, content)
# Try to get cached AI analysis
cached_analysis = self.cache.get_ai_analysis(content_hash)
if cached_analysis and cached_analysis.moderation:
# Use cached moderation data
result.moderation_data = cached_analysis.moderation
result.score_breakdown['quality'] = cached_analysis.quality_score
logger.debug(f"Moderator: Cache hit for {content_hash[:8]}...")
return result
# No cache, need to moderate
if self.ai_client:
moderation, quality_score, sentiment = self._moderate_with_ai(title, content)
else:
# Fallback: Safe defaults
moderation, quality_score, sentiment = self._moderate_fallback(post)
# Update or create AI analysis result
if cached_analysis:
# Update existing analysis with moderation data
cached_analysis.moderation = moderation
cached_analysis.quality_score = quality_score
cached_analysis.sentiment = sentiment.get('sentiment')
cached_analysis.sentiment_score = sentiment.get('score', 0.0)
ai_analysis = cached_analysis
else:
# Create new analysis
ai_analysis = AIAnalysisResult(
content_hash=content_hash,
moderation=moderation,
quality_score=quality_score,
sentiment=sentiment.get('sentiment'),
sentiment_score=sentiment.get('score', 0.0),
analyzed_at=datetime.now(),
model_used=self.ai_client.model if self.ai_client else 'fallback'
)
# Cache AI analysis
self.cache.set_ai_analysis(content_hash, ai_analysis)
# Update result
result.moderation_data = moderation
result.score_breakdown['quality'] = quality_score
result.score_breakdown['sentiment'] = sentiment.get('score', 0.0)
logger.debug(f"Moderator: Analyzed {content_hash[:8]}... (quality: {quality_score:.2f})")
return result
def _moderate_with_ai(self, title: str, content: str) -> tuple:
"""
Moderate using AI.
Returns:
(moderation dict, quality_score float, sentiment dict)
"""
try:
# Run moderation
moderation = self.ai_client.moderate(title, content)
# Run quality scoring
quality_score = self.ai_client.score_quality(title, content)
# Run sentiment analysis
sentiment = self.ai_client.analyze_sentiment(title, content)
return moderation, quality_score, sentiment
except Exception as e:
logger.error(f"AI moderation failed: {e}")
return self._moderate_fallback({})
def _moderate_fallback(self, post: Dict[str, Any]) -> tuple:
"""
Fallback moderation with safe defaults.
Returns:
(moderation dict, quality_score float, sentiment dict)
"""
moderation = {
'violence': 0.0,
'sexual_content': 0.0,
'hate_speech': 0.0,
'harassment': 0.0,
'is_safe': True
}
quality_score = 0.5 # Neutral quality
sentiment = {
'sentiment': 'neutral',
'score': 0.0
}
return moderation, quality_score, sentiment

View File

@@ -0,0 +1,201 @@
"""
Ranker Stage
Score and rank posts based on quality, recency, and source.
"""
import logging
from typing import Dict, Any
from datetime import datetime
from .base_stage import BaseStage
from ..models import FilterResult
logger = logging.getLogger(__name__)
class RankerStage(BaseStage):
"""
Stage 4: Score and rank posts.
Combines multiple factors:
- Quality score (from Moderator)
- Recency (how recent the post is)
- Source tier (platform/source reputation)
- User engagement (score, replies)
"""
def __init__(self, config, cache):
super().__init__(config, cache)
# Scoring weights
self.weights = {
'quality': 0.3,
'recency': 0.25,
'source_tier': 0.25,
'engagement': 0.20
}
# Source tiers (higher = better)
self.source_tiers = {
'tier1': ['hackernews', 'arxiv', 'nature', 'science'],
'tier2': ['reddit', 'stackoverflow', 'github'],
'tier3': ['twitter', 'medium', 'dev.to']
}
def get_name(self) -> str:
return "Ranker"
def process(self, post: Dict[str, Any], result: FilterResult) -> FilterResult:
"""
Calculate final score for post.
Args:
post: Post data
result: Current FilterResult
Returns:
Updated FilterResult with final score
"""
# Calculate component scores
quality_score = self._get_quality_score(result)
recency_score = self._calculate_recency_score(post)
source_score = self._calculate_source_score(post)
engagement_score = self._calculate_engagement_score(post)
# Store breakdown
result.score_breakdown.update({
'quality': quality_score,
'recency': recency_score,
'source_tier': source_score,
'engagement': engagement_score
})
# Calculate weighted final score
final_score = (
quality_score * self.weights['quality'] +
recency_score * self.weights['recency'] +
source_score * self.weights['source_tier'] +
engagement_score * self.weights['engagement']
)
result.score = final_score
logger.debug(
f"Ranker: Post {post.get('uuid', '')[:8]}... score={final_score:.3f} "
f"(q:{quality_score:.2f}, r:{recency_score:.2f}, s:{source_score:.2f}, e:{engagement_score:.2f})"
)
return result
def _get_quality_score(self, result: FilterResult) -> float:
"""Get quality score from Moderator stage"""
return result.score_breakdown.get('quality', 0.5)
def _calculate_recency_score(self, post: Dict[str, Any]) -> float:
"""
Calculate recency score based on post age.
Returns:
Score 0.0-1.0 (1.0 = very recent, 0.0 = very old)
"""
timestamp = post.get('timestamp')
if not timestamp:
return 0.5 # Neutral if no timestamp
try:
# Convert to datetime
if isinstance(timestamp, int):
post_time = datetime.fromtimestamp(timestamp)
else:
post_time = datetime.fromisoformat(str(timestamp))
# Calculate age in hours
age_seconds = (datetime.now() - post_time).total_seconds()
age_hours = age_seconds / 3600
# Scoring curve
if age_hours < 1:
return 1.0
elif age_hours < 6:
return 0.9
elif age_hours < 12:
return 0.75
elif age_hours < 24:
return 0.6
elif age_hours < 48:
return 0.4
elif age_hours < 168: # 1 week
return 0.25
else:
return 0.1
except Exception as e:
logger.debug(f"Error calculating recency: {e}")
return 0.5
def _calculate_source_score(self, post: Dict[str, Any]) -> float:
"""
Calculate source tier score.
Returns:
Score 0.0-1.0 based on source reputation
"""
platform = post.get('platform', '').lower()
source = post.get('source', '').lower()
# Check tier 1
if any(t in platform or t in source for t in self.source_tiers['tier1']):
return 1.0
# Check tier 2
if any(t in platform or t in source for t in self.source_tiers['tier2']):
return 0.7
# Check tier 3
if any(t in platform or t in source for t in self.source_tiers['tier3']):
return 0.5
# Unknown source
return 0.3
def _calculate_engagement_score(self, post: Dict[str, Any]) -> float:
"""
Calculate engagement score based on upvotes/score and comments.
Returns:
Score 0.0-1.0 based on engagement metrics
"""
score = post.get('score', 0)
replies = post.get('replies', 0)
# Normalize scores (logarithmic scale)
import math
# Score component (0-1)
if score <= 0:
score_component = 0.0
elif score < 10:
score_component = score / 10
elif score < 100:
score_component = 0.1 + (math.log10(score) - 1) * 0.3 # 0.1-0.4
elif score < 1000:
score_component = 0.4 + (math.log10(score) - 2) * 0.3 # 0.4-0.7
else:
score_component = min(1.0, 0.7 + (math.log10(score) - 3) * 0.1) # 0.7-1.0
# Replies component (0-1)
if replies <= 0:
replies_component = 0.0
elif replies < 5:
replies_component = replies / 5
elif replies < 20:
replies_component = 0.2 + (replies - 5) / 15 * 0.3 # 0.2-0.5
elif replies < 100:
replies_component = 0.5 + (math.log10(replies) - math.log10(20)) / (2 - math.log10(20)) * 0.3 # 0.5-0.8
else:
replies_component = min(1.0, 0.8 + (math.log10(replies) - 2) * 0.1) # 0.8-1.0
# Weighted combination (score matters more than replies)
engagement_score = score_component * 0.7 + replies_component * 0.3
return engagement_score

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

54
migrate_password_reset.py Normal file
View File

@@ -0,0 +1,54 @@
#!/usr/bin/env python3
"""
Database migration to add password reset fields to users table.
Run this once to add the new columns for password reset functionality.
"""
import sys
from app import app, db
def migrate():
"""Add password reset columns to users table"""
with app.app_context():
try:
# Check if columns already exist
from sqlalchemy import inspect
inspector = inspect(db.engine)
columns = [col['name'] for col in inspector.get_columns('users')]
if 'reset_token' in columns and 'reset_token_expiry' in columns:
print("✓ Password reset columns already exist")
return True
# Add the new columns using raw SQL
with db.engine.connect() as conn:
if 'reset_token' not in columns:
print("Adding reset_token column...")
conn.execute(db.text(
"ALTER TABLE users ADD COLUMN reset_token VARCHAR(100) UNIQUE"
))
conn.execute(db.text(
"CREATE INDEX IF NOT EXISTS ix_users_reset_token ON users(reset_token)"
))
conn.commit()
if 'reset_token_expiry' not in columns:
print("Adding reset_token_expiry column...")
conn.execute(db.text(
"ALTER TABLE users ADD COLUMN reset_token_expiry TIMESTAMP"
))
conn.commit()
print("✓ Password reset columns added successfully")
return True
except Exception as e:
print(f"✗ Migration failed: {e}")
import traceback
traceback.print_exc()
return False
if __name__ == '__main__':
print("Running password reset migration...")
success = migrate()
sys.exit(0 if success else 1)

View File

@@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""
Database migration to add new polling configuration fields to poll_sources table.
Run this once to add the new columns: max_posts, fetch_comments, priority
"""
import sys
from app import app, db
def migrate():
"""Add polling configuration columns to poll_sources table"""
with app.app_context():
try:
# Check if columns already exist
from sqlalchemy import inspect
inspector = inspect(db.engine)
columns = [col['name'] for col in inspector.get_columns('poll_sources')]
if 'max_posts' in columns and 'fetch_comments' in columns and 'priority' in columns:
print("✓ Polling configuration columns already exist")
return True
# Add the new columns using raw SQL
with db.engine.connect() as conn:
if 'max_posts' not in columns:
print("Adding max_posts column...")
conn.execute(db.text(
"ALTER TABLE poll_sources ADD COLUMN max_posts INTEGER NOT NULL DEFAULT 100"
))
conn.commit()
if 'fetch_comments' not in columns:
print("Adding fetch_comments column...")
conn.execute(db.text(
"ALTER TABLE poll_sources ADD COLUMN fetch_comments BOOLEAN NOT NULL DEFAULT TRUE"
))
conn.commit()
if 'priority' not in columns:
print("Adding priority column...")
conn.execute(db.text(
"ALTER TABLE poll_sources ADD COLUMN priority VARCHAR(20) NOT NULL DEFAULT 'medium'"
))
conn.commit()
print("✓ Polling configuration columns added successfully")
print("\nUpdating existing poll sources with default values...")
# Update existing rows to have default values
with db.engine.connect() as conn:
result = conn.execute(db.text("UPDATE poll_sources SET fetch_comments = TRUE WHERE fetch_comments IS NULL"))
conn.commit()
print(f"✓ Updated {result.rowcount} rows with default fetch_comments=TRUE")
return True
except Exception as e:
print(f"✗ Migration failed: {e}")
import traceback
traceback.print_exc()
return False
if __name__ == '__main__':
print("Running poll source fields migration...")
success = migrate()
sys.exit(0 if success else 1)

View File

@@ -41,6 +41,10 @@ class User(UserMixin, db.Model):
# User settings (JSON stored as text) # User settings (JSON stored as text)
settings = db.Column(db.Text, default='{}') settings = db.Column(db.Text, default='{}')
# Password reset
reset_token = db.Column(db.String(100), nullable=True, unique=True, index=True)
reset_token_expiry = db.Column(db.DateTime, nullable=True)
def __init__(self, username, email, password=None, is_admin=False, auth0_id=None): def __init__(self, username, email, password=None, is_admin=False, auth0_id=None):
""" """
Initialize a new user. Initialize a new user.
@@ -102,6 +106,32 @@ class User(UserMixin, db.Model):
self.last_login = datetime.utcnow() self.last_login = datetime.utcnow()
db.session.commit() db.session.commit()
def generate_reset_token(self):
"""Generate a password reset token that expires in 1 hour"""
import secrets
from datetime import timedelta
self.reset_token = secrets.token_urlsafe(32)
self.reset_token_expiry = datetime.utcnow() + timedelta(hours=1)
db.session.commit()
return self.reset_token
def verify_reset_token(self, token):
"""Verify if the provided reset token is valid and not expired"""
if not self.reset_token or not self.reset_token_expiry:
return False
if self.reset_token != token:
return False
if datetime.utcnow() > self.reset_token_expiry:
return False
return True
def clear_reset_token(self):
"""Clear the reset token after use"""
self.reset_token = None
self.reset_token_expiry = None
db.session.commit()
def get_id(self): def get_id(self):
"""Required by Flask-Login""" """Required by Flask-Login"""
return self.id return self.id
@@ -140,6 +170,9 @@ class PollSource(db.Model):
# Polling configuration # Polling configuration
enabled = db.Column(db.Boolean, default=True, nullable=False) enabled = db.Column(db.Boolean, default=True, nullable=False)
poll_interval_minutes = db.Column(db.Integer, default=60, nullable=False) # How often to poll poll_interval_minutes = db.Column(db.Integer, default=60, nullable=False) # How often to poll
max_posts = db.Column(db.Integer, default=100, nullable=False) # Max posts per poll
fetch_comments = db.Column(db.Boolean, default=True, nullable=False) # Whether to fetch comments
priority = db.Column(db.String(20), default='medium', nullable=False) # low, medium, high
# Status tracking # Status tracking
last_poll_time = db.Column(db.DateTime, nullable=True) last_poll_time = db.Column(db.DateTime, nullable=True)
@@ -184,3 +217,32 @@ class PollLog(db.Model):
def __repr__(self): def __repr__(self):
return f'<PollLog {self.id} for source {self.source_id}>' return f'<PollLog {self.id} for source {self.source_id}>'
class Bookmark(db.Model):
"""User bookmarks for posts"""
__tablename__ = 'bookmarks'
id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
user_id = db.Column(db.String(36), db.ForeignKey('users.id'), nullable=False, index=True)
post_uuid = db.Column(db.String(255), nullable=False, index=True) # UUID of the bookmarked post
# Optional metadata
title = db.Column(db.String(500), nullable=True) # Cached post title
platform = db.Column(db.String(50), nullable=True) # Cached platform info
source = db.Column(db.String(100), nullable=True) # Cached source info
# Timestamps
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
# Relationships
user = db.relationship('User', backref=db.backref('bookmarks', lazy='dynamic', order_by='Bookmark.created_at.desc()'))
# Unique constraint - user can only bookmark a post once
__table_args__ = (
db.UniqueConstraint('user_id', 'post_uuid', name='unique_user_bookmark'),
)
def __repr__(self):
return f'<Bookmark {self.post_uuid} by user {self.user_id}>'

View File

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

View File

@@ -161,14 +161,14 @@ class PollingService:
end_iso = end_date.isoformat() end_iso = end_date.isoformat()
try: try:
# Call the existing collect_platform function # Call the existing collect_platform function using source settings
posts_collected = collect_platform( posts_collected = collect_platform(
platform=source.platform, platform=source.platform,
community=source.source_id, community=source.source_id,
start_date=start_iso, start_date=start_iso,
end_date=end_iso, end_date=end_iso,
max_posts=100, # Default limit max_posts=source.max_posts or 100,
fetch_comments=True, fetch_comments=source.fetch_comments if hasattr(source, 'fetch_comments') else True,
index=index, index=index,
dirs=dirs dirs=dirs
) )

View File

@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Page Not Found - BalanceBoard</title> <title>Page Not Found - {{ APP_NAME }}</title>
<link rel="stylesheet" href="{{ url_for('serve_theme', filename='modern-card-ui/styles.css') }}"> <link rel="stylesheet" href="{{ url_for('serve_theme', filename='modern-card-ui/styles.css') }}">
<style> <style>
.error-container { .error-container {

View File

@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Server Error - BalanceBoard</title> <title>Server Error - {{ APP_NAME }}</title>
<link rel="stylesheet" href="{{ url_for('serve_theme', filename='modern-card-ui/styles.css') }}"> <link rel="stylesheet" href="{{ url_for('serve_theme', filename='modern-card-ui/styles.css') }}">
<style> <style>
.error-container { .error-container {

587
templates/_admin_base.html Normal file
View File

@@ -0,0 +1,587 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Admin Panel - {{ APP_NAME }}{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('serve_theme', filename='modern-card-ui/styles.css') }}">
<style>
/* ===== SHARED ADMIN STYLES ===== */
.admin-container {
max-width: 1400px;
margin: 0 auto;
padding: 24px;
}
.admin-header {
background: linear-gradient(135deg, var(--primary-dark) 0%, #2a5068 100%);
color: white;
padding: 32px;
border-radius: 12px;
margin-bottom: 24px;
border-bottom: 3px solid var(--primary-color);
}
.admin-header h1 {
margin: 0 0 8px 0;
font-size: 2rem;
}
.admin-header p {
margin: 0;
opacity: 0.9;
}
.admin-section {
background: var(--surface-color);
border-radius: 12px;
padding: 24px;
margin-bottom: 24px;
box-shadow: 0 2px 4px var(--surface-elevation-1);
}
.section-title {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 16px;
color: var(--text-primary);
}
/* ===== ADMIN NAVIGATION ===== */
.admin-tabs {
display: flex;
gap: 8px;
margin-bottom: 24px;
border-bottom: 2px solid var(--divider-color);
}
.tab-btn {
padding: 12px 24px;
background: none;
border: none;
border-bottom: 3px solid transparent;
cursor: pointer;
font-weight: 500;
color: var(--text-secondary);
transition: all 0.2s;
}
.tab-btn.active {
color: var(--primary-color);
border-bottom-color: var(--primary-color);
}
.tab-btn:hover {
color: var(--primary-color);
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
/* ===== BUTTONS ===== */
.btn,
.btn-primary,
.btn-secondary {
padding: 8px 16px;
border: none;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
text-decoration: none;
display: inline-block;
text-align: center;
border: 1px solid transparent;
}
.btn-primary {
background: var(--primary-color);
color: white;
border-color: var(--primary-color);
}
.btn-primary:hover {
background: var(--primary-hover);
border-color: var(--primary-hover);
transform: translateY(-1px);
}
.btn-secondary {
background: var(--surface-elevation-1);
color: var(--text-primary);
border-color: var(--divider-color);
}
.btn-secondary:hover {
background: var(--surface-elevation-2);
border-color: var(--divider-color);
transform: translateY(-1px);
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none !important;
}
.btn:disabled:hover {
background: var(--primary-color) !important;
border-color: var(--primary-color) !important;
}
.action-btn {
padding: 6px 12px;
border: none;
border-radius: 6px;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
margin-right: 8px;
}
.action-btn-primary {
background: var(--primary-color);
color: white;
}
.action-btn-primary:hover {
background: var(--primary-hover);
}
.action-btn-danger {
background: #dc3545;
color: white;
}
.action-btn-danger:hover {
background: #c82333;
}
.action-btn-warning {
background: #ffc107;
color: #212529;
}
.action-btn-warning:hover {
background: #e0a800;
}
/* ===== STATUS BADGES ===== */
.badge {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
font-size: 0.8rem;
font-weight: 600;
}
.badge-admin {
background: var(--primary-color);
color: white;
}
.badge-user {
background: var(--background-color);
color: var(--text-secondary);
}
.badge-active {
background: #28a745;
color: white;
}
.badge-inactive {
background: #6c757d;
color: white;
}
.status-badge {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
font-size: 0.85rem;
font-weight: 500;
}
.status-enabled, .status-success {
background: #d4edda;
color: #155724;
}
.status-disabled, .status-error {
background: #f8d7da;
color: #721c24;
}
.status-running {
background: #fff3cd;
color: #856404;
}
/* ===== TABLES ===== */
.admin-table {
background: var(--surface-color);
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 4px var(--surface-elevation-1);
}
.admin-table table {
width: 100%;
border-collapse: collapse;
}
.admin-table th {
background: var(--primary-dark);
color: white;
padding: 16px;
text-align: left;
font-weight: 600;
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.admin-table td {
padding: 16px;
border-bottom: 1px solid var(--divider-color);
}
.admin-table tr:hover {
background: var(--hover-overlay);
}
.admin-table tr:last-child td {
border-bottom: none;
}
/* ===== STATS & CARDS ===== */
.admin-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 16px;
margin-bottom: 24px;
max-width: 100%;
}
@media (max-width: 768px) {
.admin-stats {
grid-template-columns: 1fr;
}
}
@media (min-width: 1200px) {
.admin-stats {
grid-template-columns: repeat(4, 1fr);
}
}
.stat-card {
background: var(--surface-color);
padding: 20px;
border-radius: 12px;
box-shadow: 0 2px 4px var(--surface-elevation-1);
border-left: 4px solid var(--primary-color);
}
.stat-number {
font-size: 2rem;
font-weight: 700;
color: var(--primary-color);
}
.stat-label {
color: var(--text-secondary);
font-size: 0.9rem;
margin-top: 4px;
}
.info-card {
background: var(--background-color);
padding: 16px;
border-radius: 8px;
border-left: 3px solid var(--primary-color);
}
.info-card h4 {
margin: 0 0 8px 0;
color: var(--primary-color);
}
.info-card p {
margin: 4px 0;
font-size: 0.9rem;
}
.system-info {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 16px;
max-width: 100%;
}
@media (max-width: 768px) {
.system-info {
grid-template-columns: 1fr;
}
}
@media (min-width: 1200px) {
.system-info {
grid-template-columns: repeat(3, 1fr);
}
}
/* ===== FORMS ===== */
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
margin-bottom: 4px;
font-weight: 500;
color: var(--text-primary);
}
.form-control,
.form-input {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--divider-color);
border-radius: 6px;
font-size: 0.9rem;
transition: border-color 0.2s ease;
}
.form-control:focus,
.form-input:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);
}
.form-select {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--divider-color);
border-radius: 6px;
font-size: 0.9rem;
background-color: var(--surface-color);
cursor: pointer;
transition: border-color 0.2s ease;
}
.form-select:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);
}
.form-label {
display: block;
margin-bottom: 4px;
font-weight: 500;
color: var(--text-primary);
}
.help-text {
margin-top: 4px;
font-size: 0.85rem;
color: var(--text-secondary);
}
.add-source-form {
background: var(--surface-color);
border-radius: 12px;
padding: 24px;
margin-bottom: 24px;
border: 1px solid var(--divider-color);
}
.add-source-form h3 {
margin: 0 0 20px 0;
color: var(--text-primary);
font-size: 1.2rem;
}
/* ===== MODALS ===== */
.modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
backdrop-filter: blur(2px);
}
.modal-overlay.active {
display: flex;
align-items: center;
justify-content: center;
}
.modal {
background: var(--surface-color);
border-radius: 12px;
padding: 24px;
max-width: 500px;
width: 90%;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
border: 1px solid var(--divider-color);
max-height: 80vh;
overflow-y: auto;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px solid var(--divider-color);
}
.modal-title {
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.modal-close {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: var(--text-secondary);
padding: 4px;
border-radius: 4px;
transition: all 0.2s ease;
}
.modal-close:hover {
background: var(--surface-elevation-1);
color: var(--text-primary);
}
.modal-body {
margin-bottom: 20px;
}
.modal-footer {
display: flex;
gap: 12px;
justify-content: flex-end;
padding-top: 16px;
border-top: 1px solid var(--divider-color);
}
.modal-alert {
background: #fff3cd;
border: 1px solid #ffeaa7;
border-radius: 8px;
padding: 12px;
margin-bottom: 16px;
color: #856404;
font-size: 0.9rem;
}
/* ===== UTILITIES ===== */
.back-link {
display: inline-block;
margin-bottom: 16px;
color: var(--primary-color);
text-decoration: none;
font-weight: 500;
}
.back-link:hover {
text-decoration: underline;
}
.flash-messages {
margin-bottom: 24px;
}
.flash-message {
padding: 12px 16px;
border-radius: 8px;
margin-bottom: 12px;
}
.flash-message.success {
background: #d4edda;
color: #155724;
}
.flash-message.error {
background: #f8d7da;
color: #721c24;
}
.flash-message.warning {
background: #fff3cd;
color: #856404;
}
/* ===== RESPONSIVE ===== */
@media (max-width: 768px) {
.admin-tabs {
overflow-x: auto;
}
.admin-stats {
grid-template-columns: 1fr;
}
.system-info {
grid-template-columns: 1fr;
}
.admin-container {
padding: 16px;
}
}
/* ===== PAGE-SPECIFIC OVERRIDES ===== */
{% block admin_styles %}{% endblock %}
</style>
</head>
<body>
{% include '_nav.html' %}
<div class="admin-container">
<a href="{{ url_for('index') }}" class="back-link">← Back to Feed</a>
<div class="admin-header">
<h1>{% block page_title %}Admin Panel{% endblock %}</h1>
<p>{% block page_description %}Manage system settings and content{% endblock %}</p>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flash-messages">
{% for category, message in messages %}
<div class="flash-message {{ category }}">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% block admin_content %}{% endblock %}
</div>
{% block admin_scripts %}{% endblock %}
</body>
</html>

48
templates/_nav.html Normal file
View File

@@ -0,0 +1,48 @@
<!-- Modern Top Navigation -->
<nav class="top-nav">
<div class="nav-content">
<div class="nav-left">
<a href="{{ url_for('index') }}" class="logo-section">
<img src="{{ url_for('serve_logo') }}" alt="{{ APP_NAME }}" class="nav-logo">
<span class="brand-text">{{ APP_NAME }}</span>
</a>
</div>
<div class="nav-center">
<div class="search-bar">
<input type="text" placeholder="Search content..." class="search-input">
<button class="search-btn">🔍</button>
</div>
</div>
<div class="nav-right">
{% if current_user.is_authenticated %}
<div class="user-menu">
<div class="user-info">
<div class="user-avatar">
{% if current_user.profile_picture_url %}
<img src="{{ current_user.profile_picture_url }}" alt="Avatar">
{% else %}
<div class="avatar-placeholder">{{ current_user.username[:2].upper() }}</div>
{% endif %}
</div>
<span class="username">{{ current_user.username }}</span>
</div>
<div class="user-dropdown">
<a href="{{ url_for('settings') }}" class="dropdown-item">⚙️ Settings</a>
<a href="{{ url_for('bookmarks') }}" class="dropdown-item">📚 Bookmarks</a>
{% if current_user.is_admin %}
<a href="{{ url_for('admin_panel') }}" class="dropdown-item">👨‍💼 Admin Panel</a>
{% endif %}
<a href="{{ url_for('logout') }}" class="dropdown-item">🚪 Logout</a>
</div>
</div>
{% else %}
<div class="anonymous-actions">
<a href="{{ url_for('login') }}" class="login-btn">🔑 Login</a>
<a href="{{ url_for('signup') }}" class="register-btn">📝 Sign Up</a>
</div>
{% endif %}
</div>
</div>
</nav>

View File

@@ -1,367 +1,32 @@
<!DOCTYPE html> {% extends "_admin_base.html" %}
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Panel - BalanceBoard</title>
<link rel="stylesheet" href="{{ url_for('serve_theme', filename='modern-card-ui/styles.css') }}">
<style>
.admin-container {
max-width: 1400px;
margin: 0 auto;
padding: 24px;
}
.admin-header { {% block title %}Admin Panel - {{ APP_NAME }}{% endblock %}
background: linear-gradient(135deg, var(--primary-dark) 0%, #2a5068 100%);
color: white;
padding: 32px;
border-radius: 12px;
margin-bottom: 24px;
border-bottom: 3px solid var(--primary-color);
}
.admin-header h1 { {% block page_title %}Admin Panel{% endblock %}
margin: 0 0 8px 0; {% block page_description %}Manage users, content, and system settings{% endblock %}
font-size: 2rem;
}
.admin-header p { {% block admin_styles %}
margin: 0; .user-avatar {
opacity: 0.9; width: 32px;
} height: 32px;
border-radius: 50%;
background: var(--primary-color);
display: inline-flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 600;
font-size: 0.8rem;
margin-right: 8px;
}
.admin-tabs { .user-info {
display: flex; display: flex;
gap: 8px; align-items: center;
margin-bottom: 24px; }
border-bottom: 2px solid var(--divider-color); {% endblock %}
}
.tab-btn { {% block admin_content %}
padding: 12px 24px;
background: none;
border: none;
border-bottom: 3px solid transparent;
cursor: pointer;
font-weight: 500;
color: var(--text-secondary);
transition: all 0.2s;
}
.tab-btn.active {
color: var(--primary-color);
border-bottom-color: var(--primary-color);
}
.tab-btn:hover {
color: var(--primary-color);
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
.admin-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
background: var(--surface-color);
padding: 20px;
border-radius: 12px;
box-shadow: 0 2px 4px var(--surface-elevation-1);
border-left: 4px solid var(--primary-color);
}
.stat-number {
font-size: 2rem;
font-weight: 700;
color: var(--primary-color);
}
.stat-label {
color: var(--text-secondary);
font-size: 0.9rem;
margin-top: 4px;
}
.admin-section {
background: var(--surface-color);
border-radius: 12px;
padding: 24px;
margin-bottom: 24px;
box-shadow: 0 2px 4px var(--surface-elevation-1);
}
.section-title {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 16px;
color: var(--text-primary);
}
.users-table {
background: var(--surface-color);
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 4px var(--surface-elevation-1);
}
.users-table table {
width: 100%;
border-collapse: collapse;
}
.users-table th {
background: var(--primary-dark);
color: white;
padding: 16px;
text-align: left;
font-weight: 600;
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.users-table td {
padding: 16px;
border-bottom: 1px solid var(--divider-color);
}
.users-table tr:hover {
background: var(--hover-overlay);
}
.badge {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
font-size: 0.8rem;
font-weight: 600;
}
.badge-admin {
background: var(--primary-color);
color: white;
}
.badge-user {
background: var(--background-color);
color: var(--text-secondary);
}
.badge-active {
background: #28a745;
color: white;
}
.badge-inactive {
background: #6c757d;
color: white;
}
.action-btn {
padding: 6px 12px;
border: none;
border-radius: 6px;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
margin-right: 8px;
}
.action-btn-primary {
background: var(--primary-color);
color: white;
}
.action-btn-primary:hover {
background: var(--primary-hover);
}
.action-btn-danger {
background: #dc3545;
color: white;
}
.action-btn-danger:hover {
background: #c82333;
}
.action-btn-warning {
background: #ffc107;
color: #212529;
}
.action-btn-warning:hover {
background: #e0a800;
}
.back-link {
display: inline-block;
margin-bottom: 16px;
color: var(--primary-color);
text-decoration: none;
font-weight: 500;
}
.back-link:hover {
text-decoration: underline;
}
.flash-messages {
margin-bottom: 24px;
}
.flash-message {
padding: 12px 16px;
border-radius: 8px;
margin-bottom: 12px;
}
.flash-message.success {
background: #d4edda;
color: #155724;
}
.flash-message.error {
background: #f8d7da;
color: #721c24;
}
.flash-message.warning {
background: #fff3cd;
color: #856404;
}
.system-info {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 16px;
}
.info-card {
background: var(--background-color);
padding: 16px;
border-radius: 8px;
border-left: 3px solid var(--primary-color);
}
.info-card h4 {
margin: 0 0 8px 0;
color: var(--primary-color);
}
.info-card p {
margin: 4px 0;
font-size: 0.9rem;
}
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
margin-bottom: 4px;
font-weight: 500;
color: var(--text-primary);
}
.form-control {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--divider-color);
border-radius: 6px;
font-size: 0.9rem;
}
.form-control:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);
}
.btn {
padding: 8px 16px;
border: none;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary {
background: var(--primary-color);
color: white;
}
.btn-primary:hover {
background: var(--primary-hover);
}
.user-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--primary-color);
display: inline-flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 600;
font-size: 0.8rem;
margin-right: 8px;
}
.user-info {
display: flex;
align-items: center;
}
@media (max-width: 768px) {
.admin-tabs {
overflow-x: auto;
}
.admin-stats {
grid-template-columns: 1fr;
}
.system-info {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<div class="admin-container">
<a href="{{ url_for('index') }}" class="back-link">← Back to Feed</a>
<div class="admin-header">
<h1>Admin Panel</h1>
<p>Manage users, content, and system settings</p>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flash-messages">
{% for category, message in messages %}
<div class="flash-message {{ category }}">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<div class="admin-tabs"> <div class="admin-tabs">
<button class="tab-btn active" onclick="showTab('overview')">Overview</button> <button class="tab-btn active" onclick="showTab('overview')">Overview</button>
@@ -425,7 +90,7 @@
<div id="users" class="tab-content"> <div id="users" class="tab-content">
<div class="admin-section"> <div class="admin-section">
<h3 class="section-title">User Management</h3> <h3 class="section-title">User Management</h3>
<div class="users-table"> <div class="admin-table">
<table> <table>
<thead> <thead>
<tr> <tr>
@@ -556,24 +221,24 @@
</div> </div>
</div> </div>
</div> </div>
</div> {% endblock %}
<script> {% block admin_scripts %}
function showTab(tabName) { <script>
// Hide all tabs function showTab(tabName) {
const tabs = document.querySelectorAll('.tab-content'); // Hide all tabs
tabs.forEach(tab => tab.classList.remove('active')); const tabs = document.querySelectorAll('.tab-content');
tabs.forEach(tab => tab.classList.remove('active'));
// Remove active class from all buttons
const buttons = document.querySelectorAll('.tab-btn'); // Remove active class from all buttons
buttons.forEach(btn => btn.classList.remove('active')); const buttons = document.querySelectorAll('.tab-btn');
buttons.forEach(btn => btn.classList.remove('active'));
// Show selected tab
document.getElementById(tabName).classList.add('active'); // Show selected tab
document.getElementById(tabName).classList.add('active');
// Add active class to clicked button
event.target.classList.add('active'); // Add active class to clicked button
} event.target.classList.add('active');
</script> }
</body> </script>
</html> {% endblock %}

View File

@@ -1,52 +1,11 @@
<!DOCTYPE html> {% extends "_admin_base.html" %}
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Polling Management - Admin - BalanceBoard</title>
<link rel="stylesheet" href="{{ url_for('serve_theme', filename='modern-card-ui/styles.css') }}">
<style>
.admin-container {
max-width: 1400px;
margin: 0 auto;
padding: 24px;
}
.admin-header { {% block title %}Polling Management - Admin - {{ APP_NAME }}{% endblock %}
background: linear-gradient(135deg, var(--primary-dark) 0%, #2a5068 100%);
color: white;
padding: 32px;
border-radius: 12px;
margin-bottom: 24px;
}
.status-badge { {% block page_title %}Polling Management{% endblock %}
display: inline-block; {% block page_description %}Manage data collection sources and schedules{% endblock %}
padding: 4px 12px;
border-radius: 12px;
font-size: 0.85rem;
font-weight: 500;
}
.status-enabled { {% block admin_styles %}
background: #d4edda;
color: #155724;
}
.status-disabled {
background: #f8d7da;
color: #721c24;
}
.status-success {
background: #d4edda;
color: #155724;
}
.status-error {
background: #f8d7da;
color: #721c24;
}
.source-card { .source-card {
background: var(--surface-color); background: var(--surface-color);
@@ -174,22 +133,62 @@
padding: 48px; padding: 48px;
color: var(--text-secondary); color: var(--text-secondary);
} }
</style>
</head>
<body>
<div class="admin-container">
<div class="admin-header">
<h1>📡 Polling Management</h1>
<p>Configure automatic data collection from content sources</p>
</div>
{% with messages = get_flashed_messages(with_categories=true) %} .add-source-form {
{% if messages %} background: var(--surface-color);
{% for category, message in messages %} border: 1px solid var(--divider-color);
<div class="alert alert-{{ category }}">{{ message }}</div> border-radius: 12px;
{% endfor %} padding: 24px;
{% endif %} margin-bottom: 24px;
{% endwith %} }
.form-label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: var(--text-primary);
}
.form-input, .form-select {
width: 100%;
padding: 10px;
border: 1px solid var(--divider-color);
border-radius: 6px;
font-size: 1rem;
}
.scheduler-status {
background: var(--surface-color);
border: 1px solid var(--divider-color);
border-radius: 12px;
padding: 16px;
margin-bottom: 24px;
}
.help-text {
font-size: 0.85rem;
color: var(--text-secondary);
margin-top: 4px;
}
.alert {
padding: 12px 16px;
border-radius: 8px;
margin-bottom: 12px;
}
.alert-success {
background: #d4edda;
color: #155724;
}
.alert-error {
background: #f8d7da;
color: #721c24;
}
{% endblock %}
{% block admin_content %}
<!-- Scheduler Status --> <!-- Scheduler Status -->
<div class="scheduler-status"> <div class="scheduler-status">
@@ -251,6 +250,35 @@
</select> </select>
</div> </div>
<div class="form-group">
<label class="form-label" for="max_posts">Max Posts Per Poll</label>
<select class="form-select" name="max_posts" id="max_posts">
<option value="25">25 posts</option>
<option value="50">50 posts</option>
<option value="100" selected>100 posts</option>
<option value="200">200 posts</option>
<option value="500">500 posts</option>
</select>
</div>
<div class="form-group">
<label class="form-label" for="fetch_comments">Fetch Comments</label>
<select class="form-select" name="fetch_comments" id="fetch_comments">
<option value="true" selected>Yes - Fetch comments</option>
<option value="false">No - Posts only</option>
</select>
</div>
<div class="form-group">
<label class="form-label" for="priority">Priority</label>
<select class="form-select" name="priority" id="priority">
<option value="low">Low</option>
<option value="medium" selected>Medium</option>
<option value="high">High</option>
</select>
<p class="help-text">Higher priority sources poll more reliably during load</p>
</div>
<button type="submit" class="btn btn-primary">Add Source</button> <button type="submit" class="btn btn-primary">Add Source</button>
</form> </form>
</div> </div>
@@ -315,6 +343,8 @@
{% endif %} {% endif %}
<div class="source-actions"> <div class="source-actions">
<button onclick="openEditModal('{{ source.id }}', '{{ source.display_name }}', {{ source.poll_interval_minutes }}, {{ source.max_posts or 100 }}, {{ 'true' if source.fetch_comments else 'false' }}, '{{ source.priority or 'medium' }}')" class="btn btn-secondary">⚙️ Edit</button>
<form action="{{ url_for('admin_polling_toggle', source_id=source.id) }}" method="POST" style="display: inline;"> <form action="{{ url_for('admin_polling_toggle', source_id=source.id) }}" method="POST" style="display: inline;">
<button type="submit" class="btn btn-secondary"> <button type="submit" class="btn btn-secondary">
{% if source.enabled %}Disable{% else %}Enable{% endif %} {% if source.enabled %}Disable{% else %}Enable{% endif %}
@@ -387,6 +417,137 @@
sourceSelect.setAttribute('required', 'required'); sourceSelect.setAttribute('required', 'required');
} }
}); });
</script>
</body> function openEditModal(sourceId, displayName, interval, maxPosts, fetchComments, priority) {
</html> const modal = document.getElementById('edit-modal');
if (!modal) {
// Create modal HTML
const modalHTML = `
<div id="edit-modal" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 1000;">
<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); background: white; padding: 24px; border-radius: 8px; max-width: 500px; width: 90%;">
<h3>Edit Poll Source</h3>
<form id="edit-form" action="" method="POST">
<div class="form-group">
<label>Display Name</label>
<input type="text" name="display_name" id="edit_display_name" class="form-input" required>
</div>
<div class="form-group">
<label>Poll Interval</label>
<select name="poll_interval" id="edit_interval" class="form-select">
<option value="15">15 minutes</option>
<option value="30">30 minutes</option>
<option value="60">1 hour</option>
<option value="120">2 hours</option>
<option value="240">4 hours</option>
<option value="360">6 hours</option>
<option value="720">12 hours</option>
<option value="1440">24 hours</option>
</select>
</div>
<div class="form-group">
<label>Max Posts</label>
<select name="max_posts" id="edit_max_posts" class="form-select">
<option value="25">25 posts</option>
<option value="50">50 posts</option>
<option value="100">100 posts</option>
<option value="200">200 posts</option>
<option value="500">500 posts</option>
</select>
</div>
<div class="form-group">
<label>Fetch Comments</label>
<select name="fetch_comments" id="edit_fetch_comments" class="form-select">
<option value="true">Yes</option>
<option value="false">No</option>
</select>
</div>
<div class="form-group">
<label>Priority</label>
<select name="priority" id="edit_priority" class="form-select">
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</div>
<div style="display: flex; gap: 8px; margin-top: 16px;">
<button type="submit" class="btn btn-primary">Save Changes</button>
<button type="button" onclick="closeEditModal()" class="btn btn-secondary">Cancel</button>
</div>
</form>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', modalHTML);
}
// 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';
}
{% 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> {% extends "_admin_base.html" %}
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Polling Logs - {{ source.display_name }} - Admin</title>
<link rel="stylesheet" href="{{ url_for('serve_theme', filename='modern-card-ui/styles.css') }}">
<style>
.admin-container {
max-width: 1400px;
margin: 0 auto;
padding: 24px;
}
.admin-header { {% block title %}Polling Logs - {{ source.display_name }} - Admin{% endblock %}
background: linear-gradient(135deg, var(--primary-dark) 0%, #2a5068 100%);
color: white;
padding: 32px;
border-radius: 12px;
margin-bottom: 24px;
}
.log-table { {% block page_title %}Polling Logs - {{ source.display_name }}{% endblock %}
width: 100%; {% block page_description %}View polling history and error logs for this source{% endblock %}
border-collapse: collapse;
background: var(--surface-color);
border-radius: 12px;
overflow: hidden;
}
.log-table th { {% block admin_styles %}
background: var(--primary-color); .error-detail {
color: white; background: #fff3cd;
padding: 12px; padding: 12px;
text-align: left; border-radius: 6px;
font-weight: 600; margin-top: 8px;
} font-size: 0.9rem;
white-space: pre-wrap;
max-height: 200px;
overflow-y: auto;
}
.log-table td { .no-logs {
padding: 12px; text-align: center;
border-bottom: 1px solid var(--divider-color); padding: 48px;
} color: var(--text-secondary);
}
{% endblock %}
.log-table tr:last-child td { {% block admin_content %}
border-bottom: none; <div class="admin-table">
} {% if logs %}
<table>
.status-badge { <thead>
display: inline-block; <tr>
padding: 4px 12px; <th>Timestamp</th>
border-radius: 12px; <th>Status</th>
font-size: 0.85rem; <th>Posts Found</th>
font-weight: 500; <th>New Posts</th>
} <th>Updated Posts</th>
<th>Error Details</th>
.status-success { </tr>
background: #d4edda; </thead>
color: #155724; <tbody>
} {% for log in logs %}
.status-error {
background: #f8d7da;
color: #721c24;
}
.status-running {
background: #fff3cd;
color: #856404;
}
.error-detail {
background: #fff3cd;
padding: 12px;
border-radius: 6px;
margin-top: 8px;
font-size: 0.9rem;
white-space: pre-wrap;
max-height: 200px;
overflow-y: auto;
}
.btn {
padding: 8px 16px;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: 500;
text-decoration: none;
display: inline-block;
transition: all 0.2s;
}
.btn-secondary {
background: var(--divider-color);
color: var(--text-primary);
}
.btn-secondary:hover {
background: #d0d0d0;
}
.no-logs {
text-align: center;
padding: 48px;
color: var(--text-secondary);
}
</style>
</head>
<body>
<div class="admin-container">
<div class="admin-header">
<h1>📋 Polling Logs</h1>
<p>{{ source.display_name }} ({{ source.platform}}:{{ source.source_id }})</p>
</div>
{% if logs %}
<table class="log-table">
<thead>
<tr> <tr>
<th>Started</th> <td>{{ log.poll_time.strftime('%Y-%m-%d %H:%M:%S') }}</td>
<th>Completed</th> <td>
<th>Duration</th> {% if log.status == 'success' %}
<th>Status</th> <span class="status-badge status-success">Success</span>
<th>Posts Found</th> {% elif log.status == 'error' %}
<th>New</th> <span class="status-badge status-error">Error</span>
<th>Updated</th> {% elif log.status == 'running' %}
<th>Details</th> <span class="status-badge status-running">Running</span>
{% else %}
<span class="status-badge">{{ log.status }}</span>
{% endif %}
</td>
<td>{{ log.posts_found }}</td>
<td>{{ log.posts_new }}</td>
<td>{{ log.posts_updated }}</td>
<td>
{% if log.error_message %}
<details>
<summary style="cursor: pointer; color: var(--primary-color);">View Error</summary>
<div class="error-detail">{{ log.error_message }}</div>
</details>
{% else %}
-
{% endif %}
</td>
</tr> </tr>
</thead> {% endfor %}
<tbody> </tbody>
{% for log in logs %} </table>
<tr> {% else %}
<td>{{ log.started_at.strftime('%Y-%m-%d %H:%M:%S') }}</td> <div class="no-logs">
<td> <p>No polling logs yet.</p>
{% if log.completed_at %} <p>Logs will appear here after the first poll.</p>
{{ log.completed_at.strftime('%Y-%m-%d %H:%M:%S') }}
{% else %}
-
{% endif %}
</td>
<td>
{% if log.completed_at %}
{{ ((log.completed_at - log.started_at).total_seconds())|round(1) }}s
{% else %}
-
{% endif %}
</td>
<td>
{% if log.status == 'success' %}
<span class="status-badge status-success">Success</span>
{% elif log.status == 'error' %}
<span class="status-badge status-error">Error</span>
{% elif log.status == 'running' %}
<span class="status-badge status-running">Running</span>
{% else %}
<span class="status-badge">{{ log.status }}</span>
{% endif %}
</td>
<td>{{ log.posts_found }}</td>
<td>{{ log.posts_new }}</td>
<td>{{ log.posts_updated }}</td>
<td>
{% if log.error_message %}
<details>
<summary style="cursor: pointer; color: var(--primary-color);">View Error</summary>
<div class="error-detail">{{ log.error_message }}</div>
</details>
{% else %}
-
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="no-logs">
<p>No polling logs yet.</p>
<p>Logs will appear here after the first poll.</p>
</div>
{% endif %}
<div style="margin-top: 24px;">
<a href="{{ url_for('admin_polling') }}" class="btn btn-secondary">← Back to Polling Management</a>
</div> </div>
</div> {% endif %}
</body> </div>
</html>
<div style="margin-top: 24px;">
<a href="{{ url_for('admin_polling') }}" class="btn btn-secondary">← Back to Polling Management</a>
</div>
{% endblock %}

View File

@@ -1,12 +1,15 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Create Admin Account - BalanceBoard{% endblock %} {% block title %}Create Admin Account - {{ APP_NAME }}{% endblock %}
{% block content %} {% block content %}
{% include '_nav.html' %}
<div class="auth-container"> <div class="auth-container">
<div class="auth-card"> <div class="auth-card">
<div class="auth-logo"> <div class="auth-logo">
<img src="{{ url_for('serve_logo') }}" alt="BalanceBoard Logo"> <a href="{{ url_for('index') }}">
<img src="{{ url_for('serve_logo') }}" alt="{{ APP_NAME }} Logo" style="max-width: 80px; border-radius: 50%;">
</a>
<h1><span class="balance">balance</span><span class="board">Board</span></h1> <h1><span class="balance">balance</span><span class="board">Board</span></h1>
<p style="color: var(--text-secondary); margin-top: 8px;">Create Administrator Account</p> <p style="color: var(--text-secondary); margin-top: 8px;">Create Administrator Account</p>
</div> </div>
@@ -74,5 +77,60 @@
.board { .board {
color: var(--text-primary); color: var(--text-primary);
} }
/* Ensure form styles are properly applied */
.auth-form .form-group {
margin-bottom: 20px;
}
.auth-form label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: var(--text-primary);
font-size: 0.95rem;
}
.auth-form input {
width: 100%;
padding: 12px 16px;
border: 2px solid var(--border-color);
border-radius: 8px;
font-size: 1rem;
background: var(--background-color);
color: var(--text-primary);
transition: all 0.2s ease;
box-sizing: border-box;
}
.auth-form input:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(77, 182, 172, 0.1);
}
.auth-form button {
width: 100%;
padding: 14px;
background: var(--primary-color);
color: white;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.auth-form button:hover {
background: var(--primary-hover);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(77, 182, 172, 0.3);
}
.auth-footer {
margin-top: 24px;
text-align: center;
}
</style> </style>
{% endblock %} {% endblock %}

View File

@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}BalanceBoard{% endblock %}</title> <title>{% block title %}{{ APP_NAME }}{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('serve_theme', filename='modern-card-ui/styles.css') }}"> <link rel="stylesheet" href="{{ url_for('serve_theme', filename='modern-card-ui/styles.css') }}">
<style> <style>
/* Auth pages styling */ /* Auth pages styling */

272
templates/bookmarks.html Normal file
View File

@@ -0,0 +1,272 @@
{% extends "base.html" %}
{% block title %}Bookmarks - {{ APP_NAME }}{% endblock %}
{% block content %}
{% include '_nav.html' %}
<div style="max-width: 1200px; margin: 0 auto; padding: 24px;">
<div style="margin-bottom: 32px;">
<h1 style="color: var(--text-primary); margin-bottom: 8px;">📚 Your Bookmarks</h1>
<p style="color: var(--text-secondary); font-size: 1.1rem;">Posts you've saved for later reading</p>
</div>
<div id="bookmarks-container">
<div id="loading" style="text-align: center; padding: 40px; color: var(--text-secondary);">
<div style="font-size: 1.2rem;">Loading your bookmarks...</div>
</div>
</div>
<!-- Pagination -->
<div id="pagination" style="display: none; text-align: center; margin-top: 32px;">
<button id="prev-btn" style="padding: 8px 16px; margin: 0 8px; background: var(--surface-elevation-1); border: 1px solid var(--border-color); border-radius: 6px; color: var(--text-primary); cursor: pointer;">← Previous</button>
<span id="page-info" style="margin: 0 16px; color: var(--text-secondary);"></span>
<button id="next-btn" style="padding: 8px 16px; margin: 0 8px; background: var(--surface-elevation-1); border: 1px solid var(--border-color); border-radius: 6px; color: var(--text-primary); cursor: pointer;">Next →</button>
</div>
</div>
<style>
.bookmark-item {
background: var(--surface-color);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 20px;
margin-bottom: 16px;
transition: all 0.2s ease;
}
.bookmark-item:hover {
border-color: var(--primary-color);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.bookmark-item.archived {
opacity: 0.6;
border-style: dashed;
}
.bookmark-header {
display: flex;
justify-content: between;
align-items: flex-start;
margin-bottom: 12px;
}
.bookmark-title {
color: var(--text-primary);
font-size: 1.2rem;
font-weight: 600;
text-decoration: none;
flex: 1;
margin-right: 12px;
}
.bookmark-title:hover {
color: var(--primary-color);
}
.bookmark-remove {
background: var(--error-color);
color: white;
border: none;
border-radius: 6px;
padding: 6px 12px;
font-size: 0.85rem;
cursor: pointer;
transition: all 0.2s ease;
}
.bookmark-remove:hover {
background: var(--error-hover);
}
.bookmark-meta {
display: flex;
gap: 16px;
margin-bottom: 8px;
font-size: 0.9rem;
color: var(--text-secondary);
}
.bookmark-meta span {
display: flex;
align-items: center;
gap: 4px;
}
.bookmark-preview {
color: var(--text-secondary);
line-height: 1.5;
margin-bottom: 12px;
}
.bookmark-date {
font-size: 0.85rem;
color: var(--text-tertiary);
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--text-secondary);
}
.empty-state h3 {
font-size: 1.3rem;
margin-bottom: 8px;
color: var(--text-primary);
}
.error-state {
text-align: center;
padding: 40px 20px;
color: var(--error-color);
background: var(--error-bg);
border-radius: 8px;
margin: 20px 0;
}
</style>
<script>
let currentPage = 1;
let pagination = null;
async function loadBookmarks(page = 1) {
try {
document.getElementById('loading').style.display = 'block';
const response = await fetch(`/api/bookmarks?page=${page}&per_page=20`);
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to load bookmarks');
}
renderBookmarks(data.posts);
updatePagination(data.pagination);
currentPage = page;
} catch (error) {
console.error('Error loading bookmarks:', error);
document.getElementById('bookmarks-container').innerHTML = `
<div class="error-state">
<h3>Error loading bookmarks</h3>
<p>${error.message}</p>
<button onclick="loadBookmarks()" style="margin-top: 12px; padding: 8px 16px; background: var(--primary-color); color: white; border: none; border-radius: 6px; cursor: pointer;">Retry</button>
</div>
`;
}
}
function renderBookmarks(posts) {
document.getElementById('loading').style.display = 'none';
const container = document.getElementById('bookmarks-container');
if (posts.length === 0) {
container.innerHTML = `
<div class="empty-state">
<h3>📚 No bookmarks yet</h3>
<p>Start exploring and bookmark posts you want to read later!</p>
<a href="/" style="display: inline-block; margin-top: 16px; padding: 12px 24px; background: var(--primary-color); color: white; text-decoration: none; border-radius: 8px;">Browse Posts</a>
</div>
`;
return;
}
container.innerHTML = posts.map(post => `
<div class="bookmark-item ${post.archived ? 'archived' : ''}">
<div class="bookmark-header">
<a href="${post.url}" class="bookmark-title">${post.title}</a>
<button class="bookmark-remove" onclick="removeBookmark('${post.id}', this)">
🗑️ Remove
</button>
</div>
<div class="bookmark-meta">
<span>👤 ${post.author}</span>
<span>📍 ${post.source}</span>
<span>⭐ ${post.score}</span>
<span>💬 ${post.comments_count}</span>
${post.archived ? '<span style="color: var(--warning-color);">📦 Archived</span>' : ''}
</div>
<div class="bookmark-preview">${post.content_preview}</div>
<div class="bookmark-date">
Bookmarked on ${new Date(post.bookmarked_at).toLocaleDateString()}
</div>
</div>
`).join('');
}
function updatePagination(paginationData) {
pagination = paginationData;
const paginationEl = document.getElementById('pagination');
if (paginationData.total_pages <= 1) {
paginationEl.style.display = 'none';
return;
}
paginationEl.style.display = 'block';
document.getElementById('prev-btn').disabled = !paginationData.has_prev;
document.getElementById('next-btn').disabled = !paginationData.has_next;
document.getElementById('page-info').textContent = `Page ${paginationData.current_page} of ${paginationData.total_pages}`;
}
async function removeBookmark(postId, button) {
if (!confirm('Are you sure you want to remove this bookmark?')) {
return;
}
try {
button.disabled = true;
button.textContent = 'Removing...';
const response = await fetch('/api/bookmark', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ post_uuid: postId })
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to remove bookmark');
}
// Reload bookmarks to reflect changes
loadBookmarks(currentPage);
} catch (error) {
console.error('Error removing bookmark:', error);
alert('Error removing bookmark: ' + error.message);
button.disabled = false;
button.textContent = '🗑️ Remove';
}
}
// Pagination event listeners
document.getElementById('prev-btn').addEventListener('click', () => {
if (pagination && pagination.has_prev) {
loadBookmarks(currentPage - 1);
}
});
document.getElementById('next-btn').addEventListener('click', () => {
if (pagination && pagination.has_next) {
loadBookmarks(currentPage + 1);
}
});
// Load bookmarks on page load
document.addEventListener('DOMContentLoaded', () => {
loadBookmarks();
});
</script>
{% endblock %}

View File

@@ -1,48 +1,9 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Dashboard - BalanceBoard{% endblock %} {% block title %}Dashboard - {{ APP_NAME }}{% endblock %}
{% block content %} {% block content %}
<!-- Modern Top Navigation --> {% include '_nav.html' %}
<nav class="top-nav">
<div class="nav-content">
<div class="nav-left">
<div class="logo-section">
<img src="{{ url_for('serve_logo') }}" alt="BalanceBoard" class="nav-logo">
<span class="brand-text"><span class="brand-balance">balance</span><span class="brand-board">Board</span></span>
</div>
</div>
<div class="nav-center">
<div class="search-bar">
<input type="text" placeholder="Search content..." class="search-input">
<button class="search-btn">🔍</button>
</div>
</div>
<div class="nav-right">
<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>
</div>
</div>
</nav>
<!-- Main Content Area --> <!-- Main Content Area -->
<main class="main-content"> <main class="main-content">
@@ -50,17 +11,9 @@
<aside class="sidebar"> <aside class="sidebar">
<div class="sidebar-section"> <div class="sidebar-section">
<h3>Content Filters</h3> <h3>Content Filters</h3>
<div class="filter-item active" data-filter="no_filter"> <div id="filter-list" class="filter-list">
<span class="filter-icon">🌐</span> <!-- Filters will be loaded dynamically -->
<span>All Content</span> <div class="loading-filters">Loading filters...</div>
</div>
<div class="filter-item" data-filter="safe_content">
<span class="filter-icon"></span>
<span>Safe Content</span>
</div>
<div class="filter-item" data-filter="custom">
<span class="filter-icon">🎯</span>
<span>Custom Filter</span>
</div> </div>
</div> </div>
@@ -76,7 +29,7 @@
<h3>Quick Stats</h3> <h3>Quick Stats</h3>
<div class="stats-grid"> <div class="stats-grid">
<div class="stat-card"> <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 class="stat-label">Posts Today</div>
</div> </div>
<div class="stat-card"> <div class="stat-card">
@@ -90,10 +43,12 @@
<!-- Content Feed --> <!-- Content Feed -->
<section class="content-section"> <section class="content-section">
<div class="content-header"> <div class="content-header">
<h1>Your Feed</h1> <h1>{% if anonymous %}Public Feed{% else %}Your Feed{% endif %}</h1>
<div class="content-actions"> <div class="content-actions">
<button class="refresh-btn" onclick="refreshFeed()">🔄 Refresh</button> <button class="refresh-btn" onclick="refreshFeed()">🔄 Refresh</button>
<a href="{{ url_for('settings_filters') }}" class="filter-btn">🔧 Customize</a> {% if not anonymous %}
<a href="{{ url_for('settings_filters') }}" class="filter-btn">🔧 Customize</a>
{% endif %}
</div> </div>
</div> </div>
@@ -348,14 +303,14 @@
font-weight: 500; font-weight: 500;
} }
.loading-communities { .loading-communities, .loading-filters {
text-align: center; text-align: center;
color: #64748b; color: #64748b;
font-style: italic; font-style: italic;
padding: 20px; padding: 20px;
} }
.no-communities { .no-communities, .no-filters {
text-align: center; text-align: center;
color: #64748b; color: #64748b;
font-style: italic; font-style: italic;
@@ -438,6 +393,24 @@
transform: translateY(-1px); transform: translateY(-1px);
} }
.clear-search-btn {
background: #f1f5f9;
color: #64748b;
border: 1px solid #e2e8f0;
padding: 10px 20px;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all 0.2s ease;
}
.clear-search-btn:hover {
background: #e2e8f0;
color: #2c3e50;
transform: translateY(-1px);
}
.feed-container { .feed-container {
padding: 0; padding: 0;
} }
@@ -714,9 +687,11 @@ let postsData = [];
let currentPage = 1; let currentPage = 1;
let currentCommunity = ''; let currentCommunity = '';
let currentPlatform = ''; let currentPlatform = '';
let currentFilter = 'no_filter';
let paginationData = {}; let paginationData = {};
let platformConfig = {}; let platformConfig = {};
let communitiesData = []; let communitiesData = [];
let filtersData = [];
// User experience settings // User experience settings
let userSettings = {{ user_settings|tojson }}; let userSettings = {{ user_settings|tojson }};
@@ -724,8 +699,8 @@ let userSettings = {{ user_settings|tojson }};
// Load posts on page load // Load posts on page load
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
loadPlatformConfig(); loadPlatformConfig();
loadFilters();
loadPosts(); loadPosts();
setupFilterSwitching();
setupInfiniteScroll(); setupInfiniteScroll();
setupAutoRefresh(); setupAutoRefresh();
}); });
@@ -734,33 +709,93 @@ document.addEventListener('DOMContentLoaded', function() {
async function loadPlatformConfig() { async function loadPlatformConfig() {
try { try {
const response = await fetch('/api/platforms'); const response = await fetch('/api/platforms');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json(); const data = await response.json();
platformConfig = data.platforms || {}; platformConfig = data.platforms || {};
communitiesData = data.communities || []; communitiesData = data.communities || [];
console.log('Loaded communities:', communitiesData);
renderCommunities(communitiesData); renderCommunities(communitiesData);
setupCommunityFiltering(); setupCommunityFiltering();
} catch (error) { } catch (error) {
console.error('Error loading platform configuration:', error); console.error('Error loading platform configuration:', error);
// Show fallback communities // Show fallback communities
const fallbackCommunities = [ const fallbackCommunities = [
{platform: 'reddit', id: 'programming', display_name: 'r/programming', icon: '💻', count: 0}, {platform: 'reddit', id: 'programming', display_name: 'r/programming', icon: '💻', count: 117},
{platform: 'reddit', id: 'python', display_name: 'r/python', icon: '🐍', count: 0}, {platform: 'hackernews', id: 'front_page', display_name: 'Hacker News', icon: '🧮', count: 117},
{platform: 'hackernews', id: 'hackernews', display_name: 'Hacker News', icon: '🧮', count: 0} {platform: 'reddit', id: 'technology', display_name: 'r/technology', icon: '', count: 0}
]; ];
communitiesData = fallbackCommunities;
renderCommunities(fallbackCommunities); renderCommunities(fallbackCommunities);
setupCommunityFiltering(); setupCommunityFiltering();
} }
} }
// Load available filters
async function loadFilters() {
try {
const response = await fetch('/api/filters');
const data = await response.json();
filtersData = data.filters || [];
renderFilters(filtersData);
setupFilterSwitching();
} catch (error) {
console.error('Error loading filters:', error);
// Show fallback filters
const fallbackFilters = [
{id: 'no_filter', name: 'All Content', icon: '🌐', active: true, description: 'No filtering'}
];
renderFilters(fallbackFilters);
setupFilterSwitching();
}
}
// Render filters in sidebar
function renderFilters(filters) {
const filterList = document.getElementById('filter-list');
if (!filterList) return;
if (filters.length === 0) {
filterList.innerHTML = '<div class="no-filters">No filters available</div>';
return;
}
const filtersHTML = filters.map(filter => {
return `
<div class="filter-item ${filter.active ? 'active' : ''}" data-filter="${filter.id}" title="${filter.description}">
<span class="filter-icon">${filter.icon}</span>
<span>${filter.name}</span>
</div>
`;
}).join('');
filterList.innerHTML = filtersHTML;
// Set current filter based on active filter
const activeFilter = filters.find(f => f.active);
if (activeFilter) {
currentFilter = activeFilter.id;
}
}
// Render communities in sidebar // Render communities in sidebar
function renderCommunities(communities) { function renderCommunities(communities) {
const communityList = document.getElementById('community-list'); const communityList = document.getElementById('community-list');
if (!communityList) return; if (!communityList) return;
if (communities.length === 0) { console.log('Rendering communities:', communities);
communityList.innerHTML = '<div class="no-communities">No communities available</div>';
return; if (!communities || communities.length === 0) {
// Always show fallback communities if none are loaded
const fallbackCommunities = [
{platform: 'reddit', id: 'programming', display_name: 'r/programming', icon: '💻', count: 117},
{platform: 'hackernews', id: 'front_page', display_name: 'Hacker News', icon: '🧮', count: 117},
{platform: 'reddit', id: 'technology', display_name: 'r/technology', icon: '⚡', count: 0}
];
communities = fallbackCommunities;
} }
// Add "All Communities" option at the top // Add "All Communities" option at the top
@@ -786,7 +821,7 @@ function renderCommunities(communities) {
} }
// Load posts from API // Load posts from API
async function loadPosts(page = 1, community = '', platform = '', append = false) { async function loadPosts(page = 1, community = '', platform = '', append = false, filter = null) {
try { try {
// Build query parameters // Build query parameters
const params = new URLSearchParams(); const params = new URLSearchParams();
@@ -794,6 +829,8 @@ async function loadPosts(page = 1, community = '', platform = '', append = false
params.append('per_page', 20); params.append('per_page', 20);
if (community) params.append('community', community); if (community) params.append('community', community);
if (platform) params.append('platform', platform); if (platform) params.append('platform', platform);
if (filter || currentFilter) params.append('filter', filter || currentFilter);
if (currentSearchQuery) params.append('q', currentSearchQuery);
const response = await fetch(`/api/posts?${params}`); const response = await fetch(`/api/posts?${params}`);
const data = await response.json(); const data = await response.json();
@@ -986,24 +1023,34 @@ function savePost(postId) {
// Filter switching functionality // Filter switching functionality
function setupFilterSwitching() { function setupFilterSwitching() {
const filterItems = document.querySelectorAll('.filter-item'); document.addEventListener('click', function(event) {
if (event.target.closest('.filter-item')) {
filterItems.forEach(item => { const filterItem = event.target.closest('.filter-item');
item.addEventListener('click', function() {
// Remove active class from all items // Remove active class from all filter items
filterItems.forEach(f => f.classList.remove('active')); document.querySelectorAll('.filter-item').forEach(f => f.classList.remove('active'));
// Add active class to clicked item // Add active class to clicked item
this.classList.add('active'); filterItem.classList.add('active');
// Get filter type // Get filter type
const filterType = this.dataset.filter; const filterType = filterItem.dataset.filter;
currentFilter = filterType;
// Apply filter (for now just reload) // Update header to show current filter
if (filterType && filterType !== 'custom') { const contentHeader = document.querySelector('.content-header h1');
loadPosts(); // In future, pass filter parameter const filterName = filterItem.textContent.trim();
} contentHeader.textContent = `${filterName} Feed`;
});
// Show loading state
const postsContainer = document.getElementById('posts-container');
const loadingIndicator = document.getElementById('loading-indicator');
loadingIndicator.style.display = 'flex';
postsContainer.innerHTML = '';
// Apply filter
loadPosts(1, currentCommunity, currentPlatform, false, filterType);
}
}); });
} }
@@ -1028,15 +1075,52 @@ function refreshFeed() {
} }
// Search functionality // Search functionality
let currentSearchQuery = '';
document.querySelector('.search-input').addEventListener('keypress', function(e) { document.querySelector('.search-input').addEventListener('keypress', function(e) {
if (e.key === 'Enter') { if (e.key === 'Enter') {
const query = this.value.trim(); const query = this.value.trim();
if (query) { performSearch(query);
alert(`Search functionality coming soon! You searched for: "${query}"`);
}
} }
}); });
document.querySelector('.search-btn').addEventListener('click', function() {
const query = document.querySelector('.search-input').value.trim();
performSearch(query);
});
function performSearch(query) {
currentSearchQuery = query;
currentPage = 1;
if (query) {
document.querySelector('.content-header h1').textContent = `Search results for "${query}"`;
// Show clear search button
if (!document.querySelector('.clear-search-btn')) {
const clearBtn = document.createElement('button');
clearBtn.className = 'clear-search-btn';
clearBtn.textContent = '✕ Clear search';
clearBtn.onclick = clearSearch;
document.querySelector('.content-actions').prepend(clearBtn);
}
}
loadPosts();
}
function clearSearch() {
currentSearchQuery = '';
document.querySelector('.search-input').value = '';
// Restore original feed title based on user state
const isAnonymous = {{ 'true' if anonymous else 'false' }};
document.querySelector('.content-header h1').textContent = isAnonymous ? 'Public Feed' : 'Your Feed';
const clearBtn = document.querySelector('.clear-search-btn');
if (clearBtn) {
clearBtn.remove();
}
loadPosts();
}
// Setup infinite scroll functionality // Setup infinite scroll functionality
function setupInfiniteScroll() { function setupInfiniteScroll() {
if (!userSettings?.experience?.infinite_scroll) { if (!userSettings?.experience?.infinite_scroll) {

View File

@@ -1,12 +1,12 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Log In - BalanceBoard{% endblock %} {% block title %}Log In - {{ APP_NAME }}{% endblock %}
{% block content %} {% block content %}
<div class="auth-container"> <div class="auth-container">
<div class="auth-card"> <div class="auth-card">
<div class="auth-logo"> <div class="auth-logo">
<img src="{{ url_for('serve_logo') }}" alt="BalanceBoard Logo"> <img src="{{ url_for('serve_logo') }}" alt="{{ APP_NAME }} Logo">
<h1><span class="balance">balance</span>Board</h1> <h1><span class="balance">balance</span>Board</h1>
<p style="color: var(--text-secondary); margin-top: 8px;">Welcome back!</p> <p style="color: var(--text-secondary); margin-top: 8px;">Welcome back!</p>
</div> </div>
@@ -37,6 +37,10 @@
<label for="remember" style="margin-bottom: 0;">Remember me</label> <label for="remember" style="margin-bottom: 0;">Remember me</label>
</div> </div>
<div style="text-align: right; margin-bottom: 16px;">
<a href="{{ url_for('password_reset_request') }}" style="color: var(--primary-color); text-decoration: none; font-size: 14px;">Forgot password?</a>
</div>
<button type="submit">Log In</button> <button type="submit">Log In</button>
</form> </form>
@@ -44,6 +48,7 @@
<span>or</span> <span>or</span>
</div> </div>
{% if auth0_configured %}
<div class="social-auth-buttons"> <div class="social-auth-buttons">
<a href="{{ url_for('auth0_login') }}" class="social-btn auth0-btn"> <a href="{{ url_for('auth0_login') }}" class="social-btn auth0-btn">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"> <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
@@ -52,6 +57,7 @@
Continue with Auth0 Continue with Auth0
</a> </a>
</div> </div>
{% endif %}
<div class="auth-footer"> <div class="auth-footer">
<p>Don't have an account? <a href="{{ url_for('signup') }}">Sign up</a></p> <p>Don't have an account? <a href="{{ url_for('signup') }}">Sign up</a></p>

View File

@@ -0,0 +1,43 @@
{% extends "base.html" %}
{% 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="{{ 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>
{% 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 %}
<form method="POST" class="auth-form">
<div class="form-group">
<label for="password">New Password</label>
<input type="password" id="password" name="password" required autofocus minlength="6">
</div>
<div class="form-group">
<label for="confirm_password">Confirm New Password</label>
<input type="password" id="confirm_password" name="confirm_password" required minlength="6">
</div>
<button type="submit">Reset Password</button>
</form>
<div class="auth-footer">
<p>Remember your password? <a href="{{ url_for('login') }}">Log in</a></p>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,41 @@
{% extends "base.html" %}
{% 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="{{ 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>
{% 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 %}
<form method="POST" class="auth-form">
<div class="form-group">
<label for="email">Email Address</label>
<input type="email" id="email" name="email" required autofocus>
<small style="color: var(--text-secondary); display: block; margin-top: 4px;">
Enter the email address associated with your account and we'll send you a password reset link.
</small>
</div>
<button type="submit">Send Reset Link</button>
</form>
<div class="auth-footer">
<p>Remember your password? <a href="{{ url_for('login') }}">Log in</a></p>
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,69 +1,29 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{{ post.title }} - BalanceBoard{% endblock %} {% block title %}{{ post.title }} - {{ APP_NAME }}{% endblock %}
{% block content %} {% block content %}
<!-- Modern Top Navigation --> {% include '_nav.html' %}
<nav class="top-nav">
<div class="nav-content">
<div class="nav-left">
<div class="logo-section">
<img src="{{ url_for('serve_logo') }}" alt="BalanceBoard" class="nav-logo">
<span class="brand-text"><span class="brand-balance">balance</span><span class="brand-board">Board</span></span>
</div>
</div>
<div class="nav-center">
<div class="search-bar">
<input type="text" placeholder="Search content..." class="search-input">
<button class="search-btn">🔍</button>
</div>
</div>
<div class="nav-right">
{% if current_user.is_authenticated %}
<div class="user-menu">
<div class="user-info">
<div class="user-avatar">
{% if current_user.profile_picture_url %}
<img src="{{ current_user.profile_picture_url }}" alt="Avatar">
{% else %}
<div class="avatar-placeholder">{{ current_user.username[:2].upper() }}</div>
{% endif %}
</div>
<span class="username">{{ current_user.username }}</span>
</div>
<div class="user-dropdown">
<a href="{{ url_for('settings') }}" class="dropdown-item">⚙️ Settings</a>
{% if current_user.is_admin %}
<a href="{{ url_for('admin_panel') }}" class="dropdown-item">👨‍💼 Admin Panel</a>
{% endif %}
<a href="{{ url_for('logout') }}" class="dropdown-item">🚪 Logout</a>
</div>
</div>
{% else %}
<div class="auth-buttons">
<a href="{{ url_for('login') }}" class="auth-btn">Login</a>
<a href="{{ url_for('signup') }}" class="auth-btn primary">Sign Up</a>
</div>
{% endif %}
</div>
</div>
</nav>
<!-- Main Content Area --> <!-- Main Content Area -->
<main class="main-content single-post"> <main class="main-content single-post">
<!-- Back Button --> <!-- Back Button -->
<div class="back-section"> <div class="back-section">
<button onclick="goBackToFeed()" class="back-btn">← Back to Feed</button> <a href="#" onclick="goBackToFeed(event)" class="back-btn">← Back to Feed</a>
</div> </div>
<!-- Post Content --> <!-- Post Content -->
<article class="post-detail"> <article class="post-detail">
<div class="post-header"> <div class="post-header">
{% if post.url and not post.url.startswith('/') %}
<a href="{{ post.url }}" target="_blank" class="platform-badge platform-{{ post.platform }}" title="View on {{ post.platform.title() }}">
{{ post.platform.title()[:1] }}
</a>
{% else %}
<div class="platform-badge platform-{{ post.platform }}"> <div class="platform-badge platform-{{ post.platform }}">
{{ post.platform.title()[:1] }} {{ post.platform.title()[:1] }}
</div> </div>
{% endif %}
<div class="post-meta"> <div class="post-meta">
<span class="post-author">{{ post.author }}</span> <span class="post-author">{{ post.author }}</span>
<span class="post-separator"></span> <span class="post-separator"></span>
@@ -105,7 +65,7 @@
🐙 View on GitHub 🐙 View on GitHub
{% elif post.platform == 'devto' %} {% elif post.platform == 'devto' %}
📝 View on Dev.to 📝 View on Dev.to
{% elif post.platform == 'stackoverflow' %} {% elif post.platform == 'stackexchange' %}
📚 View on Stack Overflow 📚 View on Stack Overflow
{% else %} {% else %}
🔗 View Original Source 🔗 View Original Source
@@ -135,25 +95,37 @@
<!-- Comments Section --> <!-- Comments Section -->
<section class="comments-section"> <section class="comments-section">
<h2>Comments ({{ comments|length }})</h2> <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 %} {% if comments %}
<div class="comments-list"> <div class="comments-list">
{% for comment in comments %} {% for comment in comments %}
<div class="comment"> {{ render_comment(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>
{% endfor %} {% endfor %}
</div> </div>
{% else %} {% else %}
@@ -190,6 +162,14 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
text-decoration: none;
color: inherit;
transition: all 0.2s ease;
}
.nav-left .logo-section:hover {
transform: scale(1.02);
text-decoration: none;
} }
.nav-logo { .nav-logo {
@@ -347,6 +327,35 @@
transform: translateY(-1px); transform: translateY(-1px);
} }
.anonymous-actions {
display: flex;
gap: 12px;
}
.login-btn, .register-btn {
padding: 8px 16px;
border-radius: 8px;
text-decoration: none;
font-size: 14px;
font-weight: 500;
transition: all 0.2s ease;
}
.login-btn {
color: #2c3e50;
border: 1px solid #e2e8f0;
}
.register-btn {
background: #4db6ac;
color: white;
}
.login-btn:hover, .register-btn:hover {
transform: translateY(-1px);
text-decoration: none;
}
/* Main Content */ /* Main Content */
.main-content.single-post { .main-content.single-post {
max-width: 1200px; max-width: 1200px;
@@ -554,12 +563,24 @@
.comment { .comment {
padding: 20px 0; padding: 20px 0;
border-bottom: 1px solid #f1f5f9; border-bottom: 1px solid #f1f5f9;
position: relative;
} }
.comment:last-child { .comment:last-child {
border-bottom: none; 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 { .comment-header {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -636,13 +657,23 @@
</style> </style>
<script> <script>
function goBackToFeed() { function goBackToFeed(event) {
// Try to go back to the dashboard if possible event.preventDefault();
if (document.referrer && document.referrer.includes(window.location.origin)) {
// Try to go back in browser history first
if (window.history.length > 1 && document.referrer && document.referrer.includes(window.location.origin)) {
window.history.back(); window.history.back();
} else { } else {
// Fallback to dashboard // Fallback to dashboard - construct URL with current query parameters
window.location.href = '/'; const urlParams = new URLSearchParams(window.location.search);
const baseUrl = {{ url_for('index')|tojson }};
// Add query parameters if they exist
if (urlParams.toString()) {
window.location.href = baseUrl + '?' + urlParams.toString();
} else {
window.location.href = baseUrl;
}
} }
} }
@@ -654,6 +685,10 @@ function sharePost() {
} }
function savePost() { function savePost() {
// TODO: Implement save post functionality
// User can save posts to their profile for later viewing
// This needs database backend integration with user_saved_posts table
// Same implementation needed as dashboard.html savePost function
alert('Save functionality coming soon!'); alert('Save functionality coming soon!');
} }

View File

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

View File

@@ -1,11 +1,11 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Community Settings - BalanceBoard{% endblock %} {% block title %}Community Settings - {{ APP_NAME }}{% endblock %}
{% block extra_css %} {% block extra_css %}
<style> <style>
.settings-container { .settings-container {
max-width: 1000px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
padding: 24px; padding: 24px;
} }
@@ -79,12 +79,25 @@
.platform-icon.reddit { background: #ff4500; } .platform-icon.reddit { background: #ff4500; }
.platform-icon.hackernews { background: #ff6600; } .platform-icon.hackernews { background: #ff6600; }
.platform-icon.lobsters { background: #ac130d; } .platform-icon.lobsters { background: #ac130d; }
.platform-icon.stackoverflow { background: #f48024; } .platform-icon.stackexchange { background: #f48024; }
.community-grid { .community-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 16px; gap: 16px;
max-width: 100%;
}
@media (max-width: 768px) {
.community-grid {
grid-template-columns: 1fr;
}
}
@media (min-width: 1400px) {
.community-grid {
grid-template-columns: repeat(3, 1fr);
}
} }
.community-item { .community-item {
@@ -235,7 +248,14 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
{% include '_nav.html' %}
<div class="settings-container"> <div class="settings-container">
<nav style="margin-bottom: 24px;">
<a href="{{ url_for('settings') }}" style="color: var(--primary-color); text-decoration: none; font-weight: 500;">
← Back to Settings
</a>
</nav>
<div class="settings-header"> <div class="settings-header">
<h1>Community Settings</h1> <h1>Community Settings</h1>
<p>Select which communities, subreddits, and sources to include in your feed</p> <p>Select which communities, subreddits, and sources to include in your feed</p>
@@ -268,7 +288,7 @@
<div class="platform-group"> <div class="platform-group">
<h3> <h3>
<span class="platform-icon {{ platform }}"> <span class="platform-icon {{ platform }}">
{% if platform == 'reddit' %}R{% elif platform == 'hackernews' %}H{% elif platform == 'lobsters' %}L{% elif platform == 'stackoverflow' %}S{% endif %} {% if platform == 'reddit' %}R{% elif platform == 'hackernews' %}H{% elif platform == 'lobsters' %}L{% elif platform == 'stackexchange' %}S{% endif %}
</span> </span>
{{ platform|title }} {{ platform|title }}
</h3> </h3>

View File

@@ -1,6 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Experience Settings - BalanceBoard{% endblock %} {% block title %}Experience Settings - {{ APP_NAME }}{% endblock %}
{% block extra_css %} {% block extra_css %}
<style> <style>
@@ -241,6 +241,7 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
{% include '_nav.html' %}
<div class="experience-settings"> <div class="experience-settings">
<div class="experience-header"> <div class="experience-header">
<h1>Experience Settings</h1> <h1>Experience Settings</h1>
@@ -330,6 +331,34 @@
<span class="toggle-slider"></span> <span class="toggle-slider"></span>
</label> </label>
</div> </div>
<!-- Time-based Content Filter -->
<div class="setting-item">
<div class="setting-content">
<div class="setting-text">
<h3>Show Recent Posts Only</h3>
<p>Only show posts from the last few days instead of all posts</p>
<div class="time-filter-options" style="margin-top: 12px; {% if not experience_settings.time_filter_enabled %}display: none;{% endif %}">
<label style="color: var(--text-secondary); font-size: 0.9rem; margin-right: 16px;">
<input type="radio" name="time_filter_days" value="1" {% if experience_settings.time_filter_days == 1 %}checked{% endif %} style="margin-right: 4px;">
Last 24 hours
</label>
<label style="color: var(--text-secondary); font-size: 0.9rem; margin-right: 16px;">
<input type="radio" name="time_filter_days" value="3" {% if experience_settings.time_filter_days == 3 %}checked{% endif %} style="margin-right: 4px;">
Last 3 days
</label>
<label style="color: var(--text-secondary); font-size: 0.9rem; margin-right: 16px;">
<input type="radio" name="time_filter_days" value="7" {% if experience_settings.time_filter_days == 7 or not experience_settings.time_filter_days %}checked{% endif %} style="margin-right: 4px;">
Last week
</label>
</div>
</div>
</div>
<label class="toggle-switch">
<input type="checkbox" name="time_filter_enabled" {% if experience_settings.time_filter_enabled %}checked{% endif %} onchange="toggleTimeFilterOptions(this)">
<span class="toggle-slider"></span>
</label>
</div>
</div> </div>
<div class="form-actions"> <div class="form-actions">
@@ -338,4 +367,15 @@
</div> </div>
</form> </form>
</div> </div>
<script>
function toggleTimeFilterOptions(checkbox) {
const options = document.querySelector('.time-filter-options');
if (checkbox.checked) {
options.style.display = 'block';
} else {
options.style.display = 'none';
}
}
</script>
{% endblock %} {% endblock %}

View File

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

View File

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

View File

@@ -1,12 +1,12 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Sign Up - BalanceBoard{% endblock %} {% block title %}Sign Up - {{ APP_NAME }}{% endblock %}
{% block content %} {% block content %}
<div class="auth-container"> <div class="auth-container">
<div class="auth-card"> <div class="auth-card">
<div class="auth-logo"> <div class="auth-logo">
<img src="{{ url_for('serve_logo') }}" alt="BalanceBoard Logo"> <img src="{{ url_for('serve_logo') }}" alt="{{ APP_NAME }} Logo">
<h1><span class="balance">balance</span>Board</h1> <h1><span class="balance">balance</span>Board</h1>
<p style="color: var(--text-secondary); margin-top: 8px;">Create your account</p> <p style="color: var(--text-secondary); margin-top: 8px;">Create your account</p>
</div> </div>

View File

@@ -40,6 +40,10 @@
<div class="engagement-info"> <div class="engagement-info">
<span class="reply-count">{{replies}} replies</span> <span class="reply-count">{{replies}} replies</span>
<button class="bookmark-btn" onclick="toggleBookmark('{{id}}', this)" data-post-id="{{id}}">
<span class="bookmark-icon">🔖</span>
<span class="bookmark-text">Save</span>
</button>
</div> </div>
</footer> </footer>

View File

@@ -228,6 +228,9 @@
<a href="/settings/filters" class="dropdown-item"> <a href="/settings/filters" class="dropdown-item">
🎛️ Filters 🎛️ Filters
</a> </a>
<a href="/bookmarks" class="dropdown-item">
📚 Bookmarks
</a>
<div class="dropdown-divider"></div> <div class="dropdown-divider"></div>
<a href="/admin" class="dropdown-item" style="display: none;"> <a href="/admin" class="dropdown-item" style="display: none;">
🛠️ Admin 🛠️ Admin
@@ -352,6 +355,79 @@
// Initialize on page load // Initialize on page load
document.addEventListener('DOMContentLoaded', checkAuthState); document.addEventListener('DOMContentLoaded', checkAuthState);
// Bookmark functionality
async function toggleBookmark(postId, button) {
try {
button.disabled = true;
const originalText = button.querySelector('.bookmark-text').textContent;
button.querySelector('.bookmark-text').textContent = 'Saving...';
const response = await fetch('/api/bookmark', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ post_uuid: postId })
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to toggle bookmark');
}
// Update button state
updateBookmarkButton(button, data.bookmarked);
} catch (error) {
console.error('Error toggling bookmark:', error);
alert('Error: ' + error.message);
button.querySelector('.bookmark-text').textContent = originalText;
} finally {
button.disabled = false;
}
}
function updateBookmarkButton(button, isBookmarked) {
const icon = button.querySelector('.bookmark-icon');
const text = button.querySelector('.bookmark-text');
if (isBookmarked) {
button.classList.add('bookmarked');
icon.textContent = '📌';
text.textContent = 'Saved';
} else {
button.classList.remove('bookmarked');
icon.textContent = '🔖';
text.textContent = 'Save';
}
}
// Load bookmark states for visible posts
async function loadBookmarkStates() {
const bookmarkButtons = document.querySelectorAll('.bookmark-btn');
for (const button of bookmarkButtons) {
const postId = button.getAttribute('data-post-id');
try {
const response = await fetch(`/api/bookmark-status/${postId}`);
const data = await response.json();
if (response.ok && data.bookmarked) {
updateBookmarkButton(button, true);
}
} catch (error) {
console.error('Error loading bookmark status:', error);
}
}
}
// Load bookmark states when page loads
document.addEventListener('DOMContentLoaded', () => {
setTimeout(loadBookmarkStates, 500); // Small delay to ensure posts are rendered
});
</script> </script>
</body> </body>
</html> </html>

View File

@@ -460,6 +460,45 @@ header .post-count::before {
.engagement-info { .engagement-info {
font-size: 0.9rem; font-size: 0.9rem;
color: var(--text-secondary); color: var(--text-secondary);
display: flex;
align-items: center;
gap: 12px;
}
/* Bookmark Button */
.bookmark-btn {
background: none;
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 6px 12px;
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
transition: all 0.2s ease;
color: var(--text-secondary);
font-size: 0.85rem;
}
.bookmark-btn:hover {
border-color: var(--primary-color);
color: var(--primary-color);
background: rgba(77, 182, 172, 0.1);
}
.bookmark-btn.bookmarked {
background: var(--primary-color);
color: white;
border-color: var(--primary-color);
}
.bookmark-btn.bookmarked .bookmark-icon {
filter: none;
}
.bookmark-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
} }
/* Tags */ /* Tags */