Compare commits

..

50 Commits

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

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

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

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

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

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

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

## Solution Implemented:

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

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

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

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

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

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

## Solution Implemented:

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

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

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

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

Commit: [current]

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

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

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

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

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

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

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

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

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

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

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

Fixes #19

~claude

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

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

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

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

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

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

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

Fixes #20

~claude

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

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

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

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

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

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

Fixes #21

~claude

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

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

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

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

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

Fixes #22

~claude

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

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

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

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

Fixes #23

~claude

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

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

Fixes #24

~claude

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

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

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

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

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

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

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

Fixes #14, #13
2025-10-11 23:56:52 -05:00
b0b9a9e912 Fix Auth0 500 error when not configured (Issue #5)
- Add check for AUTH0 credentials before attempting login
- Show friendly error message if Auth0 not configured
- Hide Auth0 button on login page when not configured
- Add try/catch for auth0.authorize_redirect() failures

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

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

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

Changed to build dictionary mapping filter names to full configs.

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 23:00:34 -05:00
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
066d90ea53 Remove iframe-like scrolling from feed container
- Remove max-height and overflow-y from feed container
- Allows natural page scrolling instead of nested scroll
- Improves browsing experience

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 18:28:29 -05:00
eead6033e2 Fix polling to use 24-hour window instead of resume feature
- Polling now always collects posts from last 24 hours
- Removes resume feature that created too-narrow time windows
- Fixes issue where polls returned 0 posts due to minutes-wide ranges

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 18:23:43 -05:00
3849da68bd Add custom source input for manually adding communities
- Add 'Custom Source' text input field to polling form
- Allows manual entry of subreddits (r/subreddit) or RSS URLs
- Custom input overrides dropdown selection if filled
- Dropdown becomes optional when custom source is entered
- Backend prioritizes custom_source_id over dropdown source_id

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 18:10:24 -05:00
1ecb0512b0 Fix label and data directory permissions
- Change 'Poll Interval (minutes)' to just 'Poll Interval'
- Create data subdirectories with correct permissions on server

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 18:04:36 -05:00
7084e01aa4 Set poll sources disabled by default with better interval options
- Poll sources now created with enabled=False by default
- Admin must manually enable sources after adding them
- Replace numeric interval input with dropdown: 15min to 24hr options
- Default interval is 1 hour
- Fix avatar upload form with proper multipart/form-data encoding

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 18:00:17 -05:00
2d633bebc6 Fix avatar upload form - use proper multipart form
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 17:57:16 -05:00
278d9c606a Fix 404 when logged out - redirect to login
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 17:46:22 -05:00
62001d08a4 Add themes, static assets, and logo
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 17:38:19 -05:00
70 changed files with 8482 additions and 837 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

655
app.py
View File

@@ -42,6 +42,11 @@ app = Flask(__name__,
template_folder='templates')
app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production')
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB max file size
app.config['ALLOW_ANONYMOUS_ACCESS'] = os.getenv('ALLOW_ANONYMOUS_ACCESS', 'true').lower() == 'true'
# Application branding configuration
app.config['APP_NAME'] = os.getenv('APP_NAME', 'BalanceBoard')
app.config['LOGO_PATH'] = os.getenv('LOGO_PATH', 'logo.png')
# Auth0 Configuration
app.config['AUTH0_DOMAIN'] = os.getenv('AUTH0_DOMAIN', '')
@@ -50,7 +55,7 @@ app.config['AUTH0_CLIENT_SECRET'] = os.getenv('AUTH0_CLIENT_SECRET', '')
app.config['AUTH0_AUDIENCE'] = os.getenv('AUTH0_AUDIENCE', '')
# Configuration constants
ALLOWED_FILTERSETS = {'no_filter', 'safe_content'}
# Note: ALLOWED_FILTERSETS will be dynamically loaded from filter_engine
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'}
UPLOAD_FOLDER = 'static/avatars'
MAX_FILENAME_LENGTH = 100
@@ -81,6 +86,11 @@ from polling_service import polling_service
polling_service.init_app(app)
polling_service.start()
# Initialize filter engine
from filter_pipeline import FilterEngine
filter_engine = FilterEngine.get_instance()
logger.info(f"FilterEngine initialized with {len(filter_engine.get_available_filtersets())} filtersets")
# Initialize OAuth for Auth0
oauth = OAuth(app)
auth0 = oauth.register(
@@ -104,7 +114,9 @@ def _is_safe_filterset(filterset):
"""Validate filterset name for security"""
if not filterset or not isinstance(filterset, str):
return False
return filterset in ALLOWED_FILTERSETS and re.match(r'^[a-zA-Z0-9_-]+$', filterset)
# Check against available filtersets from filter_engine
allowed_filtersets = set(filter_engine.get_available_filtersets())
return filterset in allowed_filtersets and re.match(r'^[a-zA-Z0-9_-]+$', filterset)
def _is_safe_path(path):
"""Validate file path for security"""
@@ -207,10 +219,15 @@ def _validate_user_settings(settings_str):
exp = settings['experience']
if isinstance(exp, dict):
safe_exp = {}
bool_fields = ['infinite_scroll', 'auto_refresh', 'push_notifications', 'dark_patterns_opt_in']
bool_fields = ['infinite_scroll', 'auto_refresh', 'push_notifications', 'dark_patterns_opt_in', 'time_filter_enabled']
for field in bool_fields:
if field in exp and isinstance(exp[field], bool):
safe_exp[field] = exp[field]
# Handle time_filter_days as integer
if 'time_filter_days' in exp and isinstance(exp['time_filter_days'], int) and exp['time_filter_days'] > 0:
safe_exp['time_filter_days'] = exp['time_filter_days']
validated['experience'] = safe_exp
return validated
@@ -263,9 +280,32 @@ def check_first_user():
pass
def calculate_quick_stats():
"""Calculate quick stats for dashboard"""
from datetime import datetime, timedelta
cached_posts, _ = _load_posts_cache()
# Calculate posts from today (last 24 hours)
now = datetime.utcnow()
today_start = now - timedelta(hours=24)
today_timestamp = today_start.timestamp()
posts_today = sum(1 for post in cached_posts.values()
if post.get('timestamp', 0) >= today_timestamp)
return {
'posts_today': posts_today,
'total_posts': len(cached_posts)
}
@app.route('/')
def index():
"""Serve the main feed page"""
# Calculate stats
quick_stats = calculate_quick_stats()
if current_user.is_authenticated:
# Load user settings
try:
@@ -273,11 +313,28 @@ def index():
except (json.JSONDecodeError, TypeError) as e:
logger.warning(f"Invalid user settings JSON for user {current_user.id}: {e}")
user_settings = {}
return render_template('dashboard.html', user_settings=user_settings)
return render_template('dashboard.html', user_settings=user_settings, quick_stats=quick_stats)
else:
# For non-authenticated users, serve static content
return send_from_directory('active_html/no_filter', 'index.html')
# Check if anonymous access is allowed
if app.config.get('ALLOW_ANONYMOUS_ACCESS', False):
# Anonymous mode - allow browsing with default settings
user_settings = {
'filter_set': 'no_filter',
'communities': [],
'experience': {
'infinite_scroll': False,
'auto_refresh': False,
'push_notifications': False,
'dark_patterns_opt_in': False,
'time_filter_enabled': False,
'time_filter_days': 7
}
}
return render_template('dashboard.html', user_settings=user_settings, anonymous=True, quick_stats=quick_stats)
else:
# Redirect non-authenticated users to login
return redirect(url_for('login'))
@app.route('/feed/<filterset>')
@@ -332,39 +389,128 @@ def api_posts():
try:
# Load platform configuration
platform_config = load_platform_config()
# Get query parameters
page = int(request.args.get('page', 1))
per_page = int(request.args.get('per_page', DEFAULT_PAGE_SIZE))
community = request.args.get('community', '')
platform = request.args.get('platform', '')
search_query = request.args.get('q', '').lower().strip()
filter_override = request.args.get('filter', '')
# Get user's filterset preference, community selections, and time filter
filterset_name = 'no_filter'
user_communities = []
time_filter_enabled = False
time_filter_days = 7
if current_user.is_authenticated:
try:
user_settings = json.loads(current_user.settings) if current_user.settings else {}
filterset_name = user_settings.get('filter_set', 'no_filter')
user_communities = user_settings.get('communities', [])
experience_settings = user_settings.get('experience', {})
time_filter_enabled = experience_settings.get('time_filter_enabled', False)
time_filter_days = experience_settings.get('time_filter_days', 7)
except:
filterset_name = 'no_filter'
user_communities = []
time_filter_enabled = False
time_filter_days = 7
# Override filterset if specified in request (for sidebar filter switching)
if filter_override and _is_safe_filterset(filter_override):
filterset_name = filter_override
# Use cached data for better performance
cached_posts, cached_comments = _load_posts_cache()
# Calculate time filter cutoff if enabled
time_cutoff = None
if time_filter_enabled:
from datetime import datetime, timedelta
cutoff_date = datetime.utcnow() - timedelta(days=time_filter_days)
time_cutoff = cutoff_date.timestamp()
# ====================================================================
# START OF REFACTORED SECTION
# ====================================================================
posts = []
# Process cached posts
for post_uuid, post_data in cached_posts.items():
def _post_should_be_included(post_data):
"""Check if a post passes all pre-filterset criteria."""
# Apply time filter
if time_filter_enabled and time_cutoff:
if post_data.get('timestamp', 0) < time_cutoff:
return False
# Apply community filter
if community and post_data.get('source', '').lower() != community.lower():
continue
return False
# Apply platform filter
if platform and post_data.get('platform', '').lower() != platform.lower():
continue
return False
# Apply user's community preferences
if user_communities:
post_source = post_data.get('source', '').lower()
post_platform = post_data.get('platform', '').lower()
if not any(
post_source == c or post_platform == c or c in post_source
for c in user_communities
):
# ====================================================================
# MODIFICATION: Add logging here
# ====================================================================
logger.error(
f"Post filtered out for user {current_user.id if current_user.is_authenticated else 'anonymous'}: "
f"Community mismatch. Platform='{post_platform}', Source='{post_source}', "
f"User Communities={user_communities}"
)
# ====================================================================
return False
# Apply search filter
if search_query:
title = post_data.get('title', '').lower()
content = post_data.get('content', '').lower()
author = post_data.get('author', '').lower()
source = post_data.get('source', '').lower()
if not (search_query in title or
search_query in content or
search_query in author or
search_query in source):
return False
# Get comment count from cache
return True
# Collect raw posts using a clean, declarative list comprehension
raw_posts = [
post_data for post_data in cached_posts.values()
if _post_should_be_included(post_data)
]
# ====================================================================
# END OF REFACTORED SECTION
# ====================================================================
# Apply filterset using FilterEngine
filtered_posts = filter_engine.apply_filterset(raw_posts, filterset_name, use_cache=True)
# Build response posts with metadata
posts = []
for post_data in filtered_posts:
post_uuid = post_data.get('uuid')
comment_count = len(cached_comments.get(post_uuid, []))
# Get proper display name for source
source_display = get_display_name_for_source(
post_data.get('platform', ''),
post_data.get('source', ''),
platform_config
)
# Create post object with actual title
# Create post object with filter metadata
post = {
'id': post_uuid,
'title': post_data.get('title', 'Untitled'),
@@ -378,12 +524,16 @@ def api_posts():
'source': post_data.get('source', ''),
'source_display': source_display,
'tags': post_data.get('tags', []),
'external_url': post_data.get('url', '')
'external_url': post_data.get('url', ''),
# Add filter metadata
'filter_score': post_data.get('_filter_score', 0.5),
'filter_categories': post_data.get('_filter_categories', []),
'filter_tags': post_data.get('_filter_tags', [])
}
posts.append(post)
# Sort by timestamp (newest first)
posts.sort(key=lambda x: x['timestamp'], reverse=True)
# Sort by filter score (highest first), then timestamp
posts.sort(key=lambda x: (x['filter_score'], x['timestamp']), reverse=True)
# Calculate pagination
total_posts = len(posts)
@@ -518,34 +668,266 @@ def api_content_timestamp():
return jsonify({'error': 'Failed to get content timestamp'}), 500
@app.route('/api/bookmark', methods=['POST'])
@login_required
def api_bookmark():
"""Toggle bookmark status for a post"""
try:
from models import Bookmark
data = request.get_json()
if not data or 'post_uuid' not in data:
return jsonify({'error': 'Missing post_uuid'}), 400
post_uuid = data['post_uuid']
if not post_uuid:
return jsonify({'error': 'Invalid post_uuid'}), 400
# Check if bookmark already exists
existing_bookmark = Bookmark.query.filter_by(
user_id=current_user.id,
post_uuid=post_uuid
).first()
if existing_bookmark:
# Remove bookmark
db.session.delete(existing_bookmark)
db.session.commit()
return jsonify({'bookmarked': False, 'message': 'Bookmark removed'})
else:
# Add bookmark - get post data for caching
cached_posts, _ = _load_posts_cache()
post_data = cached_posts.get(post_uuid, {})
bookmark = Bookmark(
user_id=current_user.id,
post_uuid=post_uuid,
title=post_data.get('title', ''),
platform=post_data.get('platform', ''),
source=post_data.get('source', '')
)
db.session.add(bookmark)
db.session.commit()
return jsonify({'bookmarked': True, 'message': 'Bookmark added'})
except Exception as e:
logger.error(f"Error toggling bookmark: {e}")
return jsonify({'error': 'Failed to toggle bookmark'}), 500
@app.route('/api/bookmarks')
@login_required
def api_bookmarks():
"""Get user's bookmarks"""
try:
from models import Bookmark
page = int(request.args.get('page', 1))
per_page = int(request.args.get('per_page', DEFAULT_PAGE_SIZE))
# Get user's bookmarks with pagination
bookmarks_query = Bookmark.query.filter_by(user_id=current_user.id).order_by(Bookmark.created_at.desc())
total_bookmarks = bookmarks_query.count()
bookmarks = bookmarks_query.offset((page - 1) * per_page).limit(per_page).all()
# Load current posts cache to get updated data
cached_posts, cached_comments = _load_posts_cache()
# Build response
bookmark_posts = []
for bookmark in bookmarks:
# Try to get current post data, fallback to cached data
post_data = cached_posts.get(bookmark.post_uuid)
if post_data:
# Post still exists in current data
comment_count = len(cached_comments.get(bookmark.post_uuid, []))
post = {
'id': bookmark.post_uuid,
'title': post_data.get('title', bookmark.title or 'Untitled'),
'author': post_data.get('author', 'Unknown'),
'platform': post_data.get('platform', bookmark.platform or 'unknown'),
'score': post_data.get('score', 0),
'timestamp': post_data.get('timestamp', 0),
'url': f'/post/{bookmark.post_uuid}',
'comments_count': comment_count,
'content_preview': (post_data.get('content', '') or '')[:200] + '...' if post_data.get('content') else '',
'source': post_data.get('source', bookmark.source or ''),
'bookmarked_at': bookmark.created_at.isoformat(),
'external_url': post_data.get('url', '')
}
else:
# Post no longer in current data, use cached bookmark data
post = {
'id': bookmark.post_uuid,
'title': bookmark.title or 'Untitled',
'author': 'Unknown',
'platform': bookmark.platform or 'unknown',
'score': 0,
'timestamp': 0,
'url': f'/post/{bookmark.post_uuid}',
'comments_count': 0,
'content_preview': 'Content no longer available',
'source': bookmark.source or '',
'bookmarked_at': bookmark.created_at.isoformat(),
'external_url': '',
'archived': True # Mark as archived
}
bookmark_posts.append(post)
total_pages = (total_bookmarks + per_page - 1) // per_page
has_next = page < total_pages
has_prev = page > 1
return jsonify({
'posts': bookmark_posts,
'pagination': {
'current_page': page,
'total_pages': total_pages,
'total_posts': total_bookmarks,
'per_page': per_page,
'has_next': has_next,
'has_prev': has_prev
}
})
except Exception as e:
logger.error(f"Error getting bookmarks: {e}")
return jsonify({'error': 'Failed to get bookmarks'}), 500
@app.route('/api/bookmark-status/<post_uuid>')
@login_required
def api_bookmark_status(post_uuid):
"""Check if a post is bookmarked by current user"""
try:
from models import Bookmark
bookmark = Bookmark.query.filter_by(
user_id=current_user.id,
post_uuid=post_uuid
).first()
return jsonify({'bookmarked': bookmark is not None})
except Exception as e:
logger.error(f"Error checking bookmark status: {e}")
return jsonify({'error': 'Failed to check bookmark status'}), 500
@app.route('/api/filters')
def api_filters():
"""API endpoint to get available filters"""
try:
filters = []
# Get current user's filter preference
current_filter = 'no_filter'
if current_user.is_authenticated:
try:
user_settings = json.loads(current_user.settings) if current_user.settings else {}
current_filter = user_settings.get('filter_set', 'no_filter')
except:
pass
# Get available filtersets from filter engine
for filterset_name in filter_engine.get_available_filtersets():
filterset_config = filter_engine.config.get_filterset(filterset_name)
if filterset_config:
# Map filter names to icons and display names
icon_map = {
'no_filter': '🌐',
'safe_content': '',
'tech_only': '💻',
'high_quality': '',
'custom_example': '🎯'
}
name_map = {
'no_filter': 'All Content',
'safe_content': 'Safe Content',
'tech_only': 'Tech Only',
'high_quality': 'High Quality',
'custom_example': 'Custom Example'
}
filters.append({
'id': filterset_name,
'name': name_map.get(filterset_name, filterset_name.replace('_', ' ').title()),
'description': filterset_config.get('description', ''),
'icon': icon_map.get(filterset_name, '🔧'),
'active': filterset_name == current_filter
})
return jsonify({'filters': filters})
except Exception as e:
logger.error(f"Error getting filters: {e}")
return jsonify({'error': 'Failed to get filters'}), 500
@app.route('/bookmarks')
@login_required
def bookmarks():
"""Bookmarks page"""
return render_template('bookmarks.html', user=current_user)
def build_comment_tree(comments):
"""Build a hierarchical comment tree from flat comment list"""
# Create lookup dict by UUID
comment_dict = {c['uuid']: {**c, 'replies': []} for c in comments}
# Build tree structure
root_comments = []
for comment in comments:
parent_uuid = comment.get('parent_comment_uuid')
if parent_uuid and parent_uuid in comment_dict:
# Add as reply to parent
comment_dict[parent_uuid]['replies'].append(comment_dict[comment['uuid']])
else:
# Top-level comment
root_comments.append(comment_dict[comment['uuid']])
# Sort at each level by timestamp
def sort_tree(comments_list):
comments_list.sort(key=lambda x: x.get('timestamp', 0))
for comment in comments_list:
if comment.get('replies'):
sort_tree(comment['replies'])
sort_tree(root_comments)
return root_comments
@app.route('/post/<post_id>')
def post_detail(post_id):
"""Serve individual post detail page with modern theme"""
try:
# Load platform configuration
platform_config = load_platform_config()
# Use cached data for better performance
cached_posts, cached_comments = _load_posts_cache()
# Get post data from cache
post_data = cached_posts.get(post_id)
if not post_data:
return render_template('404.html'), 404
# Add source display name
post_data['source_display'] = get_display_name_for_source(
post_data.get('platform', ''),
post_data.get('source', ''),
platform_config
)
# Get comments from cache
comments = cached_comments.get(post_id, [])
# Sort comments by timestamp
comments.sort(key=lambda x: x.get('timestamp', 0))
comments_flat = cached_comments.get(post_id, [])
logger.info(f"Loading post {post_id}: found {len(comments_flat)} comments")
# Build comment tree
comments = build_comment_tree(comments_flat)
# Load user settings if authenticated
user_settings = {}
if current_user.is_authenticated:
@@ -553,9 +935,9 @@ def post_detail(post_id):
user_settings = json.loads(current_user.settings) if current_user.settings else {}
except:
user_settings = {}
return render_template('post_detail.html', post=post_data, comments=comments, user_settings=user_settings)
except Exception as e:
print(f"Error loading post {post_id}: {e}")
return render_template('404.html'), 404
@@ -573,8 +955,16 @@ def serve_theme(filename):
@app.route('/logo.png')
def serve_logo():
"""Serve logo"""
return send_from_directory('.', 'logo.png')
"""Serve configurable logo"""
logo_path = app.config['LOGO_PATH']
# If it's just a filename, serve from current directory
if '/' not in logo_path:
return send_from_directory('.', logo_path)
else:
# If it's a full path, split directory and filename
directory = os.path.dirname(logo_path)
filename = os.path.basename(logo_path)
return send_from_directory(directory, filename)
@app.route('/static/<path:filename>')
def serve_static(filename):
@@ -596,6 +986,9 @@ def login():
if current_user.is_authenticated:
return redirect(url_for('index'))
# Check if Auth0 is configured
auth0_configured = bool(app.config.get('AUTH0_DOMAIN') and app.config.get('AUTH0_CLIENT_ID'))
if request.method == 'POST':
username = request.form.get('username')
password = request.form.get('password')
@@ -603,7 +996,7 @@ def login():
if not user_service:
flash('User service not available', 'error')
return render_template('login.html')
return render_template('login.html', auth0_configured=auth0_configured)
user = user_service.authenticate(username, password)
@@ -617,15 +1010,97 @@ def login():
else:
flash('Invalid username or password', 'error')
return render_template('login.html')
return render_template('login.html', auth0_configured=auth0_configured)
@app.route('/password-reset-request', methods=['GET', 'POST'])
def password_reset_request():
"""Request a password reset"""
if current_user.is_authenticated:
return redirect(url_for('index'))
if request.method == 'POST':
email = request.form.get('email', '').strip().lower()
if not email:
flash('Please enter your email address', 'error')
return render_template('password_reset_request.html')
# Find user by email
user = User.query.filter_by(email=email).first()
# Always show success message for security (don't reveal if email exists)
flash('If an account exists with that email, a password reset link has been sent.', 'success')
if user and user.password_hash: # Only send reset if user has a password (not OAuth only)
# Generate reset token
token = user.generate_reset_token()
# Build reset URL
reset_url = url_for('password_reset', token=token, _external=True)
# Log the reset URL (in production, this would be emailed)
logger.info(f"Password reset requested for {email}. Reset URL: {reset_url}")
# For now, also flash it for development (remove in production)
flash(f'Reset link (development only): {reset_url}', 'info')
return redirect(url_for('login'))
return render_template('password_reset_request.html')
@app.route('/password-reset/<token>', methods=['GET', 'POST'])
def password_reset(token):
"""Reset password with token"""
if current_user.is_authenticated:
return redirect(url_for('index'))
# Find user by token
user = User.query.filter_by(reset_token=token).first()
if not user or not user.verify_reset_token(token):
flash('Invalid or expired reset token', 'error')
return redirect(url_for('login'))
if request.method == 'POST':
password = request.form.get('password', '')
confirm_password = request.form.get('confirm_password', '')
if not password or len(password) < 6:
flash('Password must be at least 6 characters', 'error')
return render_template('password_reset.html')
if password != confirm_password:
flash('Passwords do not match', 'error')
return render_template('password_reset.html')
# Set new password
user.set_password(password)
user.clear_reset_token()
flash('Your password has been reset successfully. You can now log in.', 'success')
return redirect(url_for('login'))
return render_template('password_reset.html')
# Auth0 Routes
@app.route('/auth0/login')
def auth0_login():
"""Redirect to Auth0 for authentication"""
redirect_uri = url_for('auth0_callback', _external=True)
return auth0.authorize_redirect(redirect_uri)
# Check if Auth0 is configured
if not app.config.get('AUTH0_DOMAIN') or not app.config.get('AUTH0_CLIENT_ID'):
flash('Auth0 authentication is not configured. Please use email/password login or contact the administrator.', 'error')
return redirect(url_for('login'))
try:
redirect_uri = url_for('auth0_callback', _external=True)
return auth0.authorize_redirect(redirect_uri)
except Exception as e:
logger.error(f"Auth0 login error: {e}")
flash('Auth0 authentication failed. Please use email/password login.', 'error')
return redirect(url_for('login'))
@app.route('/auth0/callback')
@@ -933,15 +1408,30 @@ def settings_communities():
except:
selected_communities = []
# Available communities
available_communities = [
{'id': 'programming', 'name': 'Programming', 'platform': 'reddit'},
{'id': 'python', 'name': 'Python', 'platform': 'reddit'},
{'id': 'technology', 'name': 'Technology', 'platform': 'reddit'},
{'id': 'hackernews', 'name': 'Hacker News', 'platform': 'hackernews'},
{'id': 'lobsters', 'name': 'Lobsters', 'platform': 'lobsters'},
{'id': 'stackoverflow', 'name': 'Stack Overflow', 'platform': 'stackoverflow'},
]
# Get available communities from platform config and collection targets
available_communities = []
# Load platform configuration
platform_config = load_platform_config()
# Get enabled communities from collection_targets (what's actually being crawled)
enabled_communities = set()
for target in platform_config.get('collection_targets', []):
enabled_communities.add((target['platform'], target['community']))
# Build community list from platform config for communities that are enabled
for platform_name, platform_info in platform_config.get('platforms', {}).items():
for community_info in platform_info.get('communities', []):
# Only include communities that are in collection_targets
if (platform_name, community_info['id']) in enabled_communities:
available_communities.append({
'id': community_info['id'],
'name': community_info['name'],
'display_name': community_info.get('display_name', community_info['name']),
'platform': platform_name,
'icon': community_info.get('icon', platform_info.get('icon', '📄')),
'description': community_info.get('description', '')
})
return render_template('settings_communities.html',
user=current_user,
@@ -986,13 +1476,10 @@ def settings_filters():
current_filter = user_settings.get('filter_set', 'no_filter')
# Load available filter sets
# Load available filter sets from FilterEngine as a dictionary
filter_sets = {}
try:
with open('filtersets.json', 'r') as f:
filter_sets = json.load(f)
except:
filter_sets = {}
for filterset_name in filter_engine.get_available_filtersets():
filter_sets[filterset_name] = filter_engine.config.get_filterset(filterset_name)
return render_template('settings_filters.html',
user=current_user,
@@ -1016,7 +1503,9 @@ def settings_experience():
'infinite_scroll': request.form.get('infinite_scroll') == 'on',
'auto_refresh': request.form.get('auto_refresh') == 'on',
'push_notifications': request.form.get('push_notifications') == 'on',
'dark_patterns_opt_in': request.form.get('dark_patterns_opt_in') == 'on'
'dark_patterns_opt_in': request.form.get('dark_patterns_opt_in') == 'on',
'time_filter_enabled': request.form.get('time_filter_enabled') == 'on',
'time_filter_days': int(request.form.get('time_filter_days', 7))
}
# Save settings
@@ -1036,7 +1525,9 @@ def settings_experience():
'infinite_scroll': False,
'auto_refresh': False,
'push_notifications': False,
'dark_patterns_opt_in': False
'dark_patterns_opt_in': False,
'time_filter_enabled': False,
'time_filter_days': 7
})
return render_template('settings_experience.html',
@@ -1347,8 +1838,16 @@ def admin_polling_add():
platform = request.form.get('platform')
source_id = request.form.get('source_id')
custom_source_id = request.form.get('custom_source_id')
display_name = request.form.get('display_name')
poll_interval = int(request.form.get('poll_interval', 60))
max_posts = int(request.form.get('max_posts', 100))
fetch_comments = request.form.get('fetch_comments', 'true') == 'true'
priority = request.form.get('priority', 'medium')
# Use custom source if provided, otherwise use dropdown
if custom_source_id and custom_source_id.strip():
source_id = custom_source_id.strip()
if not platform or not source_id or not display_name:
flash('Missing required fields', 'error')
@@ -1360,13 +1859,16 @@ def admin_polling_add():
flash(f'Source {platform}:{source_id} already exists', 'warning')
return redirect(url_for('admin_polling'))
# Create new source
# Create new source (disabled by default)
source = PollSource(
platform=platform,
source_id=source_id,
display_name=display_name,
poll_interval_minutes=poll_interval,
enabled=True,
max_posts=max_posts,
fetch_comments=fetch_comments,
priority=priority,
enabled=False,
created_by=current_user.id
)
@@ -1418,11 +1920,24 @@ def admin_polling_update(source_id):
flash('Source not found', 'error')
return redirect(url_for('admin_polling'))
poll_interval = request.form.get('poll_interval')
if poll_interval:
source.poll_interval_minutes = int(poll_interval)
db.session.commit()
flash(f'Updated interval for {source.display_name}', 'success')
# Update all configurable fields
if request.form.get('poll_interval'):
source.poll_interval_minutes = int(request.form.get('poll_interval'))
if request.form.get('max_posts'):
source.max_posts = int(request.form.get('max_posts'))
if request.form.get('fetch_comments') is not None:
source.fetch_comments = request.form.get('fetch_comments') == 'true'
if request.form.get('priority'):
source.priority = request.form.get('priority')
if request.form.get('display_name'):
source.display_name = request.form.get('display_name')
db.session.commit()
flash(f'Updated settings for {source.display_name}', 'success')
return redirect(url_for('admin_polling'))
@@ -1502,6 +2017,18 @@ def admin_polling_logs(source_id):
logs=logs)
# ============================================================
# TEMPLATE CONTEXT PROCESSORS
# ============================================================
@app.context_processor
def inject_app_config():
"""Inject app configuration into all templates"""
return {
'APP_NAME': app.config['APP_NAME']
}
# ============================================================
# ERROR HANDLERS
# ============================================================

View File

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

View File

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

View File

@@ -40,6 +40,7 @@ services:
FLASK_ENV: production
DEBUG: "False"
SECRET_KEY: ${SECRET_KEY:-change-this-secret-key-in-production}
ALLOW_ANONYMOUS_ACCESS: ${ALLOW_ANONYMOUS_ACCESS:-true}
# Auth0 configuration (optional)
AUTH0_DOMAIN: ${AUTH0_DOMAIN:-}
@@ -47,8 +48,9 @@ services:
AUTH0_CLIENT_SECRET: ${AUTH0_CLIENT_SECRET:-}
AUTH0_AUDIENCE: ${AUTH0_AUDIENCE:-}
volumes:
# Persistent data storage
- ./data:/app/data
# Application-managed data (using a named volume)
- app_data:/app/data
# User-editable content (using bind mounts)
- ./static:/app/static
- ./backups:/app/backups
- ./active_html:/app/active_html
@@ -70,4 +72,5 @@ networks:
driver: bridge
volumes:
postgres_data:
postgres_data:
app_data: # <-- New named volume declared here

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

BIN
logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 407 KiB

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)
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):
"""
Initialize a new user.
@@ -102,6 +106,32 @@ class User(UserMixin, db.Model):
self.last_login = datetime.utcnow()
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):
"""Required by Flask-Login"""
return self.id
@@ -140,6 +170,9 @@ class PollSource(db.Model):
# Polling configuration
enabled = db.Column(db.Boolean, default=True, nullable=False)
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
last_poll_time = db.Column(db.DateTime, nullable=True)
@@ -184,3 +217,32 @@ class PollLog(db.Model):
def __repr__(self):
return f'<PollLog {self.id} for source {self.source_id}>'
class Bookmark(db.Model):
"""User bookmarks for posts"""
__tablename__ = 'bookmarks'
id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
user_id = db.Column(db.String(36), db.ForeignKey('users.id'), nullable=False, index=True)
post_uuid = db.Column(db.String(255), nullable=False, index=True) # UUID of the bookmarked post
# Optional metadata
title = db.Column(db.String(500), nullable=True) # Cached post title
platform = db.Column(db.String(50), nullable=True) # Cached platform info
source = db.Column(db.String(100), nullable=True) # Cached source info
# Timestamps
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
# Relationships
user = db.relationship('User', backref=db.backref('bookmarks', lazy='dynamic', order_by='Bookmark.created_at.desc()'))
# Unique constraint - user can only bookmark a post once
__table_args__ = (
db.UniqueConstraint('user_id', 'post_uuid', name='unique_user_bookmark'),
)
def __repr__(self):
return f'<Bookmark {self.post_uuid} by user {self.user_id}>'

View File

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

View File

@@ -145,25 +145,30 @@ class PollingService:
Collect data from a source.
Wraps the existing data_collection.py functionality.
"""
from data_collection import ensure_directories, load_index, save_index, calculate_date_range, load_state, save_state
from data_collection import ensure_directories, load_index, save_index, load_state, save_state
from datetime import datetime, timedelta
# Setup directories and load state
dirs = ensure_directories(self.storage_dir)
index = load_index(self.storage_dir)
state = load_state(self.storage_dir)
# Calculate date range (collect last 1 day)
start_iso, end_iso = calculate_date_range(1, state)
# Calculate date range - always use last 24 hours for polling
# Don't use the resume feature as it can create too narrow windows
end_date = datetime.now()
start_date = end_date - timedelta(hours=24)
start_iso = start_date.isoformat()
end_iso = end_date.isoformat()
try:
# Call the existing collect_platform function
# Call the existing collect_platform function using source settings
posts_collected = collect_platform(
platform=source.platform,
community=source.source_id,
start_date=start_iso,
end_date=end_iso,
max_posts=100, # Default limit
fetch_comments=True,
max_posts=source.max_posts or 100,
fetch_comments=source.fetch_comments if hasattr(source, 'fetch_comments') else True,
index=index,
dirs=dirs
)

