Initial commit: BalanceBoard - Reddit-style content aggregator

- Flask-based web application with PostgreSQL
- User authentication and session management
- Content moderation and filtering
- Docker deployment with docker-compose
- Admin interface for content management

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-10-11 16:11:13 -05:00
commit e821a26b48
35 changed files with 10736 additions and 0 deletions

56
Dockerfile Normal file
View File

@@ -0,0 +1,56 @@
FROM python:3.12-slim
LABEL maintainer="BalanceBoard"
LABEL description="BalanceBoard - Content aggregation platform with ethical design"
# Install system dependencies
RUN apt-get update && apt-get install -y \
postgresql-client \
libpq-dev \
gcc \
curl \
&& rm -rf /var/lib/apt/lists/*
# Create non-root user for security
RUN useradd -m -u 1000 appuser && \
mkdir -p /app && \
chown -R appuser:appuser /app
# Set working directory
WORKDIR /app
# Copy requirements first for better caching
COPY --chown=appuser:appuser requirements.txt .
# Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY --chown=appuser:appuser . .
# Create necessary directories with proper permissions
RUN mkdir -p \
/app/data/posts \
/app/data/comments \
/app/data/moderation \
/app/static/avatars \
/app/backups \
/app/active_html \
&& chown -R appuser:appuser /app
# Switch to non-root user
USER appuser
# Expose Flask port
EXPOSE 5021
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD curl -f http://localhost:5021/ || exit 1
# Set Flask app environment variable
ENV FLASK_APP=app.py
# Run the application directly with Flask
# Note: start_server.py has venv checks that don't apply in Docker
CMD ["python", "-m", "flask", "run", "--host=0.0.0.0", "--port=5021"]

1531
app.py Normal file

File diff suppressed because it is too large Load Diff

159
comment_lib.py Normal file
View File

@@ -0,0 +1,159 @@
"""
Comment Library
Atomic functions for comment processing and tree manipulation.
"""
import json
from pathlib import Path
from typing import List, Dict, Optional
class comment_lib:
"""Atomic comment processing functions"""
@staticmethod
def build_comment_tree(flat_comments: List[Dict]) -> List[Dict]:
"""
Convert flat array of comments to nested tree structure.
Returns list of root-level comments with nested children.
"""
if not flat_comments:
return []
# Create lookup dict
comment_map = {c['uuid']: {**c, 'children': []} for c in flat_comments}
# Build tree
roots = []
for comment in flat_comments:
parent_uuid = comment.get('parent_comment_uuid')
if parent_uuid and parent_uuid in comment_map:
comment_map[parent_uuid]['children'].append(comment_map[comment['uuid']])
else:
roots.append(comment_map[comment['uuid']])
return roots
@staticmethod
def flatten_comment_tree(tree: List[Dict]) -> List[Dict]:
"""
Convert nested tree structure to flat array.
Removes 'children' key from each comment.
"""
flat = []
def traverse(nodes):
for node in nodes:
children = node.pop('children', [])
flat.append(node)
if children:
traverse(children)
traverse(tree)
return flat
@staticmethod
def load_comments_for_post(post_uuid: str, data_dir: str) -> List[Dict]:
"""
Load all comment files linked to a post.
Scans comment directory for comments with matching post_uuid.
"""
comments_dir = Path(data_dir) / 'comments'
if not comments_dir.exists():
return []
comments = []
for comment_file in comments_dir.glob('*.json'):
with open(comment_file, 'r') as f:
comment = json.load(f)
if comment.get('post_uuid') == post_uuid:
comments.append(comment)
return comments
@staticmethod
def sort_comments(comments: List[Dict], by: str = 'score', order: str = 'desc') -> List[Dict]:
"""
Sort comments by specified field.
Args:
comments: List of comment dicts
by: Field to sort by ('score', 'timestamp', 'depth', 'author')
order: 'asc' or 'desc'
Returns:
Sorted list of comments
"""
reverse = (order == 'desc')
return sorted(comments, key=lambda c: c.get(by, 0), reverse=reverse)
@staticmethod
def get_comment_depth(comment: Dict, comment_map: Dict) -> int:
"""
Calculate actual depth of a comment by traversing up parent chain.
Useful for recalculating depth after filtering.
"""
depth = 0
current_uuid = comment.get('parent_comment_uuid')
while current_uuid and current_uuid in comment_map:
depth += 1
current_uuid = comment_map[current_uuid].get('parent_comment_uuid')
return depth
@staticmethod
def get_comment_stats(comments: List[Dict]) -> Dict:
"""
Get statistics about a comment list.
Returns:
Dict with total, max_depth, avg_score, etc.
"""
if not comments:
return {
'total': 0,
'max_depth': 0,
'avg_score': 0,
'total_score': 0
}
depths = [c.get('depth', 0) for c in comments]
scores = [c.get('score', 0) for c in comments]
return {
'total': len(comments),
'max_depth': max(depths) if depths else 0,
'avg_score': sum(scores) / len(scores) if scores else 0,
'total_score': sum(scores)
}
@staticmethod
def filter_by_depth(comments: List[Dict], max_depth: int) -> List[Dict]:
"""
Filter comments to only include those at or below max_depth.
"""
return [c for c in comments if c.get('depth', 0) <= max_depth]
@staticmethod
def get_top_level_comments(comments: List[Dict]) -> List[Dict]:
"""
Get only top-level comments (depth 0, no parent).
"""
return [c for c in comments if c.get('depth', 0) == 0 or not c.get('parent_comment_uuid')]
@staticmethod
def count_replies(comment_uuid: str, comments: List[Dict]) -> int:
"""
Count total number of replies (direct and nested) for a comment.
"""
count = 0
for comment in comments:
if comment.get('parent_comment_uuid') == comment_uuid:
count += 1
# Recursively count this comment's replies
count += comment_lib.count_replies(comment['uuid'], comments)
return count

58
create_admin.py Normal file
View File

@@ -0,0 +1,58 @@
#!/usr/bin/env python3
"""
Create admin account script
Creates admin/password123 account for testing
"""
import os
import sys
from pathlib import Path
# Add current directory to path
sys.path.insert(0, str(Path(__file__).parent))
def create_admin_account():
"""Create admin account with credentials admin/password123"""
# Set up environment
os.environ['POSTGRES_HOST'] = 'localhost'
os.environ['POSTGRES_USER'] = 'balanceboard'
os.environ['POSTGRES_PASSWORD'] = 'balanceboard123'
os.environ['POSTGRES_DB'] = 'balanceboard'
os.environ['SECRET_KEY'] = 'dev-secret-key-change-in-production'
try:
from app import app, db
from models import User
with app.app_context():
# Check if admin user already exists
existing_admin = User.query.filter_by(username='admin').first()
if existing_admin:
print("✓ Admin account 'admin' already exists")
return True
# Create admin user
admin_user = User(
username='admin',
email='admin@balanceboard.local',
password='password123',
is_admin=True
)
db.session.add(admin_user)
db.session.commit()
print("✓ Admin account created successfully!")
print(" Username: admin")
print(" Password: password123")
print(" Email: admin@balanceboard.local")
return True
except Exception as e:
print(f"✗ Failed to create admin account: {e}")
return False
if __name__ == '__main__':
success = create_admin_account()
sys.exit(0 if success else 1)

390
data_collection.py Normal file
View File

@@ -0,0 +1,390 @@
#!/usr/bin/env python3
"""
Data Collection Script
Collects posts and comments from multiple platforms with UUID-based storage.
Functional approach - no classes, just functions.
"""
import json
import uuid
from datetime import datetime, timedelta
from pathlib import Path
from typing import List, Dict, Tuple
from data_collection_lib import data_methods
# ===== STORAGE FUNCTIONS =====
def ensure_directories(storage_dir: str) -> Dict[str, Path]:
"""Create and return directory paths"""
base = Path(storage_dir)
dirs = {
'posts': base / 'posts',
'comments': base / 'comments',
'moderation': base / 'moderation',
'base': base
}
for path in dirs.values():
path.mkdir(parents=True, exist_ok=True)
return dirs
def load_index(storage_dir: str) -> Dict:
"""Load post index from disk"""
index_file = Path(storage_dir) / 'post_index.json'
if index_file.exists():
with open(index_file, 'r') as f:
index = json.load(f)
print(f"Loaded index with {len(index)} posts")
return index
return {}
def save_index(index: Dict, storage_dir: str):
"""Save post index to disk"""
index_file = Path(storage_dir) / 'post_index.json'
with open(index_file, 'w') as f:
json.dump(index, f, indent=2)
def load_state(storage_dir: str) -> Dict:
"""Load collection state from disk"""
state_file = Path(storage_dir) / 'collection_state.json'
if state_file.exists():
with open(state_file, 'r') as f:
state = json.load(f)
print(f"Loaded collection state: {state.get('last_run', 'never')}")
return state
return {}
def save_state(state: Dict, storage_dir: str):
"""Save collection state to disk"""
state_file = Path(storage_dir) / 'collection_state.json'
with open(state_file, 'w') as f:
json.dump(state, f, indent=2)
def generate_uuid() -> str:
"""Generate a new UUID"""
return str(uuid.uuid4())
# ===== MODERATION FUNCTIONS =====
def create_moderation_stub(target_id: str, target_type: str, dirs: Dict) -> str:
"""Create moderation stub file and return UUID"""
mod_uuid = generate_uuid()
moderation_data = {
"target_id": target_id,
"target_type": target_type,
"analyzed_at": int(datetime.now().timestamp()),
"model_version": "stub-1.0",
"flags": {
"requires_review": False,
"is_blocked": False,
"is_flagged": False,
"is_safe": True
}
}
mod_file = dirs['moderation'] / f"{mod_uuid}.json"
with open(mod_file, 'w') as f:
json.dump(moderation_data, f, indent=2)
return mod_uuid
# ===== POST FUNCTIONS =====
def save_post(post: Dict, platform: str, index: Dict, dirs: Dict) -> str:
"""Save post to UUID-based file, return UUID"""
post_id = f"{platform}_{post['id']}"
# Check if already exists
if post_id in index:
return index[post_id]
# Generate UUID and save
post_uuid = generate_uuid()
post['uuid'] = post_uuid
post['moderation_uuid'] = create_moderation_stub(post_id, 'post', dirs)
post_file = dirs['posts'] / f"{post_uuid}.json"
with open(post_file, 'w') as f:
json.dump(post, f, indent=2)
# Update index
index[post_id] = post_uuid
return post_uuid
# ===== COMMENT FUNCTIONS =====
def save_comment(comment: Dict, post_uuid: str, platform: str, dirs: Dict) -> str:
"""Save comment to UUID-based file, return UUID"""
comment_uuid = generate_uuid()
comment['uuid'] = comment_uuid
comment['post_uuid'] = post_uuid
comment['platform'] = platform
comment['moderation_uuid'] = create_moderation_stub(
f"{platform}_comment_{comment['id']}",
'comment',
dirs
)
comment_file = dirs['comments'] / f"{comment_uuid}.json"
with open(comment_file, 'w') as f:
json.dump(comment, f, indent=2)
return comment_uuid
def fetch_and_save_comments(post: Dict, platform: str, dirs: Dict, max_comments: int = 50) -> List[str]:
"""Fetch comments for post and save them, return list of UUIDs"""
comments = []
post_id = post.get('id')
# Fetch comments based on platform
if platform == 'reddit':
source = post.get('source', '').replace('r/', '')
comments = data_methods.comment_fetchers.fetch_reddit_comments(post_id, source, max_comments)
elif platform == 'hackernews':
if post_id.startswith('hn_'):
story_id = post_id[3:]
comments = data_methods.comment_fetchers.fetch_hackernews_comments(story_id, max_comments)
# Save comments with parent UUID mapping
comment_uuid_map = {}
comment_uuids = []
post_uuid = post.get('uuid')
for comment in comments:
# Map parent ID to UUID
parent_id = comment.get('parent_comment_id')
if parent_id and parent_id in comment_uuid_map:
comment['parent_comment_uuid'] = comment_uuid_map[parent_id]
else:
comment['parent_comment_uuid'] = None
# Save comment
comment_uuid = save_comment(comment, post_uuid, platform, dirs)
comment_uuid_map[comment['id']] = comment_uuid
comment_uuids.append(comment_uuid)
return comment_uuids
# ===== COLLECTION FUNCTIONS =====
def collect_platform(platform: str, community: str, start_date: str, end_date: str,
max_posts: int, fetch_comments: bool, index: Dict, dirs: Dict) -> int:
"""Collect posts and comments from a platform, return count of new posts"""
print(f"\nCollecting from {platform}" + (f"/{community}" if community else ""))
try:
# Fetch posts
new_posts = data_methods.getData(platform, start_date, end_date, community, max_posts)
if not new_posts:
print(f" No posts retrieved")
return 0
print(f" Retrieved {len(new_posts)} posts")
# Process each post
added_count = 0
for post in new_posts:
post_id = f"{platform}_{post['id']}"
# Skip if already collected
if post_id in index:
continue
# Save post
post_uuid = save_post(post, platform, index, dirs)
added_count += 1
# Fetch and save comments
if fetch_comments:
comment_uuids = fetch_and_save_comments(post, platform, dirs)
if comment_uuids:
print(f" Post {post['id']}: saved {len(comment_uuids)} comments")
if added_count > 0:
print(f" Added {added_count} new posts")
return added_count
except Exception as e:
print(f" Error: {e}")
import traceback
traceback.print_exc()
return 0
def calculate_date_range(days_back: int, state: Dict) -> Tuple[str, str]:
"""Calculate start and end dates for collection, considering resume"""
end_date = datetime.now()
start_date = end_date - timedelta(days=days_back)
# Resume from last run if recent
if state.get('last_run'):
last_run = datetime.fromisoformat(state['last_run'])
if (end_date - last_run).total_seconds() < 3600: # Less than 1 hour ago
print(f"Last run was {last_run.isoformat()}, resuming from that point")
start_date = last_run
return start_date.isoformat(), end_date.isoformat()
def collect_batch(sources: List[Dict], storage_dir: str, days_back: int = 1, fetch_comments: bool = True):
"""Main collection function - orchestrates everything"""
# Setup
dirs = ensure_directories(storage_dir)
index = load_index(storage_dir)
state = load_state(storage_dir)
# Calculate date range
start_iso, end_iso = calculate_date_range(days_back, state)
print(f"\n{'='*60}")
print(f"Collection Period: {start_iso} to {end_iso}")
print(f"Fetch comments: {fetch_comments}")
print(f"{'='*60}")
# Collect from each source
total_new = 0
for source in sources:
platform = source['platform']
community = source.get('community', '')
max_posts = source.get('max_posts', 100)
count = collect_platform(
platform, community, start_iso, end_iso,
max_posts, fetch_comments, index, dirs
)
total_new += count
# Update and save state
state['last_run'] = end_iso
state['total_posts'] = len(index)
state['last_batch_count'] = total_new
save_index(index, storage_dir)
save_state(state, storage_dir)
print(f"\n{'='*60}")
print(f"Collection Complete")
print(f" New posts this run: {total_new}")
print(f" Total posts in stash: {len(index)}")
print(f"{'='*60}\n")
def get_stats(storage_dir: str) -> Dict:
"""Get collection statistics"""
dirs = ensure_directories(storage_dir)
index = load_index(storage_dir)
state = load_state(storage_dir)
post_count = len(list(dirs['posts'].glob('*.json')))
comment_count = len(list(dirs['comments'].glob('*.json')))
moderation_count = len(list(dirs['moderation'].glob('*.json')))
return {
'total_posts': post_count,
'total_comments': comment_count,
'total_moderation_records': moderation_count,
'index_entries': len(index),
'last_run': state.get('last_run', 'never'),
'storage_dir': storage_dir
}
def print_stats(storage_dir: str):
"""Print collection statistics"""
stats = get_stats(storage_dir)
print(f"\n{'='*60}")
print(f"Collection Statistics")
print(f"{'='*60}")
print(f"Total posts: {stats['total_posts']}")
print(f"Total comments: {stats['total_comments']}")
print(f"Total moderation records: {stats['total_moderation_records']}")
print(f"Index entries: {stats['index_entries']}")
print(f"Last run: {stats['last_run']}")
print(f"Storage: {stats['storage_dir']}")
print(f"{'='*60}\n")
# ===== MAIN ENTRY POINT =====
def load_platform_config(config_file: str = "./platform_config.json") -> Dict:
"""Load platform configuration from JSON file"""
try:
with open(config_file, 'r') as f:
return json.load(f)
except Exception as e:
print(f"Error loading platform config: {e}")
# Return minimal fallback config
return {
"collection_targets": [
{'platform': 'reddit', 'community': 'python', 'max_posts': 50, 'priority': 'high'},
{'platform': 'reddit', 'community': 'programming', 'max_posts': 50, 'priority': 'high'},
{'platform': 'hackernews', 'community': 'front_page', 'max_posts': 50, 'priority': 'high'},
]
}
def get_collection_sources(config: Dict, priority_filter: str = None) -> List[Dict]:
"""Extract collection sources from platform config, optionally filtered by priority"""
sources = []
for target in config.get('collection_targets', []):
# Apply priority filter if specified
if priority_filter and target.get('priority') != priority_filter:
continue
sources.append({
'platform': target['platform'],
'community': target['community'],
'max_posts': target['max_posts']
})
return sources
def main():
"""Main entry point"""
storage_dir = "./data"
# Load platform configuration
platform_config = load_platform_config()
# Get collection sources (all priorities for comprehensive collection)
sources = get_collection_sources(platform_config)
print(f"Loaded {len(sources)} collection targets from platform configuration")
for source in sources:
print(f" - {source['platform']}/{source['community']}: {source['max_posts']} posts")
# Collect posts and comments
collect_batch(sources, storage_dir, days_back=1, fetch_comments=True)
# Print statistics
print_stats(storage_dir)
if __name__ == "__main__":
main()

623
data_collection_lib.py Normal file
View File

@@ -0,0 +1,623 @@
import requests
import json
import datetime as dt
import time
from collections import defaultdict, deque
class RateLimiter:
"""
Simple rate limiter to prevent excessive API calls.
Tracks requests per domain and enforces delays.
"""
def __init__(self):
self.request_times = defaultdict(deque) # domain -> deque of timestamps
self.domain_limits = {
'reddit.com': {'requests': 60, 'window': 60}, # 60 requests per minute
'api.stackexchange.com': {'requests': 300, 'window': 86400}, # 300 per day
'hacker-news.firebaseio.com': {'requests': 300, 'window': 60}, # 300 per minute
'lobste.rs': {'requests': 30, 'window': 60}, # 30 per minute
'default': {'requests': 60, 'window': 60} # Default rate limit
}
def wait_if_needed(self, url: str):
"""
Check rate limit and wait if necessary before making request.
Args:
url: The URL being requested
"""
from urllib.parse import urlparse
domain = urlparse(url).netloc
current_time = time.time()
# Get rate limit for this domain
limit_config = self.domain_limits.get(domain, self.domain_limits['default'])
max_requests = limit_config['requests']
time_window = limit_config['window']
# Get request times for this domain
times = self.request_times[domain]
# Remove requests outside the time window
cutoff_time = current_time - time_window
while times and times[0] < cutoff_time:
times.popleft()
# Check if we're at the rate limit
if len(times) >= max_requests:
# Calculate how long to wait
oldest_request = times[0]
wait_time = time_window - (current_time - oldest_request)
if wait_time > 0:
import logging
logger = logging.getLogger(__name__)
logger.info(f"Rate limit reached for {domain}. Waiting {wait_time:.1f}s")
time.sleep(wait_time)
# Record this request
times.append(current_time)
# Global rate limiter instance
_rate_limiter = RateLimiter()
#a collection of static methods to grab reddit and reddit like data from various sources
class data_methods():
@staticmethod
def getData(platform, start_date, end_date, community, max_posts):
if platform == "reddit":
return data_methods.fetchers.getRedditData(start_date, end_date, community, max_posts)
elif platform == "pushshift":
return data_methods.fetchers.getPushshiftData(start_date, end_date, community, max_posts)
elif platform == "hackernews":
return data_methods.fetchers.getHackerNewsData(start_date, end_date, community, max_posts)
elif platform == "lobsters":
return data_methods.fetchers.getLobstersData(start_date, end_date, community, max_posts)
elif platform == "stackexchange":
return data_methods.fetchers.getStackExchangeData(start_date, end_date, community, max_posts)
else:
print("dataGrab.getData: platform not recognized")
return None
# ===== ATOMIC UTILITY FUNCTIONS =====
class utils():
"""Generic utility functions used across all fetchers"""
@staticmethod
def http_get_json(url, headers=None, params=None, timeout=30, max_retries=3):
"""
Generic HTTP GET request that returns JSON with comprehensive error handling.
Args:
url: Target URL
headers: HTTP headers
params: Query parameters
timeout: Request timeout in seconds
max_retries: Maximum number of retry attempts
Returns:
JSON response data
Raises:
requests.RequestException: On persistent failure after retries
"""
import time
import logging
logger = logging.getLogger(__name__)
for attempt in range(max_retries + 1):
try:
# Add retry delay for subsequent attempts
if attempt > 0:
delay = min(2 ** attempt, 30) # Exponential backoff, max 30s
logger.info(f"Retrying request to {url} in {delay}s (attempt {attempt + 1}/{max_retries + 1})")
time.sleep(delay)
# Apply rate limiting before making the request
_rate_limiter.wait_if_needed(url)
response = requests.get(url, headers=headers, params=params, timeout=timeout)
# Handle different HTTP status codes
if response.status_code == 429: # Rate limited
retry_after = int(response.headers.get('Retry-After', 60))
if attempt < max_retries:
logger.warning(f"Rate limited. Waiting {retry_after}s before retry")
time.sleep(retry_after)
continue
response.raise_for_status()
# Validate JSON response
try:
json_data = response.json()
return json_data
except ValueError as e:
logger.error(f"Invalid JSON response from {url}: {e}")
if attempt < max_retries:
continue
raise requests.RequestException(f"Invalid JSON response: {e}")
except requests.exceptions.Timeout:
logger.warning(f"Request timeout for {url} (attempt {attempt + 1})")
if attempt == max_retries:
raise
except requests.exceptions.ConnectionError:
logger.warning(f"Connection error for {url} (attempt {attempt + 1})")
if attempt == max_retries:
raise
except requests.exceptions.HTTPError as e:
# Don't retry on client errors (4xx) except rate limiting
if 400 <= e.response.status_code < 500 and e.response.status_code != 429:
logger.error(f"Client error {e.response.status_code} for {url}: {e}")
raise
logger.warning(f"HTTP error {e.response.status_code} for {url} (attempt {attempt + 1})")
if attempt == max_retries:
raise
except Exception as e:
logger.error(f"Unexpected error for {url}: {e}")
if attempt == max_retries:
raise
raise requests.RequestException(f"Failed to fetch {url} after {max_retries + 1} attempts")
@staticmethod
def filter_by_date_range(posts, start_date, end_date):
"""Filter posts by timestamp range"""
start_ts = int(dt.datetime.fromisoformat(start_date).timestamp())
end_ts = int(dt.datetime.fromisoformat(end_date).timestamp())
return [p for p in posts if p and start_ts <= p['timestamp'] <= end_ts]
@staticmethod
def convert_iso_to_timestamp(iso_string):
"""Convert ISO format datetime string to Unix timestamp"""
return int(dt.datetime.fromisoformat(iso_string.replace('Z', '+00:00')).timestamp())
# ===== URL AND PARAMETER BUILDERS =====
class builders():
"""Functions to build URLs, headers, and parameters"""
@staticmethod
def build_reddit_url(subreddit):
return f"https://www.reddit.com/r/{subreddit}/new.json"
@staticmethod
def build_reddit_headers():
return {'User-Agent': 'Mozilla/5.0 (compatible; DataCollector/1.0)'}
@staticmethod
def build_reddit_params(limit):
return {'limit': limit}
@staticmethod
def build_reddit_search_url(subreddit, start_date, end_date):
"""Build Reddit search URL for time-based queries"""
return f"https://www.reddit.com/r/{subreddit}/search.json"
@staticmethod
def build_reddit_search_params(limit, start_date, end_date):
"""Build search parameters for Reddit API with time constraints"""
import datetime
# Convert date strings to timestamps for Reddit API
try:
start_ts = int(datetime.datetime.fromisoformat(start_date.replace('Z', '+00:00')).timestamp())
end_ts = int(datetime.datetime.fromisoformat(end_date.replace('Z', '+00:00')).timestamp())
# Use Reddit's search syntax for time-based queries
# Reddit search uses 'after:' and 'before:' with timestamps
query = f"after:{start_ts} before:{end_ts}"
return {
'q': query,
'sort': 'new',
'restrict_sr': 'true', # Restrict to subreddit
'limit': limit,
't': 'all' # Time period: all
}
except (ValueError, TypeError):
# Fallback to simple search without time constraints
return {
'q': '*', # Match all posts
'sort': 'new',
'restrict_sr': 'true',
'limit': limit,
't': 'week' # Default to past week
}
@staticmethod
def build_hackernews_top_stories_url():
return "https://hacker-news.firebaseio.com/v0/topstories.json"
@staticmethod
def build_hackernews_story_url(story_id):
return f"https://hacker-news.firebaseio.com/v0/item/{story_id}.json"
@staticmethod
def build_lobsters_url():
return "https://lobste.rs/hottest.json"
@staticmethod
def build_stackexchange_url():
return f"https://api.stackexchange.com/2.3/questions"
@staticmethod
def build_stackexchange_params(site, limit, start_date, end_date):
start_ts = int(dt.datetime.fromisoformat(start_date).timestamp())
end_ts = int(dt.datetime.fromisoformat(end_date).timestamp())
return {
'site': site,
'pagesize': limit,
'fromdate': start_ts,
'todate': end_ts,
'sort': 'votes',
'order': 'desc'
}
# ===== SCHEMA CONVERTERS =====
class converters():
"""Functions to convert platform-specific data to unified schema"""
@staticmethod
def reddit_to_schema(child):
post = child['data']
return {
'platform': 'reddit',
'id': post.get('id'),
'title': post.get('title'),
'author': post.get('author'),
'timestamp': int(post.get('created_utc', 0)),
'score': post.get('score', 0),
'replies': post.get('num_comments', 0),
'url': post.get('url'),
'content': post.get('selftext', ''),
'source': post.get('subreddit'),
'tags': [post.get('link_flair_text', '')],
'meta': {'is_self': post.get('is_self', False)}
}
@staticmethod
def hackernews_to_schema(raw):
if not raw or raw.get('type') != 'story':
return None
return {
'platform': 'hackernews',
'id': f"hn_{raw.get('id')}",
'title': raw.get('title'),
'author': raw.get('by', 'unknown'),
'timestamp': int(raw.get('time', 0)),
'score': raw.get('score', 0),
'replies': raw.get('descendants', 0),
'url': raw.get('url', f"https://news.ycombinator.com/item?id={raw.get('id')}"),
'content': raw.get('text', ''),
'source': 'hackernews',
'tags': ['hackernews'],
'meta': {}
}
@staticmethod
def lobsters_to_schema(raw):
submitter = raw.get('submitter_user', 'unknown')
author = submitter.get('username', 'unknown') if isinstance(submitter, dict) else submitter
return {
'platform': 'lobsters',
'id': f"lob_{raw.get('short_id')}",
'title': raw.get('title'),
'author': author,
'timestamp': data_methods.utils.convert_iso_to_timestamp(raw.get('created_at')),
'score': raw.get('score', 0),
'replies': raw.get('comment_count', 0),
'url': raw.get('url', raw.get('comments_url')),
'content': raw.get('description', ''),
'source': 'lobsters',
'tags': raw.get('tags', []),
'meta': {}
}
@staticmethod
def stackexchange_to_schema(raw, community):
return {
'platform': 'stackexchange',
'id': f"se_{raw.get('question_id')}",
'title': raw.get('title'),
'author': raw.get('owner', {}).get('display_name', 'unknown'),
'timestamp': int(raw.get('creation_date', 0)),
'score': raw.get('score', 0),
'replies': raw.get('answer_count', 0),
'url': raw.get('link'),
'content': '',
'source': community,
'tags': raw.get('tags', []),
'meta': {'view_count': raw.get('view_count', 0)}
}
# ===== COMMENT FETCHERS =====
class comment_fetchers():
"""Functions to fetch comments for posts from various platforms"""
@staticmethod
def fetch_reddit_comments(post_id, subreddit, max_comments=50):
"""
Fetch comments for a Reddit post.
Note: Reddit JSON API has limited comment support without auth.
Returns list of comment dicts with parent relationships.
"""
# Reddit comment API: /r/{subreddit}/comments/{post_id}.json
url = f"https://www.reddit.com/r/{subreddit}/comments/{post_id}.json"
headers = {'User-Agent': 'Mozilla/5.0 (compatible; DataCollector/1.0)'}
try:
raw = data_methods.utils.http_get_json(url, headers=headers)
# Reddit returns [post_data, comments_data]
if len(raw) < 2:
return []
comments_data = raw[1]['data']['children']
comments = []
def extract_comment(comment_obj, parent_id=None, depth=0):
if comment_obj['kind'] != 't1': # t1 = comment
return
data = comment_obj['data']
comments.append({
'id': data.get('id'),
'parent_comment_id': parent_id,
'author': data.get('author', '[deleted]'),
'content': data.get('body', ''),
'timestamp': int(data.get('created_utc', 0)),
'score': data.get('score', 0),
'depth': depth
})
# Process replies
if 'replies' in data and isinstance(data['replies'], dict):
for reply in data['replies']['data']['children']:
extract_comment(reply, data.get('id'), depth + 1)
# Extract all comments
for comment_obj in comments_data:
extract_comment(comment_obj, None, 0)
return comments[:max_comments]
except Exception as e:
print(f"Error fetching Reddit comments: {e}")
return []
@staticmethod
def fetch_hackernews_comments(story_id, max_comments=50):
"""
Fetch comments for a HackerNews story.
HN provides comment IDs in the 'kids' field.
"""
comments = []
def fetch_comment_recursive(comment_id, parent_id=None, depth=0):
if len(comments) >= max_comments:
return
url = f"https://hacker-news.firebaseio.com/v0/item/{comment_id}.json"
try:
raw = data_methods.utils.http_get_json(url)
if not raw or raw.get('deleted') or raw.get('dead'):
return
comments.append({
'id': str(raw.get('id')),
'parent_comment_id': parent_id,
'author': raw.get('by', 'unknown'),
'content': raw.get('text', ''),
'timestamp': int(raw.get('time', 0)),
'score': 0, # HN doesn't provide comment scores via API
'depth': depth
})
# Fetch child comments
if 'kids' in raw:
for kid_id in raw['kids'][:5]: # Limit children
fetch_comment_recursive(kid_id, str(raw.get('id')), depth + 1)
except Exception as e:
print(f"Error fetching HN comment {comment_id}: {e}")
# Start with top-level comment IDs from story
try:
story_url = f"https://hacker-news.firebaseio.com/v0/item/{story_id}.json"
story = data_methods.utils.http_get_json(story_url)
if 'kids' in story:
for kid_id in story['kids'][:10]: # Limit top-level comments
fetch_comment_recursive(kid_id, None, 0)
except Exception as e:
print(f"Error fetching HN story for comments: {e}")
return comments
@staticmethod
def fetch_lobsters_comments(story_id):
"""
Lobsters provides comments in the story JSON.
"""
# Lobsters API doesn't easily provide comment trees
# Would need to parse HTML or use authenticated API
return []
@staticmethod
def fetch_stackexchange_comments(question_id, site='stackoverflow'):
"""
Fetch comments for a StackExchange question and its answers.
Uses the public StackExchange API v2.3.
"""
import datetime
comments = []
try:
# First, get question comments
question_comments_url = f"https://api.stackexchange.com/2.3/questions/{question_id}/comments"
params = {
'site': site,
'filter': 'default', # Includes basic comment data
'page': 1,
'pagesize': 100
}
response = data_methods.utils.http_get_json(question_comments_url, params=params)
if response and 'items' in response:
for comment in response['items']:
comments.append({
'uuid': f"se_{site}_{comment['comment_id']}",
'platform': 'stackexchange',
'source': site,
'content': comment.get('body', ''),
'author': comment.get('owner', {}).get('display_name', 'Anonymous'),
'timestamp': datetime.datetime.fromtimestamp(
comment.get('creation_date', 0)
).isoformat() + 'Z',
'score': comment.get('score', 0),
'parent_post_id': str(question_id),
'parent_comment_uuid': None, # Top-level comment
'depth': 0,
'se_comment_id': comment['comment_id'],
'se_post_id': comment.get('post_id'),
'se_post_type': comment.get('post_type', 'question')
})
# Then get answer IDs for this question
answers_url = f"https://api.stackexchange.com/2.3/questions/{question_id}/answers"
answers_params = {
'site': site,
'filter': 'default',
'page': 1,
'pagesize': 50
}
answers_response = data_methods.utils.http_get_json(answers_url, params=answers_params)
if answers_response and 'items' in answers_response:
# Get comments for each answer
for answer in answers_response['items']:
answer_id = answer['answer_id']
answer_comments_url = f"https://api.stackexchange.com/2.3/answers/{answer_id}/comments"
answer_comments_response = data_methods.utils.http_get_json(answer_comments_url, params=params)
if answer_comments_response and 'items' in answer_comments_response:
for comment in answer_comments_response['items']:
comments.append({
'uuid': f"se_{site}_{comment['comment_id']}",
'platform': 'stackexchange',
'source': site,
'content': comment.get('body', ''),
'author': comment.get('owner', {}).get('display_name', 'Anonymous'),
'timestamp': datetime.datetime.fromtimestamp(
comment.get('creation_date', 0)
).isoformat() + 'Z',
'score': comment.get('score', 0),
'parent_post_id': str(answer_id),
'parent_comment_uuid': None, # SE comments are flat
'depth': 0,
'se_comment_id': comment['comment_id'],
'se_post_id': comment.get('post_id'),
'se_post_type': comment.get('post_type', 'answer')
})
return comments[:100] # Limit total comments
except Exception as e:
print(f"Error fetching StackExchange comments for {question_id} on {site}: {e}")
return []
# ===== PLATFORM FETCHERS (ORCHESTRATION) =====
class fetchers():
"""Orchestration functions that compose atomic functions"""
@staticmethod
def getRedditData(start_date, end_date, community, max_posts):
# Build request components
url = data_methods.builders.build_reddit_url(community)
headers = data_methods.builders.build_reddit_headers()
params = data_methods.builders.build_reddit_params(max_posts)
# Fetch and extract
raw = data_methods.utils.http_get_json(url, headers, params)
children = raw['data']['children']
# Convert and filter
posts = [data_methods.converters.reddit_to_schema(c) for c in children]
return data_methods.utils.filter_by_date_range(posts, start_date, end_date)
@staticmethod
def getPushshiftData(start_date, end_date, community, max_posts):
"""
Alternative Reddit data collection using official Reddit API.
Since Pushshift is deprecated, we use Reddit's native search/listing endpoints.
"""
try:
# Use Reddit's native search for historical posts within date range
# Build search URL for the specific subreddit and time range
url = data_methods.builders.build_reddit_search_url(community, start_date, end_date)
headers = data_methods.builders.build_reddit_headers()
params = data_methods.builders.build_reddit_search_params(max_posts, start_date, end_date)
# Fetch data from Reddit search
raw = data_methods.utils.http_get_json(url, headers, params)
if not raw or 'data' not in raw or 'children' not in raw['data']:
return []
children = raw['data']['children']
# Convert and filter by date range
posts = [data_methods.converters.reddit_to_schema(c) for c in children]
return data_methods.utils.filter_by_date_range(posts, start_date, end_date)
except Exception as e:
print(f"Error fetching Reddit search data: {e}")
return []
@staticmethod
def getHackerNewsData(start_date, end_date, community, max_posts):
# Fetch story IDs
ids_url = data_methods.builders.build_hackernews_top_stories_url()
ids = data_methods.utils.http_get_json(ids_url)[:max_posts]
# Fetch individual stories
stories = []
for story_id in ids:
story_url = data_methods.builders.build_hackernews_story_url(story_id)
stories.append(data_methods.utils.http_get_json(story_url))
# Convert and filter
posts = [data_methods.converters.hackernews_to_schema(s) for s in stories]
return data_methods.utils.filter_by_date_range(posts, start_date, end_date)
@staticmethod
def getLobstersData(start_date, end_date, community, max_posts):
# Fetch posts
url = data_methods.builders.build_lobsters_url()
raw = data_methods.utils.http_get_json(url)[:max_posts]
# Convert and filter
posts = [data_methods.converters.lobsters_to_schema(r) for r in raw]
return data_methods.utils.filter_by_date_range(posts, start_date, end_date)
@staticmethod
def getStackExchangeData(start_date, end_date, community, max_posts):
# Build request components
url = data_methods.builders.build_stackexchange_url()
params = data_methods.builders.build_stackexchange_params(community, max_posts, start_date, end_date)
# Fetch and convert
raw = data_methods.utils.http_get_json(url, params=params)
return [data_methods.converters.stackexchange_to_schema(q, community) for q in raw.get('items', [])]

53
database.py Normal file
View File

@@ -0,0 +1,53 @@
"""
Database Configuration
SQLAlchemy setup for PostgreSQL connection.
"""
import os
from flask_sqlalchemy import SQLAlchemy
# Initialize SQLAlchemy instance
db = SQLAlchemy()
def init_db(app):
"""
Initialize database with Flask app.
Args:
app: Flask application instance
"""
# Get database URL from environment variable
database_url = os.getenv('DATABASE_URL')
if not database_url:
# Fallback to individual environment variables
db_user = os.getenv('POSTGRES_USER', 'balanceboard')
db_password = os.getenv('POSTGRES_PASSWORD', 'changeme')
db_host = os.getenv('POSTGRES_HOST', 'localhost')
db_port = os.getenv('POSTGRES_PORT', '5432')
db_name = os.getenv('POSTGRES_DB', 'balanceboard')
database_url = f'postgresql+psycopg2://{db_user}:{db_password}@{db_host}:{db_port}/{db_name}'
# Configure Flask app
app.config['SQLALCHEMY_DATABASE_URI'] = database_url
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config['SQLALCHEMY_ENGINE_OPTIONS'] = {
'pool_size': 10,
'pool_recycle': 3600,
'pool_pre_ping': True, # Verify connections before using
}
# Initialize db with app
db.init_app(app)
# Create tables
with app.app_context():
db.create_all()
print("✓ Database tables created")
def get_db():
"""Get database instance"""
return db

73
docker-compose.yml Normal file
View File

@@ -0,0 +1,73 @@
version: '3.8'
services:
postgres:
image: postgres:15
container_name: balanceboard_postgres
environment:
POSTGRES_DB: balanceboard
POSTGRES_USER: balanceboard
POSTGRES_PASSWORD: balanceboard123
ports:
- "5433:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U balanceboard -d balanceboard"]
interval: 30s
timeout: 10s
retries: 3
networks:
- balanceboard-network
app:
build:
context: .
dockerfile: Dockerfile
container_name: balanceboard_app
ports:
- "5021:5021"
environment:
# Database configuration
POSTGRES_HOST: postgres
POSTGRES_PORT: 5432
POSTGRES_USER: balanceboard
POSTGRES_PASSWORD: balanceboard123
POSTGRES_DB: balanceboard
# Flask configuration
FLASK_ENV: production
DEBUG: "False"
SECRET_KEY: ${SECRET_KEY:-change-this-secret-key-in-production}
# Auth0 configuration (optional)
AUTH0_DOMAIN: ${AUTH0_DOMAIN:-}
AUTH0_CLIENT_ID: ${AUTH0_CLIENT_ID:-}
AUTH0_CLIENT_SECRET: ${AUTH0_CLIENT_SECRET:-}
AUTH0_AUDIENCE: ${AUTH0_AUDIENCE:-}
volumes:
# Persistent data storage
- ./data:/app/data
- ./static:/app/static
- ./backups:/app/backups
- ./active_html:/app/active_html
depends_on:
postgres:
condition: service_healthy
restart: unless-stopped
networks:
- balanceboard-network
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5021/"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
balanceboard-network:
driver: bridge
volumes:
postgres_data:

345
filter_lib.py Normal file
View File

@@ -0,0 +1,345 @@
"""
Filter Library
Bare bones utilities for filtering posts and comments based on rules.
"""
import json
from pathlib import Path
from typing import Dict, List, Any, Optional
from abc import ABC, abstractmethod
class filter_lib:
"""Atomic filter utility functions"""
@staticmethod
def load_filterset(path: str) -> Dict:
"""Load filterset JSON from file"""
with open(path, 'r') as f:
return json.load(f)
@staticmethod
def load_data_by_uuid(uuid: str, data_dir: str) -> Optional[Dict]:
"""Load single JSON file by UUID"""
file_path = Path(data_dir) / f"{uuid}.json"
if not file_path.exists():
return None
with open(file_path, 'r') as f:
return json.load(f)
@staticmethod
def merge_moderation(item: Dict, moderation_data: Dict) -> Dict:
"""Merge item with its moderation data by UUID"""
mod_uuid = item.get('moderation_uuid')
if mod_uuid and mod_uuid in moderation_data:
item['moderation'] = moderation_data[mod_uuid]
else:
item['moderation'] = {}
return item
@staticmethod
def get_nested_value(obj: Dict, path: str) -> Any:
"""Get value from nested dict using dot notation (e.g., 'moderation.flags.is_safe')"""
keys = path.split('.')
value = obj
for key in keys:
if isinstance(value, dict) and key in value:
value = value[key]
else:
return None
return value
@staticmethod
def evaluate_rule(value: Any, operator: str, target: Any) -> bool:
"""Evaluate single rule: value operator target"""
if value is None:
return False
if operator == 'equals':
return value == target
elif operator == 'not_equals':
return value != target
elif operator == 'in':
return value in target
elif operator == 'not_in':
return value not in target
elif operator == 'min':
return value >= target
elif operator == 'max':
return value <= target
elif operator == 'after':
return value > target
elif operator == 'before':
return value < target
elif operator == 'contains':
return target in value
elif operator == 'excludes':
if isinstance(value, list):
return not any(item in target for item in value)
return target not in value
elif operator == 'includes':
if isinstance(value, list):
return target in value
return False
elif operator == 'includes_any':
# Special case for topic matching
if isinstance(value, list) and isinstance(target, list):
for topic_item in value:
for rule in target:
if (topic_item.get('topic') == rule.get('topic') and
topic_item.get('confidence', 0) >= rule.get('confidence_min', 0)):
return True
return False
elif operator == 'min_length':
return len(str(value)) >= target
elif operator == 'max_length':
return len(str(value)) <= target
else:
return False
@staticmethod
def apply_rules(item: Dict, rules: Dict) -> bool:
"""
Apply multiple rules to item, return True if all pass (AND logic).
Rules format: {"field.path": {"operator": value}}
"""
if not rules:
return True # Empty rules = pass all
for field_path, rule_def in rules.items():
value = filter_lib.get_nested_value(item, field_path)
# Support multiple operators per field
for operator, target in rule_def.items():
if not filter_lib.evaluate_rule(value, operator, target):
return False
return True
class CommentFilterMode(ABC):
"""Abstract base class for comment filtering modes"""
@staticmethod
@abstractmethod
def filter(comments: List[Dict], rules: Dict, moderation_data: Dict) -> List[Dict]:
"""Filter comments based on rules and moderation data. Override in subclasses."""
pass
class TreePruningMode(CommentFilterMode):
"""
Tree Pruning Filter Mode (Default)
Fruit of the poisonous tree: if parent fails moderation, remove all children.
"""
@staticmethod
def filter(comments: List[Dict], rules: Dict, moderation_data: Dict) -> List[Dict]:
"""
Filter comments using tree pruning.
Build tree structure, evaluate from root down, prune toxic branches.
"""
if not comments:
return []
# Merge moderation data into comments
for comment in comments:
filter_lib.merge_moderation(comment, moderation_data)
# Build tree structure
tree = TreePruningMode._build_tree(comments)
# Prune tree based on rules
pruned = TreePruningMode._prune_tree(tree, rules)
# Flatten back to list
return TreePruningMode._flatten_tree(pruned)
@staticmethod
def _build_tree(comments: List[Dict]) -> List[Dict]:
"""Build nested tree from flat comment list"""
# Create lookup dict
comment_map = {c['uuid']: {**c, 'children': []} for c in comments}
# Build tree
roots = []
for comment in comments:
parent_uuid = comment.get('parent_comment_uuid')
if parent_uuid and parent_uuid in comment_map:
comment_map[parent_uuid]['children'].append(comment_map[comment['uuid']])
else:
roots.append(comment_map[comment['uuid']])
return roots
@staticmethod
def _prune_tree(tree: List[Dict], rules: Dict) -> List[Dict]:
"""
Recursively prune tree.
If node fails rules, remove it and all children.
"""
pruned = []
for node in tree:
# Check if this node passes rules
if filter_lib.apply_rules(node, rules):
# Node passes, recursively check children
if node.get('children'):
node['children'] = TreePruningMode._prune_tree(node['children'], rules)
pruned.append(node)
# If node fails, it and all children are discarded (tree pruning)
return pruned
@staticmethod
def _flatten_tree(tree: List[Dict]) -> List[Dict]:
"""Flatten tree back to list"""
flat = []
def traverse(nodes):
for node in nodes:
children = node.pop('children', [])
flat.append(node)
if children:
traverse(children)
traverse(tree)
return flat
class IndividualFilterMode(CommentFilterMode):
"""
Individual Filter Mode
Each comment evaluated independently, no tree pruning.
"""
@staticmethod
def filter(comments: List[Dict], rules: Dict, moderation_data: Dict) -> List[Dict]:
"""Filter comments individually"""
filtered = []
for comment in comments:
# Merge moderation
filter_lib.merge_moderation(comment, moderation_data)
# Apply rules
if filter_lib.apply_rules(comment, rules):
filtered.append(comment)
return filtered
class ScoreBasedFilterMode(CommentFilterMode):
"""
Score-Based Filter Mode
Filter comments based on score thresholds, keeping high-quality content.
"""
@staticmethod
def filter(comments: List[Dict], rules: Dict, moderation_data: Dict) -> List[Dict]:
"""Filter comments based on score and rules"""
filtered = []
for comment in comments:
# Merge moderation
filter_lib.merge_moderation(comment, moderation_data)
# Apply basic rules first
if not filter_lib.apply_rules(comment, rules):
continue
# Additional score-based filtering
score = comment.get('score', 0)
min_score = rules.get('score', {}).get('min', -1000) # Default very low threshold
if score >= min_score:
filtered.append(comment)
return filtered
class TimeBoundFilterMode(CommentFilterMode):
"""
Time-Bound Filter Mode
Filter comments within specific time ranges.
"""
@staticmethod
def filter(comments: List[Dict], rules: Dict, moderation_data: Dict) -> List[Dict]:
"""Filter comments within time bounds"""
from datetime import datetime
filtered = []
for comment in comments:
# Merge moderation
filter_lib.merge_moderation(comment, moderation_data)
# Apply basic rules first
if not filter_lib.apply_rules(comment, rules):
continue
# Time-based filtering
timestamp = comment.get('timestamp')
if timestamp:
try:
comment_time = datetime.fromisoformat(timestamp.replace('Z', '+00:00'))
time_rules = rules.get('timestamp', {})
after = time_rules.get('after')
before = time_rules.get('before')
if after:
after_time = datetime.fromisoformat(after.replace('Z', '+00:00'))
if comment_time <= after_time:
continue
if before:
before_time = datetime.fromisoformat(before.replace('Z', '+00:00'))
if comment_time >= before_time:
continue
filtered.append(comment)
except (ValueError, TypeError):
# Skip malformed timestamps
continue
else:
# No timestamp, include if no time rules
if 'timestamp' not in rules:
filtered.append(comment)
return filtered
class ContentLengthFilterMode(CommentFilterMode):
"""
Content Length Filter Mode
Filter comments based on content length criteria.
"""
@staticmethod
def filter(comments: List[Dict], rules: Dict, moderation_data: Dict) -> List[Dict]:
"""Filter comments based on content length"""
filtered = []
for comment in comments:
# Merge moderation
filter_lib.merge_moderation(comment, moderation_data)
# Apply basic rules first
if not filter_lib.apply_rules(comment, rules):
continue
# Content length filtering
content = comment.get('content', '')
content_length = len(content)
length_rules = rules.get('content_length', {})
min_length = length_rules.get('min', 0)
max_length = length_rules.get('max', float('inf'))
if min_length <= content_length <= max_length:
filtered.append(comment)
return filtered

297
generate_html.py Normal file
View File

@@ -0,0 +1,297 @@
#!/usr/bin/env python3
"""
Static HTML Generator
Generates static HTML from collected posts/comments with filtering and moderation.
"""
import argparse
import json
from pathlib import Path
from datetime import datetime
from typing import Dict, List, Optional
from filter_lib import filter_lib, TreePruningMode, IndividualFilterMode
from comment_lib import comment_lib
from html_generation_lib import html_generation_lib
class HTMLGenerator:
"""Generate static HTML from filtered posts and comments"""
def __init__(self, data_dir: str = "./data", filtersets_path: str = "./filtersets.json"):
self.data_dir = Path(data_dir)
self.filtersets_path = filtersets_path
# Load filtersets
self.filtersets = filter_lib.load_filterset(filtersets_path)
# Load moderation data into memory for faster access
self.moderation_data = self._load_all_moderation()
def _load_all_moderation(self) -> Dict:
"""Load all moderation files into a dict keyed by UUID"""
moderation_dir = self.data_dir / "moderation"
moderation_data = {}
if moderation_dir.exists():
for mod_file in moderation_dir.glob("*.json"):
mod_uuid = mod_file.stem
with open(mod_file, 'r') as f:
moderation_data[mod_uuid] = json.load(f)
return moderation_data
def _load_post_index(self) -> Dict:
"""Load post index"""
index_file = self.data_dir / "post_index.json"
if index_file.exists():
with open(index_file, 'r') as f:
return json.load(f)
return {}
def _load_post_by_uuid(self, post_uuid: str) -> Optional[Dict]:
"""Load a post by UUID"""
return filter_lib.load_data_by_uuid(post_uuid, str(self.data_dir / "posts"))
def generate(self, filterset_name: str, theme_name: str, output_dir: str):
"""
Main generation function.
Loads data, applies filters, renders HTML.
"""
print(f"\n{'='*60}")
print(f"Generating HTML")
print(f" Filterset: {filterset_name}")
print(f" Theme: {theme_name}")
print(f" Output: {output_dir}")
print(f"{'='*60}\n")
# Load filterset
if filterset_name not in self.filtersets:
print(f"Error: Filterset '{filterset_name}' not found")
return
filterset = self.filtersets[filterset_name]
post_rules = filterset.get('post_rules', {})
comment_rules = filterset.get('comment_rules', {})
comment_filter_mode = filterset.get('comment_filter_mode', 'tree_pruning')
# Choose comment filter mode
if comment_filter_mode == 'tree_pruning':
comment_filter = TreePruningMode
else:
comment_filter = IndividualFilterMode
# Load theme
try:
theme = html_generation_lib.load_theme(theme_name)
except Exception as e:
print(f"Error loading theme: {e}")
return
# Load post index
post_index = self._load_post_index()
print(f"Found {len(post_index)} posts in index")
# Filter and render posts
filtered_posts = []
generation_stats = {
'total_posts_checked': 0,
'posts_passed': 0,
'posts_failed': 0,
'total_comments_checked': 0,
'comments_passed': 0,
'comments_failed': 0
}
for post_id, post_uuid in post_index.items():
generation_stats['total_posts_checked'] += 1
# Load post
post = self._load_post_by_uuid(post_uuid)
if not post:
continue
# Merge moderation data
filter_lib.merge_moderation(post, self.moderation_data)
# Apply post rules
if not filter_lib.apply_rules(post, post_rules):
generation_stats['posts_failed'] += 1
continue
generation_stats['posts_passed'] += 1
# Load comments for this post
comments = comment_lib.load_comments_for_post(post_uuid, str(self.data_dir))
if comments:
generation_stats['total_comments_checked'] += len(comments)
# Filter comments using selected mode
filtered_comments = comment_filter.filter(comments, comment_rules, self.moderation_data)
generation_stats['comments_passed'] += len(filtered_comments)
generation_stats['comments_failed'] += len(comments) - len(filtered_comments)
# Build comment tree for rendering
comment_tree = comment_lib.build_comment_tree(filtered_comments)
post['comments'] = comment_tree
else:
post['comments'] = []
filtered_posts.append(post)
print(f"\nFiltering Results:")
print(f" Posts: {generation_stats['posts_passed']}/{generation_stats['total_posts_checked']} passed")
print(f" Comments: {generation_stats['comments_passed']}/{generation_stats['total_comments_checked']} passed")
# Create output directory
output_path = Path(output_dir) / filterset_name
output_path.mkdir(parents=True, exist_ok=True)
# Render index page
for post in filtered_posts:
post['post_url'] = f"{post['uuid']}.html"
index_html = html_generation_lib.render_index(filtered_posts, theme, filterset_name)
html_generation_lib.write_html_file(index_html, str(output_path / "index.html"))
# Render individual post pages
for post in filtered_posts:
post_html = html_generation_lib.render_post_page(post, theme, post.get('comments'))
post_filename = f"{post['uuid']}.html"
html_generation_lib.write_html_file(post_html, str(output_path / post_filename))
# Generate metadata file
metadata = {
"generated_at": datetime.now().isoformat(),
"filterset": filterset_name,
"filterset_config": filterset,
"theme": theme_name,
"output_directory": str(output_path),
"statistics": {
**generation_stats,
"posts_generated": len(filtered_posts)
},
"comment_filter_mode": comment_filter_mode
}
metadata_file = output_path / "metadata.json"
with open(metadata_file, 'w') as f:
json.dump(metadata, f, indent=2)
print(f"\nGeneration Complete:")
print(f" Index page: {output_path / 'index.html'}")
print(f" Individual posts: {len(filtered_posts)} files")
print(f" Metadata: {metadata_file}")
print(f"{'='*60}\n")
def interactive_mode():
"""Interactive mode for human use"""
print("\n=== HTML Generator - Interactive Mode ===\n")
# List available filtersets
try:
filtersets = filter_lib.load_filterset("./filtersets.json")
print("Available filtersets:")
for i, (name, config) in enumerate(filtersets.items(), 1):
desc = config.get('description', 'No description')
print(f" {i}. {name} - {desc}")
filterset_choice = input("\nEnter filterset name or number: ").strip()
# Handle numeric choice
if filterset_choice.isdigit():
idx = int(filterset_choice) - 1
filterset_name = list(filtersets.keys())[idx]
else:
filterset_name = filterset_choice
# List available themes
themes_dir = Path("./themes")
if themes_dir.exists():
themes = [d.name for d in themes_dir.iterdir() if d.is_dir()]
print("\nAvailable themes:")
for i, theme in enumerate(themes, 1):
print(f" {i}. {theme}")
theme_choice = input("\nEnter theme name or number: ").strip()
if theme_choice.isdigit():
idx = int(theme_choice) - 1
theme_name = themes[idx]
else:
theme_name = theme_choice
else:
theme_name = "vanilla-js"
# Output directory
output_dir = input("\nOutput directory [./active_html]: ").strip()
if not output_dir:
output_dir = "./active_html"
# Run generation
generator = HTMLGenerator()
generator.generate(filterset_name, theme_name, output_dir)
except Exception as e:
print(f"Error: {e}")
import traceback
traceback.print_exc()
def main():
"""Main entry point with CLI argument parsing"""
parser = argparse.ArgumentParser(
description="Generate static HTML from collected posts with filtering"
)
parser.add_argument(
'--filterset',
default='safe_content',
help='Filterset name to use (default: safe_content)'
)
parser.add_argument(
'--theme',
default='vanilla-js',
help='Theme name to use (default: vanilla-js)'
)
parser.add_argument(
'--output',
default='./active_html',
help='Output directory (default: ./active_html)'
)
parser.add_argument(
'--interactive',
action='store_true',
help='Run in interactive mode'
)
parser.add_argument(
'--data-dir',
default='./data',
help='Data directory (default: ./data)'
)
parser.add_argument(
'--filtersets-file',
default='./filtersets.json',
help='Filtersets file (default: ./filtersets.json)'
)
args = parser.parse_args()
if args.interactive:
interactive_mode()
else:
generator = HTMLGenerator(
data_dir=args.data_dir,
filtersets_path=args.filtersets_file
)
generator.generate(args.filterset, args.theme, args.output)
if __name__ == "__main__":
main()

515
html_generation_lib.py Normal file
View File

@@ -0,0 +1,515 @@
"""
HTML Generation Library
Atomic functions for loading themes and rendering HTML from templates.
"""
import json
from pathlib import Path
from typing import Dict, List, Optional
from datetime import datetime
import jinja2
class html_generation_lib:
"""Atomic HTML generation functions"""
@staticmethod
def load_theme(theme_name: str, themes_dir: str = './themes') -> Dict:
"""
Load theme configuration and templates.
Returns:
Dict with theme config, template paths, and metadata
"""
theme_dir = Path(themes_dir) / theme_name
theme_config_path = theme_dir / 'theme.json'
if not theme_config_path.exists():
raise FileNotFoundError(f"Theme config not found: {theme_config_path}")
with open(theme_config_path, 'r') as f:
config = json.load(f)
# Load template files
templates = {}
if 'templates' in config:
for template_name, template_path in config['templates'].items():
full_path = Path(template_path)
if full_path.exists():
with open(full_path, 'r') as f:
templates[template_name] = f.read()
config['loaded_templates'] = templates
config['theme_dir'] = str(theme_dir)
return config
@staticmethod
def render_template(template_string: str, data: Dict) -> str:
"""
Render template string with data using Jinja2 templating.
Handles nested expressions and complex logic better.
Args:
template_string: Template with {{variable}} placeholders
data: Dict of data to inject
Returns:
Rendered HTML string
"""
# Add helper functions to data context
context = {
**data,
'formatTime': html_generation_lib.format_time,
'formatTimeAgo': html_generation_lib.format_time_ago,
'formatDateTime': html_generation_lib.format_datetime,
'truncate': html_generation_lib.truncate,
'renderMarkdown': html_generation_lib.render_markdown,
'escapeHtml': html_generation_lib.escape_html
}
# Extract template content from <template> tag if present
if '<template' in template_string:
import re
match = re.search(r'<template[^>]*>(.*?)</template>', template_string, re.DOTALL)
if match:
template_string = match.group(1)
# Use Jinja2 for template rendering
try:
template = jinja2.Template(template_string)
return template.render(**context)
except Exception as e:
print(f"Template rendering error: {e}")
return f"<!-- Template error: {e} -->"
@staticmethod
def render_post(post: Dict, theme: Dict, comments: Optional[List[Dict]] = None) -> str:
"""
Render single post to HTML using theme's post/card/detail template.
Args:
post: Post data dict
theme: Theme config with loaded templates
comments: Optional list of comments to render with post
Returns:
Rendered HTML string
"""
# Choose template (prefer 'detail' if comments, else 'card')
template_name = 'detail' if comments else 'card'
if template_name not in theme.get('loaded_templates', {}):
template_name = 'card' # Fallback
template = theme['loaded_templates'].get(template_name)
if not template:
return f"<!-- No template found for {template_name} -->"
# Render comments if provided
comments_section = ''
if comments:
comments_section = html_generation_lib.render_comment_tree(comments, theme)
# Create post data with comments_section
post_data = dict(post)
post_data['comments_section'] = comments_section
# Render post
return html_generation_lib.render_template(template, post_data)
@staticmethod
def render_post_page(post: Dict, theme: Dict, comments: Optional[List[Dict]] = None) -> str:
"""
Render single post as a complete HTML page with navigation.
Args:
post: Post data dict
theme: Theme config with loaded templates
comments: Optional list of comments to render with post
Returns:
Complete HTML page string
"""
# Render the post content
post_content = html_generation_lib.render_post(post, theme, comments)
# Build CSS links
css_links = ''
if theme.get('css_dependencies'):
for css_path in theme['css_dependencies']:
adjusted_path = css_path.replace('./themes/', '../../themes/')
css_links += f' <link rel="stylesheet" href="{adjusted_path}">\n'
# Build JS scripts
js_scripts = ''
if theme.get('js_dependencies'):
for js_path in theme['js_dependencies']:
adjusted_path = js_path.replace('./themes/', '../../themes/')
js_scripts += f' <script src="{adjusted_path}"></script>\n'
# Create full page
page_html = f'''<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{post.get('title', 'Post')} - BalanceBoard</title>
{css_links}
</head>
<body>
<!-- BalanceBoard Navigation -->
<nav class="balanceboard-nav">
<div class="nav-container">
<a href="/index.html" class="nav-brand">
<img src="../../logo.png" alt="BalanceBoard Logo" class="nav-logo">
<div>
<div class="nav-brand-text">
<span class="brand-balance">balance</span><span class="brand-board">Board</span>
</div>
<div class="nav-subtitle">Filtered Content Feed</div>
</div>
</a>
</div>
</nav>
<div class="app-layout">
<!-- Sidebar -->
<aside class="sidebar">
<!-- User Card -->
<div class="sidebar-section user-card">
<div class="login-prompt">
<div class="user-avatar">?</div>
<p>Join BalanceBoard to customize your feed</p>
<a href="/login" class="btn-login">Log In</a>
<a href="/signup" class="btn-signup">Sign Up</a>
</div>
</div>
<!-- Navigation -->
<div class="sidebar-section">
<h3>Navigation</h3>
<ul class="nav-menu">
<li><a href="/index.html" class="nav-item">
<span class="nav-icon">🏠</span>
<span>Home</span>
</a></li>
<li><a href="#" class="nav-item">
<span class="nav-icon">🔥</span>
<span>Popular</span>
</a></li>
<li><a href="#" class="nav-item">
<span class="nav-icon">⭐</span>
<span>Saved</span>
</a></li>
<li><a href="#" class="nav-item">
<span class="nav-icon">📊</span>
<span>Analytics</span>
</a></li>
</ul>
</div>
<!-- Post Info -->
<div class="sidebar-section">
<h3>Post Info</h3>
<p style="font-size: 0.85rem; color: var(--text-secondary); line-height: 1.5; margin-bottom: 12px;">
<strong>Author:</strong> {post.get('author', 'Unknown')}<br>
<strong>Platform:</strong> {post.get('platform', 'Unknown').title()}<br>
<strong>Score:</strong> {post.get('score', 0)} points
</p>
</div>
<!-- About -->
<div class="sidebar-section">
<h3>About</h3>
<p style="font-size: 0.85rem; color: var(--text-secondary); line-height: 1.5;">
BalanceBoard filters and curates content from multiple platforms.
</p>
</div>
</aside>
<!-- Main Content -->
<main class="main-content">
<div class="container">
{post_content}
</div>
</main>
</div>
{js_scripts}
</body>
</html>'''
return page_html
@staticmethod
def render_comment_tree(comments: List[Dict], theme: Dict, depth: int = 0) -> str:
"""
Recursively render nested comment tree (unlimited depth).
Args:
comments: List of comment dicts (may have 'children')
theme: Theme config with loaded templates
depth: Current nesting depth
Returns:
Rendered HTML string for all comments
"""
if not comments:
return ''
template = theme['loaded_templates'].get('comment')
if not template:
return '<!-- No comment template -->'
html_parts = []
for comment in comments:
# Recursively render children first
children = comment.get('children', [])
if children:
children_html = html_generation_lib.render_comment_tree(children, theme, depth + 1)
else:
children_html = ''
# Add depth and children_section to comment data
comment_data = {**comment, 'depth': depth, 'children_section': children_html}
# Render this comment
comment_html = html_generation_lib.render_template(template, comment_data)
html_parts.append(comment_html)
return '\n'.join(html_parts)
@staticmethod
def render_index(posts: List[Dict], theme: Dict, filterset_name: str = '') -> str:
"""
Render index/list page with all posts.
Args:
posts: List of post dicts
theme: Theme config with loaded templates
filterset_name: Name of filterset used (for display)
Returns:
Complete HTML page
"""
template = theme['loaded_templates'].get('list') or theme['loaded_templates'].get('card')
if not template:
return '<!-- No list template -->'
# Render each post
post_items = []
for post in posts:
# Update post URL to use Flask route
post_data = dict(post)
post_data['post_url'] = f"/post/{post['uuid']}"
post_html = html_generation_lib.render_template(template, post_data)
post_items.append(post_html)
# Create full page
css_links = ''
if theme.get('css_dependencies'):
for css_path in theme['css_dependencies']:
# Adjust relative paths to work from subdirectories (e.g., active_html/no_filter/)
# Convert ./themes/... to ../../themes/...
adjusted_path = css_path.replace('./themes/', '../../themes/')
css_links += f' <link rel="stylesheet" href="{adjusted_path}">\n'
js_scripts = ''
if theme.get('js_dependencies'):
for js_path in theme['js_dependencies']:
# Adjust relative paths to work from subdirectories
adjusted_path = js_path.replace('./themes/', '../../themes/')
js_scripts += f' <script src="{adjusted_path}"></script>\n'
page_html = f'''<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BalanceBoard - Content Feed</title>
{css_links}
</head>
<body>
<!-- BalanceBoard Navigation -->
<nav class="balanceboard-nav">
<div class="nav-container">
<a href="index.html" class="nav-brand">
<img src="../../logo.png" alt="BalanceBoard Logo" class="nav-logo">
<div>
<div class="nav-brand-text">
<span class="brand-balance">balance</span><span class="brand-board">Board</span>
</div>
<div class="nav-subtitle">Filtered Content Feed</div>
</div>
</a>
</div>
</nav>
<div class="app-layout">
<!-- Sidebar -->
<aside class="sidebar">
<!-- User Card -->
<div class="sidebar-section user-card">
<div class="login-prompt">
<div class="user-avatar">?</div>
<p>Join BalanceBoard to customize your feed</p>
<a href="/login" class="btn-login">Log In</a>
<a href="/signup" class="btn-signup">Sign Up</a>
</div>
</div>
<!-- Navigation -->
<div class="sidebar-section">
<h3>Navigation</h3>
<ul class="nav-menu">
<li><a href="index.html" class="nav-item active">
<span class="nav-icon">🏠</span>
<span>Home</span>
</a></li>
<li><a href="#" class="nav-item">
<span class="nav-icon">🔥</span>
<span>Popular</span>
</a></li>
<li><a href="#" class="nav-item">
<span class="nav-icon">⭐</span>
<span>Saved</span>
</a></li>
<li><a href="#" class="nav-item">
<span class="nav-icon">📊</span>
<span>Analytics</span>
</a></li>
</ul>
</div>
<!-- Filters -->
<div class="sidebar-section">
<h3>Filter by Platform</h3>
<div class="filter-tags">
<a href="#" class="filter-tag active">All</a>
<a href="#" class="filter-tag">Reddit</a>
<a href="#" class="filter-tag">HackerNews</a>
<a href="#" class="filter-tag">Lobsters</a>
</div>
</div>
<!-- About -->
<div class="sidebar-section">
<h3>About</h3>
<p style="font-size: 0.85rem; color: var(--text-secondary); line-height: 1.5;">
BalanceBoard filters and curates content from multiple platforms to help you stay informed.
</p>
</div>
</aside>
<!-- Main Content -->
<main class="main-content">
<div class="container">
<header>
<h1>{filterset_name.replace('_', ' ').title() if filterset_name else 'All Posts'}</h1>
<p class="post-count">{len(posts)} posts</p>
</header>
<div id="posts-container">
{''.join(post_items)}
</div>
</div>
</main>
</div>
</body>
</html>'''
return page_html
@staticmethod
def write_html_file(html: str, output_path: str) -> None:
"""
Write HTML string to file.
Args:
html: HTML content
output_path: File path to write to
"""
output_file = Path(output_path)
output_file.parent.mkdir(parents=True, exist_ok=True)
with open(output_file, 'w', encoding='utf-8') as f:
f.write(html)
# Helper functions for templates
@staticmethod
def format_time(timestamp: int) -> str:
"""Format timestamp as time"""
dt = datetime.fromtimestamp(timestamp)
return dt.strftime('%H:%M')
@staticmethod
def format_time_ago(timestamp: int) -> str:
"""Format timestamp as relative time (e.g., '2 hours ago')"""
now = datetime.now()
dt = datetime.fromtimestamp(timestamp)
diff = now - dt
seconds = diff.total_seconds()
if seconds < 60:
return 'just now'
elif seconds < 3600:
minutes = int(seconds / 60)
return f'{minutes} minute{"s" if minutes != 1 else ""} ago'
elif seconds < 86400:
hours = int(seconds / 3600)
return f'{hours} hour{"s" if hours != 1 else ""} ago'
elif seconds < 604800:
days = int(seconds / 86400)
return f'{days} day{"s" if days != 1 else ""} ago'
else:
weeks = int(seconds / 604800)
return f'{weeks} week{"s" if weeks != 1 else ""} ago'
@staticmethod
def format_datetime(timestamp: int) -> str:
"""Format timestamp as full datetime"""
dt = datetime.fromtimestamp(timestamp)
return dt.strftime('%B %d, %Y at %H:%M')
@staticmethod
def truncate(text: str, max_length: int) -> str:
"""Truncate text to max length"""
if len(text) <= max_length:
return text
return text[:max_length].strip() + '...'
@staticmethod
def render_markdown(text: str) -> str:
"""Basic markdown rendering"""
if not text:
return ''
# Basic markdown conversions
html = text
html = html.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
html = html.replace('\n\n', '</p><p>')
html = html.replace('\n', '<br>')
# Bold and italic
import re
html = re.sub(r'\*\*(.*?)\*\*', r'<strong>\1</strong>', html)
html = re.sub(r'\*(.*?)\*', r'<em>\1</em>', html)
# Images (must be processed before links since they use similar syntax)
html = re.sub(r'!\[(.*?)\]\((.*?)\)', r'<img src="\2" alt="\1" style="max-width: 100%; height: auto; display: block; margin: 0.5em 0;" />', html)
# Links
html = re.sub(r'\[(.*?)\]\((.*?)\)', r'<a href="\2" target="_blank">\1</a>', html)
return f'<p>{html}</p>'
@staticmethod
def escape_html(text: str) -> str:
"""Escape HTML entities"""
return (text
.replace('&', '&amp;')
.replace('<', '&lt;')
.replace('>', '&gt;')
.replace('"', '&quot;')
.replace("'", '&#x27;'))

186
models.py Normal file
View File

@@ -0,0 +1,186 @@
"""
Database Models
SQLAlchemy models for the application.
"""
import uuid
from datetime import datetime
from flask_login import UserMixin
from flask_bcrypt import Bcrypt
from database import db
# Initialize bcrypt
bcrypt = Bcrypt()
class User(UserMixin, db.Model):
"""User model with bcrypt password hashing"""
__tablename__ = 'users'
# Primary fields
id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
username = db.Column(db.String(80), unique=True, nullable=False, index=True)
email = db.Column(db.String(120), unique=True, nullable=False, index=True)
password_hash = db.Column(db.String(128), nullable=True) # Nullable for OAuth users
# OAuth fields
auth0_id = db.Column(db.String(255), unique=True, nullable=True, index=True)
# User attributes
is_admin = db.Column(db.Boolean, default=False, nullable=False)
is_active = db.Column(db.Boolean, default=True, nullable=False)
# Profile
profile_picture_url = db.Column(db.String(255), nullable=True)
# Timestamps
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
last_login = db.Column(db.DateTime, nullable=True)
# User settings (JSON stored as text)
settings = db.Column(db.Text, default='{}')
def __init__(self, username, email, password=None, is_admin=False, auth0_id=None):
"""
Initialize a new user.
Args:
username: Unique username
email: Unique email address
password: Plain text password (will be hashed) - optional for OAuth users
is_admin: Whether user is admin (default False)
auth0_id: Auth0 user ID for OAuth users (optional)
"""
# Validate inputs
if not username or not isinstance(username, str) or len(username) > 80:
raise ValueError("Invalid username")
if not email or not isinstance(email, str) or len(email) > 120:
raise ValueError("Invalid email")
if password is not None and (not isinstance(password, str) or len(password) < 1):
raise ValueError("Invalid password")
if password is None and auth0_id is None:
raise ValueError("Either password or auth0_id must be provided")
self.id = str(uuid.uuid4())
self.username = username.strip()
self.email = email.strip().lower()
self.auth0_id = auth0_id
if password:
self.set_password(password)
else:
self.password_hash = None # OAuth users don't have passwords
self.is_admin = bool(is_admin)
self.is_active = True
self.created_at = datetime.utcnow()
def set_password(self, password):
"""
Hash and set user password using bcrypt.
Args:
password: Plain text password
"""
self.password_hash = bcrypt.generate_password_hash(password).decode('utf-8')
def check_password(self, password):
"""
Verify password against stored hash.
Args:
password: Plain text password to check
Returns:
True if password matches, False otherwise
"""
return bcrypt.check_password_hash(self.password_hash, password)
def update_last_login(self):
"""Update the last login timestamp"""
self.last_login = datetime.utcnow()
db.session.commit()
def get_id(self):
"""Required by Flask-Login"""
return self.id
def __repr__(self):
return f'<User {self.username}>'
class Session(db.Model):
"""User session model for tracking active sessions"""
__tablename__ = 'user_sessions'
session_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)
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
expires_at = db.Column(db.DateTime, nullable=False)
# Relationship
user = db.relationship('User', backref=db.backref('sessions', lazy=True))
def __repr__(self):
return f'<Session {self.session_id} for user {self.user_id}>'
class PollSource(db.Model):
"""Source polling configuration"""
__tablename__ = 'poll_sources'
id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
platform = db.Column(db.String(50), nullable=False, index=True) # reddit, hackernews, etc.
source_id = db.Column(db.String(100), nullable=False) # programming, python, etc.
display_name = db.Column(db.String(200), nullable=False)
# Polling configuration
enabled = db.Column(db.Boolean, default=True, nullable=False)
poll_interval_minutes = db.Column(db.Integer, default=60, nullable=False) # How often to poll
# Status tracking
last_poll_time = db.Column(db.DateTime, nullable=True)
last_poll_status = db.Column(db.String(50), nullable=True) # success, error, etc.
last_poll_error = db.Column(db.Text, nullable=True)
posts_collected = db.Column(db.Integer, default=0, nullable=False)
# Metadata
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow)
created_by = db.Column(db.String(36), db.ForeignKey('users.id'), nullable=True)
# Unique constraint on platform + source_id
__table_args__ = (
db.UniqueConstraint('platform', 'source_id', name='unique_platform_source'),
)
def __repr__(self):
return f'<PollSource {self.platform}:{self.source_id}>'
class PollLog(db.Model):
"""Log of polling activities"""
__tablename__ = 'poll_logs'
id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
source_id = db.Column(db.String(36), db.ForeignKey('poll_sources.id'), nullable=False, index=True)
started_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
completed_at = db.Column(db.DateTime, nullable=True)
status = db.Column(db.String(50), nullable=False) # running, success, error
posts_found = db.Column(db.Integer, default=0)
posts_new = db.Column(db.Integer, default=0)
posts_updated = db.Column(db.Integer, default=0)
error_message = db.Column(db.Text, nullable=True)
# Relationship
source = db.relationship('PollSource', backref=db.backref('logs', lazy='dynamic', order_by='PollLog.started_at.desc()'))
def __repr__(self):
return f'<PollLog {self.id} for source {self.source_id}>'

215
polling_service.py Normal file
View File

@@ -0,0 +1,215 @@
"""
Polling Service
Background service for collecting data from configured sources.
"""
import logging
import traceback
from datetime import datetime
from pathlib import Path
from typing import Dict, List
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.interval import IntervalTrigger
from database import db
from models import PollSource, PollLog
from data_collection import collect_platform, get_collection_sources, load_platform_config
logger = logging.getLogger(__name__)
class PollingService:
"""Background polling service using APScheduler"""
def __init__(self, app=None):
self.scheduler = BackgroundScheduler()
self.app = app
self.storage_dir = 'data'
def init_app(self, app):
"""Initialize with Flask app"""
self.app = app
self.storage_dir = app.config.get('POLL_STORAGE_DIR', 'data')
def start(self):
"""Start the scheduler"""
if not self.scheduler.running:
self.scheduler.start()
logger.info("Polling scheduler started")
# Schedule the poll checker to run every minute
self.scheduler.add_job(
func=self._check_and_poll,
trigger=IntervalTrigger(minutes=1),
id='poll_checker',
name='Check and poll sources',
replace_existing=True
)
logger.info("Poll checker job scheduled")
def stop(self):
"""Stop the scheduler"""
if self.scheduler.running:
self.scheduler.shutdown()
logger.info("Polling scheduler stopped")
def _check_and_poll(self):
"""Check which sources need polling and poll them"""
if not self.app:
logger.error("No app context available")
return
with self.app.app_context():
try:
# Get all enabled sources
sources = PollSource.query.filter_by(enabled=True).all()
for source in sources:
# Check if source needs polling
if self._should_poll(source):
self._poll_source(source)
except Exception as e:
logger.error(f"Error in poll checker: {e}")
logger.error(traceback.format_exc())
def _should_poll(self, source: PollSource) -> bool:
"""Determine if a source should be polled now"""
if not source.last_poll_time:
# Never polled, should poll
return True
# Calculate time since last poll
time_since_poll = datetime.utcnow() - source.last_poll_time
minutes_since_poll = time_since_poll.total_seconds() / 60
# Poll if interval has elapsed
return minutes_since_poll >= source.poll_interval_minutes
def _poll_source(self, source: PollSource):
"""Poll a single source"""
logger.info(f"Polling {source.platform}:{source.source_id}")
# Create poll log
poll_log = PollLog(
source_id=source.id,
started_at=datetime.utcnow(),
status='running'
)
db.session.add(poll_log)
db.session.commit()
try:
# Perform the actual data collection
result = self._collect_data(source)
# Update poll log
poll_log.completed_at = datetime.utcnow()
poll_log.status = 'success'
poll_log.posts_found = result.get('posts_found', 0)
poll_log.posts_new = result.get('posts_new', 0)
poll_log.posts_updated = result.get('posts_updated', 0)
# Update source
source.last_poll_time = datetime.utcnow()
source.last_poll_status = 'success'
source.last_poll_error = None
source.posts_collected += result.get('posts_new', 0)
db.session.commit()
logger.info(f"Polling completed for {source.platform}:{source.source_id} - "
f"{result.get('posts_new', 0)} new posts")
except Exception as e:
error_msg = str(e)
error_trace = traceback.format_exc()
logger.error(f"Error polling {source.platform}:{source.source_id}: {error_msg}")
logger.error(error_trace)
# Update poll log
poll_log.completed_at = datetime.utcnow()
poll_log.status = 'error'
poll_log.error_message = f"{error_msg}\n\n{error_trace}"
# Update source
source.last_poll_time = datetime.utcnow()
source.last_poll_status = 'error'
source.last_poll_error = error_msg
db.session.commit()
def _collect_data(self, source: PollSource) -> Dict:
"""
Collect data from a source.
Wraps the existing data_collection.py functionality.
"""
from data_collection import ensure_directories, load_index, save_index, calculate_date_range, load_state, save_state
# Setup directories and load state
dirs = ensure_directories(self.storage_dir)
index = load_index(self.storage_dir)
state = load_state(self.storage_dir)
# Calculate date range (collect last 1 day)
start_iso, end_iso = calculate_date_range(1, state)
try:
# Call the existing collect_platform function
posts_collected = collect_platform(
platform=source.platform,
community=source.source_id,
start_date=start_iso,
end_date=end_iso,
max_posts=100, # Default limit
fetch_comments=True,
index=index,
dirs=dirs
)
# Save updated index and state
save_index(index, self.storage_dir)
state['last_run'] = end_iso
save_state(state, self.storage_dir)
return {
'posts_found': posts_collected,
'posts_new': posts_collected,
'posts_updated': 0
}
except Exception as e:
logger.error(f"Error in _collect_data: {e}")
return {
'posts_found': 0,
'posts_new': 0,
'posts_updated': 0
}
def poll_now(self, source_id: str):
"""Manually trigger polling for a specific source"""
with self.app.app_context():
source = PollSource.query.get(source_id)
if source:
self._poll_source(source)
return True
return False
def get_status(self) -> Dict:
"""Get scheduler status"""
return {
'running': self.scheduler.running,
'jobs': [
{
'id': job.id,
'name': job.name,
'next_run': job.next_run_time.isoformat() if job.next_run_time else None
}
for job in self.scheduler.get_jobs()
]
}
# Global polling service instance
polling_service = PollingService()

13
requirements.txt Normal file
View File

@@ -0,0 +1,13 @@
flask==3.1.2
flask-login==0.6.3
flask-sqlalchemy==3.1.1
flask-bcrypt==1.0.1
werkzeug==3.1.3
python-dotenv==1.1.1
requests==2.32.3
jinja2==3.1.6
psycopg2-binary==2.9.10
sqlalchemy==2.0.36
authlib==1.3.2
APScheduler==3.10.4
praw==7.7.1

59
run_app.py Executable file
View File

@@ -0,0 +1,59 @@
#!/usr/bin/env python3
"""
BalanceBoard Application Runner
Starts the Flask web app with PostgreSQL/SQLAlchemy integration.
"""
import os
import sys
from app import app
def main():
"""Initialize and run the application"""
print("=" * 60)
print("BalanceBoard - Content Feed Application")
print("=" * 60)
print()
print("Database: PostgreSQL with SQLAlchemy")
print("Authentication: bcrypt + Flask-Login")
print()
# Check if we can import the database components
try:
from database import init_db
from models import User
print("✓ Database modules imported successfully")
except ImportError as e:
print(f"✗ Error importing database modules: {e}")
print("Please ensure all dependencies are installed:")
print("pip install -r requirements.txt")
sys.exit(1)
# Database is already initialized in app.py
print("✓ Database initialized successfully")
# Print access info
host = os.getenv('FLASK_HOST', '0.0.0.0')
port = int(os.getenv('FLASK_PORT', '5021'))
print()
print("=" * 60)
print("Server starting...")
print(f" URL: http://localhost:{port}")
print(f" Login: http://localhost:{port}/login")
print(f" Sign Up: http://localhost:{port}/signup")
print(f" Admin: http://localhost:{port}/admin")
print("=" * 60)
print()
print("Press Ctrl+C to stop")
print()
# Run Flask app
debug_mode = os.getenv('FLASK_DEBUG', 'True').lower() == 'true'
app.run(host=host, port=port, debug=debug_mode)
if __name__ == '__main__':
main()

165
start_server.py Executable file
View File

@@ -0,0 +1,165 @@
#!/usr/bin/env python3
"""
BalanceBoard - Startup Script
Starts the Flask server with PostgreSQL/SQLAlchemy
"""
import os
import sys
import signal
from pathlib import Path
# Configuration
FLASK_PORT = 5021
def print_color(text, color='blue'):
"""Print colored text"""
colors = {
'red': '\033[0;31m',
'green': '\033[0;32m',
'yellow': '\033[1;33m',
'blue': '\033[0;34m',
'reset': '\033[0m'
}
print(f"{colors.get(color, '')}{text}{colors['reset']}")
def cleanup(signum=None, frame=None):
"""Cleanup: stop Flask server"""
print()
print_color("Shutting down...", 'yellow')
print_color("Goodbye!", 'green')
sys.exit(0)
def is_port_in_use(port):
"""Check if a port is already in use"""
try:
import socket
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
return s.connect_ex(('localhost', port)) == 0
except:
return False
def check_postgres_connection():
"""Check if PostgreSQL is available"""
try:
import psycopg2
from dotenv import load_dotenv
load_dotenv()
# Get database connection details
db_host = os.getenv('POSTGRES_HOST', 'localhost')
db_port = os.getenv('POSTGRES_PORT', '5432')
db_name = os.getenv('POSTGRES_DB', 'balanceboard')
db_user = os.getenv('POSTGRES_USER', 'balanceboard')
db_password = os.getenv('POSTGRES_PASSWORD', 'changeme')
# Try to connect
conn = psycopg2.connect(
host=db_host,
port=db_port,
database=db_name,
user=db_user,
password=db_password
)
conn.close()
return True
except Exception as e:
print_color(f"PostgreSQL connection error: {e}", 'red')
return False
def start_flask():
"""Start Flask server"""
print_color("Starting Flask server...", 'blue')
# Check virtual environment
if not Path('venv').exists():
print_color("Error: Virtual environment not found!", 'red')
print("Run: python3 -m venv venv && source venv/bin/activate && pip install -r requirements.txt")
return False
# Check PostgreSQL connection
if not check_postgres_connection():
print_color("Error: Cannot connect to PostgreSQL!", 'red')
print()
print("Please ensure PostgreSQL is running and configured:")
print("1. Install PostgreSQL: sudo apt install postgresql postgresql-contrib")
print("2. Create database: sudo -u postgres createdb balanceboard")
print("3. Create user: sudo -u postgres createuser balanceboard")
print("4. Set password: sudo -u postgres psql -c \"ALTER USER balanceboard PASSWORD 'changeme';\"")
print("5. Update .env file with your database settings")
print()
return False
# Create .env if it doesn't exist
if not Path('.env').exists():
print_color("Creating .env from .env.example...", 'yellow')
import secrets
with open('.env.example', 'r') as f:
env_content = f.read()
secret_key = secrets.token_hex(32)
env_content = env_content.replace('your-secret-key-here-change-this', secret_key)
with open('.env', 'w') as f:
f.write(env_content)
print_color("✓ .env created with random SECRET_KEY", 'green')
print()
print_color("=" * 60, 'green')
print_color("BalanceBoard is running!", 'green')
print_color("=" * 60, 'green')
print()
print_color(f" Main Feed: http://localhost:{FLASK_PORT}", 'blue')
print_color(f" Login: http://localhost:{FLASK_PORT}/login", 'blue')
print_color(f" Sign Up: http://localhost:{FLASK_PORT}/signup", 'blue')
print_color(f" Admin Panel: http://localhost:{FLASK_PORT}/admin", 'blue')
print()
print_color("Database: PostgreSQL with SQLAlchemy", 'blue')
print_color("Authentication: bcrypt + Flask-Login", 'blue')
print()
print_color("Press Ctrl+C to stop the server", 'yellow')
print()
# Import and run Flask app
try:
from app import app
print_color("✓ Flask app imported successfully", 'green')
print_color("✓ Database initialized with SQLAlchemy", 'green')
print_color("✓ User authentication ready", 'green')
print()
# Run Flask
app.run(host='0.0.0.0', port=FLASK_PORT, debug=True, use_reloader=False)
except Exception as e:
print_color(f"✗ Failed to start Flask app: {e}", 'red')
return False
def main():
"""Main entry point"""
# Register signal handlers
signal.signal(signal.SIGINT, cleanup)
signal.signal(signal.SIGTERM, cleanup)
print_color("=" * 60, 'blue')
print_color("BalanceBoard - PostgreSQL + SQLAlchemy", 'blue')
print_color("=" * 60, 'blue')
print()
# Start Flask (blocks until Ctrl+C)
try:
start_flask()
except KeyboardInterrupt:
pass
finally:
cleanup()
if __name__ == '__main__':
main()

57
templates/404.html Normal file
View File

@@ -0,0 +1,57 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Page Not Found - BalanceBoard</title>
<link rel="stylesheet" href="{{ url_for('serve_theme', filename='modern-card-ui/styles.css') }}">
<style>
.error-container {
max-width: 600px;
margin: 100px auto;
text-align: center;
padding: 40px;
}
.error-code {
font-size: 6rem;
font-weight: 700;
color: var(--primary-color);
margin-bottom: 20px;
}
.error-message {
font-size: 1.5rem;
color: var(--text-primary);
margin-bottom: 30px;
}
.error-description {
color: var(--text-secondary);
margin-bottom: 40px;
line-height: 1.6;
}
.btn-home {
display: inline-block;
padding: 12px 24px;
background: var(--primary-color);
color: white;
text-decoration: none;
border-radius: 8px;
font-weight: 500;
transition: background-color 0.2s;
}
.btn-home:hover {
background: var(--primary-hover);
}
</style>
</head>
<body>
<div class="error-container">
<div class="error-code">404</div>
<h1 class="error-message">Page Not Found</h1>
<p class="error-description">
Sorry, the page you're looking for doesn't exist or has been moved.
The content you're trying to access might not be available yet.
</p>
<a href="{{ url_for('index') }}" class="btn-home">Go Home</a>
</div>
</body>
</html>

74
templates/500.html Normal file
View File

@@ -0,0 +1,74 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Server Error - BalanceBoard</title>
<link rel="stylesheet" href="{{ url_for('serve_theme', filename='modern-card-ui/styles.css') }}">
<style>
.error-container {
max-width: 600px;
margin: 100px auto;
text-align: center;
padding: 40px;
}
.error-code {
font-size: 6rem;
font-weight: 700;
color: #dc3545;
margin-bottom: 20px;
}
.error-message {
font-size: 1.5rem;
color: var(--text-primary);
margin-bottom: 30px;
}
.error-description {
color: var(--text-secondary);
margin-bottom: 40px;
line-height: 1.6;
}
.btn-home {
display: inline-block;
padding: 12px 24px;
background: var(--primary-color);
color: white;
text-decoration: none;
border-radius: 8px;
font-weight: 500;
transition: background-color 0.2s;
margin-right: 10px;
}
.btn-home:hover {
background: var(--primary-hover);
}
.btn-retry {
display: inline-block;
padding: 12px 24px;
background: #6c757d;
color: white;
text-decoration: none;
border-radius: 8px;
font-weight: 500;
transition: background-color 0.2s;
}
.btn-retry:hover {
background: #5a6268;
}
</style>
</head>
<body>
<div class="error-container">
<div class="error-code">500</div>
<h1 class="error-message">Server Error</h1>
<p class="error-description">
Something went wrong on our end. We're working to fix the issue.
Please try again in a few moments.
</p>
<div>
<a href="{{ url_for('index') }}" class="btn-home">Go Home</a>
<a href="javascript:history.back()" class="btn-retry">Go Back</a>
</div>
</div>
</body>
</html>

579
templates/admin.html Normal file
View File

@@ -0,0 +1,579 @@
<!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;
}
.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-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);
}
.user-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--primary-color);
display: inline-flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 600;
font-size: 0.8rem;
margin-right: 8px;
}
.user-info {
display: flex;
align-items: center;
}
@media (max-width: 768px) {
.admin-tabs {
overflow-x: auto;
}
.admin-stats {
grid-template-columns: 1fr;
}
.system-info {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<div class="admin-container">
<a href="{{ url_for('index') }}" class="back-link">← Back to Feed</a>
<div class="admin-header">
<h1>Admin Panel</h1>
<p>Manage users, content, and system settings</p>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flash-messages">
{% for category, message in messages %}
<div class="flash-message {{ category }}">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<div class="admin-tabs">
<button class="tab-btn active" onclick="showTab('overview')">Overview</button>
<button class="tab-btn" onclick="showTab('users')">Users</button>
<button class="tab-btn" onclick="showTab('content')">Content</button>
<button class="tab-btn" onclick="showTab('system')">System</button>
</div>
<!-- Overview Tab -->
<div id="overview" class="tab-content active">
<div class="admin-stats">
<div class="stat-card">
<div class="stat-number">{{ users|length }}</div>
<div class="stat-label">Total Users</div>
</div>
<div class="stat-card">
<div class="stat-number">{{ users|selectattr('3', 'equalto', 1)|list|length }}</div>
<div class="stat-label">Admins</div>
</div>
<div class="stat-card">
<div class="stat-number">{{ users|selectattr('5', 'ne', None)|list|length }}</div>
<div class="stat-label">Active Users</div>
</div>
<div class="stat-card">
<div class="stat-number">73</div>
<div class="stat-label">Total Posts</div>
</div>
<div class="stat-card">
<div class="stat-number">1,299</div>
<div class="stat-label">Total Comments</div>
</div>
<div class="stat-card">
<div class="stat-number">3</div>
<div class="stat-label">Content Sources</div>
</div>
</div>
<div class="admin-section">
<h3 class="section-title">Recent Activity</h3>
<div class="system-info">
<div class="info-card">
<h4>Latest User</h4>
<p><strong>{{ users[-1].username if users else 'None' }}</strong></p>
<p>Joined: {{ users[-1].created_at.strftime('%Y-%m-%d') if users and users[-1].created_at else 'N/A' }}</p>
</div>
<div class="info-card">
<h4>System Status</h4>
<p><strong>🟢 Operational</strong></p>
<p>Last update: Just now</p>
</div>
<div class="info-card">
<h4>Storage Usage</h4>
<p><strong>~50 MB</strong></p>
<p>Posts and comments</p>
</div>
</div>
</div>
</div>
<!-- Users Tab -->
<div id="users" class="tab-content">
<div class="admin-section">
<h3 class="section-title">User Management</h3>
<div class="users-table">
<table>
<thead>
<tr>
<th>User</th>
<th>Email</th>
<th>Role</th>
<th>Status</th>
<th>Created</th>
<th>Last Login</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td>
<div class="user-info">
<div class="user-avatar">{{ user.username[:2].upper() }}</div>
<strong>{{ user.username }}</strong>
</div>
</td>
<td>{{ user.email }}</td>
<td>
{% if user.is_admin %}
<span class="badge badge-admin">Admin</span>
{% else %}
<span class="badge badge-user">User</span>
{% endif %}
</td>
<td>
{% if user.last_login %}
<span class="badge badge-active">Active</span>
{% else %}
<span class="badge badge-inactive">Inactive</span>
{% endif %}
</td>
<td>{{ user.created_at.strftime('%Y-%m-%d') if user.created_at else 'N/A' }}</td>
<td>{{ user.last_login.strftime('%Y-%m-%d') if user.last_login else 'Never' }}</td>
<td>
<form method="POST" action="{{ url_for('admin_toggle_admin', user_id=user.id) }}" style="display: inline;">
<button type="submit" class="action-btn action-btn-primary">
{% if user.is_admin %}Remove Admin{% else %}Make Admin{% endif %}
</button>
</form>
<form method="POST" action="{{ url_for('admin_delete_user', user_id=user.id) }}" style="display: inline;"
onsubmit="return confirm('Are you sure you want to delete this user?');">
<button type="submit" class="action-btn action-btn-danger">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<!-- Content Tab -->
<div id="content" class="tab-content">
<div class="admin-section">
<h3 class="section-title">Content Management</h3>
<div class="system-info">
<div class="info-card">
<h4>Content Sources</h4>
<p>Reddit - Active</p>
<p>Hacker News - Active</p>
<p>Lobsters - Active</p>
</div>
<div class="info-card">
<h4>Filter Sets</h4>
<p>safe_content - Default</p>
<p>no_filter - Unfiltered</p>
</div>
<div class="info-card">
<h4>Content Stats</h4>
<p>Posts today: 12</p>
<p>Comments today: 45</p>
</div>
</div>
</div>
<div class="admin-section">
<h3 class="section-title">Content Actions</h3>
<form method="POST" action="{{ url_for('admin_regenerate_content') }}">
<button type="submit" class="btn btn-primary">Regenerate All Content</button>
<p style="margin-top: 8px; font-size: 0.85rem; color: var(--text-secondary);">
This will regenerate all HTML files with current templates and filters.
</p>
</form>
</div>
</div>
<!-- System Tab -->
<div id="system" class="tab-content">
<div class="admin-section">
<h3 class="section-title">System Information</h3>
<div class="system-info">
<div class="info-card">
<h4>Application</h4>
<p>BalanceBoard v2.0</p>
<p>Python 3.9+</p>
<p>Flask Framework</p>
</div>
<div class="info-card">
<h4>Database</h4>
<p>PostgreSQL</p>
<p>Connection: Active</p>
</div>
<div class="info-card">
<h4>Storage</h4>
<p>Posts: 73 files</p>
<p>Comments: 1,299 files</p>
<p>Themes: 2 available</p>
</div>
</div>
</div>
<div class="admin-section">
<h3 class="section-title">System Maintenance</h3>
<div style="display: flex; gap: 12px; flex-wrap: wrap;">
<a href="{{ url_for('admin_polling') }}" class="action-btn action-btn-primary">📡 Manage Polling</a>
<form method="POST" action="{{ url_for('admin_clear_cache') }}" style="display: inline;">
<button type="submit" class="action-btn action-btn-warning">Clear Cache</button>
</form>
<form method="POST" action="{{ url_for('admin_backup_data') }}" style="display: inline;">
<button type="submit" class="action-btn action-btn-primary">Backup Data</button>
</form>
</div>
</div>
</div>
</div>
<script>
function showTab(tabName) {
// Hide all tabs
const tabs = document.querySelectorAll('.tab-content');
tabs.forEach(tab => tab.classList.remove('active'));
// Remove active class from all buttons
const buttons = document.querySelectorAll('.tab-btn');
buttons.forEach(btn => btn.classList.remove('active'));
// Show selected tab
document.getElementById(tabName).classList.add('active');
// Add active class to clicked button
event.target.classList.add('active');
}
</script>
</body>
</html>

View File

@@ -0,0 +1,366 @@
<!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;
}
.admin-header {
background: linear-gradient(135deg, var(--primary-dark) 0%, #2a5068 100%);
color: white;
padding: 32px;
border-radius: 12px;
margin-bottom: 24px;
}
.status-badge {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
font-size: 0.85rem;
font-weight: 500;
}
.status-enabled {
background: #d4edda;
color: #155724;
}
.status-disabled {
background: #f8d7da;
color: #721c24;
}
.status-success {
background: #d4edda;
color: #155724;
}
.status-error {
background: #f8d7da;
color: #721c24;
}
.source-card {
background: var(--surface-color);
border: 1px solid var(--divider-color);
border-radius: 12px;
padding: 20px;
margin-bottom: 16px;
}
.source-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.source-title {
font-size: 1.2rem;
font-weight: 600;
color: var(--text-primary);
}
.source-meta {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 16px;
}
.meta-item {
display: flex;
flex-direction: column;
}
.meta-label {
font-size: 0.85rem;
color: var(--text-secondary);
margin-bottom: 4px;
}
.meta-value {
font-weight: 500;
color: var(--text-primary);
}
.source-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.btn {
padding: 8px 16px;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: 500;
transition: all 0.2s;
}
.btn-primary {
background: var(--primary-color);
color: white;
}
.btn-primary:hover {
background: var(--primary-dark);
}
.btn-secondary {
background: var(--divider-color);
color: var(--text-primary);
}
.btn-secondary:hover {
background: #d0d0d0;
}
.btn-danger {
background: #dc3545;
color: white;
}
.btn-danger:hover {
background: #c82333;
}
.add-source-form {
background: var(--surface-color);
border: 2px dashed var(--divider-color);
border-radius: 12px;
padding: 24px;
margin-bottom: 24px;
}
.form-group {
margin-bottom: 16px;
}
.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;
}
.no-sources {
text-align: center;
padding: 48px;
color: var(--text-secondary);
}
</style>
</head>
<body>
<div class="admin-container">
<div class="admin-header">
<h1>📡 Polling Management</h1>
<p>Configure automatic data collection from content sources</p>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
<!-- Scheduler Status -->
<div class="scheduler-status">
<h3>Scheduler Status</h3>
<p><strong>Status:</strong>
{% if scheduler_status.running %}
<span class="status-badge status-enabled">Running</span>
{% else %}
<span class="status-badge status-disabled">Stopped</span>
{% endif %}
</p>
<p><strong>Active Jobs:</strong> {{ scheduler_status.jobs|length }}</p>
</div>
<!-- Add New Source Form -->
<div class="add-source-form">
<h3>Add New Source</h3>
<form action="{{ url_for('admin_polling_add') }}" method="POST">
<div class="form-group">
<label class="form-label" for="platform">Platform</label>
<select class="form-select" name="platform" id="platform" required onchange="updateSourceOptions()">
<option value="">Select platform...</option>
{% for platform_id, platform_data in platform_config.platforms.items() %}
<option value="{{ platform_id }}">{{ platform_data.name }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label class="form-label" for="source_id">Source</label>
<select class="form-select" name="source_id" id="source_id" required onchange="updateDisplayName()">
<option value="">Select source...</option>
</select>
</div>
<div class="form-group">
<label class="form-label" for="display_name">Display Name</label>
<input type="text" class="form-input" name="display_name" id="display_name" required>
</div>
<div class="form-group">
<label class="form-label" for="poll_interval">Poll Interval (minutes)</label>
<input type="number" class="form-input" name="poll_interval" id="poll_interval" value="60" min="5" required>
</div>
<button type="submit" class="btn btn-primary">Add Source</button>
</form>
</div>
<!-- Existing Sources -->
<h3>Configured Sources ({{ sources|length }})</h3>
{% if sources %}
{% for source in sources %}
<div class="source-card">
<div class="source-header">
<div>
<div class="source-title">{{ source.display_name }}</div>
<small>{{ source.platform }}:{{ source.source_id }}</small>
</div>
<div>
{% if source.enabled %}
<span class="status-badge status-enabled">Enabled</span>
{% else %}
<span class="status-badge status-disabled">Disabled</span>
{% endif %}
</div>
</div>
<div class="source-meta">
<div class="meta-item">
<span class="meta-label">Poll Interval</span>
<span class="meta-value">{{ source.poll_interval_minutes }} minutes</span>
</div>
<div class="meta-item">
<span class="meta-label">Last Poll</span>
<span class="meta-value">
{% if source.last_poll_time %}
{{ source.last_poll_time.strftime('%Y-%m-%d %H:%M:%S') }}
{% else %}
Never
{% endif %}
</span>
</div>
<div class="meta-item">
<span class="meta-label">Status</span>
<span class="meta-value">
{% if source.last_poll_status == 'success' %}
<span class="status-badge status-success">Success</span>
{% elif source.last_poll_status == 'error' %}
<span class="status-badge status-error">Error</span>
{% else %}
<span class="status-badge">{{ source.last_poll_status or 'N/A' }}</span>
{% endif %}
</span>
</div>
<div class="meta-item">
<span class="meta-label">Posts Collected</span>
<span class="meta-value">{{ source.posts_collected }}</span>
</div>
</div>
{% if source.last_poll_error %}
<div style="background: #fff3cd; padding: 12px; border-radius: 6px; margin-bottom: 16px;">
<strong>Last Error:</strong> {{ source.last_poll_error }}
</div>
{% endif %}
<div class="source-actions">
<form action="{{ url_for('admin_polling_toggle', source_id=source.id) }}" method="POST" style="display: inline;">
<button type="submit" class="btn btn-secondary">
{% if source.enabled %}Disable{% else %}Enable{% endif %}
</button>
</form>
<form action="{{ url_for('admin_polling_poll_now', source_id=source.id) }}" method="POST" style="display: inline;">
<button type="submit" class="btn btn-primary">Poll Now</button>
</form>
<a href="{{ url_for('admin_polling_logs', source_id=source.id) }}" class="btn btn-secondary">View Logs</a>
<form action="{{ url_for('admin_polling_delete', source_id=source.id) }}" method="POST" style="display: inline;" onsubmit="return confirm('Are you sure you want to delete this source?');">
<button type="submit" class="btn btn-danger">Delete</button>
</form>
</div>
</div>
{% endfor %}
{% else %}
<div class="no-sources">
<p>No polling sources configured yet.</p>
<p>Add your first source above to start collecting content!</p>
</div>
{% endif %}
<div style="margin-top: 24px;">
<a href="{{ url_for('admin_panel') }}" class="btn btn-secondary">← Back to Admin Panel</a>
</div>
</div>
<script>
const platformConfig = {{ platform_config|tojson }};
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;
}
}
</script>
</body>
</html>

View File

@@ -0,0 +1,188 @@
<!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;
}
.admin-header {
background: linear-gradient(135deg, var(--primary-dark) 0%, #2a5068 100%);
color: white;
padding: 32px;
border-radius: 12px;
margin-bottom: 24px;
}
.log-table {
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;
}
.error-detail {
background: #fff3cd;
padding: 12px;
border-radius: 6px;
margin-top: 8px;
font-size: 0.9rem;
white-space: pre-wrap;
max-height: 200px;
overflow-y: auto;
}
.btn {
padding: 8px 16px;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: 500;
text-decoration: none;
display: inline-block;
transition: all 0.2s;
}
.btn-secondary {
background: var(--divider-color);
color: var(--text-primary);
}
.btn-secondary:hover {
background: #d0d0d0;
}
.no-logs {
text-align: center;
padding: 48px;
color: var(--text-secondary);
}
</style>
</head>
<body>
<div class="admin-container">
<div class="admin-header">
<h1>📋 Polling Logs</h1>
<p>{{ source.display_name }} ({{ source.platform}}:{{ source.source_id }})</p>
</div>
{% if logs %}
<table class="log-table">
<thead>
<tr>
<th>Started</th>
<th>Completed</th>
<th>Duration</th>
<th>Status</th>
<th>Posts Found</th>
<th>New</th>
<th>Updated</th>
<th>Details</th>
</tr>
</thead>
<tbody>
{% for log in logs %}
<tr>
<td>{{ log.started_at.strftime('%Y-%m-%d %H:%M:%S') }}</td>
<td>
{% if log.completed_at %}
{{ log.completed_at.strftime('%Y-%m-%d %H:%M:%S') }}
{% else %}
-
{% endif %}
</td>
<td>
{% if log.completed_at %}
{{ ((log.completed_at - log.started_at).total_seconds())|round(1) }}s
{% else %}
-
{% endif %}
</td>
<td>
{% if log.status == 'success' %}
<span class="status-badge status-success">Success</span>
{% elif log.status == 'error' %}
<span class="status-badge status-error">Error</span>
{% elif log.status == 'running' %}
<span class="status-badge status-running">Running</span>
{% else %}
<span class="status-badge">{{ log.status }}</span>
{% endif %}
</td>
<td>{{ log.posts_found }}</td>
<td>{{ log.posts_new }}</td>
<td>{{ log.posts_updated }}</td>
<td>
{% if log.error_message %}
<details>
<summary style="cursor: pointer; color: var(--primary-color);">View Error</summary>
<div class="error-detail">{{ log.error_message }}</div>
</details>
{% else %}
-
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="no-logs">
<p>No polling logs yet.</p>
<p>Logs will appear here after the first poll.</p>
</div>
{% endif %}
<div style="margin-top: 24px;">
<a href="{{ url_for('admin_polling') }}" class="btn btn-secondary">← Back to Polling Management</a>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,78 @@
{% extends "base.html" %}
{% block title %}Create Admin Account - 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><span class="board">Board</span></h1>
<p style="color: var(--text-secondary); margin-top: 8px;">Create Administrator Account</p>
</div>
<div class="flash-messages">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="flash-message {{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
</div>
<form method="POST" class="auth-form">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" required
placeholder="Choose admin username" autocomplete="username">
</div>
<div class="form-group">
<label for="email">Email Address</label>
<input type="email" id="email" name="email" required
placeholder="admin@example.com" autocomplete="email">
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" required
placeholder="Create strong password" autocomplete="new-password">
</div>
<div class="form-group">
<label for="password_confirm">Confirm Password</label>
<input type="password" id="password_confirm" name="password_confirm" required
placeholder="Confirm your password" autocomplete="new-password">
</div>
<button type="submit">Create Admin Account</button>
</form>
<div class="auth-footer">
<p style="color: var(--text-secondary); font-size: 0.9rem; text-align: center;">
This will create the first administrator account for BalanceBoard.
<br>This user will have full system access.
</p>
</div>
</div>
</div>
<style>
.auth-container {
background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-hover) 100%);
}
.auth-card {
border-top: 4px solid var(--primary-color);
}
.balance {
color: var(--primary-color);
}
.board {
color: var(--text-primary);
}
</style>
{% endblock %}

251
templates/base.html Normal file
View File

@@ -0,0 +1,251 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}BalanceBoard{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('serve_theme', filename='modern-card-ui/styles.css') }}">
<style>
/* Auth pages styling */
.auth-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--background-color);
padding: 24px;
}
.auth-card {
background: var(--surface-color);
border-radius: 16px;
box-shadow: 0 4px 12px var(--surface-elevation-2);
padding: 48px;
max-width: 450px;
width: 100%;
border-top: 4px solid var(--primary-color);
}
.auth-logo {
text-align: center;
margin-bottom: 32px;
}
.auth-logo img {
width: 80px;
height: 80px;
border-radius: 50%;
}
.auth-logo h1 {
margin-top: 16px;
font-size: 1.75rem;
color: var(--text-primary);
}
.auth-logo h1 .balance {
color: var(--primary-color);
}
.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;
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 .checkbox-group {
display: flex;
align-items: center;
gap: 8px;
}
.auth-form .checkbox-group input[type="checkbox"] {
width: auto;
}
.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 {
text-align: center;
margin-top: 24px;
padding-top: 24px;
border-top: 1px solid var(--divider-color);
}
.auth-footer a {
color: var(--primary-color);
text-decoration: none;
font-weight: 500;
}
.auth-footer a:hover {
text-decoration: underline;
}
.flash-messages {
margin-bottom: 24px;
}
.flash-message {
padding: 12px 16px;
border-radius: 8px;
margin-bottom: 12px;
font-size: 0.95rem;
}
.flash-message.success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.flash-message.error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.flash-message.info {
background: #d1ecf1;
color: #0c5460;
border: 1px solid #bee5eb;
}
.divider {
text-align: center;
margin: 24px 0;
position: relative;
}
.divider::before {
content: '';
position: absolute;
left: 0;
top: 50%;
width: 100%;
height: 1px;
background: var(--divider-color);
}
.divider span {
background: var(--surface-color);
padding: 0 16px;
position: relative;
color: var(--text-secondary);
font-size: 0.9rem;
}
/* Social Authentication Styles */
.social-auth-separator {
text-align: center;
margin: 24px 0 20px 0;
position: relative;
}
.social-auth-separator::before {
content: '';
position: absolute;
top: 50%;
left: 0;
right: 0;
height: 1px;
background: var(--border-color);
}
.social-auth-separator span {
background: var(--surface-color);
padding: 0 16px;
position: relative;
color: var(--text-secondary);
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.social-auth-buttons {
margin-bottom: 24px;
}
.social-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
width: 100%;
padding: 12px 16px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--surface-color);
color: var(--text-primary);
text-decoration: none;
font-weight: 500;
transition: all 0.2s ease;
margin-bottom: 8px;
}
.social-btn:hover {
background: var(--surface-elevation-1);
border-color: var(--primary-color);
transform: translateY(-1px);
box-shadow: 0 2px 8px var(--surface-elevation-2);
}
.auth0-btn {
border-color: #eb5424;
color: #eb5424;
}
.auth0-btn:hover {
background: #eb5424;
color: white;
}
.social-btn svg {
flex-shrink: 0;
}
</style>
{% block extra_css %}{% endblock %}
</head>
<body>
{% block content %}{% endblock %}
</body>
</html>

