Compare commits
2 Commits
lnwc
...
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
|
|
||||||
779
app.py
779
app.py
@@ -7,6 +7,7 @@ import os
|
|||||||
import re
|
import re
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
|
import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from werkzeug.utils import secure_filename
|
from werkzeug.utils import secure_filename
|
||||||
from flask import Flask, render_template, request, redirect, url_for, flash, send_from_directory, abort, session, jsonify
|
from flask import Flask, render_template, request, redirect, url_for, flash, send_from_directory, abort, session, jsonify
|
||||||
@@ -44,10 +45,6 @@ app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-pro
|
|||||||
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB max file size
|
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB max file size
|
||||||
app.config['ALLOW_ANONYMOUS_ACCESS'] = os.getenv('ALLOW_ANONYMOUS_ACCESS', 'true').lower() == 'true'
|
app.config['ALLOW_ANONYMOUS_ACCESS'] = os.getenv('ALLOW_ANONYMOUS_ACCESS', 'true').lower() == 'true'
|
||||||
|
|
||||||
# Application branding configuration
|
|
||||||
app.config['APP_NAME'] = os.getenv('APP_NAME', 'BalanceBoard')
|
|
||||||
app.config['LOGO_PATH'] = os.getenv('LOGO_PATH', 'logo.png')
|
|
||||||
|
|
||||||
# Auth0 Configuration
|
# Auth0 Configuration
|
||||||
app.config['AUTH0_DOMAIN'] = os.getenv('AUTH0_DOMAIN', '')
|
app.config['AUTH0_DOMAIN'] = os.getenv('AUTH0_DOMAIN', '')
|
||||||
app.config['AUTH0_CLIENT_ID'] = os.getenv('AUTH0_CLIENT_ID', '')
|
app.config['AUTH0_CLIENT_ID'] = os.getenv('AUTH0_CLIENT_ID', '')
|
||||||
@@ -55,7 +52,7 @@ app.config['AUTH0_CLIENT_SECRET'] = os.getenv('AUTH0_CLIENT_SECRET', '')
|
|||||||
app.config['AUTH0_AUDIENCE'] = os.getenv('AUTH0_AUDIENCE', '')
|
app.config['AUTH0_AUDIENCE'] = os.getenv('AUTH0_AUDIENCE', '')
|
||||||
|
|
||||||
# Configuration constants
|
# Configuration constants
|
||||||
# Note: ALLOWED_FILTERSETS will be dynamically loaded from filter_engine
|
ALLOWED_FILTERSETS = {'no_filter', 'safe_content'}
|
||||||
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'}
|
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'}
|
||||||
UPLOAD_FOLDER = 'static/avatars'
|
UPLOAD_FOLDER = 'static/avatars'
|
||||||
MAX_FILENAME_LENGTH = 100
|
MAX_FILENAME_LENGTH = 100
|
||||||
@@ -86,11 +83,6 @@ from polling_service import polling_service
|
|||||||
polling_service.init_app(app)
|
polling_service.init_app(app)
|
||||||
polling_service.start()
|
polling_service.start()
|
||||||
|
|
||||||
# Initialize filter engine
|
|
||||||
from filter_pipeline import FilterEngine
|
|
||||||
filter_engine = FilterEngine.get_instance()
|
|
||||||
logger.info(f"FilterEngine initialized with {len(filter_engine.get_available_filtersets())} filtersets")
|
|
||||||
|
|
||||||
# Initialize OAuth for Auth0
|
# Initialize OAuth for Auth0
|
||||||
oauth = OAuth(app)
|
oauth = OAuth(app)
|
||||||
auth0 = oauth.register(
|
auth0 = oauth.register(
|
||||||
@@ -114,9 +106,7 @@ def _is_safe_filterset(filterset):
|
|||||||
"""Validate filterset name for security"""
|
"""Validate filterset name for security"""
|
||||||
if not filterset or not isinstance(filterset, str):
|
if not filterset or not isinstance(filterset, str):
|
||||||
return False
|
return False
|
||||||
# Check against available filtersets from filter_engine
|
return filterset in ALLOWED_FILTERSETS and re.match(r'^[a-zA-Z0-9_-]+$', filterset)
|
||||||
allowed_filtersets = set(filter_engine.get_available_filtersets())
|
|
||||||
return filterset in allowed_filtersets and re.match(r'^[a-zA-Z0-9_-]+$', filterset)
|
|
||||||
|
|
||||||
def _is_safe_path(path):
|
def _is_safe_path(path):
|
||||||
"""Validate file path for security"""
|
"""Validate file path for security"""
|
||||||
@@ -219,15 +209,10 @@ def _validate_user_settings(settings_str):
|
|||||||
exp = settings['experience']
|
exp = settings['experience']
|
||||||
if isinstance(exp, dict):
|
if isinstance(exp, dict):
|
||||||
safe_exp = {}
|
safe_exp = {}
|
||||||
bool_fields = ['infinite_scroll', 'auto_refresh', 'push_notifications', 'dark_patterns_opt_in', 'time_filter_enabled']
|
bool_fields = ['infinite_scroll', 'auto_refresh', 'push_notifications', 'dark_patterns_opt_in']
|
||||||
for field in bool_fields:
|
for field in bool_fields:
|
||||||
if field in exp and isinstance(exp[field], bool):
|
if field in exp and isinstance(exp[field], bool):
|
||||||
safe_exp[field] = exp[field]
|
safe_exp[field] = exp[field]
|
||||||
|
|
||||||
# Handle time_filter_days as integer
|
|
||||||
if 'time_filter_days' in exp and isinstance(exp['time_filter_days'], int) and exp['time_filter_days'] > 0:
|
|
||||||
safe_exp['time_filter_days'] = exp['time_filter_days']
|
|
||||||
|
|
||||||
validated['experience'] = safe_exp
|
validated['experience'] = safe_exp
|
||||||
|
|
||||||
return validated
|
return validated
|
||||||
@@ -280,32 +265,9 @@ def check_first_user():
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def calculate_quick_stats():
|
|
||||||
"""Calculate quick stats for dashboard"""
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
|
|
||||||
cached_posts, _ = _load_posts_cache()
|
|
||||||
|
|
||||||
# Calculate posts from today (last 24 hours)
|
|
||||||
now = datetime.utcnow()
|
|
||||||
today_start = now - timedelta(hours=24)
|
|
||||||
today_timestamp = today_start.timestamp()
|
|
||||||
|
|
||||||
posts_today = sum(1 for post in cached_posts.values()
|
|
||||||
if post.get('timestamp', 0) >= today_timestamp)
|
|
||||||
|
|
||||||
return {
|
|
||||||
'posts_today': posts_today,
|
|
||||||
'total_posts': len(cached_posts)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
def index():
|
def index():
|
||||||
"""Serve the main feed page"""
|
"""Serve the main feed page"""
|
||||||
# Calculate stats
|
|
||||||
quick_stats = calculate_quick_stats()
|
|
||||||
|
|
||||||
if current_user.is_authenticated:
|
if current_user.is_authenticated:
|
||||||
# Load user settings
|
# Load user settings
|
||||||
try:
|
try:
|
||||||
@@ -314,24 +276,19 @@ def index():
|
|||||||
logger.warning(f"Invalid user settings JSON for user {current_user.id}: {e}")
|
logger.warning(f"Invalid user settings JSON for user {current_user.id}: {e}")
|
||||||
user_settings = {}
|
user_settings = {}
|
||||||
|
|
||||||
return render_template('dashboard.html', user_settings=user_settings, quick_stats=quick_stats)
|
return render_template('dashboard.html', user_settings=user_settings)
|
||||||
else:
|
else:
|
||||||
# Check if anonymous access is allowed
|
# Check if anonymous access is allowed
|
||||||
if app.config.get('ALLOW_ANONYMOUS_ACCESS', False):
|
if app.config.get('ALLOW_ANONYMOUS_ACCESS', False):
|
||||||
# Anonymous mode - allow browsing with default settings
|
# Anonymous access allowed - use default settings
|
||||||
user_settings = {
|
user_settings = {
|
||||||
'filter_set': 'no_filter',
|
|
||||||
'communities': [],
|
|
||||||
'experience': {
|
'experience': {
|
||||||
'infinite_scroll': False,
|
'infinite_scroll': False,
|
||||||
'auto_refresh': False,
|
'auto_refresh': False
|
||||||
'push_notifications': False,
|
},
|
||||||
'dark_patterns_opt_in': False,
|
'communities': []
|
||||||
'time_filter_enabled': False,
|
|
||||||
'time_filter_days': 7
|
|
||||||
}
|
}
|
||||||
}
|
return render_template('dashboard.html', user_settings=user_settings)
|
||||||
return render_template('dashboard.html', user_settings=user_settings, anonymous=True, quick_stats=quick_stats)
|
|
||||||
else:
|
else:
|
||||||
# Redirect non-authenticated users to login
|
# Redirect non-authenticated users to login
|
||||||
return redirect(url_for('login'))
|
return redirect(url_for('login'))
|
||||||
@@ -395,112 +352,23 @@ def api_posts():
|
|||||||
per_page = int(request.args.get('per_page', DEFAULT_PAGE_SIZE))
|
per_page = int(request.args.get('per_page', DEFAULT_PAGE_SIZE))
|
||||||
community = request.args.get('community', '')
|
community = request.args.get('community', '')
|
||||||
platform = request.args.get('platform', '')
|
platform = request.args.get('platform', '')
|
||||||
search_query = request.args.get('q', '').lower().strip()
|
|
||||||
filter_override = request.args.get('filter', '')
|
|
||||||
|
|
||||||
# Get user's filterset preference, community selections, and time filter
|
|
||||||
filterset_name = 'no_filter'
|
|
||||||
user_communities = []
|
|
||||||
time_filter_enabled = False
|
|
||||||
time_filter_days = 7
|
|
||||||
if current_user.is_authenticated:
|
|
||||||
try:
|
|
||||||
user_settings = json.loads(current_user.settings) if current_user.settings else {}
|
|
||||||
filterset_name = user_settings.get('filter_set', 'no_filter')
|
|
||||||
user_communities = user_settings.get('communities', [])
|
|
||||||
|
|
||||||
experience_settings = user_settings.get('experience', {})
|
|
||||||
time_filter_enabled = experience_settings.get('time_filter_enabled', False)
|
|
||||||
time_filter_days = experience_settings.get('time_filter_days', 7)
|
|
||||||
except:
|
|
||||||
filterset_name = 'no_filter'
|
|
||||||
user_communities = []
|
|
||||||
time_filter_enabled = False
|
|
||||||
time_filter_days = 7
|
|
||||||
|
|
||||||
# Override filterset if specified in request (for sidebar filter switching)
|
|
||||||
if filter_override and _is_safe_filterset(filter_override):
|
|
||||||
filterset_name = filter_override
|
|
||||||
|
|
||||||
# Use cached data for better performance
|
# Use cached data for better performance
|
||||||
cached_posts, cached_comments = _load_posts_cache()
|
cached_posts, cached_comments = _load_posts_cache()
|
||||||
|
|
||||||
# Calculate time filter cutoff if enabled
|
posts = []
|
||||||
time_cutoff = None
|
|
||||||
if time_filter_enabled:
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
cutoff_date = datetime.utcnow() - timedelta(days=time_filter_days)
|
|
||||||
time_cutoff = cutoff_date.timestamp()
|
|
||||||
|
|
||||||
# ====================================================================
|
|
||||||
# START OF REFACTORED SECTION
|
|
||||||
# ====================================================================
|
|
||||||
|
|
||||||
def _post_should_be_included(post_data):
|
|
||||||
"""Check if a post passes all pre-filterset criteria."""
|
|
||||||
# Apply time filter
|
|
||||||
if time_filter_enabled and time_cutoff:
|
|
||||||
if post_data.get('timestamp', 0) < time_cutoff:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
# Process cached posts
|
||||||
|
for post_uuid, post_data in cached_posts.items():
|
||||||
# Apply community filter
|
# Apply community filter
|
||||||
if community and post_data.get('source', '').lower() != community.lower():
|
if community and post_data.get('source', '').lower() != community.lower():
|
||||||
return False
|
continue
|
||||||
|
|
||||||
# Apply platform filter
|
# Apply platform filter
|
||||||
if platform and post_data.get('platform', '').lower() != platform.lower():
|
if platform and post_data.get('platform', '').lower() != platform.lower():
|
||||||
return False
|
continue
|
||||||
|
|
||||||
# Apply user's community preferences
|
# Get comment count from cache
|
||||||
if user_communities:
|
|
||||||
post_source = post_data.get('source', '').lower()
|
|
||||||
post_platform = post_data.get('platform', '').lower()
|
|
||||||
if not any(
|
|
||||||
post_source == c or post_platform == c or c in post_source
|
|
||||||
for c in user_communities
|
|
||||||
):
|
|
||||||
# ====================================================================
|
|
||||||
# MODIFICATION: Add logging here
|
|
||||||
# ====================================================================
|
|
||||||
logger.error(
|
|
||||||
f"Post filtered out for user {current_user.id if current_user.is_authenticated else 'anonymous'}: "
|
|
||||||
f"Community mismatch. Platform='{post_platform}', Source='{post_source}', "
|
|
||||||
f"User Communities={user_communities}"
|
|
||||||
)
|
|
||||||
# ====================================================================
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Apply search filter
|
|
||||||
if search_query:
|
|
||||||
title = post_data.get('title', '').lower()
|
|
||||||
content = post_data.get('content', '').lower()
|
|
||||||
author = post_data.get('author', '').lower()
|
|
||||||
source = post_data.get('source', '').lower()
|
|
||||||
if not (search_query in title or
|
|
||||||
search_query in content or
|
|
||||||
search_query in author or
|
|
||||||
search_query in source):
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Collect raw posts using a clean, declarative list comprehension
|
|
||||||
raw_posts = [
|
|
||||||
post_data for post_data in cached_posts.values()
|
|
||||||
if _post_should_be_included(post_data)
|
|
||||||
]
|
|
||||||
|
|
||||||
# ====================================================================
|
|
||||||
# END OF REFACTORED SECTION
|
|
||||||
# ====================================================================
|
|
||||||
|
|
||||||
# Apply filterset using FilterEngine
|
|
||||||
filtered_posts = filter_engine.apply_filterset(raw_posts, filterset_name, use_cache=True)
|
|
||||||
|
|
||||||
# Build response posts with metadata
|
|
||||||
posts = []
|
|
||||||
for post_data in filtered_posts:
|
|
||||||
post_uuid = post_data.get('uuid')
|
|
||||||
comment_count = len(cached_comments.get(post_uuid, []))
|
comment_count = len(cached_comments.get(post_uuid, []))
|
||||||
|
|
||||||
# Get proper display name for source
|
# Get proper display name for source
|
||||||
@@ -510,7 +378,7 @@ def api_posts():
|
|||||||
platform_config
|
platform_config
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create post object with filter metadata
|
# Create post object with actual title
|
||||||
post = {
|
post = {
|
||||||
'id': post_uuid,
|
'id': post_uuid,
|
||||||
'title': post_data.get('title', 'Untitled'),
|
'title': post_data.get('title', 'Untitled'),
|
||||||
@@ -524,16 +392,12 @@ def api_posts():
|
|||||||
'source': post_data.get('source', ''),
|
'source': post_data.get('source', ''),
|
||||||
'source_display': source_display,
|
'source_display': source_display,
|
||||||
'tags': post_data.get('tags', []),
|
'tags': post_data.get('tags', []),
|
||||||
'external_url': post_data.get('url', ''),
|
'external_url': post_data.get('url', '')
|
||||||
# Add filter metadata
|
|
||||||
'filter_score': post_data.get('_filter_score', 0.5),
|
|
||||||
'filter_categories': post_data.get('_filter_categories', []),
|
|
||||||
'filter_tags': post_data.get('_filter_tags', [])
|
|
||||||
}
|
}
|
||||||
posts.append(post)
|
posts.append(post)
|
||||||
|
|
||||||
# Sort by filter score (highest first), then timestamp
|
# Sort by timestamp (newest first)
|
||||||
posts.sort(key=lambda x: (x['filter_score'], x['timestamp']), reverse=True)
|
posts.sort(key=lambda x: x['timestamp'], reverse=True)
|
||||||
|
|
||||||
# Calculate pagination
|
# Calculate pagination
|
||||||
total_posts = len(posts)
|
total_posts = len(posts)
|
||||||
@@ -668,235 +532,122 @@ def api_content_timestamp():
|
|||||||
return jsonify({'error': 'Failed to get content timestamp'}), 500
|
return jsonify({'error': 'Failed to get content timestamp'}), 500
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/bookmark', methods=['POST'])
|
@app.route('/api/stats')
|
||||||
@login_required
|
def api_stats():
|
||||||
def api_bookmark():
|
"""API endpoint to get quick stats data"""
|
||||||
"""Toggle bookmark status for a post"""
|
|
||||||
try:
|
try:
|
||||||
from models import Bookmark
|
# Load cached posts
|
||||||
|
|
||||||
data = request.get_json()
|
|
||||||
if not data or 'post_uuid' not in data:
|
|
||||||
return jsonify({'error': 'Missing post_uuid'}), 400
|
|
||||||
|
|
||||||
post_uuid = data['post_uuid']
|
|
||||||
if not post_uuid:
|
|
||||||
return jsonify({'error': 'Invalid post_uuid'}), 400
|
|
||||||
|
|
||||||
# Check if bookmark already exists
|
|
||||||
existing_bookmark = Bookmark.query.filter_by(
|
|
||||||
user_id=current_user.id,
|
|
||||||
post_uuid=post_uuid
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if existing_bookmark:
|
|
||||||
# Remove bookmark
|
|
||||||
db.session.delete(existing_bookmark)
|
|
||||||
db.session.commit()
|
|
||||||
return jsonify({'bookmarked': False, 'message': 'Bookmark removed'})
|
|
||||||
else:
|
|
||||||
# Add bookmark - get post data for caching
|
|
||||||
cached_posts, _ = _load_posts_cache()
|
|
||||||
post_data = cached_posts.get(post_uuid, {})
|
|
||||||
|
|
||||||
bookmark = Bookmark(
|
|
||||||
user_id=current_user.id,
|
|
||||||
post_uuid=post_uuid,
|
|
||||||
title=post_data.get('title', ''),
|
|
||||||
platform=post_data.get('platform', ''),
|
|
||||||
source=post_data.get('source', '')
|
|
||||||
)
|
|
||||||
db.session.add(bookmark)
|
|
||||||
db.session.commit()
|
|
||||||
return jsonify({'bookmarked': True, 'message': 'Bookmark added'})
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error toggling bookmark: {e}")
|
|
||||||
return jsonify({'error': 'Failed to toggle bookmark'}), 500
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/bookmarks')
|
|
||||||
@login_required
|
|
||||||
def api_bookmarks():
|
|
||||||
"""Get user's bookmarks"""
|
|
||||||
try:
|
|
||||||
from models import Bookmark
|
|
||||||
|
|
||||||
page = int(request.args.get('page', 1))
|
|
||||||
per_page = int(request.args.get('per_page', DEFAULT_PAGE_SIZE))
|
|
||||||
|
|
||||||
# Get user's bookmarks with pagination
|
|
||||||
bookmarks_query = Bookmark.query.filter_by(user_id=current_user.id).order_by(Bookmark.created_at.desc())
|
|
||||||
total_bookmarks = bookmarks_query.count()
|
|
||||||
bookmarks = bookmarks_query.offset((page - 1) * per_page).limit(per_page).all()
|
|
||||||
|
|
||||||
# Load current posts cache to get updated data
|
|
||||||
cached_posts, cached_comments = _load_posts_cache()
|
cached_posts, cached_comments = _load_posts_cache()
|
||||||
|
|
||||||
# Build response
|
# Count posts from today
|
||||||
bookmark_posts = []
|
today = datetime.utcnow().date()
|
||||||
for bookmark in bookmarks:
|
posts_today = 0
|
||||||
# Try to get current post data, fallback to cached data
|
|
||||||
post_data = cached_posts.get(bookmark.post_uuid)
|
|
||||||
if post_data:
|
|
||||||
# Post still exists in current data
|
|
||||||
comment_count = len(cached_comments.get(bookmark.post_uuid, []))
|
|
||||||
post = {
|
|
||||||
'id': bookmark.post_uuid,
|
|
||||||
'title': post_data.get('title', bookmark.title or 'Untitled'),
|
|
||||||
'author': post_data.get('author', 'Unknown'),
|
|
||||||
'platform': post_data.get('platform', bookmark.platform or 'unknown'),
|
|
||||||
'score': post_data.get('score', 0),
|
|
||||||
'timestamp': post_data.get('timestamp', 0),
|
|
||||||
'url': f'/post/{bookmark.post_uuid}',
|
|
||||||
'comments_count': comment_count,
|
|
||||||
'content_preview': (post_data.get('content', '') or '')[:200] + '...' if post_data.get('content') else '',
|
|
||||||
'source': post_data.get('source', bookmark.source or ''),
|
|
||||||
'bookmarked_at': bookmark.created_at.isoformat(),
|
|
||||||
'external_url': post_data.get('url', '')
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
# Post no longer in current data, use cached bookmark data
|
|
||||||
post = {
|
|
||||||
'id': bookmark.post_uuid,
|
|
||||||
'title': bookmark.title or 'Untitled',
|
|
||||||
'author': 'Unknown',
|
|
||||||
'platform': bookmark.platform or 'unknown',
|
|
||||||
'score': 0,
|
|
||||||
'timestamp': 0,
|
|
||||||
'url': f'/post/{bookmark.post_uuid}',
|
|
||||||
'comments_count': 0,
|
|
||||||
'content_preview': 'Content no longer available',
|
|
||||||
'source': bookmark.source or '',
|
|
||||||
'bookmarked_at': bookmark.created_at.isoformat(),
|
|
||||||
'external_url': '',
|
|
||||||
'archived': True # Mark as archived
|
|
||||||
}
|
|
||||||
bookmark_posts.append(post)
|
|
||||||
|
|
||||||
total_pages = (total_bookmarks + per_page - 1) // per_page
|
for post_uuid, post_data in cached_posts.items():
|
||||||
has_next = page < total_pages
|
post_timestamp = post_data.get('timestamp', 0)
|
||||||
has_prev = page > 1
|
post_date = datetime.fromtimestamp(post_timestamp).date()
|
||||||
|
|
||||||
|
if post_date == today:
|
||||||
|
posts_today += 1
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'posts': bookmark_posts,
|
'posts_today': posts_today,
|
||||||
|
'total_posts': len(cached_posts)
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting stats: {e}")
|
||||||
|
return jsonify({'error': 'Failed to get stats'}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/search')
|
||||||
|
def api_search():
|
||||||
|
"""API endpoint to search posts"""
|
||||||
|
try:
|
||||||
|
query = request.args.get('q', '').strip()
|
||||||
|
page = int(request.args.get('page', 1))
|
||||||
|
per_page = int(request.args.get('per_page', 20))
|
||||||
|
|
||||||
|
if not query:
|
||||||
|
return jsonify({
|
||||||
|
'error': 'Search query is required'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Load cached posts
|
||||||
|
cached_posts, cached_comments = _load_posts_cache()
|
||||||
|
|
||||||
|
# Simple text search in title, content, and author
|
||||||
|
search_results = []
|
||||||
|
query_lower = query.lower()
|
||||||
|
|
||||||
|
for post_uuid, post_data in cached_posts.items():
|
||||||
|
title = post_data.get('title', '').lower()
|
||||||
|
content_preview = post_data.get('content_preview', '').lower()
|
||||||
|
author = post_data.get('author', '').lower()
|
||||||
|
tags = ' '.join(post_data.get('tags', [])).lower()
|
||||||
|
|
||||||
|
# Check if query matches any text field
|
||||||
|
if (query_lower in title or
|
||||||
|
query_lower in content_preview or
|
||||||
|
query_lower in author or
|
||||||
|
query_lower in tags):
|
||||||
|
|
||||||
|
# Get comment count
|
||||||
|
comment_count = len(cached_comments.get(post_uuid, []))
|
||||||
|
|
||||||
|
# Add search score (simple keyword matching)
|
||||||
|
score = 0
|
||||||
|
if query_lower in title:
|
||||||
|
score += 3 # Title matches are more important
|
||||||
|
if query_lower in content_preview:
|
||||||
|
score += 1
|
||||||
|
if query_lower in author:
|
||||||
|
score += 2
|
||||||
|
if query_lower in tags:
|
||||||
|
score += 1
|
||||||
|
|
||||||
|
# Create search result with post data
|
||||||
|
search_result = post_data.copy()
|
||||||
|
search_result['id'] = post_uuid
|
||||||
|
search_result['comment_count'] = comment_count
|
||||||
|
search_result['search_score'] = score
|
||||||
|
search_result['matched_fields'] = []
|
||||||
|
|
||||||
|
if query_lower in title:
|
||||||
|
search_result['matched_fields'].append('title')
|
||||||
|
if query_lower in content_preview:
|
||||||
|
search_result['matched_fields'].append('content')
|
||||||
|
if query_lower in author:
|
||||||
|
search_result['matched_fields'].append('author')
|
||||||
|
if query_lower in tags:
|
||||||
|
search_result['matched_fields'].append('tags')
|
||||||
|
|
||||||
|
search_results.append(search_result)
|
||||||
|
|
||||||
|
# Sort by search score (descending) and then by timestamp (descending)
|
||||||
|
search_results.sort(key=lambda x: (-x.get('search_score', 0), -x.get('timestamp', 0)))
|
||||||
|
|
||||||
|
# Apply pagination
|
||||||
|
start_idx = (page - 1) * per_page
|
||||||
|
end_idx = start_idx + per_page
|
||||||
|
paginated_results = search_results[start_idx:end_idx]
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'query': query,
|
||||||
|
'posts': paginated_results,
|
||||||
'pagination': {
|
'pagination': {
|
||||||
'current_page': page,
|
'current_page': page,
|
||||||
'total_pages': total_pages,
|
|
||||||
'total_posts': total_bookmarks,
|
|
||||||
'per_page': per_page,
|
'per_page': per_page,
|
||||||
'has_next': has_next,
|
'total_posts': len(search_results),
|
||||||
'has_prev': has_prev
|
'total_pages': (len(search_results) + per_page - 1) // per_page,
|
||||||
|
'has_next': end_idx < len(search_results),
|
||||||
|
'has_prev': page > 1
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting bookmarks: {e}")
|
logger.error(f"Error in search: {e}")
|
||||||
return jsonify({'error': 'Failed to get bookmarks'}), 500
|
return jsonify({'error': 'Failed to perform search'}), 500
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/bookmark-status/<post_uuid>')
|
|
||||||
@login_required
|
|
||||||
def api_bookmark_status(post_uuid):
|
|
||||||
"""Check if a post is bookmarked by current user"""
|
|
||||||
try:
|
|
||||||
from models import Bookmark
|
|
||||||
|
|
||||||
bookmark = Bookmark.query.filter_by(
|
|
||||||
user_id=current_user.id,
|
|
||||||
post_uuid=post_uuid
|
|
||||||
).first()
|
|
||||||
|
|
||||||
return jsonify({'bookmarked': bookmark is not None})
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error checking bookmark status: {e}")
|
|
||||||
return jsonify({'error': 'Failed to check bookmark status'}), 500
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/filters')
|
|
||||||
def api_filters():
|
|
||||||
"""API endpoint to get available filters"""
|
|
||||||
try:
|
|
||||||
filters = []
|
|
||||||
|
|
||||||
# Get current user's filter preference
|
|
||||||
current_filter = 'no_filter'
|
|
||||||
if current_user.is_authenticated:
|
|
||||||
try:
|
|
||||||
user_settings = json.loads(current_user.settings) if current_user.settings else {}
|
|
||||||
current_filter = user_settings.get('filter_set', 'no_filter')
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Get available filtersets from filter engine
|
|
||||||
for filterset_name in filter_engine.get_available_filtersets():
|
|
||||||
filterset_config = filter_engine.config.get_filterset(filterset_name)
|
|
||||||
if filterset_config:
|
|
||||||
# Map filter names to icons and display names
|
|
||||||
icon_map = {
|
|
||||||
'no_filter': '🌐',
|
|
||||||
'safe_content': '✅',
|
|
||||||
'tech_only': '💻',
|
|
||||||
'high_quality': '⭐',
|
|
||||||
'custom_example': '🎯'
|
|
||||||
}
|
|
||||||
|
|
||||||
name_map = {
|
|
||||||
'no_filter': 'All Content',
|
|
||||||
'safe_content': 'Safe Content',
|
|
||||||
'tech_only': 'Tech Only',
|
|
||||||
'high_quality': 'High Quality',
|
|
||||||
'custom_example': 'Custom Example'
|
|
||||||
}
|
|
||||||
|
|
||||||
filters.append({
|
|
||||||
'id': filterset_name,
|
|
||||||
'name': name_map.get(filterset_name, filterset_name.replace('_', ' ').title()),
|
|
||||||
'description': filterset_config.get('description', ''),
|
|
||||||
'icon': icon_map.get(filterset_name, '🔧'),
|
|
||||||
'active': filterset_name == current_filter
|
|
||||||
})
|
|
||||||
|
|
||||||
return jsonify({'filters': filters})
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error getting filters: {e}")
|
|
||||||
return jsonify({'error': 'Failed to get filters'}), 500
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/bookmarks')
|
|
||||||
@login_required
|
|
||||||
def bookmarks():
|
|
||||||
"""Bookmarks page"""
|
|
||||||
return render_template('bookmarks.html', user=current_user)
|
|
||||||
|
|
||||||
|
|
||||||
def build_comment_tree(comments):
|
|
||||||
"""Build a hierarchical comment tree from flat comment list"""
|
|
||||||
# Create lookup dict by UUID
|
|
||||||
comment_dict = {c['uuid']: {**c, 'replies': []} for c in comments}
|
|
||||||
|
|
||||||
# Build tree structure
|
|
||||||
root_comments = []
|
|
||||||
for comment in comments:
|
|
||||||
parent_uuid = comment.get('parent_comment_uuid')
|
|
||||||
if parent_uuid and parent_uuid in comment_dict:
|
|
||||||
# Add as reply to parent
|
|
||||||
comment_dict[parent_uuid]['replies'].append(comment_dict[comment['uuid']])
|
|
||||||
else:
|
|
||||||
# Top-level comment
|
|
||||||
root_comments.append(comment_dict[comment['uuid']])
|
|
||||||
|
|
||||||
# Sort at each level by timestamp
|
|
||||||
def sort_tree(comments_list):
|
|
||||||
comments_list.sort(key=lambda x: x.get('timestamp', 0))
|
|
||||||
for comment in comments_list:
|
|
||||||
if comment.get('replies'):
|
|
||||||
sort_tree(comment['replies'])
|
|
||||||
|
|
||||||
sort_tree(root_comments)
|
|
||||||
return root_comments
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/post/<post_id>')
|
@app.route('/post/<post_id>')
|
||||||
@@ -922,11 +673,10 @@ def post_detail(post_id):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Get comments from cache
|
# Get comments from cache
|
||||||
comments_flat = cached_comments.get(post_id, [])
|
comments = cached_comments.get(post_id, [])
|
||||||
logger.info(f"Loading post {post_id}: found {len(comments_flat)} comments")
|
|
||||||
|
|
||||||
# Build comment tree
|
# Sort comments by timestamp
|
||||||
comments = build_comment_tree(comments_flat)
|
comments.sort(key=lambda x: x.get('timestamp', 0))
|
||||||
|
|
||||||
# Load user settings if authenticated
|
# Load user settings if authenticated
|
||||||
user_settings = {}
|
user_settings = {}
|
||||||
@@ -955,16 +705,8 @@ def serve_theme(filename):
|
|||||||
|
|
||||||
@app.route('/logo.png')
|
@app.route('/logo.png')
|
||||||
def serve_logo():
|
def serve_logo():
|
||||||
"""Serve configurable logo"""
|
"""Serve logo"""
|
||||||
logo_path = app.config['LOGO_PATH']
|
return send_from_directory('.', 'logo.png')
|
||||||
# If it's just a filename, serve from current directory
|
|
||||||
if '/' not in logo_path:
|
|
||||||
return send_from_directory('.', logo_path)
|
|
||||||
else:
|
|
||||||
# If it's a full path, split directory and filename
|
|
||||||
directory = os.path.dirname(logo_path)
|
|
||||||
filename = os.path.basename(logo_path)
|
|
||||||
return send_from_directory(directory, filename)
|
|
||||||
|
|
||||||
@app.route('/static/<path:filename>')
|
@app.route('/static/<path:filename>')
|
||||||
def serve_static(filename):
|
def serve_static(filename):
|
||||||
@@ -986,9 +728,6 @@ def login():
|
|||||||
if current_user.is_authenticated:
|
if current_user.is_authenticated:
|
||||||
return redirect(url_for('index'))
|
return redirect(url_for('index'))
|
||||||
|
|
||||||
# Check if Auth0 is configured
|
|
||||||
auth0_configured = bool(app.config.get('AUTH0_DOMAIN') and app.config.get('AUTH0_CLIENT_ID'))
|
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
username = request.form.get('username')
|
username = request.form.get('username')
|
||||||
password = request.form.get('password')
|
password = request.form.get('password')
|
||||||
@@ -996,7 +735,7 @@ def login():
|
|||||||
|
|
||||||
if not user_service:
|
if not user_service:
|
||||||
flash('User service not available', 'error')
|
flash('User service not available', 'error')
|
||||||
return render_template('login.html', auth0_configured=auth0_configured)
|
return render_template('login.html')
|
||||||
|
|
||||||
user = user_service.authenticate(username, password)
|
user = user_service.authenticate(username, password)
|
||||||
|
|
||||||
@@ -1010,97 +749,175 @@ def login():
|
|||||||
else:
|
else:
|
||||||
flash('Invalid username or password', 'error')
|
flash('Invalid username or password', 'error')
|
||||||
|
|
||||||
return render_template('login.html', auth0_configured=auth0_configured)
|
return render_template('login.html')
|
||||||
|
|
||||||
|
|
||||||
@app.route('/password-reset-request', methods=['GET', 'POST'])
|
@app.route('/forgot_username', methods=['GET', 'POST'])
|
||||||
def password_reset_request():
|
def forgot_username():
|
||||||
"""Request a password reset"""
|
"""Forgot username page"""
|
||||||
if current_user.is_authenticated:
|
|
||||||
return redirect(url_for('index'))
|
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
email = request.form.get('email', '').strip().lower()
|
email = request.form.get('email', '').strip()
|
||||||
|
|
||||||
if not email:
|
if not email:
|
||||||
flash('Please enter your email address', 'error')
|
flash('Email address is required', 'error')
|
||||||
return render_template('password_reset_request.html')
|
return render_template('forgot_username.html')
|
||||||
|
|
||||||
|
try:
|
||||||
# Find user by email
|
# Find user by email
|
||||||
user = User.query.filter_by(email=email).first()
|
user = User.query.filter_by(email=email).first()
|
||||||
|
|
||||||
# Always show success message for security (don't reveal if email exists)
|
if user:
|
||||||
flash('If an account exists with that email, a password reset link has been sent.', 'success')
|
# Send username notification (simplified)
|
||||||
|
logger.info(f"Username requested for user {user.username} ({email})")
|
||||||
if user and user.password_hash: # Only send reset if user has a password (not OAuth only)
|
flash(f'Your username is: {user.username}', 'success')
|
||||||
# Generate reset token
|
else:
|
||||||
token = user.generate_reset_token()
|
# Don't reveal if email exists for security
|
||||||
|
logger.info(f"Username requested for unknown email: {email}")
|
||||||
# Build reset URL
|
flash('If this email address is registered, you will receive your username.', 'success')
|
||||||
reset_url = url_for('password_reset', token=token, _external=True)
|
|
||||||
|
|
||||||
# Log the reset URL (in production, this would be emailed)
|
|
||||||
logger.info(f"Password reset requested for {email}. Reset URL: {reset_url}")
|
|
||||||
|
|
||||||
# For now, also flash it for development (remove in production)
|
|
||||||
flash(f'Reset link (development only): {reset_url}', 'info')
|
|
||||||
|
|
||||||
return redirect(url_for('login'))
|
return redirect(url_for('login'))
|
||||||
|
|
||||||
return render_template('password_reset_request.html')
|
except Exception as e:
|
||||||
|
logger.error(f"Error in username request: {e}")
|
||||||
|
flash('An error occurred. Please try again.', 'error')
|
||||||
|
return render_template('forgot_username.html')
|
||||||
|
|
||||||
|
return render_template('forgot_username.html')
|
||||||
|
|
||||||
|
|
||||||
@app.route('/password-reset/<token>', methods=['GET', 'POST'])
|
@app.route('/forgot_password', methods=['GET', 'POST'])
|
||||||
def password_reset(token):
|
def forgot_password():
|
||||||
"""Reset password with token"""
|
"""Forgot password page"""
|
||||||
if current_user.is_authenticated:
|
|
||||||
return redirect(url_for('index'))
|
|
||||||
|
|
||||||
# Find user by token
|
|
||||||
user = User.query.filter_by(reset_token=token).first()
|
|
||||||
|
|
||||||
if not user or not user.verify_reset_token(token):
|
|
||||||
flash('Invalid or expired reset token', 'error')
|
|
||||||
return redirect(url_for('login'))
|
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
password = request.form.get('password', '')
|
email = request.form.get('email', '').strip()
|
||||||
confirm_password = request.form.get('confirm_password', '')
|
|
||||||
|
|
||||||
if not password or len(password) < 6:
|
if not email:
|
||||||
flash('Password must be at least 6 characters', 'error')
|
flash('Email address is required', 'error')
|
||||||
return render_template('password_reset.html')
|
return render_template('forgot_password.html')
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Find user by email
|
||||||
|
user = User.query.filter_by(email=email).first()
|
||||||
|
|
||||||
|
if user:
|
||||||
|
# Generate password reset token (simplified)
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
# Create a simple token
|
||||||
|
timestamp = datetime.utcnow().isoformat()
|
||||||
|
token_string = f"{user.id}:{email}:{timestamp}"
|
||||||
|
token = hashlib.sha256(token_string.encode()).hexdigest()[:32]
|
||||||
|
|
||||||
|
# Store token with expiration
|
||||||
|
user.settings = user.settings or '{}'
|
||||||
|
import json
|
||||||
|
settings = json.loads(user.settings)
|
||||||
|
settings['password_reset_token'] = token
|
||||||
|
settings['password_reset_expires'] = (datetime.utcnow() + timedelta(hours=1)).isoformat()
|
||||||
|
user.settings = json.dumps(settings)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Log the reset request for security
|
||||||
|
logger.info(f"Password reset requested for user {user.username} ({email})")
|
||||||
|
flash('Password reset instructions have been sent to your email address.', 'success')
|
||||||
|
|
||||||
|
# Note: In production, you would send an actual email with the reset link here
|
||||||
|
# Example: send_password_reset_email(user.email, token)
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Don't reveal if email exists for security
|
||||||
|
logger.info(f"Password reset requested for unknown email: {email}")
|
||||||
|
flash('If this email address is registered, you will receive reset instructions.', 'success')
|
||||||
|
|
||||||
|
return redirect(url_for('login'))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in password reset request: {e}")
|
||||||
|
flash('An error occurred. Please try again.', 'error')
|
||||||
|
return render_template('forgot_password.html')
|
||||||
|
|
||||||
|
return render_template('forgot_password.html')
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/reset_password/<token>', methods=['GET', 'POST'])
|
||||||
|
def reset_password(token):
|
||||||
|
"""Password reset page with token"""
|
||||||
|
try:
|
||||||
|
# Find user with this token
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
user = User.query.filter(
|
||||||
|
(User.settings.op('~*')(token)) | # Token in settings
|
||||||
|
(User.email.op('~*')(token[:20])) # Partial match in email as fallback
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
flash('Invalid or expired reset link.', 'error')
|
||||||
|
return redirect(url_for('forgot_password'))
|
||||||
|
|
||||||
|
# Check if token exists and is valid (simplified check)
|
||||||
|
user_settings = json.loads(user.settings or '{}')
|
||||||
|
stored_token = user_settings.get('password_reset_token')
|
||||||
|
expires_str = user_settings.get('password_reset_expires')
|
||||||
|
|
||||||
|
if not stored_token or stored_token != token:
|
||||||
|
flash('Invalid or expired reset link.', 'error')
|
||||||
|
return redirect(url_for('forgot_password'))
|
||||||
|
|
||||||
|
# Check expiration
|
||||||
|
if expires_str:
|
||||||
|
expires_time = datetime.fromisoformat(expires_str)
|
||||||
|
if datetime.utcnow() > expires_time:
|
||||||
|
flash('Reset link has expired.', 'error')
|
||||||
|
return redirect(url_for('forgot_password'))
|
||||||
|
|
||||||
|
# Handle password reset
|
||||||
|
if request.method == 'POST':
|
||||||
|
password = request.form.get('password', '').strip()
|
||||||
|
confirm_password = request.form.get('confirm_password', '').strip()
|
||||||
|
|
||||||
|
if not password or not confirm_password:
|
||||||
|
flash('Both password fields are required.', 'error')
|
||||||
|
return render_template('reset_password.html', token=token)
|
||||||
|
|
||||||
if password != confirm_password:
|
if password != confirm_password:
|
||||||
flash('Passwords do not match', 'error')
|
flash('Passwords do not match.', 'error')
|
||||||
return render_template('password_reset.html')
|
return render_template('reset_password.html', token=token)
|
||||||
|
|
||||||
# Set new password
|
if len(password) < 8:
|
||||||
|
flash('Password must be at least 8 characters long.', 'error')
|
||||||
|
return render_template('reset_password.html', token=token)
|
||||||
|
|
||||||
|
# Update password
|
||||||
user.set_password(password)
|
user.set_password(password)
|
||||||
user.clear_reset_token()
|
|
||||||
|
|
||||||
flash('Your password has been reset successfully. You can now log in.', 'success')
|
# Clear the reset token
|
||||||
|
user_settings.pop('password_reset_token', None)
|
||||||
|
user_settings.pop('password_reset_expires', None)
|
||||||
|
user.settings = json.dumps(user_settings)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
logger.info(f"Password reset completed for user {user.username}")
|
||||||
|
flash('Your password has been successfully reset. Please log in.', 'success')
|
||||||
|
|
||||||
return redirect(url_for('login'))
|
return redirect(url_for('login'))
|
||||||
|
|
||||||
return render_template('password_reset.html')
|
return render_template('reset_password.html', token=token)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in password reset: {e}")
|
||||||
|
flash('An error occurred. Please try again.', 'error')
|
||||||
|
return redirect(url_for('forgot_password'))
|
||||||
|
|
||||||
|
|
||||||
# Auth0 Routes
|
# Auth0 Routes
|
||||||
@app.route('/auth0/login')
|
@app.route('/auth0/login')
|
||||||
def auth0_login():
|
def auth0_login():
|
||||||
"""Redirect to Auth0 for authentication"""
|
"""Redirect to Auth0 for authentication"""
|
||||||
# Check if Auth0 is configured
|
|
||||||
if not app.config.get('AUTH0_DOMAIN') or not app.config.get('AUTH0_CLIENT_ID'):
|
|
||||||
flash('Auth0 authentication is not configured. Please use email/password login or contact the administrator.', 'error')
|
|
||||||
return redirect(url_for('login'))
|
|
||||||
|
|
||||||
try:
|
|
||||||
redirect_uri = url_for('auth0_callback', _external=True)
|
redirect_uri = url_for('auth0_callback', _external=True)
|
||||||
return auth0.authorize_redirect(redirect_uri)
|
return auth0.authorize_redirect(redirect_uri)
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Auth0 login error: {e}")
|
|
||||||
flash('Auth0 authentication failed. Please use email/password login.', 'error')
|
|
||||||
return redirect(url_for('login'))
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/auth0/callback')
|
@app.route('/auth0/callback')
|
||||||
@@ -1408,30 +1225,15 @@ def settings_communities():
|
|||||||
except:
|
except:
|
||||||
selected_communities = []
|
selected_communities = []
|
||||||
|
|
||||||
# Get available communities from platform config and collection targets
|
# Available communities
|
||||||
available_communities = []
|
available_communities = [
|
||||||
|
{'id': 'programming', 'name': 'Programming', 'platform': 'reddit'},
|
||||||
# Load platform configuration
|
{'id': 'python', 'name': 'Python', 'platform': 'reddit'},
|
||||||
platform_config = load_platform_config()
|
{'id': 'technology', 'name': 'Technology', 'platform': 'reddit'},
|
||||||
|
{'id': 'hackernews', 'name': 'Hacker News', 'platform': 'hackernews'},
|
||||||
# Get enabled communities from collection_targets (what's actually being crawled)
|
{'id': 'lobsters', 'name': 'Lobsters', 'platform': 'lobsters'},
|
||||||
enabled_communities = set()
|
{'id': 'stackoverflow', 'name': 'Stack Overflow', 'platform': 'stackoverflow'},
|
||||||
for target in platform_config.get('collection_targets', []):
|
]
|
||||||
enabled_communities.add((target['platform'], target['community']))
|
|
||||||
|
|
||||||
# Build community list from platform config for communities that are enabled
|
|
||||||
for platform_name, platform_info in platform_config.get('platforms', {}).items():
|
|
||||||
for community_info in platform_info.get('communities', []):
|
|
||||||
# Only include communities that are in collection_targets
|
|
||||||
if (platform_name, community_info['id']) in enabled_communities:
|
|
||||||
available_communities.append({
|
|
||||||
'id': community_info['id'],
|
|
||||||
'name': community_info['name'],
|
|
||||||
'display_name': community_info.get('display_name', community_info['name']),
|
|
||||||
'platform': platform_name,
|
|
||||||
'icon': community_info.get('icon', platform_info.get('icon', '📄')),
|
|
||||||
'description': community_info.get('description', '')
|
|
||||||
})
|
|
||||||
|
|
||||||
return render_template('settings_communities.html',
|
return render_template('settings_communities.html',
|
||||||
user=current_user,
|
user=current_user,
|
||||||
@@ -1476,10 +1278,13 @@ def settings_filters():
|
|||||||
|
|
||||||
current_filter = user_settings.get('filter_set', 'no_filter')
|
current_filter = user_settings.get('filter_set', 'no_filter')
|
||||||
|
|
||||||
# Load available filter sets from FilterEngine as a dictionary
|
# Load available filter sets
|
||||||
|
filter_sets = {}
|
||||||
|
try:
|
||||||
|
with open('filtersets.json', 'r') as f:
|
||||||
|
filter_sets = json.load(f)
|
||||||
|
except:
|
||||||
filter_sets = {}
|
filter_sets = {}
|
||||||
for filterset_name in filter_engine.get_available_filtersets():
|
|
||||||
filter_sets[filterset_name] = filter_engine.config.get_filterset(filterset_name)
|
|
||||||
|
|
||||||
return render_template('settings_filters.html',
|
return render_template('settings_filters.html',
|
||||||
user=current_user,
|
user=current_user,
|
||||||
@@ -1503,9 +1308,7 @@ def settings_experience():
|
|||||||
'infinite_scroll': request.form.get('infinite_scroll') == 'on',
|
'infinite_scroll': request.form.get('infinite_scroll') == 'on',
|
||||||
'auto_refresh': request.form.get('auto_refresh') == 'on',
|
'auto_refresh': request.form.get('auto_refresh') == 'on',
|
||||||
'push_notifications': request.form.get('push_notifications') == 'on',
|
'push_notifications': request.form.get('push_notifications') == 'on',
|
||||||
'dark_patterns_opt_in': request.form.get('dark_patterns_opt_in') == 'on',
|
'dark_patterns_opt_in': request.form.get('dark_patterns_opt_in') == 'on'
|
||||||
'time_filter_enabled': request.form.get('time_filter_enabled') == 'on',
|
|
||||||
'time_filter_days': int(request.form.get('time_filter_days', 7))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Save settings
|
# Save settings
|
||||||
@@ -1525,9 +1328,7 @@ def settings_experience():
|
|||||||
'infinite_scroll': False,
|
'infinite_scroll': False,
|
||||||
'auto_refresh': False,
|
'auto_refresh': False,
|
||||||
'push_notifications': False,
|
'push_notifications': False,
|
||||||
'dark_patterns_opt_in': False,
|
'dark_patterns_opt_in': False
|
||||||
'time_filter_enabled': False,
|
|
||||||
'time_filter_days': 7
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return render_template('settings_experience.html',
|
return render_template('settings_experience.html',
|
||||||
@@ -2017,18 +1818,6 @@ def admin_polling_logs(source_id):
|
|||||||
logs=logs)
|
logs=logs)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# TEMPLATE CONTEXT PROCESSORS
|
|
||||||
# ============================================================
|
|
||||||
|
|
||||||
@app.context_processor
|
|
||||||
def inject_app_config():
|
|
||||||
"""Inject app configuration into all templates"""
|
|
||||||
return {
|
|
||||||
'APP_NAME': app.config['APP_NAME']
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# ERROR HANDLERS
|
# ERROR HANDLERS
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ from data_collection_lib import data_methods
|
|||||||
# ===== STORAGE FUNCTIONS =====
|
# ===== STORAGE FUNCTIONS =====
|
||||||
|
|
||||||
def ensure_directories(storage_dir: str) -> Dict[str, Path]:
|
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)
|
base = Path(storage_dir)
|
||||||
|
|
||||||
dirs = {
|
dirs = {
|
||||||
@@ -27,7 +30,40 @@ def ensure_directories(storage_dir: str) -> Dict[str, Path]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
for path in dirs.values():
|
for path in dirs.values():
|
||||||
|
try:
|
||||||
path.mkdir(parents=True, exist_ok=True)
|
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
|
return dirs
|
||||||
|
|
||||||
@@ -46,11 +82,41 @@ def load_index(storage_dir: str) -> Dict:
|
|||||||
|
|
||||||
|
|
||||||
def save_index(index: Dict, storage_dir: str):
|
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'
|
index_file = Path(storage_dir) / 'post_index.json'
|
||||||
|
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:
|
with open(index_file, 'w') as f:
|
||||||
json.dump(index, f, indent=2)
|
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:
|
def load_state(storage_dir: str) -> Dict:
|
||||||
"""Load collection state from disk"""
|
"""Load collection state from disk"""
|
||||||
@@ -66,10 +132,29 @@ def load_state(storage_dir: str) -> Dict:
|
|||||||
|
|
||||||
|
|
||||||
def save_state(state: Dict, storage_dir: str):
|
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'
|
state_file = Path(storage_dir) / 'collection_state.json'
|
||||||
|
try:
|
||||||
with open(state_file, 'w') as f:
|
with open(state_file, 'w') as f:
|
||||||
json.dump(state, f, indent=2)
|
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:
|
def generate_uuid() -> str:
|
||||||
@@ -211,12 +296,6 @@ def collect_platform(platform: str, community: str, start_date: str, end_date: s
|
|||||||
if post_id in index:
|
if post_id in index:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# ====================================================================
|
|
||||||
# FIX: Correct the post's source field BEFORE saving
|
|
||||||
# ====================================================================
|
|
||||||
post['source'] = community if community else platform
|
|
||||||
# ====================================================================
|
|
||||||
|
|
||||||
# Save post
|
# Save post
|
||||||
post_uuid = save_post(post, platform, index, dirs)
|
post_uuid = save_post(post, platform, index, dirs)
|
||||||
added_count += 1
|
added_count += 1
|
||||||
|
|||||||
@@ -292,10 +292,8 @@ class data_methods():
|
|||||||
'meta': {'is_self': post.get('is_self', False)}
|
'meta': {'is_self': post.get('is_self', False)}
|
||||||
}
|
}
|
||||||
|
|
||||||
# In data_methods.converters.hackernews_to_schema()
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def hackernews_to_schema(raw, community='front_page'): # Add community parameter
|
def hackernews_to_schema(raw):
|
||||||
if not raw or raw.get('type') != 'story':
|
if not raw or raw.get('type') != 'story':
|
||||||
return None
|
return None
|
||||||
return {
|
return {
|
||||||
@@ -308,11 +306,7 @@ class data_methods():
|
|||||||
'replies': raw.get('descendants', 0),
|
'replies': raw.get('descendants', 0),
|
||||||
'url': raw.get('url', f"https://news.ycombinator.com/item?id={raw.get('id')}"),
|
'url': raw.get('url', f"https://news.ycombinator.com/item?id={raw.get('id')}"),
|
||||||
'content': raw.get('text', ''),
|
'content': raw.get('text', ''),
|
||||||
# ====================================================================
|
'source': 'hackernews',
|
||||||
# FIX: Use the community parameter for the source
|
|
||||||
# ====================================================================
|
|
||||||
'source': community,
|
|
||||||
# ====================================================================
|
|
||||||
'tags': ['hackernews'],
|
'tags': ['hackernews'],
|
||||||
'meta': {}
|
'meta': {}
|
||||||
}
|
}
|
||||||
@@ -687,7 +681,7 @@ class data_methods():
|
|||||||
stories.append(data_methods.utils.http_get_json(story_url))
|
stories.append(data_methods.utils.http_get_json(story_url))
|
||||||
|
|
||||||
# Convert and filter
|
# Convert and filter
|
||||||
posts = [data_methods.converters.hackernews_to_schema(s, community) for s in stories]
|
posts = [data_methods.converters.hackernews_to_schema(s) for s in stories]
|
||||||
return data_methods.utils.filter_by_date_range(posts, start_date, end_date)
|
return data_methods.utils.filter_by_date_range(posts, start_date, end_date)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -48,9 +48,8 @@ services:
|
|||||||
AUTH0_CLIENT_SECRET: ${AUTH0_CLIENT_SECRET:-}
|
AUTH0_CLIENT_SECRET: ${AUTH0_CLIENT_SECRET:-}
|
||||||
AUTH0_AUDIENCE: ${AUTH0_AUDIENCE:-}
|
AUTH0_AUDIENCE: ${AUTH0_AUDIENCE:-}
|
||||||
volumes:
|
volumes:
|
||||||
# Application-managed data (using a named volume)
|
# Persistent data storage
|
||||||
- app_data:/app/data
|
- ./data:/app/data
|
||||||
# User-editable content (using bind mounts)
|
|
||||||
- ./static:/app/static
|
- ./static:/app/static
|
||||||
- ./backups:/app/backups
|
- ./backups:/app/backups
|
||||||
- ./active_html:/app/active_html
|
- ./active_html:/app/active_html
|
||||||
@@ -73,4 +72,3 @@ networks:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
app_data: # <-- New named volume declared here
|
|
||||||
@@ -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)
|
# User settings (JSON stored as text)
|
||||||
settings = db.Column(db.Text, default='{}')
|
settings = db.Column(db.Text, default='{}')
|
||||||
|
|
||||||
# Password reset
|
|
||||||
reset_token = db.Column(db.String(100), nullable=True, unique=True, index=True)
|
|
||||||
reset_token_expiry = db.Column(db.DateTime, nullable=True)
|
|
||||||
|
|
||||||
def __init__(self, username, email, password=None, is_admin=False, auth0_id=None):
|
def __init__(self, username, email, password=None, is_admin=False, auth0_id=None):
|
||||||
"""
|
"""
|
||||||
Initialize a new user.
|
Initialize a new user.
|
||||||
@@ -106,32 +102,6 @@ class User(UserMixin, db.Model):
|
|||||||
self.last_login = datetime.utcnow()
|
self.last_login = datetime.utcnow()
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
def generate_reset_token(self):
|
|
||||||
"""Generate a password reset token that expires in 1 hour"""
|
|
||||||
import secrets
|
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
self.reset_token = secrets.token_urlsafe(32)
|
|
||||||
self.reset_token_expiry = datetime.utcnow() + timedelta(hours=1)
|
|
||||||
db.session.commit()
|
|
||||||
return self.reset_token
|
|
||||||
|
|
||||||
def verify_reset_token(self, token):
|
|
||||||
"""Verify if the provided reset token is valid and not expired"""
|
|
||||||
if not self.reset_token or not self.reset_token_expiry:
|
|
||||||
return False
|
|
||||||
if self.reset_token != token:
|
|
||||||
return False
|
|
||||||
if datetime.utcnow() > self.reset_token_expiry:
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
def clear_reset_token(self):
|
|
||||||
"""Clear the reset token after use"""
|
|
||||||
self.reset_token = None
|
|
||||||
self.reset_token_expiry = None
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
def get_id(self):
|
def get_id(self):
|
||||||
"""Required by Flask-Login"""
|
"""Required by Flask-Login"""
|
||||||
return self.id
|
return self.id
|
||||||
@@ -217,32 +187,3 @@ class PollLog(db.Model):
|
|||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f'<PollLog {self.id} for source {self.source_id}>'
|
return f'<PollLog {self.id} for source {self.source_id}>'
|
||||||
|
|
||||||
|
|
||||||
class Bookmark(db.Model):
|
|
||||||
"""User bookmarks for posts"""
|
|
||||||
|
|
||||||
__tablename__ = 'bookmarks'
|
|
||||||
|
|
||||||
id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
|
||||||
user_id = db.Column(db.String(36), db.ForeignKey('users.id'), nullable=False, index=True)
|
|
||||||
post_uuid = db.Column(db.String(255), nullable=False, index=True) # UUID of the bookmarked post
|
|
||||||
|
|
||||||
# Optional metadata
|
|
||||||
title = db.Column(db.String(500), nullable=True) # Cached post title
|
|
||||||
platform = db.Column(db.String(50), nullable=True) # Cached platform info
|
|
||||||
source = db.Column(db.String(100), nullable=True) # Cached source info
|
|
||||||
|
|
||||||
# Timestamps
|
|
||||||
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
|
||||||
|
|
||||||
# Relationships
|
|
||||||
user = db.relationship('User', backref=db.backref('bookmarks', lazy='dynamic', order_by='Bookmark.created_at.desc()'))
|
|
||||||
|
|
||||||
# Unique constraint - user can only bookmark a post once
|
|
||||||
__table_args__ = (
|
|
||||||
db.UniqueConstraint('user_id', 'post_uuid', name='unique_user_bookmark'),
|
|
||||||
)
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return f'<Bookmark {self.post_uuid} by user {self.user_id}>'
|
|
||||||
|
|||||||
@@ -143,20 +143,13 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"stackexchange": {
|
"stackoverflow": {
|
||||||
"name": "Stack Overflow",
|
"name": "Stack Overflow",
|
||||||
"icon": "📚",
|
"icon": "📚",
|
||||||
"color": "#f48024",
|
"color": "#f48024",
|
||||||
"prefix": "",
|
"prefix": "",
|
||||||
"supports_communities": false,
|
"supports_communities": false,
|
||||||
"communities": [
|
"communities": [
|
||||||
{
|
|
||||||
"id": "stackoverflow",
|
|
||||||
"name": "Stack Overflow",
|
|
||||||
"display_name": "Stack Overflow",
|
|
||||||
"icon": "📚",
|
|
||||||
"description": "Programming Q&A community"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"id": "featured",
|
"id": "featured",
|
||||||
"name": "Featured",
|
"name": "Featured",
|
||||||
@@ -264,12 +257,6 @@
|
|||||||
"community": "https://hnrss.org/frontpage",
|
"community": "https://hnrss.org/frontpage",
|
||||||
"max_posts": 50,
|
"max_posts": 50,
|
||||||
"priority": "low"
|
"priority": "low"
|
||||||
},
|
|
||||||
{
|
|
||||||
"platform": "stackexchange",
|
|
||||||
"community": "stackoverflow",
|
|
||||||
"max_posts": 50,
|
|
||||||
"priority": "medium"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Page Not Found - {{ APP_NAME }}</title>
|
<title>Page Not Found - BalanceBoard</title>
|
||||||
<link rel="stylesheet" href="{{ url_for('serve_theme', filename='modern-card-ui/styles.css') }}">
|
<link rel="stylesheet" href="{{ url_for('serve_theme', filename='modern-card-ui/styles.css') }}">
|
||||||
<style>
|
<style>
|
||||||
.error-container {
|
.error-container {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Server Error - {{ APP_NAME }}</title>
|
<title>Server Error - BalanceBoard</title>
|
||||||
<link rel="stylesheet" href="{{ url_for('serve_theme', filename='modern-card-ui/styles.css') }}">
|
<link rel="stylesheet" href="{{ url_for('serve_theme', filename='modern-card-ui/styles.css') }}">
|
||||||
<style>
|
<style>
|
||||||
.error-container {
|
.error-container {
|
||||||
|
|||||||
@@ -1,409 +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 {
|
|
||||||
padding: 8px 16px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
text-decoration: none;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background: var(--primary-color);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover {
|
|
||||||
background: var(--primary-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary {
|
|
||||||
background: var(--surface-elevation-1);
|
|
||||||
color: var(--text-primary);
|
|
||||||
border: 1px solid var(--divider-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary:hover {
|
|
||||||
background: var(--surface-elevation-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn {
|
|
||||||
padding: 6px 12px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
margin-right: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn-primary {
|
|
||||||
background: var(--primary-color);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn-primary:hover {
|
|
||||||
background: var(--primary-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn-danger {
|
|
||||||
background: #dc3545;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn-danger:hover {
|
|
||||||
background: #c82333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn-warning {
|
|
||||||
background: #ffc107;
|
|
||||||
color: #212529;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn-warning:hover {
|
|
||||||
background: #e0a800;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== STATUS BADGES ===== */
|
|
||||||
.badge {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 4px 12px;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-admin {
|
|
||||||
background: var(--primary-color);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-user {
|
|
||||||
background: var(--background-color);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-active {
|
|
||||||
background: #28a745;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-inactive {
|
|
||||||
background: #6c757d;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 4px 12px;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-enabled, .status-success {
|
|
||||||
background: #d4edda;
|
|
||||||
color: #155724;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-disabled, .status-error {
|
|
||||||
background: #f8d7da;
|
|
||||||
color: #721c24;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-running {
|
|
||||||
background: #fff3cd;
|
|
||||||
color: #856404;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== TABLES ===== */
|
|
||||||
.admin-table {
|
|
||||||
background: var(--surface-color);
|
|
||||||
border-radius: 12px;
|
|
||||||
overflow: hidden;
|
|
||||||
box-shadow: 0 2px 4px var(--surface-elevation-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-table table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-table th {
|
|
||||||
background: var(--primary-dark);
|
|
||||||
color: white;
|
|
||||||
padding: 16px;
|
|
||||||
text-align: left;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-table td {
|
|
||||||
padding: 16px;
|
|
||||||
border-bottom: 1px solid var(--divider-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-table tr:hover {
|
|
||||||
background: var(--hover-overlay);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-table tr:last-child td {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== STATS & CARDS ===== */
|
|
||||||
.admin-stats {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
||||||
gap: 16px;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card {
|
|
||||||
background: var(--surface-color);
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 12px;
|
|
||||||
box-shadow: 0 2px 4px var(--surface-elevation-1);
|
|
||||||
border-left: 4px solid var(--primary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-number {
|
|
||||||
font-size: 2rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--primary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-label {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-card {
|
|
||||||
background: var(--background-color);
|
|
||||||
padding: 16px;
|
|
||||||
border-radius: 8px;
|
|
||||||
border-left: 3px solid var(--primary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-card h4 {
|
|
||||||
margin: 0 0 8px 0;
|
|
||||||
color: var(--primary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-card p {
|
|
||||||
margin: 4px 0;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.system-info {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== FORMS ===== */
|
|
||||||
.form-group {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group label {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-control {
|
|
||||||
width: 100%;
|
|
||||||
padding: 8px 12px;
|
|
||||||
border: 1px solid var(--divider-color);
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-control:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--primary-color);
|
|
||||||
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== UTILITIES ===== */
|
|
||||||
.back-link {
|
|
||||||
display: inline-block;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
color: var(--primary-color);
|
|
||||||
text-decoration: none;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.back-link:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flash-messages {
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flash-message {
|
|
||||||
padding: 12px 16px;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flash-message.success {
|
|
||||||
background: #d4edda;
|
|
||||||
color: #155724;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flash-message.error {
|
|
||||||
background: #f8d7da;
|
|
||||||
color: #721c24;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flash-message.warning {
|
|
||||||
background: #fff3cd;
|
|
||||||
color: #856404;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== RESPONSIVE ===== */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.admin-tabs {
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-stats {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.system-info {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-container {
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== PAGE-SPECIFIC OVERRIDES ===== */
|
|
||||||
{% block admin_styles %}{% endblock %}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
{% include '_nav.html' %}
|
|
||||||
|
|
||||||
<div class="admin-container">
|
|
||||||
<a href="{{ url_for('index') }}" class="back-link">← Back to Feed</a>
|
|
||||||
|
|
||||||
<div class="admin-header">
|
|
||||||
<h1>{% block page_title %}Admin Panel{% endblock %}</h1>
|
|
||||||
<p>{% block page_description %}Manage system settings and content{% endblock %}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
|
||||||
{% if messages %}
|
|
||||||
<div class="flash-messages">
|
|
||||||
{% for category, message in messages %}
|
|
||||||
<div class="flash-message {{ category }}">{{ message }}</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endwith %}
|
|
||||||
|
|
||||||
{% block admin_content %}{% endblock %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% block admin_scripts %}{% endblock %}
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -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,11 +1,315 @@
|
|||||||
{% 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 %}
|
.admin-header h1 {
|
||||||
{% block page_description %}Manage users, content, and system settings{% endblock %}
|
margin: 0 0 8px 0;
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-header p {
|
||||||
|
margin: 0;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
{% block admin_styles %}
|
|
||||||
.user-avatar {
|
.user-avatar {
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
@@ -24,9 +328,40 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block admin_content %}
|
@media (max-width: 768px) {
|
||||||
|
.admin-tabs {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stats {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-info {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="admin-container">
|
||||||
|
<a href="{{ url_for('index') }}" class="back-link">← Back to Feed</a>
|
||||||
|
|
||||||
|
<div class="admin-header">
|
||||||
|
<h1>Admin Panel</h1>
|
||||||
|
<p>Manage users, content, and system settings</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
<div class="flash-messages">
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="flash-message {{ category }}">{{ message }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
<div class="admin-tabs">
|
<div class="admin-tabs">
|
||||||
<button class="tab-btn active" onclick="showTab('overview')">Overview</button>
|
<button class="tab-btn active" onclick="showTab('overview')">Overview</button>
|
||||||
@@ -90,7 +425,7 @@
|
|||||||
<div id="users" class="tab-content">
|
<div id="users" class="tab-content">
|
||||||
<div class="admin-section">
|
<div class="admin-section">
|
||||||
<h3 class="section-title">User Management</h3>
|
<h3 class="section-title">User Management</h3>
|
||||||
<div class="admin-table">
|
<div class="users-table">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -221,9 +556,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
</div>
|
||||||
|
|
||||||
{% block admin_scripts %}
|
|
||||||
<script>
|
<script>
|
||||||
function showTab(tabName) {
|
function showTab(tabName) {
|
||||||
// Hide all tabs
|
// Hide all tabs
|
||||||
@@ -241,4 +575,5 @@ function showTab(tabName) {
|
|||||||
event.target.classList.add('active');
|
event.target.classList.add('active');
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
</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 %}
|
.status-badge {
|
||||||
{% block page_description %}Manage data collection sources and schedules{% endblock %}
|
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 {
|
.source-card {
|
||||||
background: var(--surface-color);
|
background: var(--surface-color);
|
||||||
@@ -133,62 +174,22 @@
|
|||||||
padding: 48px;
|
padding: 48px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="admin-container">
|
||||||
|
<div class="admin-header">
|
||||||
|
<h1>📡 Polling Management</h1>
|
||||||
|
<p>Configure automatic data collection from content sources</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
.add-source-form {
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
background: var(--surface-color);
|
{% if messages %}
|
||||||
border: 1px solid var(--divider-color);
|
{% for category, message in messages %}
|
||||||
border-radius: 12px;
|
<div class="alert alert-{{ category }}">{{ message }}</div>
|
||||||
padding: 24px;
|
{% endfor %}
|
||||||
margin-bottom: 24px;
|
{% endif %}
|
||||||
}
|
{% endwith %}
|
||||||
|
|
||||||
.form-label {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-input, .form-select {
|
|
||||||
width: 100%;
|
|
||||||
padding: 10px;
|
|
||||||
border: 1px solid var(--divider-color);
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scheduler-status {
|
|
||||||
background: var(--surface-color);
|
|
||||||
border: 1px solid var(--divider-color);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 16px;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.help-text {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert {
|
|
||||||
padding: 12px 16px;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-success {
|
|
||||||
background: #d4edda;
|
|
||||||
color: #155724;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-error {
|
|
||||||
background: #f8d7da;
|
|
||||||
color: #721c24;
|
|
||||||
}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block admin_content %}
|
|
||||||
|
|
||||||
<!-- Scheduler Status -->
|
<!-- Scheduler Status -->
|
||||||
<div class="scheduler-status">
|
<div class="scheduler-status">
|
||||||
@@ -493,61 +494,9 @@
|
|||||||
modal2.style.display = 'block';
|
modal2.style.display = 'block';
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeEditModal() {
|
|
||||||
document.getElementById('edit-modal').style.display = 'none';
|
|
||||||
}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block admin_scripts %}
|
|
||||||
<script>
|
|
||||||
const platformConfig = {{ platform_config|tojson|safe }};
|
|
||||||
|
|
||||||
function updateSourceOptions() {
|
|
||||||
const platformSelect = document.getElementById('platform');
|
|
||||||
const sourceSelect = document.getElementById('source_id');
|
|
||||||
const selectedPlatform = platformSelect.value;
|
|
||||||
|
|
||||||
// Clear existing options
|
|
||||||
sourceSelect.innerHTML = '<option value="">Select source...</option>';
|
|
||||||
|
|
||||||
if (selectedPlatform && platformConfig.platforms[selectedPlatform]) {
|
|
||||||
const communities = platformConfig.platforms[selectedPlatform].communities || [];
|
|
||||||
communities.forEach(community => {
|
|
||||||
const option = document.createElement('option');
|
|
||||||
option.value = community.id;
|
|
||||||
option.textContent = community.display_name || community.name;
|
|
||||||
option.dataset.displayName = community.display_name || community.name;
|
|
||||||
sourceSelect.appendChild(option);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateDisplayName() {
|
|
||||||
const sourceSelect = document.getElementById('source_id');
|
|
||||||
const displayNameInput = document.getElementById('display_name');
|
|
||||||
const selectedOption = sourceSelect.options[sourceSelect.selectedIndex];
|
|
||||||
|
|
||||||
if (selectedOption && selectedOption.dataset.displayName) {
|
|
||||||
displayNameInput.value = selectedOption.dataset.displayName;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openEditModal(sourceId, displayName, interval, maxPosts, fetchComments, priority) {
|
|
||||||
// Fill form with current values
|
|
||||||
const modal2 = document.getElementById('edit-modal');
|
|
||||||
const form = document.getElementById('edit-form');
|
|
||||||
form.action = `/admin/polling/${sourceId}/update`;
|
|
||||||
document.getElementById('edit_display_name').value = displayName;
|
|
||||||
document.getElementById('edit_interval').value = interval;
|
|
||||||
document.getElementById('edit_max_posts').value = maxPosts;
|
|
||||||
document.getElementById('edit_fetch_comments').value = fetchComments;
|
|
||||||
document.getElementById('edit_priority').value = priority;
|
|
||||||
|
|
||||||
modal2.style.display = 'block';
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeEditModal() {
|
function closeEditModal() {
|
||||||
document.getElementById('edit-modal').style.display = 'none';
|
document.getElementById('edit-modal').style.display = 'none';
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
</body>
|
||||||
|
</html>
|
||||||
|
|||||||
@@ -1,11 +1,73 @@
|
|||||||
{% 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 %}
|
.log-table {
|
||||||
{% block page_description %}View polling history and error logs for this source{% endblock %}
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
background: var(--surface-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-table th {
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
padding: 12px;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-table td {
|
||||||
|
padding: 12px;
|
||||||
|
border-bottom: 1px solid var(--divider-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
{% block admin_styles %}
|
|
||||||
.error-detail {
|
.error-detail {
|
||||||
background: #fff3cd;
|
background: #fff3cd;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
@@ -17,31 +79,72 @@
|
|||||||
overflow-y: auto;
|
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 {
|
.no-logs {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 48px;
|
padding: 48px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
{% endblock %}
|
</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>
|
||||||
|
|
||||||
{% block admin_content %}
|
|
||||||
<div class="admin-table">
|
|
||||||
{% if logs %}
|
{% if logs %}
|
||||||
<table>
|
<table class="log-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Timestamp</th>
|
<th>Started</th>
|
||||||
|
<th>Completed</th>
|
||||||
|
<th>Duration</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Posts Found</th>
|
<th>Posts Found</th>
|
||||||
<th>New Posts</th>
|
<th>New</th>
|
||||||
<th>Updated Posts</th>
|
<th>Updated</th>
|
||||||
<th>Error Details</th>
|
<th>Details</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for log in logs %}
|
{% for log in logs %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ log.poll_time.strftime('%Y-%m-%d %H:%M:%S') }}</td>
|
<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>
|
<td>
|
||||||
{% if log.status == 'success' %}
|
{% if log.status == 'success' %}
|
||||||
<span class="status-badge status-success">Success</span>
|
<span class="status-badge status-success">Success</span>
|
||||||
@@ -76,9 +179,10 @@
|
|||||||
<p>Logs will appear here after the first poll.</p>
|
<p>Logs will appear here after the first poll.</p>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="margin-top: 24px;">
|
<div style="margin-top: 24px;">
|
||||||
<a href="{{ url_for('admin_polling') }}" class="btn btn-secondary">← Back to Polling Management</a>
|
<a href="{{ url_for('admin_polling') }}" class="btn btn-secondary">← Back to Polling Management</a>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}Create Admin Account - {{ APP_NAME }}{% endblock %}
|
{% block title %}Create Admin Account - BalanceBoard{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% include '_nav.html' %}
|
|
||||||
<div class="auth-container">
|
<div class="auth-container">
|
||||||
<div class="auth-card">
|
<div class="auth-card">
|
||||||
<div class="auth-logo">
|
<div class="auth-logo">
|
||||||
<a href="{{ url_for('index') }}">
|
<img src="{{ url_for('serve_logo') }}" alt="BalanceBoard Logo">
|
||||||
<img src="{{ url_for('serve_logo') }}" alt="{{ APP_NAME }} Logo" style="max-width: 80px; border-radius: 50%;">
|
|
||||||
</a>
|
|
||||||
<h1><span class="balance">balance</span><span class="board">Board</span></h1>
|
<h1><span class="balance">balance</span><span class="board">Board</span></h1>
|
||||||
<p style="color: var(--text-secondary); margin-top: 8px;">Create Administrator Account</p>
|
<p style="color: var(--text-secondary); margin-top: 8px;">Create Administrator Account</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -77,60 +74,5 @@
|
|||||||
.board {
|
.board {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ensure form styles are properly applied */
|
|
||||||
.auth-form .form-group {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-form label {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: 0.95rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-form input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 12px 16px;
|
|
||||||
border: 2px solid var(--border-color);
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 1rem;
|
|
||||||
background: var(--background-color);
|
|
||||||
color: var(--text-primary);
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-form input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--primary-color);
|
|
||||||
box-shadow: 0 0 0 3px rgba(77, 182, 172, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-form button {
|
|
||||||
width: 100%;
|
|
||||||
padding: 14px;
|
|
||||||
background: var(--primary-color);
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-form button:hover {
|
|
||||||
background: var(--primary-hover);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 4px 12px rgba(77, 182, 172, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-footer {
|
|
||||||
margin-top: 24px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{% block title %}{{ APP_NAME }}{% endblock %}</title>
|
<title>{% block title %}BalanceBoard{% endblock %}</title>
|
||||||
<link rel="stylesheet" href="{{ url_for('serve_theme', filename='modern-card-ui/styles.css') }}">
|
<link rel="stylesheet" href="{{ url_for('serve_theme', filename='modern-card-ui/styles.css') }}">
|
||||||
<style>
|
<style>
|
||||||
/* Auth pages styling */
|
/* Auth pages styling */
|
||||||
|
|||||||
@@ -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" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}Dashboard - {{ APP_NAME }}{% endblock %}
|
{% block title %}Dashboard - BalanceBoard{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% 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 Content Area -->
|
||||||
<main class="main-content">
|
<main class="main-content">
|
||||||
@@ -11,9 +50,17 @@
|
|||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="sidebar-section">
|
<div class="sidebar-section">
|
||||||
<h3>Content Filters</h3>
|
<h3>Content Filters</h3>
|
||||||
<div id="filter-list" class="filter-list">
|
<div class="filter-item active" data-filter="no_filter">
|
||||||
<!-- Filters will be loaded dynamically -->
|
<span class="filter-icon">🌐</span>
|
||||||
<div class="loading-filters">Loading filters...</div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -29,7 +76,7 @@
|
|||||||
<h3>Quick Stats</h3>
|
<h3>Quick Stats</h3>
|
||||||
<div class="stats-grid">
|
<div class="stats-grid">
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-number">{{ quick_stats.posts_today if quick_stats else 0 }}</div>
|
<div class="stat-number">156</div>
|
||||||
<div class="stat-label">Posts Today</div>
|
<div class="stat-label">Posts Today</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
@@ -43,12 +90,10 @@
|
|||||||
<!-- Content Feed -->
|
<!-- Content Feed -->
|
||||||
<section class="content-section">
|
<section class="content-section">
|
||||||
<div class="content-header">
|
<div class="content-header">
|
||||||
<h1>{% if anonymous %}Public Feed{% else %}Your Feed{% endif %}</h1>
|
<h1>Your Feed</h1>
|
||||||
<div class="content-actions">
|
<div class="content-actions">
|
||||||
<button class="refresh-btn" onclick="refreshFeed()">🔄 Refresh</button>
|
<button class="refresh-btn" onclick="refreshFeed()">🔄 Refresh</button>
|
||||||
{% if not anonymous %}
|
|
||||||
<a href="{{ url_for('settings_filters') }}" class="filter-btn">🔧 Customize</a>
|
<a href="{{ url_for('settings_filters') }}" class="filter-btn">🔧 Customize</a>
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -303,14 +348,14 @@
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-communities, .loading-filters {
|
.loading-communities {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-communities, .no-filters {
|
.no-communities {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
@@ -393,24 +438,6 @@
|
|||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.clear-search-btn {
|
|
||||||
background: #f1f5f9;
|
|
||||||
color: #64748b;
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
padding: 10px 20px;
|
|
||||||
border-radius: 8px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.clear-search-btn:hover {
|
|
||||||
background: #e2e8f0;
|
|
||||||
color: #2c3e50;
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.feed-container {
|
.feed-container {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
@@ -687,11 +714,9 @@ let postsData = [];
|
|||||||
let currentPage = 1;
|
let currentPage = 1;
|
||||||
let currentCommunity = '';
|
let currentCommunity = '';
|
||||||
let currentPlatform = '';
|
let currentPlatform = '';
|
||||||
let currentFilter = 'no_filter';
|
|
||||||
let paginationData = {};
|
let paginationData = {};
|
||||||
let platformConfig = {};
|
let platformConfig = {};
|
||||||
let communitiesData = [];
|
let communitiesData = [];
|
||||||
let filtersData = [];
|
|
||||||
|
|
||||||
// User experience settings
|
// User experience settings
|
||||||
let userSettings = {{ user_settings|tojson }};
|
let userSettings = {{ user_settings|tojson }};
|
||||||
@@ -699,12 +724,32 @@ let userSettings = {{ user_settings|tojson }};
|
|||||||
// Load posts on page load
|
// Load posts on page load
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
loadPlatformConfig();
|
loadPlatformConfig();
|
||||||
loadFilters();
|
|
||||||
loadPosts();
|
loadPosts();
|
||||||
|
setupFilterSwitching();
|
||||||
setupInfiniteScroll();
|
setupInfiniteScroll();
|
||||||
setupAutoRefresh();
|
setupAutoRefresh();
|
||||||
|
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
|
// Load platform configuration and communities
|
||||||
async function loadPlatformConfig() {
|
async function loadPlatformConfig() {
|
||||||
try {
|
try {
|
||||||
@@ -728,54 +773,6 @@ async function loadPlatformConfig() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load available filters
|
|
||||||
async function loadFilters() {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/filters');
|
|
||||||
const data = await response.json();
|
|
||||||
filtersData = data.filters || [];
|
|
||||||
|
|
||||||
renderFilters(filtersData);
|
|
||||||
setupFilterSwitching();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading filters:', error);
|
|
||||||
// Show fallback filters
|
|
||||||
const fallbackFilters = [
|
|
||||||
{id: 'no_filter', name: 'All Content', icon: '🌐', active: true, description: 'No filtering'}
|
|
||||||
];
|
|
||||||
renderFilters(fallbackFilters);
|
|
||||||
setupFilterSwitching();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render filters in sidebar
|
|
||||||
function renderFilters(filters) {
|
|
||||||
const filterList = document.getElementById('filter-list');
|
|
||||||
if (!filterList) return;
|
|
||||||
|
|
||||||
if (filters.length === 0) {
|
|
||||||
filterList.innerHTML = '<div class="no-filters">No filters available</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const filtersHTML = filters.map(filter => {
|
|
||||||
return `
|
|
||||||
<div class="filter-item ${filter.active ? 'active' : ''}" data-filter="${filter.id}" title="${filter.description}">
|
|
||||||
<span class="filter-icon">${filter.icon}</span>
|
|
||||||
<span>${filter.name}</span>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
filterList.innerHTML = filtersHTML;
|
|
||||||
|
|
||||||
// Set current filter based on active filter
|
|
||||||
const activeFilter = filters.find(f => f.active);
|
|
||||||
if (activeFilter) {
|
|
||||||
currentFilter = activeFilter.id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render communities in sidebar
|
// Render communities in sidebar
|
||||||
function renderCommunities(communities) {
|
function renderCommunities(communities) {
|
||||||
const communityList = document.getElementById('community-list');
|
const communityList = document.getElementById('community-list');
|
||||||
@@ -809,7 +806,7 @@ function renderCommunities(communities) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load posts from API
|
// 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 {
|
try {
|
||||||
// Build query parameters
|
// Build query parameters
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
@@ -817,8 +814,6 @@ async function loadPosts(page = 1, community = '', platform = '', append = false
|
|||||||
params.append('per_page', 20);
|
params.append('per_page', 20);
|
||||||
if (community) params.append('community', community);
|
if (community) params.append('community', community);
|
||||||
if (platform) params.append('platform', platform);
|
if (platform) params.append('platform', platform);
|
||||||
if (filter || currentFilter) params.append('filter', filter || currentFilter);
|
|
||||||
if (currentSearchQuery) params.append('q', currentSearchQuery);
|
|
||||||
|
|
||||||
const response = await fetch(`/api/posts?${params}`);
|
const response = await fetch(`/api/posts?${params}`);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
@@ -1011,35 +1006,25 @@ function savePost(postId) {
|
|||||||
|
|
||||||
// Filter switching functionality
|
// Filter switching functionality
|
||||||
function setupFilterSwitching() {
|
function setupFilterSwitching() {
|
||||||
document.addEventListener('click', function(event) {
|
const filterItems = document.querySelectorAll('.filter-item');
|
||||||
if (event.target.closest('.filter-item')) {
|
|
||||||
const filterItem = event.target.closest('.filter-item');
|
|
||||||
|
|
||||||
// Remove active class from all filter items
|
filterItems.forEach(item => {
|
||||||
document.querySelectorAll('.filter-item').forEach(f => f.classList.remove('active'));
|
item.addEventListener('click', function() {
|
||||||
|
// Remove active class from all items
|
||||||
|
filterItems.forEach(f => f.classList.remove('active'));
|
||||||
|
|
||||||
// Add active class to clicked item
|
// Add active class to clicked item
|
||||||
filterItem.classList.add('active');
|
this.classList.add('active');
|
||||||
|
|
||||||
// Get filter type
|
// Get filter type
|
||||||
const filterType = filterItem.dataset.filter;
|
const filterType = this.dataset.filter;
|
||||||
currentFilter = filterType;
|
|
||||||
|
|
||||||
// Update header to show current filter
|
// Apply filter (for now just reload)
|
||||||
const contentHeader = document.querySelector('.content-header h1');
|
if (filterType && filterType !== 'custom') {
|
||||||
const filterName = filterItem.textContent.trim();
|
loadPosts(); // In future, pass filter parameter
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh feed function
|
// Refresh feed function
|
||||||
@@ -1063,50 +1048,172 @@ function refreshFeed() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Search functionality
|
// Search functionality
|
||||||
let currentSearchQuery = '';
|
|
||||||
|
|
||||||
document.querySelector('.search-input').addEventListener('keypress', function(e) {
|
document.querySelector('.search-input').addEventListener('keypress', function(e) {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
const query = this.value.trim();
|
const query = this.value.trim();
|
||||||
|
if (query) {
|
||||||
performSearch(query);
|
performSearch(query);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Search button functionality
|
||||||
document.querySelector('.search-btn').addEventListener('click', function() {
|
document.querySelector('.search-btn').addEventListener('click', function() {
|
||||||
const query = document.querySelector('.search-input').value.trim();
|
const query = document.querySelector('.search-input').value.trim();
|
||||||
|
if (query) {
|
||||||
performSearch(query);
|
performSearch(query);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function performSearch(query) {
|
// Search posts function
|
||||||
currentSearchQuery = query;
|
async function performSearch(query) {
|
||||||
currentPage = 1;
|
try {
|
||||||
|
// Show loading state in search bar
|
||||||
|
const searchInput = document.querySelector('.search-input');
|
||||||
|
const searchBtn = document.querySelector('.search-btn');
|
||||||
|
const originalPlaceholder = searchInput.placeholder;
|
||||||
|
|
||||||
if (query) {
|
searchInput.placeholder = 'Searching...';
|
||||||
document.querySelector('.content-header h1').textContent = `Search results for "${query}"`;
|
searchBtn.disabled = true;
|
||||||
// Show clear search button
|
|
||||||
if (!document.querySelector('.clear-search-btn')) {
|
// Build search parameters
|
||||||
const clearBtn = document.createElement('button');
|
const params = new URLSearchParams();
|
||||||
clearBtn.className = 'clear-search-btn';
|
params.append('q', query);
|
||||||
clearBtn.textContent = '✕ Clear search';
|
params.append('page', 1);
|
||||||
clearBtn.onclick = clearSearch;
|
params.append('per_page', 20);
|
||||||
document.querySelector('.content-actions').prepend(clearBtn);
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
// 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loadPosts();
|
// 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearSearch() {
|
// Create post cards for search results
|
||||||
currentSearchQuery = '';
|
const postsHTML = searchData.posts.map(post => createSearchResultPostCard(post, query)).join('');
|
||||||
document.querySelector('.search-input').value = '';
|
postsContainer.innerHTML = postsHTML;
|
||||||
// Restore original feed title based on user state
|
|
||||||
const isAnonymous = {{ 'true' if anonymous else 'false' }};
|
|
||||||
document.querySelector('.content-header h1').textContent = isAnonymous ? 'Public Feed' : 'Your Feed';
|
|
||||||
const clearBtn = document.querySelector('.clear-search-btn');
|
|
||||||
if (clearBtn) {
|
|
||||||
clearBtn.remove();
|
|
||||||
}
|
}
|
||||||
loadPosts();
|
|
||||||
|
// 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
|
// Setup infinite scroll functionality
|
||||||
@@ -1265,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() {
|
function loadPreviousPage() {
|
||||||
if (paginationData.has_prev) {
|
if (paginationData.has_prev) {
|
||||||
loadPosts(currentPage - 1, currentCommunity, currentPlatform);
|
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" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}Log In - {{ APP_NAME }}{% endblock %}
|
{% block title %}Log In - BalanceBoard{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="auth-container">
|
<div class="auth-container">
|
||||||
<div class="auth-card">
|
<div class="auth-card">
|
||||||
<div class="auth-logo">
|
<div class="auth-logo">
|
||||||
<img src="{{ url_for('serve_logo') }}" alt="{{ APP_NAME }} Logo">
|
<img src="{{ url_for('serve_logo') }}" alt="BalanceBoard Logo">
|
||||||
<h1><span class="balance">balance</span>Board</h1>
|
<h1><span class="balance">balance</span>Board</h1>
|
||||||
<p style="color: var(--text-secondary); margin-top: 8px;">Welcome back!</p>
|
<p style="color: var(--text-secondary); margin-top: 8px;">Welcome back!</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -37,10 +37,6 @@
|
|||||||
<label for="remember" style="margin-bottom: 0;">Remember me</label>
|
<label for="remember" style="margin-bottom: 0;">Remember me</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="text-align: right; margin-bottom: 16px;">
|
|
||||||
<a href="{{ url_for('password_reset_request') }}" style="color: var(--primary-color); text-decoration: none; font-size: 14px;">Forgot password?</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="submit">Log In</button>
|
<button type="submit">Log In</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -48,7 +44,6 @@
|
|||||||
<span>or</span>
|
<span>or</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if auth0_configured %}
|
|
||||||
<div class="social-auth-buttons">
|
<div class="social-auth-buttons">
|
||||||
<a href="{{ url_for('auth0_login') }}" class="social-btn auth0-btn">
|
<a href="{{ url_for('auth0_login') }}" class="social-btn auth0-btn">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||||
@@ -57,10 +52,14 @@
|
|||||||
Continue with Auth0
|
Continue with Auth0
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="auth-footer">
|
<div class="auth-footer">
|
||||||
<p>Don't have an account? <a href="{{ url_for('signup') }}">Sign up</a></p>
|
<p>Don't have an account? <a href="{{ url_for('signup') }}">Sign up</a></p>
|
||||||
|
<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>
|
</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,6 +1,6 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}{{ post.title }} - {{ APP_NAME }}{% endblock %}
|
{% block title %}{{ post.title }} - BalanceBoard{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<!-- Modern Top Navigation -->
|
<!-- Modern Top Navigation -->
|
||||||
@@ -8,8 +8,8 @@
|
|||||||
<div class="nav-content">
|
<div class="nav-content">
|
||||||
<div class="nav-left">
|
<div class="nav-left">
|
||||||
<div class="logo-section">
|
<div class="logo-section">
|
||||||
<img src="{{ url_for('serve_logo') }}" alt="{{ APP_NAME }}" class="nav-logo">
|
<img src="{{ url_for('serve_logo') }}" alt="BalanceBoard" class="nav-logo">
|
||||||
<span class="brand-text">{{ APP_NAME }}</span>
|
<span class="brand-text"><span class="brand-balance">balance</span><span class="brand-board">Board</span></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -105,7 +105,7 @@
|
|||||||
🐙 View on GitHub
|
🐙 View on GitHub
|
||||||
{% elif post.platform == 'devto' %}
|
{% elif post.platform == 'devto' %}
|
||||||
📝 View on Dev.to
|
📝 View on Dev.to
|
||||||
{% elif post.platform == 'stackexchange' %}
|
{% elif post.platform == 'stackoverflow' %}
|
||||||
📚 View on Stack Overflow
|
📚 View on Stack Overflow
|
||||||
{% else %}
|
{% else %}
|
||||||
🔗 View Original Source
|
🔗 View Original Source
|
||||||
@@ -136,8 +136,10 @@
|
|||||||
<section class="comments-section">
|
<section class="comments-section">
|
||||||
<h2>Comments ({{ comments|length }})</h2>
|
<h2>Comments ({{ comments|length }})</h2>
|
||||||
|
|
||||||
{% macro render_comment(comment, depth=0) %}
|
{% if comments %}
|
||||||
<div class="comment" style="margin-left: {{ depth * 24 }}px;">
|
<div class="comments-list">
|
||||||
|
{% for comment in comments %}
|
||||||
|
<div class="comment">
|
||||||
<div class="comment-header">
|
<div class="comment-header">
|
||||||
<span class="comment-author">{{ comment.author }}</span>
|
<span class="comment-author">{{ comment.author }}</span>
|
||||||
<span class="comment-separator">•</span>
|
<span class="comment-separator">•</span>
|
||||||
@@ -151,21 +153,7 @@
|
|||||||
<span>▲ {{ comment.score or 0 }}</span>
|
<span>▲ {{ comment.score or 0 }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if comment.replies %}
|
|
||||||
<div class="comment-replies">
|
|
||||||
{% for reply in comment.replies %}
|
|
||||||
{{ render_comment(reply, depth + 1) }}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endmacro %}
|
|
||||||
|
|
||||||
{% if comments %}
|
|
||||||
<div class="comments-list">
|
|
||||||
{% for comment in comments %}
|
|
||||||
{{ render_comment(comment) }}
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -566,24 +554,12 @@
|
|||||||
.comment {
|
.comment {
|
||||||
padding: 20px 0;
|
padding: 20px 0;
|
||||||
border-bottom: 1px solid #f1f5f9;
|
border-bottom: 1px solid #f1f5f9;
|
||||||
position: relative;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.comment:last-child {
|
.comment:last-child {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Threaded comment styling */
|
|
||||||
.comment[style*="margin-left"] {
|
|
||||||
padding-left: 16px;
|
|
||||||
border-left: 2px solid #e2e8f0;
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comment-replies {
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comment-header {
|
.comment-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -678,10 +654,6 @@ function sharePost() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function savePost() {
|
function savePost() {
|
||||||
// TODO: Implement save post functionality
|
|
||||||
// User can save posts to their profile for later viewing
|
|
||||||
// This needs database backend integration with user_saved_posts table
|
|
||||||
// Same implementation needed as dashboard.html savePost function
|
|
||||||
alert('Save functionality coming soon!');
|
alert('Save functionality coming soon!');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}Settings - {{ APP_NAME }}{% endblock %}
|
{% block title %}Settings - BalanceBoard{% endblock %}
|
||||||
|
|
||||||
{% block extra_css %}
|
{% block extra_css %}
|
||||||
<style>
|
<style>
|
||||||
@@ -230,7 +230,6 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% include '_nav.html' %}
|
|
||||||
<div class="settings-container">
|
<div class="settings-container">
|
||||||
<div class="settings-header">
|
<div class="settings-header">
|
||||||
<h1>Settings</h1>
|
<h1>Settings</h1>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}Community Settings - {{ APP_NAME }}{% endblock %}
|
{% block title %}Community Settings - BalanceBoard{% endblock %}
|
||||||
|
|
||||||
{% block extra_css %}
|
{% block extra_css %}
|
||||||
<style>
|
<style>
|
||||||
@@ -79,7 +79,7 @@
|
|||||||
.platform-icon.reddit { background: #ff4500; }
|
.platform-icon.reddit { background: #ff4500; }
|
||||||
.platform-icon.hackernews { background: #ff6600; }
|
.platform-icon.hackernews { background: #ff6600; }
|
||||||
.platform-icon.lobsters { background: #ac130d; }
|
.platform-icon.lobsters { background: #ac130d; }
|
||||||
.platform-icon.stackexchange { background: #f48024; }
|
.platform-icon.stackoverflow { background: #f48024; }
|
||||||
|
|
||||||
.community-grid {
|
.community-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -235,7 +235,6 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% include '_nav.html' %}
|
|
||||||
<div class="settings-container">
|
<div class="settings-container">
|
||||||
<div class="settings-header">
|
<div class="settings-header">
|
||||||
<h1>Community Settings</h1>
|
<h1>Community Settings</h1>
|
||||||
@@ -269,7 +268,7 @@
|
|||||||
<div class="platform-group">
|
<div class="platform-group">
|
||||||
<h3>
|
<h3>
|
||||||
<span class="platform-icon {{ platform }}">
|
<span class="platform-icon {{ platform }}">
|
||||||
{% if platform == 'reddit' %}R{% elif platform == 'hackernews' %}H{% elif platform == 'lobsters' %}L{% elif platform == 'stackexchange' %}S{% endif %}
|
{% if platform == 'reddit' %}R{% elif platform == 'hackernews' %}H{% elif platform == 'lobsters' %}L{% elif platform == 'stackoverflow' %}S{% endif %}
|
||||||
</span>
|
</span>
|
||||||
{{ platform|title }}
|
{{ platform|title }}
|
||||||
</h3>
|
</h3>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}Experience Settings - {{ APP_NAME }}{% endblock %}
|
{% block title %}Experience Settings - BalanceBoard{% endblock %}
|
||||||
|
|
||||||
{% block extra_css %}
|
{% block extra_css %}
|
||||||
<style>
|
<style>
|
||||||
@@ -241,7 +241,6 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% include '_nav.html' %}
|
|
||||||
<div class="experience-settings">
|
<div class="experience-settings">
|
||||||
<div class="experience-header">
|
<div class="experience-header">
|
||||||
<h1>Experience Settings</h1>
|
<h1>Experience Settings</h1>
|
||||||
@@ -331,34 +330,6 @@
|
|||||||
<span class="toggle-slider"></span>
|
<span class="toggle-slider"></span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Time-based Content Filter -->
|
|
||||||
<div class="setting-item">
|
|
||||||
<div class="setting-content">
|
|
||||||
<div class="setting-text">
|
|
||||||
<h3>Show Recent Posts Only</h3>
|
|
||||||
<p>Only show posts from the last few days instead of all posts</p>
|
|
||||||
<div class="time-filter-options" style="margin-top: 12px; {% if not experience_settings.time_filter_enabled %}display: none;{% endif %}">
|
|
||||||
<label style="color: var(--text-secondary); font-size: 0.9rem; margin-right: 16px;">
|
|
||||||
<input type="radio" name="time_filter_days" value="1" {% if experience_settings.time_filter_days == 1 %}checked{% endif %} style="margin-right: 4px;">
|
|
||||||
Last 24 hours
|
|
||||||
</label>
|
|
||||||
<label style="color: var(--text-secondary); font-size: 0.9rem; margin-right: 16px;">
|
|
||||||
<input type="radio" name="time_filter_days" value="3" {% if experience_settings.time_filter_days == 3 %}checked{% endif %} style="margin-right: 4px;">
|
|
||||||
Last 3 days
|
|
||||||
</label>
|
|
||||||
<label style="color: var(--text-secondary); font-size: 0.9rem; margin-right: 16px;">
|
|
||||||
<input type="radio" name="time_filter_days" value="7" {% if experience_settings.time_filter_days == 7 or not experience_settings.time_filter_days %}checked{% endif %} style="margin-right: 4px;">
|
|
||||||
Last week
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<label class="toggle-switch">
|
|
||||||
<input type="checkbox" name="time_filter_enabled" {% if experience_settings.time_filter_enabled %}checked{% endif %} onchange="toggleTimeFilterOptions(this)">
|
|
||||||
<span class="toggle-slider"></span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
@@ -367,15 +338,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
|
||||||
function toggleTimeFilterOptions(checkbox) {
|
|
||||||
const options = document.querySelector('.time-filter-options');
|
|
||||||
if (checkbox.checked) {
|
|
||||||
options.style.display = 'block';
|
|
||||||
} else {
|
|
||||||
options.style.display = 'none';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}Filter Settings - {{ APP_NAME }}{% endblock %}
|
{% block title %}Filter Settings - BalanceBoard{% endblock %}
|
||||||
|
|
||||||
{% block extra_css %}
|
{% block extra_css %}
|
||||||
<style>
|
<style>
|
||||||
@@ -263,7 +263,6 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% include '_nav.html' %}
|
|
||||||
<div class="settings-container">
|
<div class="settings-container">
|
||||||
<div class="settings-header">
|
<div class="settings-header">
|
||||||
<h1>Filter Settings</h1>
|
<h1>Filter Settings</h1>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}Profile Settings - {{ APP_NAME }}{% endblock %}
|
{% block title %}Profile Settings - BalanceBoard{% endblock %}
|
||||||
|
|
||||||
{% block extra_css %}
|
{% block extra_css %}
|
||||||
<style>
|
<style>
|
||||||
@@ -225,7 +225,6 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% include '_nav.html' %}
|
|
||||||
<div class="settings-container">
|
<div class="settings-container">
|
||||||
<div class="settings-header">
|
<div class="settings-header">
|
||||||
<h1>Profile Settings</h1>
|
<h1>Profile Settings</h1>
|
||||||
@@ -243,6 +242,7 @@
|
|||||||
{% endwith %}
|
{% endwith %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<form method="POST">
|
||||||
<div class="profile-section">
|
<div class="profile-section">
|
||||||
<h2>Profile Picture</h2>
|
<h2>Profile Picture</h2>
|
||||||
<div class="profile-avatar">
|
<div class="profile-avatar">
|
||||||
@@ -258,7 +258,7 @@
|
|||||||
<p>Upload a new profile picture to personalize your account</p>
|
<p>Upload a new profile picture to personalize your account</p>
|
||||||
<form id="upload-form" method="POST" action="{{ url_for('upload_avatar') }}" enctype="multipart/form-data">
|
<form id="upload-form" method="POST" action="{{ url_for('upload_avatar') }}" enctype="multipart/form-data">
|
||||||
<div class="file-upload">
|
<div class="file-upload">
|
||||||
<input type="file" id="avatar" name="avatar" accept="image/*">
|
<input type="file" id="avatar" name="avatar" accept="image/*" onchange="this.form.submit()">
|
||||||
<label for="avatar" class="file-upload-label">Choose New Picture</label>
|
<label for="avatar" class="file-upload-label">Choose New Picture</label>
|
||||||
</div>
|
</div>
|
||||||
<p class="help-text">PNG, JPG, or GIF. Maximum size 2MB.</p>
|
<p class="help-text">PNG, JPG, or GIF. Maximum size 2MB.</p>
|
||||||
@@ -266,6 +266,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
<form method="POST">
|
<form method="POST">
|
||||||
<div class="profile-section">
|
<div class="profile-section">
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}Sign Up - {{ APP_NAME }}{% endblock %}
|
{% block title %}Sign Up - BalanceBoard{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="auth-container">
|
<div class="auth-container">
|
||||||
<div class="auth-card">
|
<div class="auth-card">
|
||||||
<div class="auth-logo">
|
<div class="auth-logo">
|
||||||
<img src="{{ url_for('serve_logo') }}" alt="{{ APP_NAME }} Logo">
|
<img src="{{ url_for('serve_logo') }}" alt="BalanceBoard Logo">
|
||||||
<h1><span class="balance">balance</span>Board</h1>
|
<h1><span class="balance">balance</span>Board</h1>
|
||||||
<p style="color: var(--text-secondary); margin-top: 8px;">Create your account</p>
|
<p style="color: var(--text-secondary); margin-top: 8px;">Create your account</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -40,10 +40,6 @@
|
|||||||
|
|
||||||
<div class="engagement-info">
|
<div class="engagement-info">
|
||||||
<span class="reply-count">{{replies}} replies</span>
|
<span class="reply-count">{{replies}} replies</span>
|
||||||
<button class="bookmark-btn" onclick="toggleBookmark('{{id}}', this)" data-post-id="{{id}}">
|
|
||||||
<span class="bookmark-icon">🔖</span>
|
|
||||||
<span class="bookmark-text">Save</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
|||||||
@@ -228,9 +228,6 @@
|
|||||||
<a href="/settings/filters" class="dropdown-item">
|
<a href="/settings/filters" class="dropdown-item">
|
||||||
🎛️ Filters
|
🎛️ Filters
|
||||||
</a>
|
</a>
|
||||||
<a href="/bookmarks" class="dropdown-item">
|
|
||||||
📚 Bookmarks
|
|
||||||
</a>
|
|
||||||
<div class="dropdown-divider"></div>
|
<div class="dropdown-divider"></div>
|
||||||
<a href="/admin" class="dropdown-item" style="display: none;">
|
<a href="/admin" class="dropdown-item" style="display: none;">
|
||||||
🛠️ Admin
|
🛠️ Admin
|
||||||
@@ -355,79 +352,6 @@
|
|||||||
|
|
||||||
// Initialize on page load
|
// Initialize on page load
|
||||||
document.addEventListener('DOMContentLoaded', checkAuthState);
|
document.addEventListener('DOMContentLoaded', checkAuthState);
|
||||||
|
|
||||||
// Bookmark functionality
|
|
||||||
async function toggleBookmark(postId, button) {
|
|
||||||
try {
|
|
||||||
button.disabled = true;
|
|
||||||
const originalText = button.querySelector('.bookmark-text').textContent;
|
|
||||||
button.querySelector('.bookmark-text').textContent = 'Saving...';
|
|
||||||
|
|
||||||
const response = await fetch('/api/bookmark', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ post_uuid: postId })
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(data.error || 'Failed to toggle bookmark');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update button state
|
|
||||||
updateBookmarkButton(button, data.bookmarked);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error toggling bookmark:', error);
|
|
||||||
alert('Error: ' + error.message);
|
|
||||||
button.querySelector('.bookmark-text').textContent = originalText;
|
|
||||||
} finally {
|
|
||||||
button.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateBookmarkButton(button, isBookmarked) {
|
|
||||||
const icon = button.querySelector('.bookmark-icon');
|
|
||||||
const text = button.querySelector('.bookmark-text');
|
|
||||||
|
|
||||||
if (isBookmarked) {
|
|
||||||
button.classList.add('bookmarked');
|
|
||||||
icon.textContent = '📌';
|
|
||||||
text.textContent = 'Saved';
|
|
||||||
} else {
|
|
||||||
button.classList.remove('bookmarked');
|
|
||||||
icon.textContent = '🔖';
|
|
||||||
text.textContent = 'Save';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load bookmark states for visible posts
|
|
||||||
async function loadBookmarkStates() {
|
|
||||||
const bookmarkButtons = document.querySelectorAll('.bookmark-btn');
|
|
||||||
|
|
||||||
for (const button of bookmarkButtons) {
|
|
||||||
const postId = button.getAttribute('data-post-id');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/bookmark-status/${postId}`);
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (response.ok && data.bookmarked) {
|
|
||||||
updateBookmarkButton(button, true);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading bookmark status:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load bookmark states when page loads
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
setTimeout(loadBookmarkStates, 500); // Small delay to ensure posts are rendered
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -460,45 +460,6 @@ header .post-count::before {
|
|||||||
.engagement-info {
|
.engagement-info {
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Bookmark Button */
|
|
||||||
.bookmark-btn {
|
|
||||||
background: none;
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 6px 12px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bookmark-btn:hover {
|
|
||||||
border-color: var(--primary-color);
|
|
||||||
color: var(--primary-color);
|
|
||||||
background: rgba(77, 182, 172, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bookmark-btn.bookmarked {
|
|
||||||
background: var(--primary-color);
|
|
||||||
color: white;
|
|
||||||
border-color: var(--primary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bookmark-btn.bookmarked .bookmark-icon {
|
|
||||||
filter: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bookmark-btn:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tags */
|
/* Tags */
|
||||||
|
|||||||
Reference in New Issue
Block a user