View File

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

View File

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

409
templates/_admin_base.html Normal file
View File

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

48
templates/_nav.html Normal file
View File

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

View File

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

View File

@@ -1,52 +1,11 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Polling Management - Admin - BalanceBoard</title>
<link rel="stylesheet" href="{{ url_for('serve_theme', filename='modern-card-ui/styles.css') }}">
<style>
.admin-container {
max-width: 1400px;
margin: 0 auto;
padding: 24px;
}
{% extends "_admin_base.html" %}
.admin-header {
background: linear-gradient(135deg, var(--primary-dark) 0%, #2a5068 100%);
color: white;
padding: 32px;
border-radius: 12px;
margin-bottom: 24px;
}
{% block title %}Polling Management - Admin - {{ APP_NAME }}{% endblock %}
.status-badge {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
font-size: 0.85rem;
font-weight: 500;
}
{% block page_title %}Polling Management{% endblock %}
{% block page_description %}Manage data collection sources and schedules{% endblock %}
.status-enabled {
background: #d4edda;
color: #155724;
}
.status-disabled {
background: #f8d7da;
color: #721c24;
}
.status-success {
background: #d4edda;
color: #155724;
}
.status-error {
background: #f8d7da;
color: #721c24;
}
{% block admin_styles %}
.source-card {
background: var(--surface-color);
@@ -174,22 +133,62 @@
padding: 48px;
color: var(--text-secondary);
}
</style>
</head>
<body>
<div class="admin-container">
<div class="admin-header">
<h1>📡 Polling Management</h1>
<p>Configure automatic data collection from content sources</p>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
.add-source-form {
background: var(--surface-color);
border: 1px solid var(--divider-color);
border-radius: 12px;
padding: 24px;
margin-bottom: 24px;
}
.form-label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: var(--text-primary);
}
.form-input, .form-select {
width: 100%;
padding: 10px;
border: 1px solid var(--divider-color);
border-radius: 6px;
font-size: 1rem;
}
.scheduler-status {
background: var(--surface-color);
border: 1px solid var(--divider-color);
border-radius: 12px;
padding: 16px;
margin-bottom: 24px;
}
.help-text {
font-size: 0.85rem;
color: var(--text-secondary);
margin-top: 4px;
}
.alert {
padding: 12px 16px;
border-radius: 8px;
margin-bottom: 12px;
}
.alert-success {
background: #d4edda;
color: #155724;
}
.alert-error {
background: #f8d7da;
color: #721c24;
}
{% endblock %}
{% block admin_content %}
<!-- Scheduler Status -->
<div class="scheduler-status">
@@ -223,6 +222,13 @@
<select class="form-select" name="source_id" id="source_id" required onchange="updateDisplayName()">
<option value="">Select source...</option>
</select>
<p class="help-text">Or enter custom source below</p>
</div>
<div class="form-group">
<label class="form-label" for="custom_source_id">Custom Source (optional)</label>
<input type="text" class="form-input" name="custom_source_id" id="custom_source_id" placeholder="e.g., r/technology or https://example.com/feed.xml">
<p class="help-text">For Reddit: r/subreddit | For RSS: full URL | Leave blank to use dropdown</p>
</div>
<div class="form-group">
@@ -231,8 +237,46 @@
</div>
<div class="form-group">
<label class="form-label" for="poll_interval">Poll Interval (minutes)</label>
<input type="number" class="form-input" name="poll_interval" id="poll_interval" value="60" min="5" required>
<label class="form-label" for="poll_interval">Poll Interval</label>
<select class="form-select" name="poll_interval" id="poll_interval" required>
<option value="15">15 minutes</option>
<option value="30">30 minutes</option>
<option value="60" selected>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 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>
@@ -299,6 +343,8 @@
{% endif %}
<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;">
<button type="submit" class="btn btn-secondary">
{% if source.enabled %}Disable{% else %}Enable{% endif %}
@@ -361,6 +407,147 @@
displayNameInput.value = selectedOption.dataset.displayName;
}
}
</script>
</body>
</html>
// Handle custom source input - make dropdown optional when custom is filled
document.getElementById('custom_source_id').addEventListener('input', function() {
const sourceSelect = document.getElementById('source_id');
if (this.value.trim()) {
sourceSelect.removeAttribute('required');
} else {
sourceSelect.setAttribute('required', 'required');
}
});
function openEditModal(sourceId, displayName, interval, maxPosts, fetchComments, priority) {
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>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Polling Logs - {{ source.display_name }} - Admin</title>
<link rel="stylesheet" href="{{ url_for('serve_theme', filename='modern-card-ui/styles.css') }}">
<style>
.admin-container {
max-width: 1400px;
margin: 0 auto;
padding: 24px;
}
{% extends "_admin_base.html" %}
.admin-header {
background: linear-gradient(135deg, var(--primary-dark) 0%, #2a5068 100%);
color: white;
padding: 32px;
border-radius: 12px;
margin-bottom: 24px;
}
{% block title %}Polling Logs - {{ source.display_name }} - Admin{% endblock %}
.log-table {
width: 100%;
border-collapse: collapse;
background: var(--surface-color);
border-radius: 12px;
overflow: hidden;
}
{% block page_title %}Polling Logs - {{ source.display_name }}{% endblock %}
{% block page_description %}View polling history and error logs for this source{% endblock %}
.log-table th {
background: var(--primary-color);
color: white;
padding: 12px;
text-align: left;
font-weight: 600;
}
{% block admin_styles %}
.error-detail {
background: #fff3cd;
padding: 12px;
border-radius: 6px;
margin-top: 8px;
font-size: 0.9rem;
white-space: pre-wrap;
max-height: 200px;
overflow-y: auto;
}
.log-table td {
padding: 12px;
border-bottom: 1px solid var(--divider-color);
}
.no-logs {
text-align: center;
padding: 48px;
color: var(--text-secondary);
}
{% endblock %}
.log-table tr:last-child td {
border-bottom: none;
}
.status-badge {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
font-size: 0.85rem;
font-weight: 500;
}
.status-success {
background: #d4edda;
color: #155724;
}
.status-error {
background: #f8d7da;
color: #721c24;
}
.status-running {
background: #fff3cd;
color: #856404;
}
.error-detail {
background: #fff3cd;
padding: 12px;
border-radius: 6px;
margin-top: 8px;
font-size: 0.9rem;
white-space: pre-wrap;
max-height: 200px;
overflow-y: auto;
}
.btn {
padding: 8px 16px;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: 500;
text-decoration: none;
display: inline-block;
transition: all 0.2s;
}
.btn-secondary {
background: var(--divider-color);
color: var(--text-primary);
}
.btn-secondary:hover {
background: #d0d0d0;
}
.no-logs {
text-align: center;
padding: 48px;
color: var(--text-secondary);
}
</style>
</head>
<body>
<div class="admin-container">
<div class="admin-header">
<h1>📋 Polling Logs</h1>
<p>{{ source.display_name }} ({{ source.platform}}:{{ source.source_id }})</p>
</div>
{% if logs %}
<table class="log-table">
<thead>
{% block admin_content %}
<div class="admin-table">
{% if logs %}
<table>
<thead>
<tr>
<th>Timestamp</th>
<th>Status</th>
<th>Posts Found</th>
<th>New Posts</th>
<th>Updated Posts</th>
<th>Error Details</th>
</tr>
</thead>
<tbody>
{% for log in logs %}
<tr>
<th>Started</th>
<th>Completed</th>
<th>Duration</th>
<th>Status</th>
<th>Posts Found</th>
<th>New</th>
<th>Updated</th>
<th>Details</th>
<td>{{ log.poll_time.strftime('%Y-%m-%d %H:%M:%S') }}</td>
<td>
{% if log.status == 'success' %}
<span class="status-badge status-success">Success</span>
{% elif log.status == 'error' %}
<span class="status-badge status-error">Error</span>
{% elif log.status == 'running' %}
<span class="status-badge status-running">Running</span>
{% else %}
<span class="status-badge">{{ log.status }}</span>
{% endif %}
</td>
<td>{{ log.posts_found }}</td>
<td>{{ log.posts_new }}</td>
<td>{{ log.posts_updated }}</td>
<td>
{% if log.error_message %}
<details>
<summary style="cursor: pointer; color: var(--primary-color);">View Error</summary>
<div class="error-detail">{{ log.error_message }}</div>
</details>
{% else %}
-
{% endif %}
</td>
</tr>
</thead>
<tbody>
{% for log in logs %}
<tr>
<td>{{ log.started_at.strftime('%Y-%m-%d %H:%M:%S') }}</td>
<td>
{% if log.completed_at %}
{{ log.completed_at.strftime('%Y-%m-%d %H:%M:%S') }}
{% else %}
-
{% endif %}
</td>
<td>
{% if log.completed_at %}
{{ ((log.completed_at - log.started_at).total_seconds())|round(1) }}s
{% else %}
-
{% endif %}
</td>
<td>
{% if log.status == 'success' %}
<span class="status-badge status-success">Success</span>
{% elif log.status == 'error' %}
<span class="status-badge status-error">Error</span>
{% elif log.status == 'running' %}
<span class="status-badge status-running">Running</span>
{% else %}
<span class="status-badge">{{ log.status }}</span>
{% endif %}
</td>
<td>{{ log.posts_found }}</td>
<td>{{ log.posts_new }}</td>
<td>{{ log.posts_updated }}</td>
<td>
{% if log.error_message %}
<details>
<summary style="cursor: pointer; color: var(--primary-color);">View Error</summary>
<div class="error-detail">{{ log.error_message }}</div>
</details>
{% else %}
-
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="no-logs">
<p>No polling logs yet.</p>
<p>Logs will appear here after the first poll.</p>
</div>
{% endif %}
<div style="margin-top: 24px;">
<a href="{{ url_for('admin_polling') }}" class="btn btn-secondary">← Back to Polling Management</a>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="no-logs">
<p>No polling logs yet.</p>
<p>Logs will appear here after the first poll.</p>
</div>
</div>
</body>
</html>
{% endif %}
</div>
<div style="margin-top: 24px;">
<a href="{{ url_for('admin_polling') }}" class="btn btn-secondary">← Back to Polling Management</a>
</div>
{% endblock %}

View File

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

View File

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

272
templates/bookmarks.html Normal file
View File

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

View File

@@ -1,48 +1,9 @@
{% extends "base.html" %}
{% block title %}Dashboard - BalanceBoard{% endblock %}
{% block title %}Dashboard - {{ APP_NAME }}{% endblock %}
{% block content %}
<!-- Modern Top Navigation -->
<nav class="top-nav">
<div class="nav-content">
<div class="nav-left">
<div class="logo-section">
<img src="{{ url_for('serve_logo') }}" alt="BalanceBoard" class="nav-logo">
<span class="brand-text"><span class="brand-balance">balance</span><span class="brand-board">Board</span></span>
</div>
</div>
<div class="nav-center">
<div class="search-bar">
<input type="text" placeholder="Search content..." class="search-input">
<button class="search-btn">🔍</button>
</div>
</div>
<div class="nav-right">
<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>
{% include '_nav.html' %}
<!-- Main Content Area -->
<main class="main-content">
@@ -50,17 +11,9 @@
<aside class="sidebar">
<div class="sidebar-section">
<h3>Content Filters</h3>
<div class="filter-item active" data-filter="no_filter">
<span class="filter-icon">🌐</span>
<span>All Content</span>
</div>
<div class="filter-item" data-filter="safe_content">
<span class="filter-icon"></span>
<span>Safe Content</span>
</div>
<div class="filter-item" data-filter="custom">
<span class="filter-icon">🎯</span>
<span>Custom Filter</span>
<div id="filter-list" class="filter-list">
<!-- Filters will be loaded dynamically -->
<div class="loading-filters">Loading filters...</div>
</div>
</div>
@@ -76,7 +29,7 @@
<h3>Quick Stats</h3>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-number">156</div>
<div class="stat-number">{{ quick_stats.posts_today if quick_stats else 0 }}</div>
<div class="stat-label">Posts Today</div>
</div>
<div class="stat-card">
@@ -90,10 +43,12 @@
<!-- Content Feed -->
<section class="content-section">
<div class="content-header">
<h1>Your Feed</h1>
<h1>{% if anonymous %}Public Feed{% else %}Your Feed{% endif %}</h1>
<div class="content-actions">
<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>
@@ -348,14 +303,14 @@
font-weight: 500;
}
.loading-communities {
.loading-communities, .loading-filters {
text-align: center;
color: #64748b;
font-style: italic;
padding: 20px;
}
.no-communities {
.no-communities, .no-filters {
text-align: center;
color: #64748b;
font-style: italic;
@@ -438,10 +393,26 @@
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 {
padding: 0;
max-height: calc(100vh - 200px);
overflow-y: auto;
}
.loading {
@@ -716,9 +687,11 @@ let postsData = [];
let currentPage = 1;
let currentCommunity = '';
let currentPlatform = '';
let currentFilter = 'no_filter';
let paginationData = {};
let platformConfig = {};
let communitiesData = [];
let filtersData = [];
// User experience settings
let userSettings = {{ user_settings|tojson }};
@@ -726,8 +699,8 @@ let userSettings = {{ user_settings|tojson }};
// Load posts on page load
document.addEventListener('DOMContentLoaded', function() {
loadPlatformConfig();
loadFilters();
loadPosts();
setupFilterSwitching();
setupInfiniteScroll();
setupAutoRefresh();
});
@@ -755,6 +728,54 @@ async function loadPlatformConfig() {
}
}
// Load available filters
async function loadFilters() {
try {
const response = await fetch('/api/filters');
const data = await response.json();
filtersData = data.filters || [];
renderFilters(filtersData);
setupFilterSwitching();
} catch (error) {
console.error('Error loading filters:', error);
// Show fallback filters
const fallbackFilters = [
{id: 'no_filter', name: 'All Content', icon: '🌐', active: true, description: 'No filtering'}
];
renderFilters(fallbackFilters);
setupFilterSwitching();
}
}
// Render filters in sidebar
function renderFilters(filters) {
const filterList = document.getElementById('filter-list');
if (!filterList) return;
if (filters.length === 0) {
filterList.innerHTML = '<div class="no-filters">No filters available</div>';
return;
}
const filtersHTML = filters.map(filter => {
return `
<div class="filter-item ${filter.active ? 'active' : ''}" data-filter="${filter.id}" title="${filter.description}">
<span class="filter-icon">${filter.icon}</span>
<span>${filter.name}</span>
</div>
`;
}).join('');
filterList.innerHTML = filtersHTML;
// Set current filter based on active filter
const activeFilter = filters.find(f => f.active);
if (activeFilter) {
currentFilter = activeFilter.id;
}
}
// Render communities in sidebar
function renderCommunities(communities) {
const communityList = document.getElementById('community-list');
@@ -788,7 +809,7 @@ function renderCommunities(communities) {
}
// Load posts from API
async function loadPosts(page = 1, community = '', platform = '', append = false) {
async function loadPosts(page = 1, community = '', platform = '', append = false, filter = null) {
try {
// Build query parameters
const params = new URLSearchParams();
@@ -796,6 +817,8 @@ async function loadPosts(page = 1, community = '', platform = '', append = false
params.append('per_page', 20);
if (community) params.append('community', community);
if (platform) params.append('platform', platform);
if (filter || currentFilter) params.append('filter', filter || currentFilter);
if (currentSearchQuery) params.append('q', currentSearchQuery);
const response = await fetch(`/api/posts?${params}`);
const data = await response.json();
@@ -988,24 +1011,34 @@ function savePost(postId) {
// Filter switching functionality
function setupFilterSwitching() {
const filterItems = document.querySelectorAll('.filter-item');
filterItems.forEach(item => {
item.addEventListener('click', function() {
// Remove active class from all items
filterItems.forEach(f => f.classList.remove('active'));
document.addEventListener('click', function(event) {
if (event.target.closest('.filter-item')) {
const filterItem = event.target.closest('.filter-item');
// Remove active class from all filter items
document.querySelectorAll('.filter-item').forEach(f => f.classList.remove('active'));
// Add active class to clicked item
this.classList.add('active');
filterItem.classList.add('active');
// Get filter type
const filterType = this.dataset.filter;
const filterType = filterItem.dataset.filter;
currentFilter = filterType;
// Apply filter (for now just reload)
if (filterType && filterType !== 'custom') {
loadPosts(); // In future, pass filter parameter
}
});
// Update header to show current filter
const contentHeader = document.querySelector('.content-header h1');
const filterName = filterItem.textContent.trim();
contentHeader.textContent = `${filterName} Feed`;
// Show loading state
const postsContainer = document.getElementById('posts-container');
const loadingIndicator = document.getElementById('loading-indicator');
loadingIndicator.style.display = 'flex';
postsContainer.innerHTML = '';
// Apply filter
loadPosts(1, currentCommunity, currentPlatform, false, filterType);
}
});
}
@@ -1030,15 +1063,52 @@ function refreshFeed() {
}
// Search functionality
let currentSearchQuery = '';
document.querySelector('.search-input').addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
const query = this.value.trim();
if (query) {
alert(`Search functionality coming soon! You searched for: "${query}"`);
}
performSearch(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
function setupInfiniteScroll() {
if (!userSettings?.experience?.infinite_scroll) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,63 @@
<!-- Modern Card UI - Post Card Template -->
<template id="modern-card-template">
<article class="post-card" data-post-id="{{id}}" data-platform="{{platform}}">
<div class="card-surface">
<!-- Header -->
<header class="card-header">
<div class="header-meta">
<span class="platform-badge platform-{{platform}}">{{platform|upper}}</span>
{% if source %}
<span class="card-source">{{source}}</span>
{% endif %}
</div>
<div class="vote-indicator">
<span class="vote-score">{{score}}</span>
<span class="vote-label">pts</span>
</div>
</header>
<!-- Title -->
<div class="card-title-section">
<h2 class="card-title">
<a href="{{post_url}}" class="title-link">{{title}}</a>
</h2>
</div>
<!-- Content Preview -->
{% if content %}
<div class="card-content-preview">
<p class="content-text">{{ truncate(content, 150) }}</p>
</div>
{% endif %}
<!-- Footer -->
<footer class="card-footer">
<div class="author-info">
<span class="author-name">{{author}}</span>
<span class="post-time">{{formatTimeAgo(timestamp)}}</span>
</div>
<div class="engagement-info">
<span class="reply-count">{{replies}} replies</span>
<button class="bookmark-btn" onclick="toggleBookmark('{{id}}', this)" data-post-id="{{id}}">
<span class="bookmark-icon">🔖</span>
<span class="bookmark-text">Save</span>
</button>
</div>
</footer>
<!-- Tags -->
{% if tags %}
<div class="card-tags">
{% for tag in tags[:3] if tag %}
<span class="tag-chip">{{tag}}</span>
{% endfor %}
{% if tags|length > 3 %}
<span class="tag-more">+{{tags|length - 3}} more</span>
{% endif %}
</div>
{% endif %}
</div>
</article>
</template>

View File

@@ -0,0 +1,41 @@
<!-- Modern Card UI - Comment Template -->
<template id="modern-comment-template">
<div class="comment-card" data-comment-id="{{uuid}}" style="margin-left: {{depth * 24}}px">
<div class="comment-surface">
<!-- Comment Header -->
<header class="comment-header">
<div class="comment-meta">
<span class="comment-author">{{author}}</span>
<span class="comment-time">{{formatTimeAgo(timestamp)}}</span>
{% if score != 0 %}
<div class="comment-score">
<span class="score-number">{{score}}</span>
<span class="score-label">pts</span>
</div>
{% endif %}
</div>
</header>
<!-- Comment Content -->
<div class="comment-body">
<div class="comment-text">
{{ renderMarkdown(content)|safe }}
</div>
{% if children_section %}
<!-- Nested replies section -->
<div class="comment-replies">
{{children_section|safe}}
</div>
{% endif %}
</div>
<!-- Comment Footer (for actions) -->
<footer class="comment-footer">
<div class="depth-indicator" data-depth="{{depth}}">
<span class="depth-label">Level {{depth + 1}}</span>
</div>
</footer>
</div>
</div>
</template>

View File

@@ -0,0 +1,69 @@
<!-- Modern Card UI - Post Detail Template -->
<template id="modern-detail-template">
<article class="post-detail" data-post-id="{{id}}" data-platform="{{platform}}">
<div class="detail-container">
<!-- Header Card -->
<header class="detail-header">
<div class="header-meta-card">
<div class="meta-row">
<span class="platform-badge platform-{{platform}}">{{platform|upper}}</span>
{% if source %}
<span class="detail-source">in {{source}}</span>
{% endif %}
</div>
<div class="headline-section">
<h1 class="detail-title">{{title}}</h1>
<div class="byline">
<span class="author-link">by {{author}}</span>
<span class="publication-time">{{formatDateTime(timestamp)}}</span>
</div>
</div>
<div class="stats-row">
<div class="stat-item">
<span class="stat-number">{{score}}</span>
<span class="stat-label">points</span>
</div>
<div class="stat-item">
<span class="stat-number">{{replies}}</span>
<span class="stat-label">comments</span>
</div>
</div>
{% if tags %}
<div class="detail-tags">
{% for tag in tags if tag %}
<span class="tag-pill">{{tag}}</span>
{% endfor %}
</div>
{% endif %}
</div>
</header>
<!-- Article Body -->
{% if content %}
<section class="article-body">
<div class="article-content">
{{ renderMarkdown(content)|safe }}
</div>
</section>
{% endif %}
<!-- Action Row -->
<div class="article-actions">
<a href="{{url}}" target="_blank" class="action-button primary">
View Original
</a>
</div>
<!-- Comments Section -->
{% if comments_section %}
<section class="comments-section">
<h2 class="comments-header">Comments ({{replies}})</h2>
{{comments_section|safe}}
</section>
{% endif %}
</div>
</article>
</template>

View File

@@ -0,0 +1,433 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BalanceBoard - Content Feed</title>
{% for css_path in theme.css_dependencies %}
<link rel="stylesheet" href="{{ css_path }}">
{% endfor %}
<style>
/* Enhanced Navigation Styles */
.nav-top {
background: var(--surface-color);
border-bottom: 1px solid var(--divider-color);
padding: 0.5rem 0;
position: sticky;
top: 0;
z-index: 100;
}
.nav-top-container {
max-width: 1200px;
margin: 0 auto;
padding: 0 1rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.nav-brand {
display: flex;
align-items: center;
gap: 0.75rem;
text-decoration: none;
color: var(--text-primary);
}
.nav-logo {
width: 32px;
height: 32px;
border-radius: 6px;
}
.nav-brand-text {
font-size: 1.25rem;
font-weight: 700;
}
.brand-balance {
color: var(--primary-color);
}
.brand-board {
color: var(--text-primary);
}
.nav-user-section {
display: flex;
align-items: center;
gap: 1rem;
}
.nav-user-info {
display: flex;
align-items: center;
gap: 0.5rem;
}
.nav-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--primary-color);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 600;
font-size: 0.875rem;
}
.nav-username {
font-weight: 500;
color: var(--text-primary);
}
.hamburger-menu {
position: relative;
}
.hamburger-toggle {
background: none;
border: none;
cursor: pointer;
padding: 0.5rem;
border-radius: 6px;
display: flex;
flex-direction: column;
gap: 3px;
transition: background-color 0.2s;
}
.hamburger-toggle:hover {
background: var(--hover-overlay);
}
.hamburger-line {
width: 20px;
height: 2px;
background: var(--text-primary);
transition: all 0.3s;
}
.hamburger-dropdown {
position: absolute;
top: 100%;
right: 0;
background: var(--surface-color);
border: 1px solid var(--divider-color);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
min-width: 200px;
z-index: 1000;
display: none;
}
.hamburger-dropdown.show {
display: block;
}
.dropdown-item {
display: block;
padding: 0.75rem 1rem;
color: var(--text-primary);
text-decoration: none;
transition: background-color 0.2s;
border-bottom: 1px solid var(--divider-color);
}
.dropdown-item:last-child {
border-bottom: none;
}
.dropdown-item:hover {
background: var(--hover-overlay);
}
.dropdown-divider {
height: 1px;
background: var(--divider-color);
margin: 0.25rem 0;
}
.nav-login-prompt {
display: flex;
align-items: center;
gap: 0.5rem;
}
.btn-nav-login, .btn-nav-signup {
padding: 0.375rem 0.75rem;
border-radius: 6px;
text-decoration: none;
font-size: 0.875rem;
font-weight: 500;
transition: all 0.2s;
}
.btn-nav-login {
background: var(--surface-color);
color: var(--text-primary);
border: 1px solid var(--divider-color);
}
.btn-nav-login:hover {
background: var(--hover-overlay);
}
.btn-nav-signup {
background: var(--primary-color);
color: white;
}
.btn-nav-signup:hover {
background: var(--primary-hover);
}
/* Mobile Responsive */
@media (max-width: 768px) {
.nav-username {
display: none;
}
}
</style>
</head>
<body>
<!-- Enhanced Top Navigation -->
<nav class="nav-top">
<div class="nav-top-container">
<a href="/" class="nav-brand">
<img src="/logo.png" alt="BalanceBoard Logo" class="nav-logo">
<div class="nav-brand-text">
<span class="brand-balance">balance</span><span class="brand-board">Board</span>
</div>
</a>
<div class="nav-user-section">
<!-- Logged in user state -->
<div class="nav-user-info" style="display: none;">
<div class="nav-avatar">JD</div>
<span class="nav-username">johndoe</span>
<div class="hamburger-menu">
<button class="hamburger-toggle" onclick="toggleDropdown()">
<div class="hamburger-line"></div>
<div class="hamburger-line"></div>
<div class="hamburger-line"></div>
</button>
<div class="hamburger-dropdown" id="userDropdown">
<a href="/settings" class="dropdown-item">
⚙️ Settings
</a>
<a href="/settings/profile" class="dropdown-item">
👤 Profile
</a>
<a href="/settings/communities" class="dropdown-item">
🌐 Communities
</a>
<a href="/settings/filters" class="dropdown-item">
🎛️ Filters
</a>
<a href="/bookmarks" class="dropdown-item">
📚 Bookmarks
</a>
<div class="dropdown-divider"></div>
<a href="/admin" class="dropdown-item" style="display: none;">
🛠️ Admin
</a>
<div class="dropdown-divider" style="display: none;"></div>
<a href="/logout" class="dropdown-item">
🚪 Sign Out
</a>
</div>
</div>
</div>
<!-- Logged out state -->
<div class="nav-login-prompt">
<a href="/login" class="btn-nav-login">Log In</a>
<a href="/signup" class="btn-nav-signup">Sign Up</a>
</div>
</div>
</div>
</nav>
<div class="app-layout">
<!-- Sidebar -->
<aside class="sidebar">
<!-- User Card -->
<div class="sidebar-section user-card">
<div class="login-prompt">
<div class="user-avatar">?</div>
<p>Join BalanceBoard to customize your feed</p>
<a href="/login" class="btn-login">Log In</a>
<a href="/signup" class="btn-signup">Sign Up</a>
</div>
</div>
<!-- Navigation -->
<div class="sidebar-section">
<h3>Navigation</h3>
<ul class="nav-menu">
<li><a href="/" class="nav-item active">
<span class="nav-icon">🏠</span>
<span>Home</span>
</a></li>
<li><a href="#" class="nav-item">
<span class="nav-icon">🔥</span>
<span>Popular</span>
</a></li>
<li><a href="#" class="nav-item">
<span class="nav-icon"></span>
<span>Saved</span>
</a></li>
<li><a href="#" class="nav-item">
<span class="nav-icon">📊</span>
<span>Analytics</span>
</a></li>
</ul>
</div>
<!-- Filters -->
<div class="sidebar-section">
<h3>Filter by Platform</h3>
<div class="filter-tags">
<a href="#" class="filter-tag active">All</a>
<a href="#" class="filter-tag">Reddit</a>
<a href="#" class="filter-tag">HackerNews</a>
<a href="#" class="filter-tag">Lobsters</a>
</div>
</div>
<!-- About -->
<div class="sidebar-section">
<h3>About</h3>
<p style="font-size: 0.85rem; color: var(--text-secondary); line-height: 1.5;">
BalanceBoard filters and curates content from multiple platforms to help you stay informed.
</p>
</div>
</aside>
<!-- Main Content -->
<main class="main-content">
<div class="container">
<header>
<h1>{{ filterset_name|title or 'All Posts' }}</h1>
<p class="post-count">{{ posts|length }} posts</p>
</header>
<div id="posts-container">
{% for post in posts %}
{{ post|safe }}
{% endfor %}
</div>
</div>
</main>
</div>
{% for js_path in theme.js_dependencies %}
<script src="{{ js_path }}"></script>
{% endfor %}
<script>
// Dropdown functionality
function toggleDropdown() {
const dropdown = document.getElementById('userDropdown');
dropdown.classList.toggle('show');
}
// Close dropdown when clicking outside
document.addEventListener('click', function(event) {
const dropdown = document.getElementById('userDropdown');
const toggle = document.querySelector('.hamburger-toggle');
if (!toggle.contains(event.target) && !dropdown.contains(event.target)) {
dropdown.classList.remove('show');
}
});
// Check user authentication state (this would be dynamic in a real app)
function checkAuthState() {
// This would normally check with the server
// For now, we'll show the logged out state
document.querySelector('.nav-user-info').style.display = 'none';
document.querySelector('.nav-login-prompt').style.display = 'flex';
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', checkAuthState);
// Bookmark functionality
async function toggleBookmark(postId, button) {
try {
button.disabled = true;
const originalText = button.querySelector('.bookmark-text').textContent;
button.querySelector('.bookmark-text').textContent = 'Saving...';
const response = await fetch('/api/bookmark', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ post_uuid: postId })
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to toggle bookmark');
}
// Update button state
updateBookmarkButton(button, data.bookmarked);
} catch (error) {
console.error('Error toggling bookmark:', error);
alert('Error: ' + error.message);
button.querySelector('.bookmark-text').textContent = originalText;
} finally {
button.disabled = false;
}
}
function updateBookmarkButton(button, isBookmarked) {
const icon = button.querySelector('.bookmark-icon');
const text = button.querySelector('.bookmark-text');
if (isBookmarked) {
button.classList.add('bookmarked');
icon.textContent = '📌';
text.textContent = 'Saved';
} else {
button.classList.remove('bookmarked');
icon.textContent = '🔖';
text.textContent = 'Save';
}
}
// Load bookmark states for visible posts
async function loadBookmarkStates() {
const bookmarkButtons = document.querySelectorAll('.bookmark-btn');
for (const button of bookmarkButtons) {
const postId = button.getAttribute('data-post-id');
try {
const response = await fetch(`/api/bookmark-status/${postId}`);
const data = await response.json();
if (response.ok && data.bookmarked) {
updateBookmarkButton(button, true);
}
} catch (error) {
console.error('Error loading bookmark status:', error);
}
}
}
// Load bookmark states when page loads
document.addEventListener('DOMContentLoaded', () => {
setTimeout(loadBookmarkStates, 500); // Small delay to ensure posts are rendered
});
</script>
</body>
</html>

View File

@@ -0,0 +1,121 @@
// Modern Card UI Theme Interactions
(function() {
'use strict';
// Enhanced hover effects
function initializeCardHoverEffects() {
const cards = document.querySelectorAll('.card-surface, .list-card, .comment-surface');
cards.forEach(card => {
card.addEventListener('mouseenter', function() {
// Subtle scale effect on hover
this.style.transform = 'translateY(-2px)';
this.style.boxShadow = '0 6px 20px rgba(0, 0, 0, 0.15)';
});
card.addEventListener('mouseleave', function() {
// Reset transform
this.style.transform = '';
this.style.boxShadow = '';
});
});
}
// Lazy loading for performance
function initializeLazyLoading() {
if ('IntersectionObserver' in window) {
const options = {
root: null,
rootMargin: '50px',
threshold: 0.1
};
const observer = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
// Add visible class for animations
entry.target.classList.add('visible');
// Unobserve after animation
observer.unobserve(entry.target);
}
});
}, options);
// Observe all cards and comments
document.querySelectorAll('.post-card, .comment-card').forEach(card => {
observer.observe(card);
});
}
}
// Improved comment thread visibility
function initializeCommentThreading() {
const toggleButtons = document.querySelectorAll('.comment-toggle');
toggleButtons.forEach(button => {
button.addEventListener('click', function() {
const comment = this.closest('.comment-card');
const replies = comment.querySelector('.comment-replies');
if (replies) {
replies.classList.toggle('collapsed');
this.textContent = replies.classList.contains('collapsed') ? '+' : '-';
}
});
});
}
// Add CSS classes for JavaScript-enhanced features
function initializeThemeFeatures() {
document.documentElement.classList.add('js-enabled');
// Add platform-specific classes to body
const platformElements = document.querySelectorAll('[data-platform]');
const platforms = new Set();
platformElements.forEach(el => {
platforms.add(el.dataset.platform);
});
platforms.forEach(platform => {
document.body.classList.add(`has-${platform}`);
});
}
// Keyboard navigation for accessibility
function initializeKeyboardNavigation() {
const cards = document.querySelectorAll('.post-card, .comment-card');
cards.forEach(card => {
card.setAttribute('tabindex', '0');
card.addEventListener('keydown', function(e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
const link = this.querySelector('a');
if (link) {
link.click();
}
}
});
});
}
// Initialize all features when DOM is ready
function initializeTheme() {
initializeThemeFeatures();
initializeCardHoverEffects();
initializeLazyLoading();
initializeCommentThreading();
initializeKeyboardNavigation();
}
// Run initialization after DOM load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeTheme);
} else {
initializeTheme();
}
})();

View File

@@ -0,0 +1,42 @@
<!-- Modern Card UI - Post List Template -->
<template id="modern-list-template">
<div class="post-list-item" data-post-id="{{id}}" data-platform="{{platform}}">
<div class="list-card">
<!-- Platform indicator -->
<div class="list-platform">
<span class="platform-badge medium platform-{{platform}}">{{platform[:1]|upper}}</span>
</div>
<!-- Main content -->
<div class="list-content">
<div class="list-vote-section">
<div class="vote-display">
<span class="vote-number">{{score}}</span>
</div>
</div>
<div class="list-meta">
<h3 class="list-title">
<a href="{{post_url}}" class="title-link">{{title}}</a>
</h3>
<div class="list-details">
<div class="list-attribution">
{% if source %}
<span class="list-source">{{source}}</span>
<span class="separator"></span>
{% endif %}
<span class="list-author">{{author}}</span>
<span class="separator"></span>
<span class="list-time">{{formatTimeAgo(timestamp)}}</span>
</div>
<div class="list-engagement">
<span class="replies-indicator">{{replies}} replies</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,975 @@
/* BalanceBoard Theme Styles */
:root {
/* BalanceBoard Color Palette */
--primary-color: #4DB6AC;
--primary-hover: #26A69A;
--primary-dark: #1B3A52;
--accent-color: #4DB6AC;
--surface-color: #FFFFFF;
--background-color: #F5F5F5;
--surface-elevation-1: rgba(0, 0, 0, 0.05);
--surface-elevation-2: rgba(0, 0, 0, 0.10);
--surface-elevation-3: rgba(0, 0, 0, 0.15);
--text-primary: #1B3A52;
--text-secondary: #757575;
--text-accent: #4DB6AC;
--border-color: rgba(0, 0, 0, 0.12);
--divider-color: rgba(0, 0, 0, 0.08);
--hover-overlay: rgba(77, 182, 172, 0.08);
--active-overlay: rgba(77, 182, 172, 0.16);
}
/* Global Styles */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background-color: var(--background-color);
color: var(--text-primary);
line-height: 1.6;
margin: 0;
padding: 0;
}
/* BalanceBoard Navigation */
.balanceboard-nav {
background: linear-gradient(135deg, var(--primary-dark) 0%, #2a5068 100%);
box-shadow: 0 2px 8px var(--surface-elevation-2);
position: sticky;
top: 0;
z-index: 1000;
border-bottom: 3px solid var(--primary-color);
}
.nav-container {
max-width: 1200px;
margin: 0 auto;
padding: 16px 24px;
display: flex;
align-items: center;
gap: 24px;
}
.nav-brand {
display: flex;
align-items: center;
gap: 12px;
text-decoration: none;
color: white;
}
.nav-logo {
width: 48px;
height: 48px;
border-radius: 50%;
background: white;
padding: 4px;
transition: transform 0.2s ease;
}
.nav-brand:hover .nav-logo {
transform: scale(1.05);
}
.nav-brand-text {
font-size: 1.5rem;
font-weight: 300;
letter-spacing: 0.5px;
}
.nav-brand-text .brand-balance {
color: var(--primary-color);
font-weight: 400;
}
.nav-brand-text .brand-board {
color: white;
font-weight: 600;
}
.nav-subtitle {
color: rgba(255, 255, 255, 0.7);
font-size: 0.85rem;
margin-top: -4px;
}
/* Main Layout */
.app-layout {
display: flex;
max-width: 1400px;
margin: 0 auto;
gap: 24px;
padding: 24px;
min-height: calc(100vh - 80px);
}
/* Sidebar */
.sidebar {
width: 280px;
flex-shrink: 0;
position: sticky;
top: 88px;
height: fit-content;
max-height: calc(100vh - 104px);
overflow-y: auto;
}
.sidebar-section {
background: var(--surface-color);
border-radius: 12px;
padding: 16px;
margin-bottom: 16px;
box-shadow: 0 2px 4px var(--surface-elevation-1);
border: 1px solid var(--border-color);
}
.sidebar-section h3 {
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-secondary);
margin-bottom: 12px;
font-weight: 600;
}
/* User Card */
.user-card {
background: linear-gradient(135deg, var(--primary-dark) 0%, #2a5068 100%);
border-radius: 12px;
padding: 20px;
color: white;
text-align: center;
border: 2px solid var(--primary-color);
}
.user-avatar {
width: 64px;
height: 64px;
border-radius: 50%;
background: var(--primary-color);
margin: 0 auto 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.75rem;
font-weight: 600;
color: white;
}
.user-name {
font-size: 1.1rem;
font-weight: 600;
margin-bottom: 4px;
}
.user-karma {
font-size: 0.85rem;
opacity: 0.8;
display: flex;
justify-content: center;
gap: 16px;
margin-top: 12px;
}
.karma-item {
display: flex;
align-items: center;
gap: 4px;
}
.login-prompt {
text-align: center;
}
.login-prompt p {
margin-bottom: 16px;
font-size: 0.95rem;
opacity: 0.9;
}
.btn-login {
display: block;
width: 100%;
padding: 10px 16px;
background: var(--primary-color);
color: white;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
text-decoration: none;
text-align: center;
}
.btn-login:hover {
background: var(--primary-hover);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(77, 182, 172, 0.3);
}
.btn-signup {
display: block;
width: 100%;
padding: 10px 16px;
background: transparent;
color: white;
border: 2px solid var(--primary-color);
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
text-decoration: none;
text-align: center;
margin-top: 8px;
}
.btn-signup:hover {
background: rgba(77, 182, 172, 0.1);
border-color: var(--primary-hover);
}
/* Navigation Menu */
.nav-menu {
list-style: none;
padding: 0;
margin: 0;
}
.nav-menu li {
margin-bottom: 4px;
}
.nav-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border-radius: 8px;
color: var(--text-primary);
text-decoration: none;
transition: all 0.2s ease;
font-size: 0.95rem;
}
.nav-item:hover {
background: var(--hover-overlay);
color: var(--primary-color);
}
.nav-item.active {
background: var(--primary-color);
color: white;
font-weight: 600;
}
.nav-icon {
font-size: 1.25rem;
width: 20px;
text-align: center;
}
/* Filter Tags */
.filter-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.filter-tag {
padding: 6px 12px;
background: var(--background-color);
border: 1px solid var(--border-color);
border-radius: 16px;
font-size: 0.85rem;
color: var(--text-primary);
text-decoration: none;
transition: all 0.2s ease;
cursor: pointer;
}
.filter-tag:hover {
background: var(--primary-color);
color: white;
border-color: var(--primary-color);
}
.filter-tag.active {
background: var(--primary-color);
color: white;
border-color: var(--primary-color);
}
/* Main Content Area */
.main-content {
flex: 1;
min-width: 0;
}
.container {
max-width: 100%;
}
/* Platform Colors */
.platform-reddit { background: linear-gradient(135deg, #FF4500, #FF6B35); color: white; }
.platform-hackernews { background: linear-gradient(135deg, #FF6600, #FF8533); color: white; }
.platform-lobsters { background: linear-gradient(135deg, #8B5A3C, #A0695A); color: white; }
/* Page Header */
.container > header {
background: white;
border-radius: 12px;
padding: 24px;
margin-bottom: 24px;
box-shadow: 0 2px 4px var(--surface-elevation-1);
border-left: 4px solid var(--primary-color);
}
header h1 {
font-size: 2rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 8px;
}
header .post-count {
color: var(--text-secondary);
font-size: 1.1rem;
display: flex;
align-items: center;
gap: 8px;
}
header .post-count::before {
content: "•";
color: var(--primary-color);
font-size: 1.5rem;
line-height: 1;
}
/* Post Cards */
.post-card {
margin-bottom: 16px;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.card-surface {
background: var(--surface-color);
border-radius: 12px;
box-shadow: 0 2px 4px var(--surface-elevation-1);
padding: 24px;
border: 1px solid var(--border-color);
transition: all 0.2s ease;
}
.card-surface:hover {
box-shadow: 0 4px 12px var(--surface-elevation-2);
transform: translateY(-2px);
background: var(--hover-overlay);
}
/* Card Header */
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.header-meta {
display: flex;
gap: 12px;
align-items: center;
}
.platform-badge {
padding: 4px 8px;
border-radius: 6px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.025em;
}
.vote-indicator {
display: flex;
align-items: center;
gap: 4px;
}
.vote-score {
font-size: 1.25rem;
font-weight: 700;
color: var(--text-accent);
}
/* Card Title */
.card-title {
font-size: 1.25rem;
font-weight: 600;
line-height: 1.4;
margin-bottom: 12px;
}
.title-link {
color: var(--text-primary);
text-decoration: none;
transition: color 0.2s ease;
}
.title-link:hover {
color: var(--primary-color);
}
/* Content Preview */
.card-content-preview {
margin-bottom: 16px;
}
.content-text {
color: var(--text-secondary);
font-size: 0.95rem;
line-height: 1.5;
}
/* Card Footer */
.card-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.author-info {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.9rem;
color: var(--text-secondary);
}
.author-name {
font-weight: 500;
color: var(--text-primary);
}
.engagement-info {
font-size: 0.9rem;
color: var(--text-secondary);
display: flex;
align-items: center;
gap: 12px;
}
/* Bookmark Button */
.bookmark-btn {
background: none;
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 6px 12px;
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
transition: all 0.2s ease;
color: var(--text-secondary);
font-size: 0.85rem;
}
.bookmark-btn:hover {
border-color: var(--primary-color);
color: var(--primary-color);
background: rgba(77, 182, 172, 0.1);
}
.bookmark-btn.bookmarked {
background: var(--primary-color);
color: white;
border-color: var(--primary-color);
}
.bookmark-btn.bookmarked .bookmark-icon {
filter: none;
}
.bookmark-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Tags */
.card-tags {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.tag-chip {
background: var(--primary-color);
color: white;
padding: 4px 8px;
border-radius: 16px;
font-size: 0.8rem;
font-weight: 500;
}
/* List View */
.post-list-item {
margin-bottom: 8px;
}
.list-card {
display: flex;
align-items: center;
background: var(--surface-color);
border-radius: 8px;
padding: 12px 16px;
border: 1px solid var(--border-color);
transition: all 0.2s ease;
}
.list-card:hover {
background: var(--hover-overlay);
box-shadow: 0 2px 8px var(--surface-elevation-1);
}
.list-content {
display: flex;
align-items: center;
flex: 1;
gap: 16px;
}
.list-vote-section {
min-width: 60px;
text-align: center;
}
.vote-number {
font-size: 1.1rem;
font-weight: 700;
color: var(--text-accent);
}
.list-meta {
flex: 1;
}
.list-title {
font-size: 1rem;
font-weight: 500;
margin-bottom: 4px;
}
.list-details {
display: flex;
justify-content: space-between;
align-items: center;
}
.list-attribution {
font-size: 0.85rem;
color: var(--text-secondary);
display: flex;
gap: 6px;
align-items: center;
}
.separator {
color: var(--divider-color);
}
.list-engagement {
font-size: 0.8rem;
color: var(--text-secondary);
}
/* Detailed View */
.post-detail {
background: var(--surface-color);
border-radius: 16px;
box-shadow: 0 4px 12px var(--surface-elevation-1);
margin-bottom: 24px;
}
.detail-container {
padding: 32px;
}
.detail-header {
margin-bottom: 32px;
}
.header-meta-card {
display: flex;
flex-direction: column;
gap: 16px;
}
.meta-row {
display: flex;
gap: 12px;
align-items: center;
}
.detail-source {
font-size: 1rem;
color: var(--text-secondary);
}
.headline-section {
margin-bottom: 24px;
}
.detail-title {
font-size: 2.5rem;
font-weight: 700;
line-height: 1.2;
color: var(--text-primary);
margin-bottom: 16px;
}
.byline {
display: flex;
gap: 16px;
font-size: 1rem;
color: var(--text-secondary);
}
.author-link {
font-weight: 500;
color: var(--primary-color);
}
.stats-row {
display: flex;
gap: 32px;
}
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
}
.stat-number {
font-size: 1.5rem;
font-weight: 700;
color: var(--text-primary);
}
.stat-label {
font-size: 0.9rem;
color: var(--text-secondary);
text-transform: lowercase;
}
.detail-tags {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.tag-pill {
background: var(--primary-color);
color: white;
padding: 6px 12px;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 500;
}
/* Article Body */
.article-body {
margin-bottom: 32px;
padding: 24px 0;
border-top: 1px solid var(--divider-color);
border-bottom: 1px solid var(--divider-color);
}
.article-content {
color: var(--text-secondary);
font-size: 1.1rem;
line-height: 1.8;
}
.article-content p {
margin-bottom: 16px;
}
.article-content strong {
font-weight: 600;
color: var(--text-primary);
}
.article-content img {
max-width: 100%;
height: auto;
border-radius: 8px;
box-shadow: 0 2px 8px var(--surface-elevation-2);
margin: 16px 0;
display: block;
}
.article-content a {
color: var(--primary-color);
text-decoration: none;
border-bottom: 1px solid transparent;
transition: border-color 0.2s ease;
}
.article-content a:hover {
border-bottom-color: var(--primary-color);
}
.article-content em {
font-style: italic;
color: var(--text-secondary);
}
/* Action Buttons */
.article-actions {
margin-bottom: 32px;
display: flex;
gap: 16px;
}
.action-button {
display: inline-flex;
align-items: center;
padding: 12px 24px;
border-radius: 8px;
text-decoration: none;
font-weight: 600;
font-size: 0.95rem;
transition: all 0.2s ease;
}
.action-button.primary {
background: var(--primary-color);
color: white;
border: none;
}
.action-button.primary:hover {
background: var(--primary-hover);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(25, 118, 210, 0.3);
}
/* Comments Section */
.comments-section {
border-top: 1px solid var(--divider-color);
padding-top: 24px;
}
.comments-header {
font-size: 1.5rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 24px;
}
/* Comments */
.comment-card {
margin-bottom: 16px;
transition: margin-left 0.2s ease;
}
.comment-surface {
background: var(--surface-color);
border-radius: 8px;
border: 1px solid var(--border-color);
padding: 16px;
box-shadow: 0 1px 3px var(--surface-elevation-1);
}
.comment-header {
margin-bottom: 8px;
}
.comment-meta {
display: flex;
gap: 12px;
align-items: center;
font-size: 0.9rem;
}
.comment-author {
font-weight: 500;
color: var(--text-primary);
}
.comment-time {
color: var(--text-secondary);
}
.comment-score {
display: flex;
align-items: center;
gap: 4px;
}
.score-number {
font-weight: 600;
color: var(--text-secondary);
}
.comment-body {
font-size: 0.95rem;
line-height: 1.6;
margin-bottom: 8px;
}
.comment-text {
margin-bottom: 12px;
}
.comment-text p {
margin-bottom: 8px;
}
.comment-text img {
max-width: 100%;
height: auto;
border-radius: 6px;
box-shadow: 0 2px 6px var(--surface-elevation-1);
margin: 12px 0;
display: block;
}
.comment-text a {
color: var(--primary-color);
text-decoration: none;
border-bottom: 1px solid transparent;
transition: border-color 0.2s ease;
}
.comment-text a:hover {
border-bottom-color: var(--primary-color);
}
.comment-text strong {
font-weight: 600;
color: var(--text-primary);
}
.comment-text em {
font-style: italic;
}
.comment-replies {
border-left: 3px solid var(--divider-color);
margin-left: 16px;
padding-left: 16px;
}
.comment-footer {
font-size: 0.8rem;
}
.depth-indicator {
color: var(--text-secondary);
}
/* Responsive Design */
@media (max-width: 1024px) {
.app-layout {
flex-direction: column;
padding: 16px;
}
.sidebar {
width: 100%;
position: static;
max-height: none;
order: -1;
}
.sidebar-section {
margin-bottom: 12px;
}
.nav-container {
padding: 12px 16px;
}
.nav-logo {
width: 40px;
height: 40px;
}
.nav-brand-text {
font-size: 1.25rem;
}
}
@media (max-width: 768px) {
.app-layout {
padding: 12px;
gap: 16px;
}
.container {
padding: 0;
}
.card-surface {
padding: 16px;
}
.detail-container {
padding: 16px;
}
.detail-title {
font-size: 2rem;
}
.stats-row {
flex-direction: row;
gap: 24px;
}
.list-card {
padding: 8px 12px;
}
.list-content {
gap: 8px;
}
.nav-subtitle {
display: none;
}
}
/* Sidebar Responsive */
@media (max-width: 1024px) {
.app-layout {
flex-direction: column;
}
.sidebar {
width: 100%;
position: static;
max-height: none;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 16px;
}
.sidebar-section {
margin-bottom: 0;
}
}
@media (max-width: 640px) {
.sidebar {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,67 @@
{
"template_id": "modern-card-ui-theme",
"template_path": "./themes/modern-card-ui",
"template_type": "card",
"data_schema": "../../schemas/post_schema.json",
"required_fields": [
"platform",
"id",
"title",
"author",
"timestamp",
"score",
"replies",
"url"
],
"optional_fields": [
"content",
"source",
"tags",
"meta"
],
"css_dependencies": [
"./themes/modern-card-ui/styles.css"
],
"js_dependencies": [
"./themes/modern-card-ui/interactions.js"
],
"templates": {
"card": "./themes/modern-card-ui/card-template.html",
"list": "./themes/modern-card-ui/list-template.html",
"detail": "./themes/modern-card-ui/detail-template.html",
"comment": "./themes/modern-card-ui/comment-template.html"
},
"render_options": {
"container_selector": "#posts-container",
"batch_size": 20,
"lazy_load": true,
"animate": true,
"hover_effects": true,
"card_elevation": true
},
"filters": {
"platform": true,
"date_range": true,
"score_threshold": true,
"source": true
},
"sorting": {
"default_field": "timestamp",
"default_order": "desc",
"available_fields": [
"timestamp",
"score",
"replies",
"title"
]
},
"color_scheme": {
"primary": "#1976D2",
"secondary": "#FFFFFF",
"accent": "#FF5722",
"background": "#FAFAFA",
"surface": "#FFFFFF",
"text_primary": "#212121",
"text_secondary": "#757575"
}
}

120
themes/template_prompt.txt Normal file
View File

@@ -0,0 +1,120 @@
# Template Creation Prompt for AI
This document describes the data structures, helper functions, and conventions an AI needs to create or modify HTML templates for this social media archive system.
## Data Structures Available
### Post Data (when rendering posts)
- **Available in all post templates (card, list, detail):**
- platform: string (e.g., "reddit", "hackernews")
- id: string (unique post identifier)
- title: string
- author: string
- timestamp: integer (unix timestamp)
- score: integer (up/down vote score)
- replies: integer (number of comments)
- url: string (original post URL)
- content: string (optional post body text)
- source: string (optional subreddit/community)
- tags: array of strings (optional tags/flair)
- meta: object (optional platform-specific metadata)
- comments: array (optional nested comment tree - only in detail templates)
- post_url: string (generated: "{uuid}.html" - for local linking to detail pages)
### Comment Data (when rendering comments)
- **Available in comment templates:**
- uuid: string (unique comment identifier)
- id: string (platform-specific identifier)
- author: string (comment author username)
- content: string (comment text)
- timestamp: integer (unix timestamp)
- score: integer (comment score)
- platform: string
- depth: integer (nesting level)
- children: array (nested replies)
- children_section: string (pre-rendered HTML of nested children)
## Template Engine: Jinja2
Templates use Jinja2 syntax (`{{ }}` for variables, `{% %}` for control flow).
### Important Filters:
- `|safe`: Mark content as safe HTML (for already-escaped content)
- Example: `{{ renderMarkdown(content)|safe }}`
### Available Control Structures:
- `{% if variable %}...{% endif %}`
- `{% for item in array %}...{% endfor %}`
- `{% set variable = value %}` (create local variables)
## Helper Functions Available
Call these in templates using `{{ function(arg) }}`:
### Time/Date Formatting:
- `formatTime(timestamp)` -> "HH:MM"
- `formatTimeAgo(timestamp)` -> "2 hours ago"
- `formatDateTime(timestamp)` -> "January 15, 2024 at 14:30"
### Text Processing:
- `truncate(text, max_length)` -> truncated string with "..."
- `escapeHtml(text)` -> HTML-escaped version
### Content Rendering:
- `renderMarkdown(text)` -> Basic HTML from markdown (returns already-escaped HTML)
## Template Types
### Card Template (for index/listing pages)
- Used for summary view of posts
- Links should use `post_url` to point to local detail pages
- Keep concise - truncated content, basic info
### List Template (compact listing)
- Even more compact than cards
- Vote scores, basic metadata, title link
### Detail Template (full post view)
- Full content, meta information
- Source link uses `url` (external)
- Must include `{{comments_section|safe}}` for rendered comments
### Comment Template (nested comments)
- Recursive rendering with depth styling
- Children rendered as flattened HTML in `children_section`
## Convenience Data Added by System
In `generate_html.py`, `post_url` is added to each post before rendering: `{post['uuid']}.html`
This allows templates to link to local detail pages instead of external Reddit.
## CSS Classes Convention
Templates use semantic CSS classes:
- Post cards: `.post-card`, `.post-header`, `.post-meta`, etc.
- Comments: `.comment`, `.comment-header`, `.comment-body`, etc.
- Platform: `.platform-{platform}` for platform-specific styling
## Examples
### Conditional Rendering:
```
{% if content %}
<p class="content">{{ renderMarkdown(content)|safe }}</p>
{% endif %}
```
### Looping Tags:
```
{% for tag in tags if tag %}
<span class="tag">{{ tag }}</span>
{% endfor %}
```
### Styling by Depth (comments):
```
<div class="comment" style="margin-left: {{depth * 20}}px">
```
When creating new templates, follow these patterns and use the available data and helper functions appropriately.

View File

@@ -0,0 +1,42 @@
<!-- Card Template - Jinja2 template -->
<template id="post-card-template">
<article class="post-card" data-post-id="{{id}}" data-platform="{{platform}}">
<header class="post-header">
<div class="post-meta">
<span class="platform-badge platform-{{platform}}">{{platform}}</span>
{% if source %}<span class="post-source">{{source}}</span>{% endif %}
</div>
<h2 class="post-title">
<a href="{{post_url}}" target="_blank" rel="noopener">{{title}}</a>
</h2>
</header>
<div class="post-info">
<span class="post-author">by {{author}}</span>
<time class="post-time" datetime="{{timestamp}}">{{formatTime(timestamp)}}</time>
</div>
<div class="post-content">
{% if content %}<p class="post-excerpt">{{ renderMarkdown(content)|safe }}</p>{% endif %}
</div>
<footer class="post-footer">
<div class="post-stats">
<span class="stat-score" title="Score">
<i class="icon-score"></i> {{score}}
</span>
<span class="stat-replies" title="Replies">
<i class="icon-replies">💬</i> {{replies}}
</span>
</div>
{% if tags %}
<div class="post-tags">
{% for tag in tags if tag %}
<span class="tag">{{tag}}</span>
{% endfor %}
</div>
{% endif %}
</footer>
</article>
</template>

View File

@@ -0,0 +1,21 @@
<!-- Comment Template - Nested comment rendering with unlimited depth -->
<template id="comment-template">
<div class="comment" data-comment-uuid="{{uuid}}" data-depth="{{depth}}" style="margin-left: {{depth * 20}}px">
<div class="comment-header">
<span class="comment-author">{{author}}</span>
<time class="comment-time" datetime="{{timestamp}}">{{formatTimeAgo(timestamp)}}</time>
<span class="comment-score" title="Score">↑ {{score}}</span>
</div>
<div class="comment-body">
<p class="comment-content">{{renderMarkdown(content)|safe}}</p>
</div>
<div class="comment-footer">
<span class="comment-depth-indicator">Depth: {{depth}}</span>
</div>
<!-- Placeholder for nested children -->
{{children_section|safe}}
</div>
</template>

View File

@@ -0,0 +1,52 @@
<!-- Detail Template - Full post view -->
<template id="post-detail-template">
<article class="post-detail" data-post-id="{{id}}" data-platform="{{platform}}">
<header class="detail-header">
<div class="breadcrumb">
<span class="platform-badge platform-{{platform}}">{{platform}}</span>
<span class="separator">/</span>
{% if source %}<span class="source-link">{{source}}</span>{% endif %}
</div>
<h1 class="detail-title">{{title}}</h1>
<div class="detail-meta">
<div class="author-info">
<span class="author-name">{{author}}</span>
<time class="post-time" datetime="{{timestamp}}">{{formatDateTime(timestamp)}}</time>
</div>
<div class="post-stats">
<span class="stat-item">
<i class="icon-score"></i> {{score}} points
</span>
<span class="stat-item">
<i class="icon-replies">💬</i> {{replies}} comments
</span>
</div>
</div>
</header>
{% if content %}
<div class="detail-content">
{{ renderMarkdown(content)|safe }}
</div>
{% endif %}
{% if tags %}
<div class="detail-tags">
{% for tag in tags if tag %}
<span class="tag">{{tag}}</span>
{% endfor %}
</div>
{% endif %}
{{comments_section|safe}}
<footer class="detail-footer">
<a href="{{url}}" target="_blank" rel="noopener" class="source-link-btn">
View on {{platform}}
</a>
</footer>
</article>
</template>

View File

@@ -0,0 +1,357 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BalanceBoard - Content Feed</title>
{% for css_path in theme.css_dependencies %}
<link rel="stylesheet" href="{{ css_path }}">
{% endfor %}
<style>
/* Enhanced Navigation Styles */
.nav-top {
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 0.5rem 0;
position: sticky;
top: 0;
z-index: 100;
}
.nav-top-container {
max-width: 1200px;
margin: 0 auto;
padding: 0 1rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.nav-brand {
display: flex;
align-items: center;
gap: 0.75rem;
text-decoration: none;
color: var(--text-primary);
}
.nav-logo {
width: 32px;
height: 32px;
border-radius: 6px;
}
.nav-brand-text {
font-size: 1.25rem;
font-weight: 700;
}
.brand-balance {
color: var(--accent);
}
.brand-board {
color: var(--text-primary);
}
.nav-user-section {
display: flex;
align-items: center;
gap: 1rem;
}
.nav-user-info {
display: flex;
align-items: center;
gap: 0.5rem;
}
.nav-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--accent);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 600;
font-size: 0.875rem;
}
.nav-username {
font-weight: 500;
color: var(--text-primary);
}
.hamburger-menu {
position: relative;
}
.hamburger-toggle {
background: none;
border: none;
cursor: pointer;
padding: 0.5rem;
border-radius: 6px;
display: flex;
flex-direction: column;
gap: 3px;
transition: background-color 0.2s;
}
.hamburger-toggle:hover {
background: var(--surface-hover);
}
.hamburger-line {
width: 20px;
height: 2px;
background: var(--text-primary);
transition: all 0.3s;
}
.hamburger-dropdown {
position: absolute;
top: 100%;
right: 0;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
min-width: 200px;
z-index: 1000;
display: none;
}
.hamburger-dropdown.show {
display: block;
}
.dropdown-item {
display: block;
padding: 0.75rem 1rem;
color: var(--text-primary);
text-decoration: none;
transition: background-color 0.2s;
border-bottom: 1px solid var(--border);
}
.dropdown-item:last-child {
border-bottom: none;
}
.dropdown-item:hover {
background: var(--surface-hover);
}
.dropdown-divider {
height: 1px;
background: var(--border);
margin: 0.25rem 0;
}
.nav-login-prompt {
display: flex;
align-items: center;
gap: 0.5rem;
}
.btn-nav-login, .btn-nav-signup {
padding: 0.375rem 0.75rem;
border-radius: 6px;
text-decoration: none;
font-size: 0.875rem;
font-weight: 500;
transition: all 0.2s;
}
.btn-nav-login {
background: var(--surface);
color: var(--text-primary);
border: 1px solid var(--border);
}
.btn-nav-login:hover {
background: var(--surface-hover);
}
.btn-nav-signup {
background: var(--accent);
color: white;
}
.btn-nav-signup:hover {
background: var(--accent-hover);
}
/* Mobile Responsive */
@media (max-width: 768px) {
.nav-username {
display: none;
}
}
</style>
</head>
<body>
<!-- Enhanced Top Navigation -->
<nav class="nav-top">
<div class="nav-top-container">
<a href="/" class="nav-brand">
<img src="/logo.png" alt="BalanceBoard Logo" class="nav-logo">
<div class="nav-brand-text">
<span class="brand-balance">balance</span><span class="brand-board">Board</span>
</div>
</a>
<div class="nav-user-section">
<!-- Logged in user state -->
<div class="nav-user-info" style="display: none;">
<div class="nav-avatar">JD</div>
<span class="nav-username">johndoe</span>
<div class="hamburger-menu">
<button class="hamburger-toggle" onclick="toggleDropdown()">
<div class="hamburger-line"></div>
<div class="hamburger-line"></div>
<div class="hamburger-line"></div>
</button>
<div class="hamburger-dropdown" id="userDropdown">
<a href="/settings" class="dropdown-item">
⚙️ Settings
</a>
<a href="/settings/profile" class="dropdown-item">
👤 Profile
</a>
<a href="/settings/communities" class="dropdown-item">
🌐 Communities
</a>
<a href="/settings/filters" class="dropdown-item">
🎛️ Filters
</a>
<div class="dropdown-divider"></div>
<a href="/admin" class="dropdown-item" style="display: none;">
🛠️ Admin
</a>
<div class="dropdown-divider" style="display: none;"></div>
<a href="/logout" class="dropdown-item">
🚪 Sign Out
</a>
</div>
</div>
</div>
<!-- Logged out state -->
<div class="nav-login-prompt">
<a href="/login" class="btn-nav-login">Log In</a>
<a href="/signup" class="btn-nav-signup">Sign Up</a>
</div>
</div>
</div>
</nav>
<div class="app-layout">
<!-- Sidebar -->
<aside class="sidebar">
<!-- User Card -->
<div class="sidebar-section user-card">
<div class="login-prompt">
<div class="user-avatar">?</div>
<p>Join BalanceBoard to customize your feed</p>
<a href="/login" class="btn-login">Log In</a>
<a href="/signup" class="btn-signup">Sign Up</a>
</div>
</div>
<!-- Navigation -->
<div class="sidebar-section">
<h3>Navigation</h3>
<ul class="nav-menu">
<li><a href="/" class="nav-item active">
<span class="nav-icon">🏠</span>
<span>Home</span>
</a></li>
<li><a href="#" class="nav-item">
<span class="nav-icon">🔥</span>
<span>Popular</span>
</a></li>
<li><a href="#" class="nav-item">
<span class="nav-icon"></span>
<span>Saved</span>
</a></li>
<li><a href="#" class="nav-item">
<span class="nav-icon">📊</span>
<span>Analytics</span>
</a></li>
</ul>
</div>
<!-- Filters -->
<div class="sidebar-section">
<h3>Filter by Platform</h3>
<div class="filter-tags">
<a href="#" class="filter-tag active">All</a>
<a href="#" class="filter-tag">Reddit</a>
<a href="#" class="filter-tag">HackerNews</a>
<a href="#" class="filter-tag">Lobsters</a>
</div>
</div>
<!-- About -->
<div class="sidebar-section">
<h3>About</h3>
<p style="font-size: 0.85rem; color: var(--text-secondary); line-height: 1.5;">
BalanceBoard filters and curates content from multiple platforms to help you stay informed.
</p>
</div>
</aside>
<!-- Main Content -->
<main class="main-content">
<div class="container">
<header>
<h1>{{ filterset_name|title or 'All Posts' }}</h1>
<p class="post-count">{{ posts|length }} posts</p>
</header>
<div id="posts-container">
{% for post in posts %}
{{ post|safe }}
{% endfor %}
</div>
</div>
</main>
</div>
{% for js_path in theme.js_dependencies %}
<script src="{{ js_path }}"></script>
{% endfor %}
<script>
// Dropdown functionality
function toggleDropdown() {
const dropdown = document.getElementById('userDropdown');
dropdown.classList.toggle('show');
}
// Close dropdown when clicking outside
document.addEventListener('click', function(event) {
const dropdown = document.getElementById('userDropdown');
const toggle = document.querySelector('.hamburger-toggle');
if (!toggle.contains(event.target) && !dropdown.contains(event.target)) {
dropdown.classList.remove('show');
}
});
// Check user authentication state (this would be dynamic in a real app)
function checkAuthState() {
// This would normally check with the server
// For now, we'll show the logged out state
document.querySelector('.nav-user-info').style.display = 'none';
document.querySelector('.nav-login-prompt').style.display = 'flex';
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', checkAuthState);
</script>
</body>
</html>

View File

@@ -0,0 +1,22 @@
<!-- List Template - Compact list view -->
<template id="post-list-template">
<div class="post-list-item" data-post-id="{{id}}" data-platform="{{platform}}">
<div class="post-vote">
<span class="vote-score">{{score}}</span>
</div>
<div class="post-main">
<h3 class="post-title">
<a href="{{post_url}}" target="_blank" rel="noopener">{{title}}</a>
</h3>
<div class="post-metadata">
<span class="platform-badge platform-{{platform}}">{{platform}}</span>
{% if source %}<span class="post-source">{{source}}</span>{% endif %}
<span class="post-author">u/{{author}}</span>
<time class="post-time" datetime="{{timestamp}}">{{formatTimeAgo(timestamp)}}</time>
<span class="post-replies">{{replies}} comments</span>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,127 @@
/**
* Vanilla JS Template Renderer
* Renders posts using HTML template literals
*/
class VanillaRenderer {
constructor() {
this.templates = new Map();
this.formatters = {
formatTime: this.formatTime.bind(this),
formatTimeAgo: this.formatTimeAgo.bind(this),
formatDateTime: this.formatDateTime.bind(this),
truncate: this.truncate.bind(this),
renderMarkdown: this.renderMarkdown.bind(this)
};
}
/**
* Load a template from HTML file
*/
async loadTemplate(templateId, templatePath) {
const response = await fetch(templatePath);
const html = await response.text();
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const template = doc.querySelector('template');
if (template) {
this.templates.set(templateId, template.innerHTML);
}
}
/**
* Render a post using a template
*/
render(templateId, postData) {
const template = this.templates.get(templateId);
if (!template) {
throw new Error(`Template ${templateId} not loaded`);
}
// Create context with data and helper functions
const context = { ...postData, ...this.formatters };
// Use Function constructor to evaluate template literal
const rendered = new Function(...Object.keys(context), `return \`${template}\`;`)(...Object.values(context));
return rendered;
}
/**
* Render multiple posts
*/
renderBatch(templateId, posts, container) {
const fragment = document.createDocumentFragment();
posts.forEach(post => {
const html = this.render(templateId, post);
const temp = document.createElement('div');
temp.innerHTML = html;
fragment.appendChild(temp.firstElementChild);
});
container.appendChild(fragment);
}
// Helper functions available in templates
formatTime(timestamp) {
const date = new Date(timestamp * 1000);
return date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit'
});
}
formatTimeAgo(timestamp) {
const seconds = Math.floor(Date.now() / 1000 - timestamp);
const intervals = {
year: 31536000,
month: 2592000,
week: 604800,
day: 86400,
hour: 3600,
minute: 60
};
for (const [unit, secondsInUnit] of Object.entries(intervals)) {
const interval = Math.floor(seconds / secondsInUnit);
if (interval >= 1) {
return `${interval} ${unit}${interval !== 1 ? 's' : ''} ago`;
}
}
return 'just now';
}
formatDateTime(timestamp) {
const date = new Date(timestamp * 1000);
return date.toLocaleString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
truncate(text, maxLength) {
if (text.length <= maxLength) return text;
return text.substring(0, maxLength).trim() + '...';
}
renderMarkdown(text) {
// Basic markdown rendering (expand as needed)
return text
.replace(/\n\n/g, '</p><p>')
.replace(/\n/g, '<br>')
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.*?)\*/g, '<em>$1</em>')
.replace(/\[(.*?)\]\((.*?)\)/g, '<a href="$2" target="_blank">$1</a>');
}
}
// Export for use
export default VanillaRenderer;

View File

@@ -0,0 +1,336 @@
/* Vanilla JS Theme Styles */
:root {
--bg-primary: #ffffff;
--bg-secondary: #f6f7f8;
--bg-hover: #f0f1f2;
--text-primary: #1c1c1c;
--text-secondary: #7c7c7c;
--border-color: #e0e0e0;
--accent-reddit: #ff4500;
--accent-hn: #ff6600;
--accent-lobsters: #990000;
--accent-se: #0077cc;
}
/* Card Template Styles */
.post-card {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
transition: box-shadow 0.2s, transform 0.2s;
}
.post-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
.post-header {
margin-bottom: 12px;
}
.post-meta {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
.platform-badge {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
}
.platform-reddit { background: var(--accent-reddit); color: white; }
.platform-hackernews { background: var(--accent-hn); color: white; }
.platform-lobsters { background: var(--accent-lobsters); color: white; }
.platform-stackexchange { background: var(--accent-se); color: white; }
.post-source {
color: var(--text-secondary);
font-size: 14px;
}
.post-title {
margin: 0 0 12px 0;
font-size: 18px;
line-height: 1.4;
}
.post-title a {
color: var(--text-primary);
text-decoration: none;
}
.post-title a:hover {
text-decoration: underline;
}
.post-info {
display: flex;
gap: 12px;
margin-bottom: 12px;
font-size: 14px;
color: var(--text-secondary);
}
.post-content {
margin-bottom: 12px;
}
.post-excerpt {
color: var(--text-secondary);
line-height: 1.6;
}
.post-footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.post-stats {
display: flex;
gap: 16px;
font-size: 14px;
}
.stat-score,
.stat-replies {
display: flex;
align-items: center;
gap: 4px;
color: var(--text-secondary);
}
.post-tags {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.tag {
background: var(--bg-secondary);
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
color: var(--text-secondary);
}
/* List Template Styles */
.post-list-item {
display: flex;
gap: 12px;
padding: 12px;
border-bottom: 1px solid var(--border-color);
transition: background 0.2s;
}
.post-list-item:hover {
background: var(--bg-hover);
}
.post-vote {
display: flex;
flex-direction: column;
align-items: center;
min-width: 40px;
}
.vote-score {
font-weight: 600;
font-size: 14px;
color: var(--text-secondary);
}
.post-main {
flex: 1;
}
.post-list-item .post-title {
margin: 0 0 8px 0;
font-size: 16px;
}
.post-metadata {
display: flex;
gap: 8px;
font-size: 12px;
color: var(--text-secondary);
flex-wrap: wrap;
}
/* Detail Template Styles */
.post-detail {
max-width: 800px;
margin: 0 auto;
padding: 24px;
}
.breadcrumb {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
font-size: 14px;
}
.separator {
color: var(--text-secondary);
}
.detail-title {
font-size: 32px;
line-height: 1.3;
margin: 0 0 16px 0;
}
.detail-meta {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 0;
border-top: 1px solid var(--border-color);
border-bottom: 1px solid var(--border-color);
margin-bottom: 24px;
}
.author-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.author-name {
font-weight: 600;
font-size: 16px;
}
.detail-content {
line-height: 1.8;
margin-bottom: 24px;
}
.detail-content p {
margin-bottom: 16px;
}
.detail-tags {
display: flex;
gap: 8px;
margin-bottom: 24px;
flex-wrap: wrap;
}
.detail-footer {
padding-top: 24px;
border-top: 1px solid var(--border-color);
}
.source-link-btn {
display: inline-block;
padding: 12px 24px;
background: var(--accent-reddit);
color: white;
text-decoration: none;
border-radius: 6px;
font-weight: 600;
transition: opacity 0.2s;
}
.source-link-btn:hover {
opacity: 0.9;
}
/* Comment Styles */
.comments-section {
margin-top: 32px;
padding-top: 24px;
border-top: 2px solid var(--border-color);
}
.comment {
background: var(--bg-primary);
border-left: 2px solid var(--border-color);
padding: 12px;
margin-bottom: 8px;
transition: background 0.2s;
}
.comment:hover {
background: var(--bg-hover);
}
.comment-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
font-size: 14px;
}
.comment-author {
font-weight: 600;
color: var(--text-primary);
}
.comment-time {
color: var(--text-secondary);
font-size: 12px;
}
.comment-score {
color: var(--text-secondary);
font-size: 12px;
margin-left: auto;
}
.comment-body {
margin-bottom: 8px;
}
.comment-content {
margin: 0;
line-height: 1.6;
color: var(--text-primary);
}
.comment-content p {
margin: 0 0 8px 0;
}
.comment-footer {
font-size: 12px;
color: var(--text-secondary);
}
.comment-depth-indicator {
opacity: 0.6;
}
.comment-children {
margin-top: 8px;
}
/* Depth-based styling */
.comment[data-depth="0"] {
border-left-color: var(--accent-reddit);
}
.comment[data-depth="1"] {
border-left-color: var(--accent-hn);
}
.comment[data-depth="2"] {
border-left-color: var(--accent-lobsters);
}
.comment[data-depth="3"] {
border-left-color: var(--accent-se);
}

View File

@@ -0,0 +1,56 @@
{
"template_id": "vanilla-js-theme",
"template_path": "./themes/vanilla-js",
"template_type": "card",
"data_schema": "../../schemas/post_schema.json",
"required_fields": [
"platform",
"id",
"title",
"author",
"timestamp",
"score",
"replies",
"url"
],
"optional_fields": [
"content",
"source",
"tags",
"meta"
],
"css_dependencies": [
"./themes/vanilla-js/styles.css"
],
"js_dependencies": [
"./themes/vanilla-js/renderer.js"
],
"templates": {
"card": "./themes/vanilla-js/card-template.html",
"list": "./themes/vanilla-js/list-template.html",
"detail": "./themes/vanilla-js/detail-template.html",
"comment": "./themes/vanilla-js/comment-template.html"
},
"render_options": {
"container_selector": "#posts-container",
"batch_size": 50,
"lazy_load": true,
"animate": true
},
"filters": {
"platform": true,
"date_range": true,
"score_threshold": true,
"source": true
},
"sorting": {
"default_field": "timestamp",
"default_order": "desc",
"available_fields": [
"timestamp",
"score",
"replies",
"title"
]
}
}