1264
templates/dashboard.html Normal file

File diff suppressed because it is too large Load Diff

61
templates/login.html Normal file
View File

@@ -0,0 +1,61 @@
{% extends "base.html" %}
{% block title %}Log In - BalanceBoard{% endblock %}
{% block content %}
<div class="auth-container">
<div class="auth-card">
<div class="auth-logo">
<img src="{{ url_for('serve_logo') }}" alt="BalanceBoard Logo">
<h1><span class="balance">balance</span>Board</h1>
<p style="color: var(--text-secondary); margin-top: 8px;">Welcome back!</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="username">Username or Email</label>
<input type="text" id="username" name="username" required autofocus>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" required>
</div>
<div class="form-group checkbox-group">
<input type="checkbox" id="remember" name="remember">
<label for="remember" style="margin-bottom: 0;">Remember me</label>
</div>
<button type="submit">Log In</button>
</form>
<div class="social-auth-separator">
<span>or</span>
</div>
<div class="social-auth-buttons">
<a href="{{ url_for('auth0_login') }}" class="social-btn auth0-btn">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M21.98 7.448L19.62 0H4.347L2.02 7.448c-1.352 4.312.03 9.206 3.815 12.015L12.007 24l6.157-4.537c3.785-2.809 5.167-7.703 3.815-12.015z"/>
</svg>
Continue with Auth0
</a>
</div>
<div class="auth-footer">
<p>Don't have an account? <a href="{{ url_for('signup') }}">Sign up</a></p>
</div>
</div>
</div>
{% endblock %}

