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:
56
Dockerfile
Normal file
56
Dockerfile
Normal 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"]
|
||||
159
comment_lib.py
Normal file
159
comment_lib.py
Normal 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
58
create_admin.py
Normal 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
390
data_collection.py
Normal 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
623
data_collection_lib.py
Normal 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
53
database.py
Normal 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
73
docker-compose.yml
Normal 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
345
filter_lib.py
Normal 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
297
generate_html.py
Normal 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
515
html_generation_lib.py
Normal 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('&', '&').replace('<', '<').replace('>', '>')
|
||||
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('&', '&')
|
||||
.replace('<', '<')
|
||||
.replace('>', '>')
|
||||
.replace('"', '"')
|
||||
.replace("'", '''))
|
||||
186
models.py
Normal file
186
models.py
Normal 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
215
polling_service.py
Normal 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
13
requirements.txt
Normal 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
59
run_app.py
Executable 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
165
start_server.py
Executable 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
57
templates/404.html
Normal 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
74
templates/500.html
Normal 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
579
templates/admin.html
Normal 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>
|
||||
366
templates/admin_polling.html
Normal file
366
templates/admin_polling.html
Normal 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>
|
||||
188
templates/admin_polling_logs.html
Normal file
188
templates/admin_polling_logs.html
Normal 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>
|
||||
78
templates/admin_setup.html
Normal file
78
templates/admin_setup.html
Normal 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
251
templates/base.html
Normal 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
1264
templates/dashboard.html
Normal file
File diff suppressed because it is too large
Load Diff
61
templates/login.html
Normal file
61
templates/login.html
Normal 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
681
templates/post_detail.html
Normal 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
382
templates/settings.html
Normal 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 %}
|
||||
357
templates/settings_communities.html
Normal file
357
templates/settings_communities.html
Normal 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 %}
|
||||
341
templates/settings_experience.html
Normal file
341
templates/settings_experience.html
Normal 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 %}
|
||||
418
templates/settings_filters.html
Normal file
418
templates/settings_filters.html
Normal 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 %}
|
||||
349
templates/settings_profile.html
Normal file
349
templates/settings_profile.html
Normal 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
70
templates/signup.html
Normal 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
85
test_db_connection.py
Normal 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
340
user_service.py
Normal 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
57
utils.py
Normal 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)))
|
||||
Reference in New Issue
Block a user