Compare commits
2 Commits
main
...
194de75904
| Author | SHA1 | Date | |
|---|---|---|---|
| 194de75904 | |||
| 83dd85ffa3 |
129
DEPLOYMENT.md
129
DEPLOYMENT.md
@@ -1,129 +0,0 @@
|
||||
# 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
|
||||
@@ -1,368 +0,0 @@
|
||||
# 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
40
LICENSE
@@ -1,40 +0,0 @@
|
||||
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
229
README.md
@@ -1,229 +0,0 @@
|
||||
# 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
|
||||
@@ -16,7 +16,10 @@ from data_collection_lib import data_methods
|
||||
# ===== STORAGE FUNCTIONS =====
|
||||
|
||||
def ensure_directories(storage_dir: str) -> Dict[str, Path]:
|
||||
"""Create and return directory paths"""
|
||||
"""Create and return directory paths with proper error handling"""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
base = Path(storage_dir)
|
||||
|
||||
dirs = {
|
||||
@@ -27,7 +30,40 @@ def ensure_directories(storage_dir: str) -> Dict[str, Path]:
|
||||
}
|
||||
|
||||
for path in dirs.values():
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
# Set proper permissions for Docker compatibility
|
||||
try:
|
||||
path.chmod(0o755)
|
||||
except (OSError, PermissionError):
|
||||
logger.warning(f"Could not set permissions for directory: {path}")
|
||||
except PermissionError as e:
|
||||
logger.error(f"Permission denied creating directory {path}: {e}")
|
||||
# For Docker compatibility, try using a temporary directory
|
||||
import tempfile
|
||||
temp_dir = Path(tempfile.gettempdir()) / 'balanceboard_data'
|
||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
temp_dir.chmod(0o755)
|
||||
logger.info(f"Using temporary directory: {temp_dir}")
|
||||
|
||||
# Update paths to use temp directory
|
||||
dirs['base'] = temp_dir
|
||||
dirs['posts'] = temp_dir / 'posts'
|
||||
dirs['comments'] = temp_dir / 'comments'
|
||||
dirs['moderation'] = temp_dir / 'moderation'
|
||||
|
||||
# Create temp directories
|
||||
for temp_path in dirs.values():
|
||||
temp_path.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
temp_path.chmod(0o755)
|
||||
except (OSError, PermissionError):
|
||||
pass # Ignore permission errors on temp files
|
||||
|
||||
except OSError as e:
|
||||
logger.error(f"Error creating directory {path}: {e}")
|
||||
# Continue with other directories
|
||||
continue
|
||||
|
||||
return dirs
|
||||
|
||||
@@ -46,10 +82,40 @@ def load_index(storage_dir: str) -> Dict:
|
||||
|
||||
|
||||
def save_index(index: Dict, storage_dir: str):
|
||||
"""Save post index to disk"""
|
||||
"""Save post index to disk with error handling"""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
index_file = Path(storage_dir) / 'post_index.json'
|
||||
with open(index_file, 'w') as f:
|
||||
json.dump(index, f, indent=2)
|
||||
try:
|
||||
# Create backup of existing index
|
||||
if index_file.exists():
|
||||
backup_file = index_file.with_suffix('.json.backup')
|
||||
try:
|
||||
import shutil
|
||||
shutil.copy2(index_file, backup_file)
|
||||
except (OSError, PermissionError):
|
||||
logger.warning(f"Could not create backup of index file: {index_file}")
|
||||
|
||||
with open(index_file, 'w') as f:
|
||||
json.dump(index, f, indent=2)
|
||||
|
||||
except PermissionError as e:
|
||||
logger.error(f"Permission denied saving index to {index_file}: {e}")
|
||||
# Try to save to temp directory as fallback
|
||||
try:
|
||||
import tempfile
|
||||
temp_dir = Path(tempfile.gettempdir()) / 'balanceboard_data'
|
||||
temp_index_file = temp_dir / 'post_index.json'
|
||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
with open(temp_index_file, 'w') as f:
|
||||
json.dump(index, f, indent=2)
|
||||
logger.info(f"Index saved to temporary location: {temp_index_file}")
|
||||
except Exception as temp_e:
|
||||
logger.error(f"Failed to save index to temp location: {temp_e}")
|
||||
|
||||
except OSError as e:
|
||||
logger.error(f"Error saving index to {index_file}: {e}")
|
||||
|
||||
|
||||
def load_state(storage_dir: str) -> Dict:
|
||||
@@ -66,10 +132,29 @@ def load_state(storage_dir: str) -> Dict:
|
||||
|
||||
|
||||
def save_state(state: Dict, storage_dir: str):
|
||||
"""Save collection state to disk"""
|
||||
"""Save collection state to disk with error handling"""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
state_file = Path(storage_dir) / 'collection_state.json'
|
||||
with open(state_file, 'w') as f:
|
||||
json.dump(state, f, indent=2)
|
||||
try:
|
||||
with open(state_file, 'w') as f:
|
||||
json.dump(state, f, indent=2)
|
||||
except PermissionError as e:
|
||||
logger.error(f"Permission denied saving state to {state_file}: {e}")
|
||||
# Try to save to temp directory as fallback
|
||||
try:
|
||||
import tempfile
|
||||
temp_dir = Path(tempfile.gettempdir()) / 'balanceboard_data'
|
||||
temp_state_file = temp_dir / 'collection_state.json'
|
||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
with open(temp_state_file, 'w') as f:
|
||||
json.dump(state, f, indent=2)
|
||||
logger.info(f"State saved to temporary location: {temp_state_file}")
|
||||
except Exception as temp_e:
|
||||
logger.error(f"Failed to save state to temp location: {temp_e}")
|
||||
except OSError as e:
|
||||
logger.error(f"Error saving state to {state_file}: {e}")
|
||||
|
||||
|
||||
def generate_uuid() -> str:
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
"""
|
||||
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'
|
||||
@@ -1,326 +0,0 @@
|
||||
"""
|
||||
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
|
||||
}
|
||||
@@ -1,259 +0,0 @@
|
||||
"""
|
||||
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")
|
||||
@@ -1,206 +0,0 @@
|
||||
"""
|
||||
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()
|
||||
}
|
||||
@@ -1,376 +0,0 @@
|
||||
"""
|
||||
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")
|
||||
@@ -1,121 +0,0 @@
|
||||
"""
|
||||
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__})
|
||||
@@ -1,10 +0,0 @@
|
||||
"""
|
||||
Filter Plugins
|
||||
Pluggable filters for content filtering.
|
||||
"""
|
||||
|
||||
from .base import BaseFilterPlugin
|
||||
from .keyword import KeywordFilterPlugin
|
||||
from .quality import QualityFilterPlugin
|
||||
|
||||
__all__ = ['BaseFilterPlugin', 'KeywordFilterPlugin', 'QualityFilterPlugin']
|
||||
@@ -1,66 +0,0 @@
|
||||
"""
|
||||
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}>"
|
||||
@@ -1,95 +0,0 @@
|
||||
"""
|
||||
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()
|
||||
@@ -1,128 +0,0 @@
|
||||
"""
|
||||
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))
|
||||
@@ -1,12 +0,0 @@
|
||||
"""
|
||||
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']
|
||||
@@ -1,62 +0,0 @@
|
||||
"""
|
||||
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}>"
|
||||
@@ -1,167 +0,0 @@
|
||||
"""
|
||||
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
|
||||
@@ -1,171 +0,0 @@
|
||||
"""
|
||||
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
|
||||
@@ -1,153 +0,0 @@
|
||||
"""
|
||||
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
|
||||
@@ -1,201 +0,0 @@
|
||||
"""
|
||||
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
|
||||
@@ -1,40 +0,0 @@
|
||||
#!/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()
|
||||
@@ -1,54 +0,0 @@
|
||||
#!/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)
|
||||
@@ -1,66 +0,0 @@
|
||||
#!/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)
|
||||
59
models.py
59
models.py
@@ -41,10 +41,6 @@ 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.
|
||||
@@ -106,32 +102,6 @@ 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
|
||||
@@ -217,32 +187,3 @@ 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}>'
|
||||
|
||||
@@ -143,20 +143,13 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"stackexchange": {
|
||||
"stackoverflow": {
|
||||
"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",
|
||||
@@ -264,12 +257,6 @@
|
||||
"community": "https://hnrss.org/frontpage",
|
||||
"max_posts": 50,
|
||||
"priority": "low"
|
||||
},
|
||||
{
|
||||
"platform": "stackexchange",
|
||||
"community": "stackoverflow",
|
||||
"max_posts": 50,
|
||||
"priority": "medium"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Page Not Found - {{ APP_NAME }}</title>
|
||||
<title>Page Not Found - BalanceBoard</title>
|
||||
<link rel="stylesheet" href="{{ url_for('serve_theme', filename='modern-card-ui/styles.css') }}">
|
||||
<style>
|
||||
.error-container {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Server Error - {{ APP_NAME }}</title>
|
||||
<title>Server Error - BalanceBoard</title>
|
||||
<link rel="stylesheet" href="{{ url_for('serve_theme', filename='modern-card-ui/styles.css') }}">
|
||||
<style>
|
||||
.error-container {
|
||||
|
||||
@@ -1,587 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Admin Panel - {{ APP_NAME }}{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('serve_theme', filename='modern-card-ui/styles.css') }}">
|
||||
<style>
|
||||
/* ===== SHARED ADMIN STYLES ===== */
|
||||
.admin-container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
background: linear-gradient(135deg, var(--primary-dark) 0%, #2a5068 100%);
|
||||
color: white;
|
||||
padding: 32px;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 24px;
|
||||
border-bottom: 3px solid var(--primary-color);
|
||||
}
|
||||
|
||||
.admin-header h1 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.admin-header p {
|
||||
margin: 0;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.admin-section {
|
||||
background: var(--surface-color);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: 0 2px 4px var(--surface-elevation-1);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ===== ADMIN NAVIGATION ===== */
|
||||
.admin-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 24px;
|
||||
border-bottom: 2px solid var(--divider-color);
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
padding: 12px 24px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 3px solid transparent;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
color: var(--primary-color);
|
||||
border-bottom-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.tab-btn:hover {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ===== BUTTONS ===== */
|
||||
.btn,
|
||||
.btn-primary,
|
||||
.btn-secondary {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--primary-hover);
|
||||
border-color: var(--primary-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--surface-elevation-1);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--divider-color);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--surface-elevation-2);
|
||||
border-color: var(--divider-color);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.btn:disabled:hover {
|
||||
background: var(--primary-color) !important;
|
||||
border-color: var(--primary-color) !important;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 6px 12px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.action-btn-primary {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.action-btn-primary:hover {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
.action-btn-danger {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.action-btn-danger:hover {
|
||||
background: #c82333;
|
||||
}
|
||||
|
||||
.action-btn-warning {
|
||||
background: #ffc107;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.action-btn-warning:hover {
|
||||
background: #e0a800;
|
||||
}
|
||||
|
||||
/* ===== STATUS BADGES ===== */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge-admin {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-user {
|
||||
background: var(--background-color);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.badge-active {
|
||||
background: #28a745;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-inactive {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-enabled, .status-success {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.status-disabled, .status-error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.status-running {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
/* ===== TABLES ===== */
|
||||
.admin-table {
|
||||
background: var(--surface-color);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 4px var(--surface-elevation-1);
|
||||
}
|
||||
|
||||
.admin-table table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.admin-table th {
|
||||
background: var(--primary-dark);
|
||||
color: white;
|
||||
padding: 16px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.admin-table td {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
|
||||
.admin-table tr:hover {
|
||||
background: var(--hover-overlay);
|
||||
}
|
||||
|
||||
.admin-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* ===== STATS & CARDS ===== */
|
||||
.admin-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.admin-stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
.admin-stats {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--surface-color);
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 4px var(--surface-elevation-1);
|
||||
border-left: 4px solid var(--primary-color);
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.info-card {
|
||||
background: var(--background-color);
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
border-left: 3px solid var(--primary-color);
|
||||
}
|
||||
|
||||
.info-card h4 {
|
||||
margin: 0 0 8px 0;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.info-card p {
|
||||
margin: 4px 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.system-info {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 16px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.system-info {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
.system-info {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== FORMS ===== */
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.form-control,
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.form-control:focus,
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);
|
||||
}
|
||||
|
||||
.form-select {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
background-color: var(--surface-color);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.form-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.help-text {
|
||||
margin-top: 4px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.add-source-form {
|
||||
background: var(--surface-color);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
border: 1px solid var(--divider-color);
|
||||
}
|
||||
|
||||
.add-source-form h3 {
|
||||
margin: 0 0 20px 0;
|
||||
color: var(--text-primary);
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
/* ===== MODALS ===== */
|
||||
.modal-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1000;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.modal-overlay.active {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--surface-color);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
|
||||
border: 1px solid var(--divider-color);
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
background: var(--surface-elevation-1);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--divider-color);
|
||||
}
|
||||
|
||||
.modal-alert {
|
||||
background: #fff3cd;
|
||||
border: 1px solid #ffeaa7;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin-bottom: 16px;
|
||||
color: #856404;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* ===== UTILITIES ===== */
|
||||
.back-link {
|
||||
display: inline-block;
|
||||
margin-bottom: 16px;
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.flash-messages {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.flash-message {
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.flash-message.success {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.flash-message.error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.flash-message.warning {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
/* ===== RESPONSIVE ===== */
|
||||
@media (max-width: 768px) {
|
||||
.admin-tabs {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.admin-stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.system-info {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.admin-container {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== PAGE-SPECIFIC OVERRIDES ===== */
|
||||
{% block admin_styles %}{% endblock %}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{% include '_nav.html' %}
|
||||
|
||||
<div class="admin-container">
|
||||
<a href="{{ url_for('index') }}" class="back-link">← Back to Feed</a>
|
||||
|
||||
<div class="admin-header">
|
||||
<h1>{% block page_title %}Admin Panel{% endblock %}</h1>
|
||||
<p>{% block page_description %}Manage system settings and content{% endblock %}</p>
|
||||
</div>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="flash-messages">
|
||||
{% for category, message in messages %}
|
||||
<div class="flash-message {{ category }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% block admin_content %}{% endblock %}
|
||||
</div>
|
||||
|
||||
{% block admin_scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,48 +0,0 @@
|
||||
<!-- Modern Top Navigation -->
|
||||
<nav class="top-nav">
|
||||
<div class="nav-content">
|
||||
<div class="nav-left">
|
||||
<a href="{{ url_for('index') }}" class="logo-section">
|
||||
<img src="{{ url_for('serve_logo') }}" alt="{{ APP_NAME }}" class="nav-logo">
|
||||
<span class="brand-text">{{ APP_NAME }}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="nav-center">
|
||||
<div class="search-bar">
|
||||
<input type="text" placeholder="Search content..." class="search-input">
|
||||
<button class="search-btn">🔍</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nav-right">
|
||||
{% if current_user.is_authenticated %}
|
||||
<div class="user-menu">
|
||||
<div class="user-info">
|
||||
<div class="user-avatar">
|
||||
{% if current_user.profile_picture_url %}
|
||||
<img src="{{ current_user.profile_picture_url }}" alt="Avatar">
|
||||
{% else %}
|
||||
<div class="avatar-placeholder">{{ current_user.username[:2].upper() }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<span class="username">{{ current_user.username }}</span>
|
||||
</div>
|
||||
<div class="user-dropdown">
|
||||
<a href="{{ url_for('settings') }}" class="dropdown-item">⚙️ Settings</a>
|
||||
<a href="{{ url_for('bookmarks') }}" class="dropdown-item">📚 Bookmarks</a>
|
||||
{% if current_user.is_admin %}
|
||||
<a href="{{ url_for('admin_panel') }}" class="dropdown-item">👨💼 Admin Panel</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('logout') }}" class="dropdown-item">🚪 Logout</a>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="anonymous-actions">
|
||||
<a href="{{ url_for('login') }}" class="login-btn">🔑 Login</a>
|
||||
<a href="{{ url_for('signup') }}" class="register-btn">📝 Sign Up</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -1,32 +1,367 @@
|
||||
{% extends "_admin_base.html" %}
|
||||
<!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;
|
||||
}
|
||||
|
||||
{% block title %}Admin Panel - {{ APP_NAME }}{% endblock %}
|
||||
.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 page_title %}Admin Panel{% endblock %}
|
||||
{% block page_description %}Manage users, content, and system settings{% endblock %}
|
||||
.admin-header h1 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
{% 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-header p {
|
||||
margin: 0;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
{% endblock %}
|
||||
.admin-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 24px;
|
||||
border-bottom: 2px solid var(--divider-color);
|
||||
}
|
||||
|
||||
{% block admin_content %}
|
||||
.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 %}
|
||||
|
||||
<div class="admin-tabs">
|
||||
<button class="tab-btn active" onclick="showTab('overview')">Overview</button>
|
||||
@@ -90,7 +425,7 @@
|
||||
<div id="users" class="tab-content">
|
||||
<div class="admin-section">
|
||||
<h3 class="section-title">User Management</h3>
|
||||
<div class="admin-table">
|
||||
<div class="users-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -221,24 +556,24 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
</div>
|
||||
|
||||
{% block admin_scripts %}
|
||||
<script>
|
||||
function showTab(tabName) {
|
||||
// Hide all tabs
|
||||
const tabs = document.querySelectorAll('.tab-content');
|
||||
tabs.forEach(tab => tab.classList.remove('active'));
|
||||
<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'));
|
||||
// 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');
|
||||
// Show selected tab
|
||||
document.getElementById(tabName).classList.add('active');
|
||||
|
||||
// Add active class to clicked button
|
||||
event.target.classList.add('active');
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
// Add active class to clicked button
|
||||
event.target.classList.add('active');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,11 +1,52 @@
|
||||
{% extends "_admin_base.html" %}
|
||||
<!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;
|
||||
}
|
||||
|
||||
{% block title %}Polling Management - Admin - {{ APP_NAME }}{% endblock %}
|
||||
.admin-header {
|
||||
background: linear-gradient(135deg, var(--primary-dark) 0%, #2a5068 100%);
|
||||
color: white;
|
||||
padding: 32px;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
{% block page_title %}Polling Management{% endblock %}
|
||||
{% block page_description %}Manage data collection sources and schedules{% endblock %}
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
{% block admin_styles %}
|
||||
.status-enabled {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.status-disabled {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.status-success {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.status-error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.source-card {
|
||||
background: var(--surface-color);
|
||||
@@ -133,62 +174,22 @@
|
||||
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>
|
||||
|
||||
.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 %}
|
||||
{% 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 %}
|
||||
|
||||
<!-- Scheduler Status -->
|
||||
<div class="scheduler-status">
|
||||
@@ -496,58 +497,6 @@
|
||||
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 %}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,84 +1,188 @@
|
||||
{% extends "_admin_base.html" %}
|
||||
<!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;
|
||||
}
|
||||
|
||||
{% block title %}Polling Logs - {{ source.display_name }} - Admin{% endblock %}
|
||||
.admin-header {
|
||||
background: linear-gradient(135deg, var(--primary-dark) 0%, #2a5068 100%);
|
||||
color: white;
|
||||
padding: 32px;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
{% block page_title %}Polling Logs - {{ source.display_name }}{% endblock %}
|
||||
{% block page_description %}View polling history and error logs for this source{% endblock %}
|
||||
.log-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: var(--surface-color);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
{% 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 th {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.no-logs {
|
||||
text-align: center;
|
||||
padding: 48px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
{% endblock %}
|
||||
.log-table td {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
|
||||
{% 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>
|
||||
<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>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="no-logs">
|
||||
<p>No polling logs yet.</p>
|
||||
<p>Logs will appear here after the first poll.</p>
|
||||
.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>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 24px;">
|
||||
<a href="{{ url_for('admin_polling') }}" class="btn btn-secondary">← Back to Polling Management</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% if logs %}
|
||||
<table class="log-table">
|
||||
<thead>
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Create Admin Account - {{ APP_NAME }}{% endblock %}
|
||||
{% block title %}Create Admin Account - BalanceBoard{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include '_nav.html' %}
|
||||
<div class="auth-container">
|
||||
<div class="auth-card">
|
||||
<div class="auth-logo">
|
||||
<a href="{{ url_for('index') }}">
|
||||
<img src="{{ url_for('serve_logo') }}" alt="{{ APP_NAME }} Logo" style="max-width: 80px; border-radius: 50%;">
|
||||
</a>
|
||||
<img src="{{ url_for('serve_logo') }}" alt="BalanceBoard Logo">
|
||||
<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>
|
||||
@@ -77,60 +74,5 @@
|
||||
.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 %}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}{{ APP_NAME }}{% endblock %}</title>
|
||||
<title>{% block title %}BalanceBoard{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('serve_theme', filename='modern-card-ui/styles.css') }}">
|
||||
<style>
|
||||
/* Auth pages styling */
|
||||
|
||||
@@ -1,272 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Bookmarks - {{ APP_NAME }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include '_nav.html' %}
|
||||
|
||||
<div style="max-width: 1200px; margin: 0 auto; padding: 24px;">
|
||||
<div style="margin-bottom: 32px;">
|
||||
<h1 style="color: var(--text-primary); margin-bottom: 8px;">📚 Your Bookmarks</h1>
|
||||
<p style="color: var(--text-secondary); font-size: 1.1rem;">Posts you've saved for later reading</p>
|
||||
</div>
|
||||
|
||||
<div id="bookmarks-container">
|
||||
<div id="loading" style="text-align: center; padding: 40px; color: var(--text-secondary);">
|
||||
<div style="font-size: 1.2rem;">Loading your bookmarks...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div id="pagination" style="display: none; text-align: center; margin-top: 32px;">
|
||||
<button id="prev-btn" style="padding: 8px 16px; margin: 0 8px; background: var(--surface-elevation-1); border: 1px solid var(--border-color); border-radius: 6px; color: var(--text-primary); cursor: pointer;">← Previous</button>
|
||||
<span id="page-info" style="margin: 0 16px; color: var(--text-secondary);"></span>
|
||||
<button id="next-btn" style="padding: 8px 16px; margin: 0 8px; background: var(--surface-elevation-1); border: 1px solid var(--border-color); border-radius: 6px; color: var(--text-primary); cursor: pointer;">Next →</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.bookmark-item {
|
||||
background: var(--surface-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.bookmark-item:hover {
|
||||
border-color: var(--primary-color);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.bookmark-item.archived {
|
||||
opacity: 0.6;
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.bookmark-header {
|
||||
display: flex;
|
||||
justify-content: between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.bookmark-title {
|
||||
color: var(--text-primary);
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
flex: 1;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.bookmark-title:hover {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.bookmark-remove {
|
||||
background: var(--error-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 6px 12px;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.bookmark-remove:hover {
|
||||
background: var(--error-hover);
|
||||
}
|
||||
|
||||
.bookmark-meta {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.bookmark-meta span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.bookmark-preview {
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.bookmark-date {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
font-size: 1.3rem;
|
||||
margin-bottom: 8px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.error-state {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
color: var(--error-color);
|
||||
background: var(--error-bg);
|
||||
border-radius: 8px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
let currentPage = 1;
|
||||
let pagination = null;
|
||||
|
||||
async function loadBookmarks(page = 1) {
|
||||
try {
|
||||
document.getElementById('loading').style.display = 'block';
|
||||
|
||||
const response = await fetch(`/api/bookmarks?page=${page}&per_page=20`);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to load bookmarks');
|
||||
}
|
||||
|
||||
renderBookmarks(data.posts);
|
||||
updatePagination(data.pagination);
|
||||
currentPage = page;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading bookmarks:', error);
|
||||
document.getElementById('bookmarks-container').innerHTML = `
|
||||
<div class="error-state">
|
||||
<h3>Error loading bookmarks</h3>
|
||||
<p>${error.message}</p>
|
||||
<button onclick="loadBookmarks()" style="margin-top: 12px; padding: 8px 16px; background: var(--primary-color); color: white; border: none; border-radius: 6px; cursor: pointer;">Retry</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderBookmarks(posts) {
|
||||
document.getElementById('loading').style.display = 'none';
|
||||
|
||||
const container = document.getElementById('bookmarks-container');
|
||||
|
||||
if (posts.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<h3>📚 No bookmarks yet</h3>
|
||||
<p>Start exploring and bookmark posts you want to read later!</p>
|
||||
<a href="/" style="display: inline-block; margin-top: 16px; padding: 12px 24px; background: var(--primary-color); color: white; text-decoration: none; border-radius: 8px;">Browse Posts</a>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = posts.map(post => `
|
||||
<div class="bookmark-item ${post.archived ? 'archived' : ''}">
|
||||
<div class="bookmark-header">
|
||||
<a href="${post.url}" class="bookmark-title">${post.title}</a>
|
||||
<button class="bookmark-remove" onclick="removeBookmark('${post.id}', this)">
|
||||
🗑️ Remove
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bookmark-meta">
|
||||
<span>👤 ${post.author}</span>
|
||||
<span>📍 ${post.source}</span>
|
||||
<span>⭐ ${post.score}</span>
|
||||
<span>💬 ${post.comments_count}</span>
|
||||
${post.archived ? '<span style="color: var(--warning-color);">📦 Archived</span>' : ''}
|
||||
</div>
|
||||
|
||||
<div class="bookmark-preview">${post.content_preview}</div>
|
||||
|
||||
<div class="bookmark-date">
|
||||
Bookmarked on ${new Date(post.bookmarked_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function updatePagination(paginationData) {
|
||||
pagination = paginationData;
|
||||
const paginationEl = document.getElementById('pagination');
|
||||
|
||||
if (paginationData.total_pages <= 1) {
|
||||
paginationEl.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
paginationEl.style.display = 'block';
|
||||
|
||||
document.getElementById('prev-btn').disabled = !paginationData.has_prev;
|
||||
document.getElementById('next-btn').disabled = !paginationData.has_next;
|
||||
document.getElementById('page-info').textContent = `Page ${paginationData.current_page} of ${paginationData.total_pages}`;
|
||||
}
|
||||
|
||||
async function removeBookmark(postId, button) {
|
||||
if (!confirm('Are you sure you want to remove this bookmark?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
button.disabled = true;
|
||||
button.textContent = 'Removing...';
|
||||
|
||||
const response = await fetch('/api/bookmark', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ post_uuid: postId })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to remove bookmark');
|
||||
}
|
||||
|
||||
// Reload bookmarks to reflect changes
|
||||
loadBookmarks(currentPage);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error removing bookmark:', error);
|
||||
alert('Error removing bookmark: ' + error.message);
|
||||
button.disabled = false;
|
||||
button.textContent = '🗑️ Remove';
|
||||
}
|
||||
}
|
||||
|
||||
// Pagination event listeners
|
||||
document.getElementById('prev-btn').addEventListener('click', () => {
|
||||
if (pagination && pagination.has_prev) {
|
||||
loadBookmarks(currentPage - 1);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('next-btn').addEventListener('click', () => {
|
||||
if (pagination && pagination.has_next) {
|
||||
loadBookmarks(currentPage + 1);
|
||||
}
|
||||
});
|
||||
|
||||
// Load bookmarks on page load
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadBookmarks();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,9 +1,48 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Dashboard - {{ APP_NAME }}{% endblock %}
|
||||
{% block title %}Dashboard - BalanceBoard{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include '_nav.html' %}
|
||||
<!-- 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>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<main class="main-content">
|
||||
@@ -11,9 +50,17 @@
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-section">
|
||||
<h3>Content Filters</h3>
|
||||
<div id="filter-list" class="filter-list">
|
||||
<!-- Filters will be loaded dynamically -->
|
||||
<div class="loading-filters">Loading filters...</div>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -29,7 +76,7 @@
|
||||
<h3>Quick Stats</h3>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-number">{{ quick_stats.posts_today if quick_stats else 0 }}</div>
|
||||
<div class="stat-number">156</div>
|
||||
<div class="stat-label">Posts Today</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
@@ -43,12 +90,10 @@
|
||||
<!-- Content Feed -->
|
||||
<section class="content-section">
|
||||
<div class="content-header">
|
||||
<h1>{% if anonymous %}Public Feed{% else %}Your Feed{% endif %}</h1>
|
||||
<h1>Your Feed</h1>
|
||||
<div class="content-actions">
|
||||
<button class="refresh-btn" onclick="refreshFeed()">🔄 Refresh</button>
|
||||
{% if not anonymous %}
|
||||
<a href="{{ url_for('settings_filters') }}" class="filter-btn">🔧 Customize</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('settings_filters') }}" class="filter-btn">🔧 Customize</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -303,14 +348,14 @@
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.loading-communities, .loading-filters {
|
||||
.loading-communities {
|
||||
text-align: center;
|
||||
color: #64748b;
|
||||
font-style: italic;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.no-communities, .no-filters {
|
||||
.no-communities {
|
||||
text-align: center;
|
||||
color: #64748b;
|
||||
font-style: italic;
|
||||
@@ -393,24 +438,6 @@
|
||||
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;
|
||||
}
|
||||
@@ -687,11 +714,9 @@ 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 }};
|
||||
@@ -699,103 +724,63 @@ let userSettings = {{ user_settings|tojson }};
|
||||
// Load posts on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadPlatformConfig();
|
||||
loadFilters();
|
||||
loadPosts();
|
||||
setupFilterSwitching();
|
||||
setupInfiniteScroll();
|
||||
setupAutoRefresh();
|
||||
loadQuickStats();
|
||||
});
|
||||
|
||||
// Load quick stats data
|
||||
async function loadQuickStats() {
|
||||
try {
|
||||
const response = await fetch('/api/stats');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.posts_today !== undefined) {
|
||||
// Update posts today stat
|
||||
const postsTodayElement = document.querySelector('.stat-card .stat-number');
|
||||
if (postsTodayElement) {
|
||||
postsTodayElement.textContent = data.posts_today;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading quick stats:', error);
|
||||
// Keep default value if API fails
|
||||
}
|
||||
}
|
||||
|
||||
// Load platform configuration and communities
|
||||
async function loadPlatformConfig() {
|
||||
try {
|
||||
const response = await fetch('/api/platforms');
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
platformConfig = data.platforms || {};
|
||||
communitiesData = data.communities || [];
|
||||
|
||||
console.log('Loaded communities:', communitiesData);
|
||||
renderCommunities(communitiesData);
|
||||
setupCommunityFiltering();
|
||||
} catch (error) {
|
||||
console.error('Error loading platform configuration:', error);
|
||||
// Show fallback communities
|
||||
const fallbackCommunities = [
|
||||
{platform: 'reddit', id: 'programming', display_name: 'r/programming', icon: '💻', count: 117},
|
||||
{platform: 'hackernews', id: 'front_page', display_name: 'Hacker News', icon: '🧮', count: 117},
|
||||
{platform: 'reddit', id: 'technology', display_name: 'r/technology', icon: '⚡', count: 0}
|
||||
{platform: 'reddit', id: 'programming', display_name: 'r/programming', icon: '💻', count: 0},
|
||||
{platform: 'reddit', id: 'python', display_name: 'r/python', icon: '🐍', count: 0},
|
||||
{platform: 'hackernews', id: 'hackernews', display_name: 'Hacker News', icon: '🧮', count: 0}
|
||||
];
|
||||
communitiesData = fallbackCommunities;
|
||||
renderCommunities(fallbackCommunities);
|
||||
setupCommunityFiltering();
|
||||
}
|
||||
}
|
||||
|
||||
// Load available filters
|
||||
async function loadFilters() {
|
||||
try {
|
||||
const response = await fetch('/api/filters');
|
||||
const data = await response.json();
|
||||
filtersData = data.filters || [];
|
||||
|
||||
renderFilters(filtersData);
|
||||
setupFilterSwitching();
|
||||
} catch (error) {
|
||||
console.error('Error loading filters:', error);
|
||||
// Show fallback filters
|
||||
const fallbackFilters = [
|
||||
{id: 'no_filter', name: 'All Content', icon: '🌐', active: true, description: 'No filtering'}
|
||||
];
|
||||
renderFilters(fallbackFilters);
|
||||
setupFilterSwitching();
|
||||
}
|
||||
}
|
||||
|
||||
// Render filters in sidebar
|
||||
function renderFilters(filters) {
|
||||
const filterList = document.getElementById('filter-list');
|
||||
if (!filterList) return;
|
||||
|
||||
if (filters.length === 0) {
|
||||
filterList.innerHTML = '<div class="no-filters">No filters available</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const filtersHTML = filters.map(filter => {
|
||||
return `
|
||||
<div class="filter-item ${filter.active ? 'active' : ''}" data-filter="${filter.id}" title="${filter.description}">
|
||||
<span class="filter-icon">${filter.icon}</span>
|
||||
<span>${filter.name}</span>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
filterList.innerHTML = filtersHTML;
|
||||
|
||||
// Set current filter based on active filter
|
||||
const activeFilter = filters.find(f => f.active);
|
||||
if (activeFilter) {
|
||||
currentFilter = activeFilter.id;
|
||||
}
|
||||
}
|
||||
|
||||
// Render communities in sidebar
|
||||
function renderCommunities(communities) {
|
||||
const communityList = document.getElementById('community-list');
|
||||
if (!communityList) return;
|
||||
|
||||
console.log('Rendering communities:', communities);
|
||||
|
||||
if (!communities || communities.length === 0) {
|
||||
// Always show fallback communities if none are loaded
|
||||
const fallbackCommunities = [
|
||||
{platform: 'reddit', id: 'programming', display_name: 'r/programming', icon: '💻', count: 117},
|
||||
{platform: 'hackernews', id: 'front_page', display_name: 'Hacker News', icon: '🧮', count: 117},
|
||||
{platform: 'reddit', id: 'technology', display_name: 'r/technology', icon: '⚡', count: 0}
|
||||
];
|
||||
communities = fallbackCommunities;
|
||||
if (communities.length === 0) {
|
||||
communityList.innerHTML = '<div class="no-communities">No communities available</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Add "All Communities" option at the top
|
||||
@@ -821,7 +806,7 @@ function renderCommunities(communities) {
|
||||
}
|
||||
|
||||
// Load posts from API
|
||||
async function loadPosts(page = 1, community = '', platform = '', append = false, filter = null) {
|
||||
async function loadPosts(page = 1, community = '', platform = '', append = false) {
|
||||
try {
|
||||
// Build query parameters
|
||||
const params = new URLSearchParams();
|
||||
@@ -829,8 +814,6 @@ 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();
|
||||
@@ -1023,34 +1006,24 @@ function savePost(postId) {
|
||||
|
||||
// Filter switching functionality
|
||||
function setupFilterSwitching() {
|
||||
document.addEventListener('click', function(event) {
|
||||
if (event.target.closest('.filter-item')) {
|
||||
const filterItem = event.target.closest('.filter-item');
|
||||
const filterItems = document.querySelectorAll('.filter-item');
|
||||
|
||||
// Remove active class from all filter items
|
||||
document.querySelectorAll('.filter-item').forEach(f => f.classList.remove('active'));
|
||||
filterItems.forEach(item => {
|
||||
item.addEventListener('click', function() {
|
||||
// Remove active class from all items
|
||||
filterItems.forEach(f => f.classList.remove('active'));
|
||||
|
||||
// Add active class to clicked item
|
||||
filterItem.classList.add('active');
|
||||
this.classList.add('active');
|
||||
|
||||
// Get filter type
|
||||
const filterType = filterItem.dataset.filter;
|
||||
currentFilter = filterType;
|
||||
const filterType = this.dataset.filter;
|
||||
|
||||
// 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);
|
||||
}
|
||||
// Apply filter (for now just reload)
|
||||
if (filterType && filterType !== 'custom') {
|
||||
loadPosts(); // In future, pass filter parameter
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1075,50 +1048,172 @@ 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) {
|
||||
performSearch(query);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Search button functionality
|
||||
document.querySelector('.search-btn').addEventListener('click', function() {
|
||||
const query = document.querySelector('.search-input').value.trim();
|
||||
if (query) {
|
||||
performSearch(query);
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelector('.search-btn').addEventListener('click', function() {
|
||||
const query = document.querySelector('.search-input').value.trim();
|
||||
performSearch(query);
|
||||
});
|
||||
// Search posts function
|
||||
async function performSearch(query) {
|
||||
try {
|
||||
// Show loading state in search bar
|
||||
const searchInput = document.querySelector('.search-input');
|
||||
const searchBtn = document.querySelector('.search-btn');
|
||||
const originalPlaceholder = searchInput.placeholder;
|
||||
|
||||
function performSearch(query) {
|
||||
currentSearchQuery = query;
|
||||
currentPage = 1;
|
||||
searchInput.placeholder = 'Searching...';
|
||||
searchBtn.disabled = true;
|
||||
|
||||
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);
|
||||
// Build search parameters
|
||||
const params = new URLSearchParams();
|
||||
params.append('q', query);
|
||||
params.append('page', 1);
|
||||
params.append('per_page', 20);
|
||||
|
||||
const response = await fetch(`/api/search?${params}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.posts) {
|
||||
// Hide loading state
|
||||
searchInput.placeholder = originalPlaceholder;
|
||||
searchBtn.disabled = false;
|
||||
|
||||
// Update UI for search results
|
||||
displaySearchResults(query, data);
|
||||
} else {
|
||||
throw new Error(data.error || 'Search failed');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Search error:', error);
|
||||
|
||||
loadPosts();
|
||||
// Hide loading state
|
||||
const searchInput = document.querySelector('.search-input');
|
||||
const searchBtn = document.querySelector('.search-btn');
|
||||
searchInput.placeholder = 'Search failed...';
|
||||
searchBtn.disabled = false;
|
||||
|
||||
setTimeout(() => {
|
||||
searchInput.placeholder = 'Search content...';
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
// Display search results in the main content area
|
||||
function displaySearchResults(query, searchData) {
|
||||
// Update page title and header
|
||||
document.title = `Search Results: "${query}" - BalanceBoard`;
|
||||
|
||||
const contentHeader = document.querySelector('.content-header h1');
|
||||
contentHeader.textContent = `Search Results for "${query}"`;
|
||||
|
||||
// Update page info to show search results
|
||||
const pageInfo = document.querySelector('.page-info');
|
||||
pageInfo.textContent = `Found ${searchData.pagination.total_posts} results`;
|
||||
|
||||
// Render search results using the same post card template
|
||||
const postsContainer = document.getElementById('posts-container');
|
||||
postsContainer.innerHTML = '';
|
||||
|
||||
if (searchData.posts.length === 0) {
|
||||
postsContainer.innerHTML = `
|
||||
<div class="no-posts">
|
||||
<h3>No results found</h3>
|
||||
<p>Try different keywords or check your spelling.</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
loadPosts();
|
||||
|
||||
// Create post cards for search results
|
||||
const postsHTML = searchData.posts.map(post => createSearchResultPostCard(post, query)).join('');
|
||||
postsContainer.innerHTML = postsHTML;
|
||||
}
|
||||
|
||||
// Create search result post card with highlighted matches
|
||||
function createSearchResultPostCard(post, query) {
|
||||
const timeAgo = formatTimeAgo(post.timestamp);
|
||||
const platformClass = `platform-${post.platform}`;
|
||||
const platformInitial = post.platform.charAt(0).toUpperCase();
|
||||
const hasExternalLink = post.external_url && !post.external_url.includes(window.location.hostname);
|
||||
|
||||
// Highlight matched fields in title and content
|
||||
const highlightedTitle = highlightText(post.title, query);
|
||||
const highlightedContent = highlightText(post.content_preview || '', query);
|
||||
|
||||
return `
|
||||
<article class="post-card" onclick="openPost('${post.id}')">
|
||||
<div class="post-header">
|
||||
<div class="platform-badge ${platformClass}" onclick="event.stopPropagation(); filterByPlatform('${post.platform}')" title="Filter by ${post.platform}">
|
||||
${platformInitial}
|
||||
</div>
|
||||
<div class="post-meta">
|
||||
<span class="post-author">${escapeHtml(post.author)}</span>
|
||||
<span class="post-separator">•</span>
|
||||
${post.source_display ? `<span class="post-source" onclick="event.stopPropagation(); filterByCommunity('${post.source}', '${post.platform}')" title="Filter by ${post.source_display}">${escapeHtml(post.source_display)}</span><span class="post-separator">•</span>` : ''}
|
||||
<span class="post-time">${timeAgo}</span>
|
||||
${hasExternalLink ? '<span class="external-link-indicator">🔗</span>' : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="post-title">${highlightedTitle}</h3>
|
||||
|
||||
${highlightedContent ? `<div class="post-preview">${highlightedContent}</div>` : ''}
|
||||
|
||||
${post.tags && post.tags.length > 0 ? `
|
||||
<div class="post-tags">
|
||||
${post.tags.map(tag => `<span class="tag">${escapeHtml(tag)}</span>`).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="post-footer">
|
||||
<div class="post-stats">
|
||||
<div class="post-score">
|
||||
<span>▲</span>
|
||||
<span>${post.score}</span>
|
||||
</div>
|
||||
<div class="post-comments">
|
||||
<span>💬</span>
|
||||
<span>${post.comment_count || 0} comments</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="post-actions">
|
||||
${hasExternalLink ? `<button class="post-action" onclick="event.stopPropagation(); window.open('${escapeHtml(post.external_url)}', '_blank')">🔗 Source</button>` : ''}
|
||||
<button class="post-action" onclick="event.stopPropagation(); sharePost('${post.id}')">Share</button>
|
||||
<button class="post-action" onclick="event.stopPropagation(); savePost('${post.id}')">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Show search match info -->
|
||||
<div class="search-match-info" style="background: #e0f7fa; padding: 8px 12px; margin-top: 8px; border-radius: 6px; font-size: 0.85rem; color: #0277bd;">
|
||||
<strong>Matched in:</strong> ${post.matched_fields.join(', ') || 'title, content'}
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
// Highlight matching text
|
||||
function highlightText(text, query) {
|
||||
if (!text || !query) return text;
|
||||
|
||||
const regex = new RegExp(`(${escapeRegex(query)})`, 'gi');
|
||||
return text.replace(regex, '<mark style="background: #fff3cd; padding: 2px 4px; border-radius: 2px;">$1</mark>');
|
||||
}
|
||||
|
||||
// Escape regex special characters
|
||||
function escapeRegex(string) {
|
||||
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
// Setup infinite scroll functionality
|
||||
@@ -1277,6 +1372,55 @@ function loadNextPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// Search result pagination functions
|
||||
function loadNextSearchPage(currentQuery, currentPage) {
|
||||
const params = new URLSearchParams();
|
||||
params.append('q', currentQuery);
|
||||
params.append('page', currentPage + 1);
|
||||
params.append('per_page', 20);
|
||||
|
||||
fetch(`/api/search?${params}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.posts) {
|
||||
// Append new results to existing ones
|
||||
const postsContainer = document.getElementById('posts-container');
|
||||
const newPostsHTML = data.posts.map(post => createSearchResultPostCard(post, currentQuery)).join('');
|
||||
postsContainer.insertAdjacentHTML('beforeend', newPostsHTML);
|
||||
|
||||
// Update pagination info
|
||||
updateSearchPagination(data.pagination);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading next search page:', error);
|
||||
});
|
||||
}
|
||||
|
||||
function loadPreviousSearchPage(currentQuery, currentPage) {
|
||||
const params = new URLSearchParams();
|
||||
params.append('q', currentQuery);
|
||||
params.append('page', currentPage - 1);
|
||||
params.append('per_page', 20);
|
||||
|
||||
fetch(`/api/search?${params}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.posts) {
|
||||
// Replace current results with previous page
|
||||
displaySearchResults(currentQuery, data);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading previous search page:', error);
|
||||
});
|
||||
}
|
||||
|
||||
function updateSearchPagination(pagination) {
|
||||
const pageInfo = document.querySelector('.page-info');
|
||||
pageInfo.textContent = `Page ${pagination.current_page} of ${pagination.total_pages} (${pagination.total_posts} results)`;
|
||||
}
|
||||
|
||||
function loadPreviousPage() {
|
||||
if (paginationData.has_prev) {
|
||||
loadPosts(currentPage - 1, currentCommunity, currentPlatform);
|
||||
|
||||
79
templates/forgot_password.html
Normal file
79
templates/forgot_password.html
Normal file
@@ -0,0 +1,79 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Reset Password - BalanceBoard{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="auth-container">
|
||||
<div class="auth-card">
|
||||
<div class="auth-logo">
|
||||
<img src="{{ url_for('serve_logo') }}" alt="BalanceBoard 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
|
||||
placeholder="Enter your registered email address"
|
||||
value="{{ request.form.email or '' }}">
|
||||
<small class="form-help">
|
||||
We'll send you instructions to reset your password.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-block">
|
||||
Send Reset Instructions
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="auth-footer">
|
||||
<p>Remember your password? <a href="{{ url_for('login') }}">Back to login</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.form-help {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary-color, #4db6ac);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--primary-dark, #26a69a);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-block {
|
||||
width: 100%;
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
79
templates/forgot_username.html
Normal file
79
templates/forgot_username.html
Normal file
@@ -0,0 +1,79 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Find Username - BalanceBoard{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="auth-container">
|
||||
<div class="auth-card">
|
||||
<div class="auth-logo">
|
||||
<img src="{{ url_for('serve_logo') }}" alt="BalanceBoard Logo">
|
||||
<h1><span class="balance">balance</span>Board</h1>
|
||||
<p style="color: var(--text-secondary); margin-top: 8px;">Find your username</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
|
||||
placeholder="Enter your registered email address"
|
||||
value="{{ request.form.email or '' }}">
|
||||
<small class="form-help">
|
||||
We'll send your username to this email address.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-block">
|
||||
Find My Username
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="auth-footer">
|
||||
<p>Remember your username? <a href="{{ url_for('login') }}">Back to login</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.form-help {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary-color, #4db6ac);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--primary-dark, #26a69a);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-block {
|
||||
width: 100%;
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -1,12 +1,12 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Log In - {{ APP_NAME }}{% endblock %}
|
||||
{% block title %}Log In - BalanceBoard{% 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">
|
||||
<img src="{{ url_for('serve_logo') }}" alt="BalanceBoard Logo">
|
||||
<h1><span class="balance">balance</span>Board</h1>
|
||||
<p style="color: var(--text-secondary); margin-top: 8px;">Welcome back!</p>
|
||||
</div>
|
||||
@@ -37,10 +37,6 @@
|
||||
<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>
|
||||
|
||||
@@ -48,7 +44,6 @@
|
||||
<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">
|
||||
@@ -57,10 +52,14 @@
|
||||
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>
|
||||
<div class="auth-links">
|
||||
<a href="{{ url_for('forgot_username') }}">Forgot username?</a>
|
||||
<span>·</span>
|
||||
<a href="{{ url_for('forgot_password') }}">Forgot password?</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,41 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,29 +1,69 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ post.title }} - {{ APP_NAME }}{% endblock %}
|
||||
{% block title %}{{ post.title }} - BalanceBoard{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include '_nav.html' %}
|
||||
<!-- Modern Top Navigation -->
|
||||
<nav class="top-nav">
|
||||
<div class="nav-content">
|
||||
<div class="nav-left">
|
||||
<div class="logo-section">
|
||||
<img src="{{ url_for('serve_logo') }}" alt="BalanceBoard" class="nav-logo">
|
||||
<span class="brand-text"><span class="brand-balance">balance</span><span class="brand-board">Board</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nav-center">
|
||||
<div class="search-bar">
|
||||
<input type="text" placeholder="Search content..." class="search-input">
|
||||
<button class="search-btn">🔍</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nav-right">
|
||||
{% if current_user.is_authenticated %}
|
||||
<div class="user-menu">
|
||||
<div class="user-info">
|
||||
<div class="user-avatar">
|
||||
{% if current_user.profile_picture_url %}
|
||||
<img src="{{ current_user.profile_picture_url }}" alt="Avatar">
|
||||
{% else %}
|
||||
<div class="avatar-placeholder">{{ current_user.username[:2].upper() }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<span class="username">{{ current_user.username }}</span>
|
||||
</div>
|
||||
<div class="user-dropdown">
|
||||
<a href="{{ url_for('settings') }}" class="dropdown-item">⚙️ Settings</a>
|
||||
{% if current_user.is_admin %}
|
||||
<a href="{{ url_for('admin_panel') }}" class="dropdown-item">👨💼 Admin Panel</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('logout') }}" class="dropdown-item">🚪 Logout</a>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="auth-buttons">
|
||||
<a href="{{ url_for('login') }}" class="auth-btn">Login</a>
|
||||
<a href="{{ url_for('signup') }}" class="auth-btn primary">Sign Up</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<main class="main-content single-post">
|
||||
<!-- Back Button -->
|
||||
<div class="back-section">
|
||||
<a href="#" onclick="goBackToFeed(event)" class="back-btn">← Back to Feed</a>
|
||||
<button onclick="goBackToFeed()" class="back-btn">← Back to Feed</button>
|
||||
</div>
|
||||
|
||||
<!-- Post Content -->
|
||||
<article class="post-detail">
|
||||
<div class="post-header">
|
||||
{% if post.url and not post.url.startswith('/') %}
|
||||
<a href="{{ post.url }}" target="_blank" class="platform-badge platform-{{ post.platform }}" title="View on {{ post.platform.title() }}">
|
||||
{{ post.platform.title()[:1] }}
|
||||
</a>
|
||||
{% else %}
|
||||
<div class="platform-badge platform-{{ post.platform }}">
|
||||
{{ post.platform.title()[:1] }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="post-meta">
|
||||
<span class="post-author">{{ post.author }}</span>
|
||||
<span class="post-separator">•</span>
|
||||
@@ -65,7 +105,7 @@
|
||||
🐙 View on GitHub
|
||||
{% elif post.platform == 'devto' %}
|
||||
📝 View on Dev.to
|
||||
{% elif post.platform == 'stackexchange' %}
|
||||
{% elif post.platform == 'stackoverflow' %}
|
||||
📚 View on Stack Overflow
|
||||
{% else %}
|
||||
🔗 View Original Source
|
||||
@@ -96,36 +136,24 @@
|
||||
<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 %}
|
||||
{{ render_comment(comment) }}
|
||||
<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>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
@@ -162,14 +190,6 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.nav-left .logo-section:hover {
|
||||
transform: scale(1.02);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav-logo {
|
||||
@@ -327,35 +347,6 @@
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.anonymous-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.login-btn, .register-btn {
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
color: #2c3e50;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.register-btn {
|
||||
background: #4db6ac;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.login-btn:hover, .register-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Main Content */
|
||||
.main-content.single-post {
|
||||
max-width: 1200px;
|
||||
@@ -563,24 +554,12 @@
|
||||
.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;
|
||||
@@ -657,23 +636,13 @@
|
||||
</style>
|
||||
|
||||
<script>
|
||||
function goBackToFeed(event) {
|
||||
event.preventDefault();
|
||||
|
||||
// Try to go back in browser history first
|
||||
if (window.history.length > 1 && document.referrer && document.referrer.includes(window.location.origin)) {
|
||||
function goBackToFeed() {
|
||||
// Try to go back to the dashboard if possible
|
||||
if (document.referrer && document.referrer.includes(window.location.origin)) {
|
||||
window.history.back();
|
||||
} else {
|
||||
// Fallback to dashboard - construct URL with current query parameters
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const baseUrl = {{ url_for('index')|tojson }};
|
||||
|
||||
// Add query parameters if they exist
|
||||
if (urlParams.toString()) {
|
||||
window.location.href = baseUrl + '?' + urlParams.toString();
|
||||
} else {
|
||||
window.location.href = baseUrl;
|
||||
}
|
||||
// Fallback to dashboard
|
||||
window.location.href = '/';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -685,10 +654,6 @@ 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!');
|
||||
}
|
||||
|
||||
|
||||
179
templates/reset_password.html
Normal file
179
templates/reset_password.html
Normal file
@@ -0,0 +1,179 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Reset Password - BalanceBoard{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="auth-container">
|
||||
<div class="auth-card">
|
||||
<div class="auth-logo">
|
||||
<img src="{{ url_for('serve_logo') }}" alt="BalanceBoard Logo">
|
||||
<h1><span class="balance">balance</span>Board</h1>
|
||||
<p style="color: var(--text-secondary); margin-top: 8px;">Create your 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" id="resetPasswordForm">
|
||||
<div class="form-group">
|
||||
<label for="password">New Password</label>
|
||||
<input type="password" id="password" name="password" required
|
||||
minlength="8"
|
||||
placeholder="Enter new password"
|
||||
oninput="checkPasswordStrength()">
|
||||
<small class="form-help">
|
||||
Password must be at least 8 characters long.
|
||||
</small>
|
||||
<div id="passwordStrength" class="password-strength" style="display: none;">
|
||||
<div class="strength-bar"></div>
|
||||
<small class="strength-text"></small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="confirm_password">Confirm New Password</label>
|
||||
<input type="password" id="confirm_password" name="confirm_password" required
|
||||
placeholder="Confirm your new password"
|
||||
oninput="checkPasswordMatch()">
|
||||
<small class="form-help" id="passwordMatch" style="display: none;"></small>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-block" id="resetBtn">
|
||||
Reset Password
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="auth-footer">
|
||||
<p><a href="{{ url_for('login') }}">Back to login</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function checkPasswordStrength() {
|
||||
const password = document.getElementById('password').value;
|
||||
const strengthDiv = document.getElementById('passwordStrength');
|
||||
const strengthBar = strengthDiv.querySelector('.strength-bar');
|
||||
const strengthText = strengthDiv.querySelector('.strength-text');
|
||||
|
||||
if (password.length === 0) {
|
||||
strengthDiv.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
strengthDiv.style.display = 'block';
|
||||
|
||||
let strength = 0;
|
||||
if (password.length >= 8) strength++;
|
||||
if (password.length >= 12) strength++;
|
||||
if (/[a-z]/.test(password) && /[A-Z]/.test(password)) strength++;
|
||||
if (/[0-9]/.test(password)) strength++;
|
||||
if (/[^A-Za-z0-9]/.test(password)) strength++;
|
||||
|
||||
const strengthLevels = ['Very Weak', 'Weak', 'Fair', 'Good', 'Strong'];
|
||||
const strengthColors = ['#ff4444', '#ff8844', '#ffaa44', '#44ff88', '#44aa44'];
|
||||
|
||||
strengthBar.style.width = `${(strength + 1) * 20}%`;
|
||||
strengthBar.style.backgroundColor = strengthColors[strength];
|
||||
strengthText.textContent = strengthLevels[strength];
|
||||
strengthText.style.color = strengthColors[strength];
|
||||
}
|
||||
|
||||
function checkPasswordMatch() {
|
||||
const password = document.getElementById('password').value;
|
||||
const confirm = document.getElementById('confirm_password').value;
|
||||
const matchDiv = document.getElementById('passwordMatch');
|
||||
|
||||
if (confirm.length === 0) {
|
||||
matchDiv.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
matchDiv.style.display = 'block';
|
||||
|
||||
if (password === confirm) {
|
||||
matchDiv.textContent = '✓ Passwords match';
|
||||
matchDiv.style.color = '#44aa44';
|
||||
document.getElementById('resetBtn').disabled = false;
|
||||
} else {
|
||||
matchDiv.textContent = '✗ Passwords do not match';
|
||||
matchDiv.style.color = '#ff4444';
|
||||
document.getElementById('resetBtn').disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('resetPasswordForm').addEventListener('submit', function(e) {
|
||||
const password = document.getElementById('password').value;
|
||||
const confirm = document.getElementById('confirm_password').value;
|
||||
|
||||
if (password !== confirm) {
|
||||
e.preventDefault();
|
||||
document.getElementById('passwordMatch').click();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.form-help {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.password-strength {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.strength-bar {
|
||||
height: 4px;
|
||||
background: #e0e0e0;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.strength-text {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary-color, #4db6ac);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--primary-dark, #26a69a);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
background: #cccccc;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.btn-block {
|
||||
width: 100%;
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Settings - {{ APP_NAME }}{% endblock %}
|
||||
{% block title %}Settings - BalanceBoard{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
@@ -230,7 +230,6 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include '_nav.html' %}
|
||||
<div class="settings-container">
|
||||
<div class="settings-header">
|
||||
<h1>Settings</h1>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Community Settings - {{ APP_NAME }}{% endblock %}
|
||||
{% block title %}Community Settings - BalanceBoard{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.settings-container {
|
||||
max-width: 1200px;
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
@@ -79,25 +79,12 @@
|
||||
.platform-icon.reddit { background: #ff4500; }
|
||||
.platform-icon.hackernews { background: #ff6600; }
|
||||
.platform-icon.lobsters { background: #ac130d; }
|
||||
.platform-icon.stackexchange { background: #f48024; }
|
||||
.platform-icon.stackoverflow { background: #f48024; }
|
||||
|
||||
.community-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.community-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1400px) {
|
||||
.community-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.community-item {
|
||||
@@ -248,14 +235,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include '_nav.html' %}
|
||||
<div class="settings-container">
|
||||
<nav style="margin-bottom: 24px;">
|
||||
<a href="{{ url_for('settings') }}" style="color: var(--primary-color); text-decoration: none; font-weight: 500;">
|
||||
← Back to Settings
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div class="settings-header">
|
||||
<h1>Community Settings</h1>
|
||||
<p>Select which communities, subreddits, and sources to include in your feed</p>
|
||||
@@ -288,7 +268,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 == 'stackexchange' %}S{% endif %}
|
||||
{% if platform == 'reddit' %}R{% elif platform == 'hackernews' %}H{% elif platform == 'lobsters' %}L{% elif platform == 'stackoverflow' %}S{% endif %}
|
||||
</span>
|
||||
{{ platform|title }}
|
||||
</h3>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Experience Settings - {{ APP_NAME }}{% endblock %}
|
||||
{% block title %}Experience Settings - BalanceBoard{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
@@ -241,7 +241,6 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include '_nav.html' %}
|
||||
<div class="experience-settings">
|
||||
<div class="experience-header">
|
||||
<h1>Experience Settings</h1>
|
||||
@@ -331,34 +330,6 @@
|
||||
<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">
|
||||
@@ -367,15 +338,4 @@
|
||||
</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 %}
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Filter Settings - {{ APP_NAME }}{% endblock %}
|
||||
{% block title %}Filter Settings - BalanceBoard{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
@@ -263,7 +263,6 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include '_nav.html' %}
|
||||
<div class="settings-container">
|
||||
<div class="settings-header">
|
||||
<h1>Filter Settings</h1>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Profile Settings - {{ APP_NAME }}{% endblock %}
|
||||
{% block title %}Profile Settings - BalanceBoard{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
@@ -225,7 +225,6 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include '_nav.html' %}
|
||||
<div class="settings-container">
|
||||
<div class="settings-header">
|
||||
<h1>Profile Settings</h1>
|
||||
@@ -243,29 +242,31 @@
|
||||
{% endwith %}
|
||||
</div>
|
||||
|
||||
<div class="profile-section">
|
||||
<h2>Profile Picture</h2>
|
||||
<div class="profile-avatar">
|
||||
<div class="avatar-preview">
|
||||
{% if user.profile_picture_url %}
|
||||
<img src="{{ user.profile_picture_url }}" alt="{{ user.username }}">
|
||||
{% else %}
|
||||
{{ user.username[0]|upper }}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="avatar-info">
|
||||
<h3>Current Avatar</h3>
|
||||
<p>Upload a new profile picture to personalize your account</p>
|
||||
<form id="upload-form" method="POST" action="{{ url_for('upload_avatar') }}" enctype="multipart/form-data">
|
||||
<div class="file-upload">
|
||||
<input type="file" id="avatar" name="avatar" accept="image/*">
|
||||
<label for="avatar" class="file-upload-label">Choose New Picture</label>
|
||||
</div>
|
||||
<p class="help-text">PNG, JPG, or GIF. Maximum size 2MB.</p>
|
||||
</form>
|
||||
<form method="POST">
|
||||
<div class="profile-section">
|
||||
<h2>Profile Picture</h2>
|
||||
<div class="profile-avatar">
|
||||
<div class="avatar-preview">
|
||||
{% if user.profile_picture_url %}
|
||||
<img src="{{ user.profile_picture_url }}" alt="{{ user.username }}">
|
||||
{% else %}
|
||||
{{ user.username[0]|upper }}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="avatar-info">
|
||||
<h3>Current Avatar</h3>
|
||||
<p>Upload a new profile picture to personalize your account</p>
|
||||
<form id="upload-form" method="POST" action="{{ url_for('upload_avatar') }}" enctype="multipart/form-data">
|
||||
<div class="file-upload">
|
||||
<input type="file" id="avatar" name="avatar" accept="image/*" onchange="this.form.submit()">
|
||||
<label for="avatar" class="file-upload-label">Choose New Picture</label>
|
||||
</div>
|
||||
<p class="help-text">PNG, JPG, or GIF. Maximum size 2MB.</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form method="POST">
|
||||
<div class="profile-section">
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Sign Up - {{ APP_NAME }}{% endblock %}
|
||||
{% block title %}Sign Up - BalanceBoard{% 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">
|
||||
<img src="{{ url_for('serve_logo') }}" alt="BalanceBoard Logo">
|
||||
<h1><span class="balance">balance</span>Board</h1>
|
||||
<p style="color: var(--text-secondary); margin-top: 8px;">Create your account</p>
|
||||
</div>
|
||||
|
||||
@@ -40,10 +40,6 @@
|
||||
|
||||
<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>
|
||||
|
||||
|
||||
@@ -228,9 +228,6 @@
|
||||
<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
|
||||
@@ -355,79 +352,6 @@
|
||||
|
||||
// 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>
|
||||
|
||||
@@ -460,45 +460,6 @@ header .post-count::before {
|
||||
.engagement-info {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Bookmark Button */
|
||||
.bookmark-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 6px 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.bookmark-btn:hover {
|
||||
border-color: var(--primary-color);
|
||||
color: var(--primary-color);
|
||||
background: rgba(77, 182, 172, 0.1);
|
||||
}
|
||||
|
||||
.bookmark-btn.bookmarked {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.bookmark-btn.bookmarked .bookmark-icon {
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.bookmark-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Tags */
|
||||
|
||||
Reference in New Issue
Block a user