681
templates/post_detail.html Normal file
View File

@@ -0,0 +1,681 @@
{% extends "base.html" %}
{% block title %}{{ post.title }} - BalanceBoard{% endblock %}
{% block content %}
<!-- Modern Top Navigation -->
<nav class="top-nav">
<div class="nav-content">
<div class="nav-left">
<div class="logo-section">
<img src="{{ url_for('serve_logo') }}" alt="BalanceBoard" class="nav-logo">
<span class="brand-text"><span class="brand-balance">balance</span><span class="brand-board">Board</span></span>
</div>
</div>
<div class="nav-center">
<div class="search-bar">
<input type="text" placeholder="Search content..." class="search-input">
<button class="search-btn">🔍</button>
</div>
</div>
<div class="nav-right">
{% if current_user.is_authenticated %}
<div class="user-menu">
<div class="user-info">
<div class="user-avatar">
{% if current_user.profile_picture_url %}
<img src="{{ current_user.profile_picture_url }}" alt="Avatar">
{% else %}
<div class="avatar-placeholder">{{ current_user.username[:2].upper() }}</div>
{% endif %}
</div>
<span class="username">{{ current_user.username }}</span>
</div>
<div class="user-dropdown">
<a href="{{ url_for('settings') }}" class="dropdown-item">⚙️ Settings</a>
{% if current_user.is_admin %}
<a href="{{ url_for('admin_panel') }}" class="dropdown-item">👨‍💼 Admin Panel</a>
{% endif %}
<a href="{{ url_for('logout') }}" class="dropdown-item">🚪 Logout</a>
</div>
</div>
{% else %}
<div class="auth-buttons">
<a href="{{ url_for('login') }}" class="auth-btn">Login</a>
<a href="{{ url_for('signup') }}" class="auth-btn primary">Sign Up</a>
</div>
{% endif %}
</div>
</div>
</nav>
<!-- Main Content Area -->
<main class="main-content single-post">
<!-- Back Button -->
<div class="back-section">
<button onclick="goBackToFeed()" class="back-btn">← Back to Feed</button>
</div>
<!-- Post Content -->
<article class="post-detail">
<div class="post-header">
<div class="platform-badge platform-{{ post.platform }}">
{{ post.platform.title()[:1] }}
</div>
<div class="post-meta">
<span class="post-author">{{ post.author }}</span>
<span class="post-separator"></span>
{% if post.source %}
<span class="post-source">{{ post.source_display if post.source_display else ('r/' + post.source if post.platform == 'reddit' else post.source) }}</span>
<span class="post-separator"></span>
{% endif %}
<span class="post-time">{{ moment(post.timestamp).fromNow() if moment else 'Recently' }}</span>
{% if post.url and not post.url.startswith('/') %}
<span class="external-link-indicator">🔗</span>
{% endif %}
</div>
</div>
{% if post.url and not post.url.startswith('/') %}
<h1 class="post-title">
<a href="{{ post.url }}" target="_blank" class="post-title-link">{{ post.title }}</a>
</h1>
{% else %}
<h1 class="post-title">{{ post.title }}</h1>
{% endif %}
{% if post.content %}
<div class="post-content">
{{ post.content | safe | nl2br }}
</div>
{% endif %}
{% if post.url and not post.url.startswith('/') %}
<div class="external-link">
<a href="{{ post.url }}" target="_blank" class="external-btn">
{% if post.platform == 'reddit' %}
🔺 View on Reddit
{% elif post.platform == 'hackernews' %}
🧮 View on Hacker News
{% elif post.platform == 'lobsters' %}
🦞 View on Lobsters
{% elif post.platform == 'github' %}
🐙 View on GitHub
{% elif post.platform == 'devto' %}
📝 View on Dev.to
{% elif post.platform == 'stackoverflow' %}
📚 View on Stack Overflow
{% else %}
🔗 View Original Source
{% endif %}
</a>
</div>
{% endif %}
<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>{{ comments|length }} comments</span>
</div>
</div>
<div class="post-actions">
<button class="post-action" onclick="sharePost()">Share</button>
<button class="post-action" onclick="savePost()">Save</button>
</div>
</div>
</article>
<!-- Comments Section -->
<section class="comments-section">
<h2>Comments ({{ comments|length }})</h2>
{% if comments %}
<div class="comments-list">
{% for comment in comments %}
<div class="comment">
<div class="comment-header">
<span class="comment-author">{{ comment.author }}</span>
<span class="comment-separator"></span>
<span class="comment-time">{{ moment(comment.timestamp).fromNow() if moment else 'Recently' }}</span>
</div>
<div class="comment-content">
{{ comment.content | safe | nl2br }}
</div>
<div class="comment-footer">
<div class="comment-score">
<span>▲ {{ comment.score or 0 }}</span>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="no-comments">
<p>No comments yet. Be the first to share your thoughts!</p>
</div>
{% endif %}
</section>
</main>
<style>
/* Inherit styles from dashboard */
/* Top Navigation */
.top-nav {
background: white;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
position: sticky;
top: 0;
z-index: 1000;
border-bottom: 1px solid #e5e7eb;
}
.nav-content {
max-width: 1400px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
height: 64px;
}
.nav-left .logo-section {
display: flex;
align-items: center;
gap: 12px;
}
.nav-logo {
width: 40px;
height: 40px;
border-radius: 8px;
}
.brand-text {
font-size: 1.5rem;
font-weight: 700;
}
.brand-balance {
color: #4db6ac;
}
.brand-board {
color: #2c3e50;
}
.search-bar {
display: flex;
align-items: center;
background: #f8fafc;
border: 2px solid #e2e8f0;
border-radius: 12px;
padding: 8px 16px;
min-width: 400px;
transition: all 0.2s ease;
}
.search-bar:focus-within {
border-color: #4db6ac;
box-shadow: 0 0 0 3px rgba(77, 182, 172, 0.1);
}
.search-input {
flex: 1;
border: none;
background: none;
outline: none;
font-size: 14px;
color: #2c3e50;
}
.search-btn {
background: none;
border: none;
cursor: pointer;
font-size: 16px;
color: #64748b;
}
.user-menu {
position: relative;
}
.user-info {
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
padding: 8px 16px;
border-radius: 12px;
transition: all 0.2s ease;
}
.user-info:hover {
background: #f1f5f9;
}
.user-avatar img, .avatar-placeholder {
width: 36px;
height: 36px;
border-radius: 50%;
object-fit: cover;
}
.avatar-placeholder {
background: linear-gradient(135deg, #4db6ac, #26a69a);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 14px;
}
.username {
font-weight: 500;
color: #2c3e50;
}
.user-dropdown {
position: absolute;
top: 100%;
right: 0;
background: white;
border-radius: 12px;
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
padding: 8px 0;
min-width: 180px;
opacity: 0;
visibility: hidden;
transform: translateY(-10px);
transition: all 0.2s ease;
}
.user-menu:hover .user-dropdown {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.dropdown-item {
display: block;
padding: 12px 20px;
color: #2c3e50;
text-decoration: none;
font-size: 14px;
transition: all 0.2s ease;
}
.dropdown-item:hover {
background: #f8fafc;
color: #4db6ac;
}
.auth-buttons {
display: flex;
gap: 12px;
}
.auth-btn {
padding: 8px 16px;
border-radius: 8px;
text-decoration: none;
font-size: 14px;
font-weight: 500;
transition: all 0.2s ease;
}
.auth-btn:not(.primary) {
color: #2c3e50;
border: 1px solid #e2e8f0;
}
.auth-btn.primary {
background: #4db6ac;
color: white;
}
.auth-btn:hover {
transform: translateY(-1px);
}
/* Main Content */
.main-content.single-post {
max-width: 1200px;
width: 100%;
margin: 0 auto;
padding: 24px;
min-height: calc(100vh - 64px);
}
.back-section {
margin-bottom: 24px;
}
.back-btn {
background: #f8fafc;
border: 1px solid #e2e8f0;
padding: 8px 16px;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
color: #2c3e50;
transition: all 0.2s ease;
}
.back-btn:hover {
background: #e2e8f0;
}
/* Post Detail */
.post-detail {
background: white;
border-radius: 16px;
padding: 32px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05);
margin-bottom: 32px;
}
.post-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.platform-badge {
width: 32px;
height: 32px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 14px;
color: white;
}
.platform-reddit {
background: #ff4500;
}
.platform-hackernews {
background: #ff6600;
}
.platform-unknown {
background: #64748b;
}
.post-meta {
display: flex;
align-items: center;
gap: 8px;
color: #64748b;
font-size: 13px;
}
.post-author {
font-weight: 500;
color: #2c3e50;
}
.post-source {
color: #4db6ac;
font-weight: 500;
}
.post-separator {
color: #cbd5e1;
}
.post-time {
color: #64748b;
}
.external-link-indicator {
color: #4db6ac;
}
.post-title {
font-size: 1.8rem;
font-weight: 700;
color: #2c3e50;
margin: 0 0 24px 0;
line-height: 1.3;
}
.post-title-link {
color: #2c3e50;
text-decoration: none;
transition: color 0.2s ease;
}
.post-title-link:hover {
color: #4db6ac;
text-decoration: underline;
}
.post-content {
color: #374151;
font-size: 16px;
line-height: 1.6;
margin-bottom: 24px;
}
.external-link {
margin-bottom: 24px;
}
.external-btn {
display: inline-flex;
align-items: center;
gap: 8px;
background: #4db6ac;
color: white;
padding: 12px 20px;
border-radius: 8px;
text-decoration: none;
font-weight: 500;
transition: all 0.2s ease;
}
.external-btn:hover {
background: #26a69a;
transform: translateY(-1px);
}
.post-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 20px;
border-top: 1px solid #f1f5f9;
}
.post-stats {
display: flex;
align-items: center;
gap: 20px;
font-size: 14px;
color: #64748b;
}
.post-score, .post-comments {
display: flex;
align-items: center;
gap: 6px;
}
.post-actions {
display: flex;
gap: 12px;
}
.post-action {
padding: 8px 16px;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: none;
color: #64748b;
font-size: 12px;
cursor: pointer;
transition: all 0.2s ease;
}
.post-action:hover {
border-color: #4db6ac;
color: #4db6ac;
}
/* Comments Section */
.comments-section {
background: white;
border-radius: 16px;
padding: 32px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05);
}
.comments-section h2 {
margin: 0 0 24px 0;
font-size: 1.3rem;
font-weight: 600;
color: #2c3e50;
}
.comment {
padding: 20px 0;
border-bottom: 1px solid #f1f5f9;
}
.comment:last-child {
border-bottom: none;
}
.comment-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
font-size: 13px;
}
.comment-author {
font-weight: 500;
color: #2c3e50;
}
.comment-separator {
color: #cbd5e1;
}
.comment-time {
color: #64748b;
}
.comment-content {
color: #374151;
line-height: 1.5;
margin-bottom: 12px;
}
.comment-footer {
display: flex;
align-items: center;
justify-content: space-between;
}
.comment-score {
font-size: 12px;
color: #64748b;
}
.no-comments {
text-align: center;
color: #64748b;
font-style: italic;
padding: 40px 0;
}
/* Responsive Design */
@media (max-width: 768px) {
.nav-center {
display: none;
}
.main-content.single-post {
padding: 16px;
max-width: 100%;
}
.post-detail, .comments-section {
padding: 20px;
}
.search-bar {
min-width: 250px;
}
.post-title {
font-size: 1.4rem;
}
.external-btn {
font-size: 14px;
padding: 10px 16px;
}
}
</style>
<script>
function goBackToFeed() {
// Try to go back to the dashboard if possible
if (document.referrer && document.referrer.includes(window.location.origin)) {
window.history.back();
} else {
// Fallback to dashboard
window.location.href = '/';
}
}
function sharePost() {
const url = window.location.href;
navigator.clipboard.writeText(url).then(() => {
alert('Link copied to clipboard!');
});
}
function savePost() {
alert('Save functionality coming soon!');
}
// Moment.js replacement for timestamp formatting
function formatTimeAgo(timestamp) {
const now = Date.now() / 1000;
const diff = now - timestamp;
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`;
return new Date(timestamp * 1000).toLocaleDateString();
}
// Update timestamps on page load
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.post-time, .comment-time').forEach(el => {
const timestamp = parseInt(el.dataset.timestamp);
if (timestamp) {
el.textContent = formatTimeAgo(timestamp);
}
});
});
</script>
{% endblock %}

382
templates/settings.html Normal file
View File

@@ -0,0 +1,382 @@
{% extends "base.html" %}
{% block title %}Settings - BalanceBoard{% endblock %}
{% block extra_css %}
<style>
.settings-container {
max-width: 1200px;
margin: 0 auto;
padding: 24px;
}
.settings-header {
margin-bottom: 32px;
}
.settings-header h1 {
color: var(--text-primary);
margin-bottom: 8px;
}
.settings-header p {
color: var(--text-secondary);
font-size: 1.1rem;
}
.settings-grid {
display: grid;
grid-template-columns: 250px 1fr;
gap: 32px;
}
.settings-sidebar {
background: var(--surface-color);
border-radius: 12px;
padding: 24px;
height: fit-content;
border: 1px solid var(--border-color);
}
.settings-nav {
list-style: none;
padding: 0;
margin: 0;
}
.settings-nav li {
margin-bottom: 8px;
}
.settings-nav a {
display: flex;
align-items: center;
padding: 12px 16px;
color: var(--text-secondary);
text-decoration: none;
border-radius: 8px;
transition: all 0.2s ease;
font-weight: 500;
}
.settings-nav a:hover {
background: var(--surface-elevation-1);
color: var(--text-primary);
}
.settings-nav a.active {
background: var(--primary-color);
color: white;
}
.settings-nav .nav-icon {
margin-right: 12px;
font-size: 1.1rem;
}
.settings-content {
background: var(--surface-color);
border-radius: 12px;
padding: 32px;
border: 1px solid var(--border-color);
}
.settings-section {
margin-bottom: 32px;
}
.settings-section:last-child {
margin-bottom: 0;
}
.settings-section h2 {
color: var(--text-primary);
margin-bottom: 16px;
font-size: 1.3rem;
}
.settings-section p {
color: var(--text-secondary);
margin-bottom: 24px;
line-height: 1.6;
}
.setting-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 0;
border-bottom: 1px solid var(--divider-color);
}
.setting-item:last-child {
border-bottom: none;
}
.setting-info h3 {
color: var(--text-primary);
margin-bottom: 4px;
font-size: 1.1rem;
}
.setting-info p {
color: var(--text-secondary);
font-size: 0.9rem;
margin: 0;
}
.setting-value {
color: var(--primary-color);
font-weight: 500;
}
.btn-settings {
padding: 8px 16px;
background: var(--primary-color);
color: white;
border: none;
border-radius: 6px;
text-decoration: none;
font-weight: 500;
transition: all 0.2s ease;
cursor: pointer;
}
.btn-settings:hover {
background: var(--primary-hover);
transform: translateY(-1px);
}
.btn-secondary {
background: var(--surface-elevation-1);
color: var(--text-primary);
}
.btn-secondary:hover {
background: var(--surface-elevation-2);
}
.user-profile {
display: flex;
align-items: center;
gap: 16px;
padding: 20px;
background: var(--surface-elevation-1);
border-radius: 8px;
margin-bottom: 24px;
}
.user-avatar {
width: 64px;
height: 64px;
border-radius: 50%;
background: var(--primary-color);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 1.5rem;
font-weight: bold;
}
.user-avatar img {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
}
.user-info h3 {
color: var(--text-primary);
margin-bottom: 4px;
}
.user-info p {
color: var(--text-secondary);
margin: 0;
}
.current-filter {
padding: 12px 16px;
background: var(--surface-elevation-1);
border-radius: 8px;
border-left: 4px solid var(--primary-color);
}
.current-filter strong {
color: var(--text-primary);
}
@media (max-width: 768px) {
.settings-grid {
grid-template-columns: 1fr;
}
.settings-sidebar {
order: 2;
}
.settings-content {
order: 1;
}
.setting-item {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
}
</style>
{% endblock %}
{% block content %}
<div class="settings-container">
<div class="settings-header">
<h1>Settings</h1>
<p>Manage your BalanceBoard preferences and account settings</p>
</div>
<div class="settings-grid">
<aside class="settings-sidebar">
<ul class="settings-nav">
<li>
<a href="{{ url_for('settings') }}" class="active">
<span class="nav-icon">⚙️</span>
<span>Overview</span>
</a>
</li>
<li>
<a href="{{ url_for('settings_profile') }}">
<span class="nav-icon">👤</span>
<span>Profile</span>
</a>
</li>
<li>
<a href="{{ url_for('settings_communities') }}">
<span class="nav-icon">🌐</span>
<span>Communities</span>
</a>
</li>
<li>
<a href="{{ url_for('settings_filters') }}">
<span class="nav-icon">🔍</span>
<span>Filters</span>
</a>
</li>
<li>
<a href="{{ url_for('settings_experience') }}">
<span class="nav-icon">🎯</span>
<span>Experience</span>
</a>
</li>
{% if current_user.is_admin %}
<li>
<a href="{{ url_for('admin_panel') }}">
<span class="nav-icon">🛡️</span>
<span>Admin Panel</span>
</a>
</li>
{% endif %}
</ul>
</aside>
<main class="settings-content">
<div class="user-profile">
<div class="user-avatar">
{% if user.profile_picture_url %}
<img src="{{ user.profile_picture_url }}" alt="{{ user.username }}">
{% else %}
{{ user.username[0]|upper }}
{% endif %}
</div>
<div class="user-info">
<h3>{{ user.username }}</h3>
<p>{{ user.email }}</p>
{% if user.is_admin %}
<p style="color: var(--primary-color); font-weight: 500;">🛡️ Administrator</p>
{% endif %}
</div>
</div>
<div class="settings-section">
<h2>Profile Settings</h2>
<p>Manage your account information and profile picture</p>
<div class="setting-item">
<div class="setting-info">
<h3>Profile Information</h3>
<p>Update your username, email, and profile picture</p>
</div>
<a href="{{ url_for('settings_profile') }}" class="btn-settings">Edit Profile</a>
</div>
</div>
<div class="settings-section">
<h2>Content Preferences</h2>
<p>Customize your content sources and filtering preferences</p>
<div class="setting-item">
<div class="setting-info">
<h3>Communities</h3>
<p>Select which subreddits, websites, and sources to follow</p>
</div>
<a href="{{ url_for('settings_communities') }}" class="btn-settings">Manage</a>
</div>
<div class="setting-item">
<div class="setting-info">
<h3>Content Filters</h3>
<p>Configure content filtering and safety preferences</p>
</div>
<a href="{{ url_for('settings_filters') }}" class="btn-settings">Configure</a>
</div>
<div class="setting-item">
<div class="setting-info">
<h3>Experience Settings</h3>
<p>Manage potentially addictive features like infinite scroll</p>
</div>
<a href="{{ url_for('settings_experience') }}" class="btn-settings">Configure</a>
</div>
</div>
<div class="settings-section">
<h2>Current Configuration</h2>
<p>Review your current settings and preferences</p>
<div class="setting-item">
<div class="setting-info">
<h3>Active Filter</h3>
<p>The content filter currently applied to your feed</p>
</div>
<div class="current-filter">
<strong>{{ filter_sets[user_settings.get('filter_set', 'no_filter')].description or 'No Filter' }}</strong>
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<h3>Selected Communities</h3>
<p>Communities and sources you're currently following</p>
</div>
<div class="setting-value">
{{ user_settings.get('communities', [])|length or 0 }} communities selected
</div>
</div>
</div>
<div class="settings-section">
<h2>Account Actions</h2>
<p>Manage your account access and security</p>
<div class="setting-item">
<div class="setting-info">
<h3>Sign Out</h3>
<p>Sign out of your current session</p>
</div>
<a href="{{ url_for('logout') }}" class="btn-settings btn-secondary">Sign Out</a>
</div>
</div>
</main>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,357 @@
{% extends "base.html" %}
{% block title %}Community Settings - BalanceBoard{% endblock %}
{% block extra_css %}
<style>
.settings-container {
max-width: 1000px;
margin: 0 auto;
padding: 24px;
}
.settings-header {
margin-bottom: 32px;
}
.settings-header h1 {
color: var(--text-primary);
margin-bottom: 8px;
}
.settings-header p {
color: var(--text-secondary);
font-size: 1.1rem;
}
.settings-content {
background: var(--surface-color);
border-radius: 12px;
padding: 32px;
border: 1px solid var(--border-color);
}
.community-section {
margin-bottom: 32px;
}
.community-section:last-child {
margin-bottom: 0;
}
.community-section h2 {
color: var(--text-primary);
margin-bottom: 16px;
font-size: 1.3rem;
}
.community-section p {
color: var(--text-secondary);
margin-bottom: 24px;
line-height: 1.6;
}
.platform-group {
margin-bottom: 32px;
}
.platform-group h3 {
color: var(--text-primary);
margin-bottom: 16px;
font-size: 1.1rem;
display: flex;
align-items: center;
gap: 8px;
}
.platform-icon {
width: 24px;
height: 24px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
color: white;
font-size: 0.8rem;
}
.platform-icon.reddit { background: #ff4500; }
.platform-icon.hackernews { background: #ff6600; }
.platform-icon.lobsters { background: #ac130d; }
.platform-icon.stackoverflow { background: #f48024; }
.community-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.community-item {
background: var(--surface-elevation-1);
border: 2px solid var(--border-color);
border-radius: 8px;
padding: 16px;
transition: all 0.2s ease;
cursor: pointer;
}
.community-item:hover {
border-color: var(--primary-color);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.community-item.selected {
border-color: var(--primary-color);
background: rgba(77, 182, 172, 0.1);
}
.community-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.community-checkbox {
width: 20px;
height: 20px;
accent-color: var(--primary-color);
}
.community-info h4 {
color: var(--text-primary);
margin-bottom: 4px;
font-size: 1rem;
}
.community-info p {
color: var(--text-secondary);
font-size: 0.9rem;
margin: 0;
}
.community-meta {
display: flex;
align-items: center;
gap: 16px;
margin-top: 8px;
font-size: 0.85rem;
color: var(--text-secondary);
}
.community-meta span {
display: flex;
align-items: center;
gap: 4px;
}
.form-actions {
display: flex;
gap: 12px;
padding-top: 24px;
border-top: 1px solid var(--divider-color);
}
.btn-primary {
padding: 12px 24px;
background: var(--primary-color);
color: white;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-primary:hover {
background: var(--primary-hover);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(77, 182, 172, 0.3);
}
.btn-secondary {
background: var(--surface-elevation-1);
color: var(--text-primary);
}
.btn-secondary:hover {
background: var(--surface-elevation-2);
}
.selected-summary {
background: var(--surface-elevation-1);
border-radius: 8px;
padding: 16px;
margin-bottom: 24px;
border-left: 4px solid var(--primary-color);
}
.selected-summary h3 {
color: var(--text-primary);
margin-bottom: 8px;
}
.selected-summary p {
color: var(--text-secondary);
margin: 0;
}
.flash-messages {
margin-bottom: 24px;
}
.flash-message {
padding: 12px 16px;
border-radius: 8px;
margin-bottom: 12px;
font-size: 0.95rem;
}
.flash-message.success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.flash-message.error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
@media (max-width: 768px) {
.community-grid {
grid-template-columns: 1fr;
}
.form-actions {
flex-direction: column;
}
}
</style>
{% endblock %}
{% block content %}
<div class="settings-container">
<div class="settings-header">
<h1>Community Settings</h1>
<p>Select which communities, subreddits, and sources to include in your feed</p>
</div>
<div class="settings-content">
<div class="flash-messages">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="flash-message {{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
</div>
<form method="POST">
<div class="selected-summary">
<h3>Current Selection</h3>
<p>You have selected <strong>{{ selected_communities|length }}</strong> communities out of <strong>{{ available_communities|length }}</strong> available.</p>
</div>
<div class="community-section">
<h2>Available Communities</h2>
<p>Choose the communities you want to follow. Content from these sources will appear in your feed.</p>
{% set platforms = available_communities|groupby('platform') %}
{% for platform, communities in platforms %}
<div class="platform-group">
<h3>
<span class="platform-icon {{ platform }}">
{% if platform == 'reddit' %}R{% elif platform == 'hackernews' %}H{% elif platform == 'lobsters' %}L{% elif platform == 'stackoverflow' %}S{% endif %}
</span>
{{ platform|title }}
</h3>
<div class="community-grid">
{% for community in communities %}
<div class="community-item {% if community.id in selected_communities %}selected{% endif %}"
onclick="toggleCommunity(this, '{{ community.id }}')">
<div class="community-header">
<input type="checkbox"
name="communities"
value="{{ community.id }}"
class="community-checkbox"
{% if community.id in selected_communities %}checked{% endif %}
onclick="event.stopPropagation()">
<div class="community-info">
<h4>{{ community.name }}</h4>
<p>{{ community.platform|title }} community</p>
</div>
</div>
<div class="community-meta">
<span>📊 {{ community.platform|title }}</span>
<span>🔗 {{ community.id }}</span>
</div>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
<div class="form-actions">
<button type="submit" class="btn-primary">Save Community Preferences</button>
<a href="{{ url_for('settings') }}" class="btn-primary btn-secondary">Cancel</a>
</div>
</form>
</div>
</div>
<script>
function toggleCommunity(element, communityId) {
const checkbox = element.querySelector('.community-checkbox');
checkbox.checked = !checkbox.checked;
if (checkbox.checked) {
element.classList.add('selected');
} else {
element.classList.remove('selected');
}
updateSummary();
}
function updateSummary() {
const checkedBoxes = document.querySelectorAll('.community-checkbox:checked');
const totalBoxes = document.querySelectorAll('.community-checkbox');
const summary = document.querySelector('.selected-summary p');
summary.innerHTML = `You have selected <strong>${checkedBoxes.length}</strong> communities out of <strong>${totalBoxes.length}</strong> available.`;
}
// Prevent form submission when clicking on community items
document.querySelectorAll('.community-item').forEach(item => {
item.addEventListener('click', function(e) {
if (e.target.type !== 'checkbox') {
const checkbox = this.querySelector('.community-checkbox');
const communityId = checkbox.value;
toggleCommunity(this, communityId);
}
});
});
// Update summary on checkbox change
document.querySelectorAll('.community-checkbox').forEach(checkbox => {
checkbox.addEventListener('change', function() {
const item = this.closest('.community-item');
if (this.checked) {
item.classList.add('selected');
} else {
item.classList.remove('selected');
}
updateSummary();
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,341 @@
{% extends "base.html" %}
{% block title %}Experience Settings - BalanceBoard{% endblock %}
{% block extra_css %}
<style>
.experience-settings {
max-width: 800px;
margin: 0 auto;
padding: 24px;
}
.experience-header {
text-align: center;
margin-bottom: 32px;
}
.experience-header h1 {
color: var(--text-primary);
margin-bottom: 8px;
}
.experience-header p {
color: var(--text-secondary);
font-size: 1.1rem;
max-width: 600px;
margin: 0 auto;
}
.warning-banner {
background: linear-gradient(135deg, #fff3cd 0%, #ffeaa7 100%);
border: 1px solid #f39c12;
border-radius: 12px;
padding: 20px;
margin-bottom: 32px;
text-align: center;
}
.warning-banner h3 {
color: #d68910;
margin-bottom: 8px;
font-size: 1.2rem;
}
.warning-banner p {
color: #8b4513;
margin: 0;
line-height: 1.6;
}
.experience-section {
background: var(--surface-color);
border-radius: 12px;
padding: 24px;
margin-bottom: 24px;
border: 1px solid var(--border-color);
}
.experience-section h2 {
color: var(--text-primary);
margin-bottom: 16px;
font-size: 1.3rem;
display: flex;
align-items: center;
gap: 12px;
}
.experience-section p {
color: var(--text-secondary);
margin-bottom: 24px;
line-height: 1.6;
}
.feature-toggle {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 0;
border-bottom: 1px solid var(--divider-color);
}
.feature-toggle:last-child {
border-bottom: none;
}
.feature-info {
flex: 1;
margin-right: 20px;
}
.feature-info h3 {
color: var(--text-primary);
margin-bottom: 4px;
font-size: 1.1rem;
}
.feature-info p {
color: var(--text-secondary);
font-size: 0.9rem;
margin: 0;
line-height: 1.5;
}
.feature-warning {
color: #e74c3c;
font-weight: 500;
font-size: 0.85rem;
margin-top: 4px;
}
.toggle-switch {
position: relative;
display: inline-block;
width: 60px;
height: 34px;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--surface-elevation-1);
transition: .4s;
border-radius: 34px;
border: 2px solid var(--border-color);
}
.toggle-slider:before {
position: absolute;
content: "";
height: 26px;
width: 26px;
left: 2px;
bottom: 2px;
background-color: white;
transition: .4s;
border-radius: 50%;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
input:checked + .toggle-slider {
background-color: var(--primary-color);
border-color: var(--primary-color);
}
input:checked + .toggle-slider:before {
transform: translateX(26px);
}
.form-actions {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 24px;
border-top: 1px solid var(--divider-color);
}
.btn-save {
padding: 12px 24px;
background: var(--primary-color);
color: white;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-save:hover {
background: var(--primary-hover);
transform: translateY(-1px);
}
.btn-cancel {
padding: 12px 24px;
background: var(--surface-elevation-1);
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: 8px;
text-decoration: none;
font-weight: 500;
transition: all 0.2s ease;
}
.btn-cancel:hover {
background: var(--surface-elevation-2);
}
.addiction-notice {
background: linear-gradient(135deg, #ffebee 0%, #ffcdd2 100%);
border: 1px solid #e57373;
border-radius: 8px;
padding: 16px;
margin-top: 12px;
}
.addiction-notice h4 {
color: #c62828;
margin-bottom: 8px;
font-size: 1rem;
}
.addiction-notice p {
color: #b71c1c;
margin: 0;
font-size: 0.9rem;
line-height: 1.5;
}
@media (max-width: 768px) {
.experience-settings {
padding: 16px;
}
.feature-toggle {
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
.form-actions {
flex-direction: column;
gap: 12px;
}
.btn-save, .btn-cancel {
width: 100%;
text-align: center;
}
}
</style>
{% endblock %}
{% block content %}
<div class="experience-settings">
<div class="experience-header">
<h1>Experience Settings</h1>
<p>Configure features that may affect your browsing habits. All features below are <strong>opt-in only</strong> and disabled by default.</p>
</div>
<div class="warning-banner">
<h3>⚠️ Conscious Choice Required</h3>
<p>These features are designed to enhance engagement but may contribute to addictive browsing patterns. Please consider your digital well-being before enabling them.</p>
</div>
<form method="POST">
<div class="experience-section">
<h2>📜 Content Loading</h2>
<p>Control how content is loaded and displayed in your feed.</p>
<div class="feature-toggle">
<div class="feature-info">
<h3>Infinite Scroll</h3>
<p>Automatically load more content as you scroll, eliminating the need to click "next page".</p>
<div class="feature-warning">⚠️ May increase time spent browsing</div>
<div class="addiction-notice">
<h4>Why this matters:</h4>
<p>Infinite scroll removes natural stopping points, potentially leading to extended browsing sessions. Studies show it can increase content consumption by 20-50%.</p>
</div>
</div>
<label class="toggle-switch">
<input type="checkbox" name="infinite_scroll" {% if experience_settings.infinite_scroll %}checked{% endif %}>
<span class="toggle-slider"></span>
</label>
</div>
<div class="feature-toggle">
<div class="feature-info">
<h3>Auto-Refresh Content</h3>
<p>Automatically check for new content once per day (when browsing the main feed).</p>
<div class="feature-warning">⚠️ May create FOMO and compulsive checking</div>
<div class="addiction-notice">
<h4>Why this matters:</h4>
<p>Even with daily refreshes, auto-updating content can create expectation patterns that encourage habitual checking behaviors.</p>
</div>
</div>
<label class="toggle-switch">
<input type="checkbox" name="auto_refresh" {% if experience_settings.auto_refresh %}checked{% endif %}>
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="experience-section">
<h2>🔔 Notifications & Alerts</h2>
<p>Manage notifications that might interrupt your workflow or create urgency.</p>
<div class="feature-toggle">
<div class="feature-info">
<h3>Push Notifications</h3>
<p>Receive browser notifications for new content and updates.</p>
<div class="feature-warning">⚠️ May interrupt focus and create urgency</div>
<div class="addiction-notice">
<h4>Why this matters:</h4>
<p>Push notifications exploit the brain's reward system, creating dopamine responses that encourage app checking habits.</p>
</div>
</div>
<label class="toggle-switch">
<input type="checkbox" name="push_notifications" {% if experience_settings.push_notifications %}checked{% endif %}>
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="experience-section">
<h2>🛡️ Behavioral Opt-in</h2>
<p>Acknowledgment and consent for potentially addictive features.</p>
<div class="feature-toggle">
<div class="feature-info">
<h3>Dark Patterns Awareness</h3>
<p>I understand that the features above may contribute to addictive browsing patterns and I choose to enable them consciously.</p>
<div class="feature-warning">⚠️ Required for enabling any addictive features</div>
<div class="addiction-notice">
<h4>Why this matters:</h4>
<p>This serves as a conscious acknowledgment that you're making an informed choice about features that may affect your digital well-being.</p>
</div>
</div>
<label class="toggle-switch">
<input type="checkbox" name="dark_patterns_opt_in" {% if experience_settings.dark_patterns_opt_in %}checked{% endif %}>
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="form-actions">
<a href="{{ url_for('settings') }}" class="btn-cancel">Cancel</a>
<button type="submit" class="btn-save">Save Settings</button>
</div>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,418 @@
{% extends "base.html" %}
{% block title %}Filter Settings - BalanceBoard{% endblock %}
{% block extra_css %}
<style>
.settings-container {
max-width: 1000px;
margin: 0 auto;
padding: 24px;
}
.settings-header {
margin-bottom: 32px;
}
.settings-header h1 {
color: var(--text-primary);
margin-bottom: 8px;
}
.settings-header p {
color: var(--text-secondary);
font-size: 1.1rem;
}
.settings-content {
background: var(--surface-color);
border-radius: 12px;
padding: 32px;
border: 1px solid var(--border-color);
}
.current-filter {
background: var(--surface-elevation-1);
border-radius: 8px;
padding: 20px;
margin-bottom: 32px;
border-left: 4px solid var(--primary-color);
}
.current-filter h3 {
color: var(--text-primary);
margin-bottom: 8px;
}
.current-filter p {
color: var(--text-secondary);
margin: 0;
line-height: 1.6;
}
.filter-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 24px;
margin-bottom: 32px;
}
.filter-card {
background: var(--surface-color);
border: 2px solid var(--border-color);
border-radius: 12px;
padding: 24px;
transition: all 0.2s ease;
cursor: pointer;
position: relative;
}
.filter-card:hover {
border-color: var(--primary-color);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.filter-card.selected {
border-color: var(--primary-color);
background: rgba(77, 182, 172, 0.05);
}
.filter-card.selected::before {
content: '✓';
position: absolute;
top: 12px;
right: 12px;
width: 24px;
height: 24px;
background: var(--primary-color);
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 14px;
}
.filter-header {
margin-bottom: 16px;
}
.filter-header h3 {
color: var(--text-primary);
margin-bottom: 8px;
font-size: 1.2rem;
}
.filter-header .filter-id {
color: var(--text-secondary);
font-size: 0.9rem;
font-family: monospace;
background: var(--surface-elevation-1);
padding: 2px 6px;
border-radius: 4px;
}
.filter-description {
color: var(--text-secondary);
line-height: 1.6;
margin-bottom: 20px;
}
.filter-details {
border-top: 1px solid var(--divider-color);
padding-top: 16px;
}
.filter-detail {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
font-size: 0.9rem;
}
.filter-detail:last-child {
margin-bottom: 0;
}
.filter-detail-label {
color: var(--text-secondary);
}
.filter-detail-value {
color: var(--text-primary);
font-weight: 500;
}
.filter-rules {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--divider-color);
}
.filter-rules h4 {
color: var(--text-primary);
margin-bottom: 12px;
font-size: 1rem;
}
.rule-item {
background: var(--surface-elevation-1);
border-radius: 6px;
padding: 12px;
margin-bottom: 8px;
font-size: 0.85rem;
}
.rule-item:last-child {
margin-bottom: 0;
}
.rule-type {
color: var(--primary-color);
font-weight: 500;
margin-bottom: 4px;
}
.rule-details {
color: var(--text-secondary);
font-family: monospace;
font-size: 0.8rem;
}
.form-actions {
display: flex;
gap: 12px;
padding-top: 24px;
border-top: 1px solid var(--divider-color);
}
.btn-primary {
padding: 12px 24px;
background: var(--primary-color);
color: white;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-primary:hover {
background: var(--primary-hover);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(77, 182, 172, 0.3);
}
.btn-secondary {
background: var(--surface-elevation-1);
color: var(--text-primary);
}
.btn-secondary:hover {
background: var(--surface-elevation-2);
}
.flash-messages {
margin-bottom: 24px;
}
.flash-message {
padding: 12px 16px;
border-radius: 8px;
margin-bottom: 12px;
font-size: 0.95rem;
}
.flash-message.success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.flash-message.error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.no-filters {
text-align: center;
padding: 48px;
color: var(--text-secondary);
}
.no-filters h3 {
color: var(--text-primary);
margin-bottom: 8px;
}
@media (max-width: 768px) {
.filter-grid {
grid-template-columns: 1fr;
}
.form-actions {
flex-direction: column;
}
}
</style>
{% endblock %}
{% block content %}
<div class="settings-container">
<div class="settings-header">
<h1>Filter Settings</h1>
<p>Configure content filtering and safety preferences for your feed</p>
</div>
<div class="settings-content">
<div class="flash-messages">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="flash-message {{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
</div>
{% if filter_sets %}
<div class="current-filter">
<h3>Currently Active Filter</h3>
<p>
<strong>{{ filter_sets[current_filter].description or 'No Filter' }}</strong>
{% if current_filter != 'no_filter' %}
<br><small>Filter ID: <code>{{ current_filter }}</code></small>
{% endif %}
</p>
</div>
<form method="POST">
<div class="filter-section">
<h2>Available Filters</h2>
<p>Select a content filter to apply to your feed. Filters help control what type of content you see.</p>
<div class="filter-grid">
{% for filter_id, filter_config in filter_sets.items() %}
<div class="filter-card {% if filter_id == current_filter %}selected{% endif %}"
onclick="selectFilter(this, '{{ filter_id }}')">
<input type="radio"
name="filter_set"
value="{{ filter_id }}"
{% if filter_id == current_filter %}checked{% endif %}
style="display: none;">
<div class="filter-header">
<h3>{{ filter_config.description or filter_id|title }}</h3>
<span class="filter-id">{{ filter_id }}</span>
</div>
<div class="filter-description">
{{ filter_config.description or 'No description available' }}
</div>
{% if filter_config.post_rules or filter_config.comment_rules %}
<div class="filter-details">
{% if filter_config.post_rules %}
<div class="filter-detail">
<span class="filter-detail-label">Post Rules:</span>
<span class="filter-detail-value">{{ filter_config.post_rules|length }} rules</span>
</div>
{% endif %}
{% if filter_config.comment_rules %}
<div class="filter-detail">
<span class="filter-detail-label">Comment Rules:</span>
<span class="filter-detail-value">{{ filter_config.comment_rules|length }} rules</span>
</div>
{% endif %}
{% if filter_config.comment_filter_mode %}
<div class="filter-detail">
<span class="filter-detail-label">Comment Mode:</span>
<span class="filter-detail-value">{{ filter_config.comment_filter_mode }}</span>
</div>
{% endif %}
</div>
{% endif %}
{% if filter_id != 'no_filter' and (filter_config.post_rules or filter_config.comment_rules) %}
<div class="filter-rules">
<h4>Filter Rules Preview</h4>
{% if filter_config.post_rules %}
<div class="rule-item">
<div class="rule-type">Post Rules</div>
<div class="rule-details">
{% for rule, condition in filter_config.post_rules.items() %}
{{ rule }}: {{ condition }}<br>
{% endfor %}
</div>
</div>
{% endif %}
{% if filter_config.comment_rules %}
<div class="rule-item">
<div class="rule-type">Comment Rules</div>
<div class="rule-details">
{% for rule, condition in filter_config.comment_rules.items() %}
{{ rule }}: {{ condition }}<br>
{% endfor %}
</div>
</div>
{% endif %}
</div>
{% endif %}
</div>
{% endfor %}
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn-primary">Save Filter Preferences</button>
<a href="{{ url_for('settings') }}" class="btn-primary btn-secondary">Cancel</a>
</div>
</form>
{% else %}
<div class="no-filters">
<h3>No Filters Available</h3>
<p>There are currently no filter sets configured. Please contact an administrator to set up content filters.</p>
</div>
{% endif %}
</div>
</div>
<script>
function selectFilter(element, filterId) {
// Remove selected class from all cards
document.querySelectorAll('.filter-card').forEach(card => {
card.classList.remove('selected');
});
// Add selected class to clicked card
element.classList.add('selected');
// Check the radio button
const radio = element.querySelector('input[type="radio"]');
radio.checked = true;
// Update current filter display
const currentFilterDiv = document.querySelector('.current-filter p');
const filterTitle = element.querySelector('h3').textContent;
currentFilterDiv.innerHTML = `<strong>${filterTitle}</strong><br><small>Filter ID: <code>${filterId}</code></small>`;
}
// Handle click on filter cards
document.querySelectorAll('.filter-card').forEach(card => {
card.addEventListener('click', function(e) {
const radio = this.querySelector('input[type="radio"]');
const filterId = radio.value;
selectFilter(this, filterId);
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,349 @@
{% extends "base.html" %}
{% block title %}Profile Settings - BalanceBoard{% endblock %}
{% block extra_css %}
<style>
.settings-container {
max-width: 800px;
margin: 0 auto;
padding: 24px;
}
.settings-header {
margin-bottom: 32px;
}
.settings-header h1 {
color: var(--text-primary);
margin-bottom: 8px;
}
.settings-header p {
color: var(--text-secondary);
font-size: 1.1rem;
}
.settings-content {
background: var(--surface-color);
border-radius: 12px;
padding: 32px;
border: 1px solid var(--border-color);
}
.profile-section {
margin-bottom: 32px;
}
.profile-section:last-child {
margin-bottom: 0;
}
.profile-section h2 {
color: var(--text-primary);
margin-bottom: 16px;
font-size: 1.3rem;
}
.profile-avatar {
display: flex;
align-items: center;
gap: 24px;
margin-bottom: 32px;
padding: 24px;
background: var(--surface-elevation-1);
border-radius: 12px;
}
.avatar-preview {
width: 120px;
height: 120px;
border-radius: 50%;
background: var(--primary-color);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 2.5rem;
font-weight: bold;
position: relative;
overflow: hidden;
}
.avatar-preview img {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
}
.avatar-info h3 {
color: var(--text-primary);
margin-bottom: 8px;
}
.avatar-info p {
color: var(--text-secondary);
margin-bottom: 16px;
}
.form-group {
margin-bottom: 24px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: var(--text-primary);
}
.form-group input {
width: 100%;
padding: 12px 16px;
border: 2px solid var(--border-color);
border-radius: 8px;
font-size: 1rem;
transition: all 0.2s ease;
box-sizing: border-box;
}
.form-group input:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(77, 182, 172, 0.1);
}
.file-upload {
position: relative;
display: inline-block;
}
.file-upload input[type="file"] {
position: absolute;
opacity: 0;
width: 100%;
height: 100%;
cursor: pointer;
}
.file-upload-label {
display: inline-block;
padding: 10px 20px;
background: var(--primary-color);
color: white;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
font-weight: 500;
}
.file-upload-label:hover {
background: var(--primary-hover);
transform: translateY(-1px);
}
.btn-primary {
padding: 12px 24px;
background: var(--primary-color);
color: white;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-primary:hover {
background: var(--primary-hover);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(77, 182, 172, 0.3);
}
.btn-secondary {
background: var(--surface-elevation-1);
color: var(--text-primary);
margin-left: 12px;
}
.btn-secondary:hover {
background: var(--surface-elevation-2);
}
.form-actions {
display: flex;
gap: 12px;
padding-top: 24px;
border-top: 1px solid var(--divider-color);
}
.help-text {
font-size: 0.9rem;
color: var(--text-secondary);
margin-top: 4px;
}
.flash-messages {
margin-bottom: 24px;
}
.flash-message {
padding: 12px 16px;
border-radius: 8px;
margin-bottom: 12px;
font-size: 0.95rem;
}
.flash-message.success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.flash-message.error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
@media (max-width: 768px) {
.profile-avatar {
flex-direction: column;
text-align: center;
}
.form-actions {
flex-direction: column;
}
.btn-secondary {
margin-left: 0;
}
}
</style>
{% endblock %}
{% block content %}
<div class="settings-container">
<div class="settings-header">
<h1>Profile Settings</h1>
<p>Manage your account information and profile picture</p>
</div>
<div class="settings-content">
<div class="flash-messages">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="flash-message {{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
</div>
<form method="POST">
<div class="profile-section">
<h2>Profile Picture</h2>
<div class="profile-avatar">
<div class="avatar-preview">
{% if user.profile_picture_url %}
<img src="{{ user.profile_picture_url }}" alt="{{ user.username }}">
{% else %}
{{ user.username[0]|upper }}
{% endif %}
</div>
<div class="avatar-info">
<h3>Current Avatar</h3>
<p>Upload a new profile picture to personalize your account</p>
<div class="file-upload">
<input type="file" id="avatar" name="avatar" accept="image/*" onchange="document.getElementById('upload-form').submit()">
<label for="avatar" class="file-upload-label">Choose New Picture</label>
</div>
<p class="help-text">PNG, JPG, or GIF. Maximum size 2MB.</p>
</div>
</div>
</div>
</form>
<form id="upload-form" method="POST" action="{{ url_for('upload_avatar') }}" enctype="multipart/form-data" style="display: none;">
<input type="hidden" name="avatar" id="avatar-hidden">
</form>
<form method="POST">
<div class="profile-section">
<h2>Account Information</h2>
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" value="{{ user.username }}" required>
<p class="help-text">This is how other users will see you on BalanceBoard</p>
</div>
<div class="form-group">
<label for="email">Email Address</label>
<input type="email" id="email" name="email" value="{{ user.email }}" required>
<p class="help-text">We'll use this for account notifications and password recovery</p>
</div>
</div>
<div class="profile-section">
<h2>Account Details</h2>
<div style="padding: 20px; background: var(--surface-elevation-1); border-radius: 8px;">
<div style="display: flex; justify-content: space-between; margin-bottom: 12px;">
<span style="color: var(--text-secondary);">Account Type:</span>
<span style="color: var(--text-primary); font-weight: 500;">
{% if user.is_admin %}Administrator{% else %}User{% endif %}
</span>
</div>
<div style="display: flex; justify-content: space-between; margin-bottom: 12px;">
<span style="color: var(--text-secondary);">Member Since:</span>
<span style="color: var(--text-primary); font-weight: 500;">
{{ user.created_at.strftime('%B %d, %Y') }}
</span>
</div>
{% if user.last_login %}
<div style="display: flex; justify-content: space-between;">
<span style="color: var(--text-secondary);">Last Login:</span>
<span style="color: var(--text-primary); font-weight: 500;">
{{ user.last_login.strftime('%B %d, %Y at %I:%M %p') }}
</span>
</div>
{% endif %}
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn-primary">Save Changes</button>
<a href="{{ url_for('settings') }}" class="btn-primary btn-secondary">Cancel</a>
</div>
</form>
</div>
</div>
<script>
// Handle file upload
document.getElementById('avatar').addEventListener('change', function(e) {
const file = e.target.files[0];
if (file) {
// Check file size (2MB limit)
if (file.size > 2 * 1024 * 1024) {
alert('File size must be less than 2MB');
e.target.value = '';
return;
}
// Check file type
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif'];
if (!allowedTypes.includes(file.type)) {
alert('Please upload a valid image file (PNG, JPG, or GIF)');
e.target.value = '';
return;
}
// Submit the upload form
document.getElementById('upload-form').submit();
}
});
</script>
{% endblock %}

70
templates/signup.html Normal file
View File

@@ -0,0 +1,70 @@
{% extends "base.html" %}
{% block title %}Sign Up - BalanceBoard{% endblock %}
{% block content %}
<div class="auth-container">
<div class="auth-card">
<div class="auth-logo">
<img src="{{ url_for('serve_logo') }}" alt="BalanceBoard Logo">
<h1><span class="balance">balance</span>Board</h1>
<p style="color: var(--text-secondary); margin-top: 8px;">Create your account</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="username">Username</label>
<input type="text" id="username" name="username" required autofocus
pattern="[a-zA-Z0-9_]{3,20}"
title="Username must be 3-20 characters, letters, numbers and underscores only">
</div>
<div class="form-group">
<label for="email">Email</label>
<input type="email" id="email" name="email" required>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" required
minlength="8"
title="Password must be at least 8 characters">
</div>
<div class="form-group">
<label for="password_confirm">Confirm Password</label>
<input type="password" id="password_confirm" name="password_confirm" required>
</div>
<button type="submit">Sign Up</button>
</form>
<div class="social-auth-separator">
<span>or</span>
</div>
<div class="social-auth-buttons">
<a href="{{ url_for('auth0_login') }}" class="social-btn auth0-btn">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M21.98 7.448L19.62 0H4.347L2.02 7.448c-1.352 4.312.03 9.206 3.815 12.015L12.007 24l6.157-4.537c3.785-2.809 5.167-7.703 3.815-12.015z"/>
</svg>
Sign up with Auth0
</a>
</div>
<div class="auth-footer">
<p>Already have an account? <a href="{{ url_for('login') }}">Log in</a></p>
</div>
</div>
</div>
{% endblock %}

85
test_db_connection.py Normal file
View File

@@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""
Test script to verify PostgreSQL connection for the app.
"""
import os
import psycopg2
from dotenv import load_dotenv
# Load environment variables
load_dotenv()
def test_connection():
"""Test database connection using app's configuration"""
# Get database configuration from environment
db_user = os.getenv('POSTGRES_USER', 'balanceboard')
db_password = os.getenv('POSTGRES_PASSWORD', 'balanceboard123')
db_host = os.getenv('POSTGRES_HOST', 'localhost')
db_port = os.getenv('POSTGRES_PORT', '5432')
db_name = os.getenv('POSTGRES_DB', 'balanceboard')
print(f"Testing connection to PostgreSQL:")
print(f" Host: {db_host}")
print(f" Port: {db_port}")
print(f" Database: {db_name}")
print(f" User: {db_user}")
try:
# Test connection
conn = psycopg2.connect(
host=db_host,
port=db_port,
database=db_name,
user=db_user,
password=db_password
)
# Create a cursor
cur = conn.cursor()
# Test query
cur.execute("SELECT version();")
version = cur.fetchone()
print(f"\n✓ Connection successful!")
print(f" PostgreSQL version: {version[0]}")
# Test if we can create a simple table
cur.execute("""
CREATE TABLE IF NOT EXISTS test_table (
id SERIAL PRIMARY KEY,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
# Insert test data
cur.execute("INSERT INTO test_table DEFAULT VALUES;")
conn.commit()
# Query test data
cur.execute("SELECT COUNT(*) FROM test_table;")
count = cur.fetchone()[0]
print(f"✓ Database operations successful!")
print(f" Test table has {count} rows")
# Clean up
cur.execute("DROP TABLE IF EXISTS test_table;")
conn.commit()
# Close connections
cur.close()
conn.close()
print("✓ Connection test completed successfully!")
return True
except Exception as e:
print(f"\n✗ Connection failed:")
print(f" Error: {e}")
return False
if __name__ == "__main__":
test_connection()

340
user_service.py Normal file
View File

@@ -0,0 +1,340 @@
"""
User Authentication Service
Handles user management, authentication, and session management using SQLAlchemy.
"""
import time
import logging
from typing import Optional, List
from functools import wraps
from models import User, db
def db_retry(max_retries=3, delay=0.1):
"""
Decorator to retry database operations with exponential backoff.
Args:
max_retries: Maximum number of retry attempts
delay: Base delay between retries (exponentially increased)
"""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
logger = logging.getLogger(__name__)
for attempt in range(max_retries + 1):
try:
return func(*args, **kwargs)
except Exception as e:
# Check if this is a database-related error
error_msg = str(e).lower()
is_db_error = any(keyword in error_msg for keyword in [
'connection', 'timeout', 'database', 'postgresql', 'psycopg2',
'server closed', 'lost connection', 'connection reset'
])
if not is_db_error or attempt == max_retries:
# Not a retryable error or final attempt
db.session.rollback()
logger.error(f"Database operation failed: {e}")
raise
# Retry with exponential backoff
retry_delay = delay * (2 ** attempt)
logger.warning(f"Database error (attempt {attempt + 1}/{max_retries + 1}): {e}")
logger.info(f"Retrying in {retry_delay:.2f}s...")
db.session.rollback()
time.sleep(retry_delay)
return None
return wrapper
return decorator
class UserService:
"""Service for managing users with SQLAlchemy and PostgreSQL"""
def __init__(self):
"""
Initialize user service.
No arguments needed - uses SQLAlchemy db instance from models.
"""
pass
@db_retry(max_retries=3, delay=0.2)
def create_user(self, username: str, email: str, password: str = None, is_admin: bool = False, auth0_id: str = None) -> Optional[str]:
"""
Create a new user.
Args:
username: Unique username
email: Unique email address
password: Plain text password (will be hashed with bcrypt) - optional for OAuth
is_admin: Whether user is admin
auth0_id: Auth0 user ID for OAuth users
Returns:
User ID if successful, None if error
"""
try:
# Create new user (password is automatically hashed in __init__)
user = User(
username=username,
email=email,
password=password,
is_admin=is_admin,
auth0_id=auth0_id
)
# Add to database
db.session.add(user)
db.session.commit()
return user.id
except ValueError as e:
# Input validation error
db.session.rollback()
print(f"Validation error creating user: {e}")
return None
except Exception as e:
db.session.rollback()
print(f"Error creating user: {e}")
return None
@db_retry(max_retries=2, delay=0.1)
def authenticate(self, username: str, password: str) -> Optional[User]:
"""
Authenticate user with username/password.
Args:
username: Username or email
password: Plain text password
Returns:
User object if authenticated, None otherwise
"""
try:
# Query for user by username or email
user = User.query.filter(
(User.username == username) | (User.email == username)
).first()
if not user:
return None
# Check password using bcrypt
if user.check_password(password):
# Update last login
user.update_last_login()
return user
return None
except Exception as e:
print(f"Error authenticating user: {e}")
return None
@db_retry(max_retries=2, delay=0.1)
def get_user_by_id(self, user_id: str) -> Optional[User]:
"""
Get user by ID.
Args:
user_id: User UUID
Returns:
User object if found, None otherwise
"""
try:
return User.query.get(user_id)
except Exception as e:
print(f"Error getting user: {e}")
return None
def get_user_by_username(self, username: str) -> Optional[User]:
"""
Get user by username.
Args:
username: Username
Returns:
User object if found, None otherwise
"""
try:
return User.query.filter_by(username=username).first()
except Exception as e:
print(f"Error getting user by username: {e}")
return None
def get_user_by_email(self, email: str) -> Optional[User]:
"""
Get user by email.
Args:
email: Email address
Returns:
User object if found, None otherwise
"""
try:
return User.query.filter_by(email=email).first()
except Exception as e:
print(f"Error getting user by email: {e}")
return None
def username_exists(self, username: str) -> bool:
"""
Check if username already exists.
Args:
username: Username to check
Returns:
True if username exists, False otherwise
"""
try:
return User.query.filter_by(username=username).first() is not None
except Exception as e:
print(f"Error checking username: {e}")
return False
def email_exists(self, email: str) -> bool:
"""
Check if email already exists.
Args:
email: Email to check
Returns:
True if email exists, False otherwise
"""
try:
return User.query.filter_by(email=email).first() is not None
except Exception as e:
print(f"Error checking email: {e}")
return False
def get_all_users(self) -> List[User]:
"""
Get all users (for admin panel).
Returns:
List of User objects
"""
try:
return User.query.order_by(User.created_at.desc()).all()
except Exception as e:
print(f"Error getting all users: {e}")
return []
def delete_user(self, user_id: str) -> bool:
"""
Delete a user (admin only).
Args:
user_id: User ID to delete
Returns:
True if successful, False otherwise
"""
try:
user = User.query.get(user_id)
if user:
db.session.delete(user)
db.session.commit()
return True
return False
except Exception as e:
db.session.rollback()
print(f"Error deleting user: {e}")
return False
def update_user_admin_status(self, user_id: str, is_admin: bool) -> bool:
"""
Update user's admin status.
Args:
user_id: User ID
is_admin: New admin status
Returns:
True if successful, False otherwise
"""
try:
user = User.query.get(user_id)
if user:
user.is_admin = is_admin
db.session.commit()
return True
return False
except Exception as e:
db.session.rollback()
print(f"Error updating admin status: {e}")
return False
def update_password(self, user_id: str, new_password: str) -> bool:
"""
Update user's password.
Args:
user_id: User ID
new_password: New plain text password (will be hashed)
Returns:
True if successful, False otherwise
"""
try:
user = User.query.get(user_id)
if user:
user.set_password(new_password)
db.session.commit()
return True
return False
except Exception as e:
db.session.rollback()
print(f"Error updating password: {e}")
return False
def get_user_by_auth0_id(self, auth0_id: str) -> Optional[User]:
"""
Get user by Auth0 ID.
Args:
auth0_id: Auth0 user identifier
Returns:
User object if found, None otherwise
"""
try:
user = User.query.filter_by(auth0_id=auth0_id).first()
return user
except Exception as e:
print(f"Error getting user by Auth0 ID: {e}")
return None
def link_auth0_account(self, user_id: str, auth0_id: str) -> bool:
"""
Link an existing user account to Auth0.
Args:
user_id: Existing user ID
auth0_id: Auth0 user identifier
Returns:
True if successful, False otherwise
"""
try:
user = User.query.filter_by(id=user_id).first()
if user:
user.auth0_id = auth0_id
db.session.commit()
return True
return False
except Exception as e:
print(f"Error linking Auth0 account: {e}")
return False

57
utils.py Normal file
View File

@@ -0,0 +1,57 @@
"""
Utilities Library
Generic utility functions shared across modules.
"""
import uuid
import json
from pathlib import Path
from typing import Dict, Any
def generate_uuid() -> str:
"""Generate a new UUID string"""
return str(uuid.uuid4())
def load_json_file(file_path: str) -> Any:
"""Load JSON from file"""
with open(file_path, 'r') as f:
return json.load(f)
def save_json_file(data: Any, file_path: str, indent: int = 2):
"""Save data to JSON file"""
path = Path(file_path)
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, 'w') as f:
json.dump(data, f, indent=indent)
def ensure_directory(dir_path: str) -> Path:
"""Create directory if it doesn't exist, return Path object"""
path = Path(dir_path)
path.mkdir(parents=True, exist_ok=True)
return path
def load_json_files_from_dir(dir_path: str, pattern: str = "*.json") -> Dict[str, Any]:
"""Load all JSON files from directory into dict keyed by filename (without extension)"""
directory = Path(dir_path)
data = {}
if directory.exists():
for file_path in directory.glob(pattern):
key = file_path.stem # filename without extension
data[key] = load_json_file(str(file_path))
return data
def count_files(dir_path: str, pattern: str = "*.json") -> int:
"""Count files matching pattern in directory"""
directory = Path(dir_path)
if not directory.exists():
return 0
return len(list(directory.glob(pattern)))