BalanceBoard - Clean release
- Docker deployment ready
- Content aggregation and filtering
- User authentication
- Polling service for updates
🤖 Generated with Claude Code
This commit is contained in:
43
.gitignore
vendored
Normal file
43
.gitignore
vendored
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.Python
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Virtual Environment
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
ENV/
|
||||||
|
|
||||||
|
# IDEs
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# Application data
|
||||||
|
data/
|
||||||
|
backups/
|
||||||
|
active_html/
|
||||||
|
*.log
|
||||||
|
*.pid
|
||||||
|
server.log
|
||||||
|
app.log
|
||||||
|
|
||||||
|
# Secrets
|
||||||
|
.env
|
||||||
|
*.txt
|
||||||
|
!requirements.txt
|
||||||
|
cookies.txt
|
||||||
|
reddit-api-key.txt
|
||||||
|
|
||||||
|
# Archives
|
||||||
|
*.tar.gz
|
||||||
|
*.zip
|
||||||
|
|
||||||
|
# Config files
|
||||||
|
platform_config.json
|
||||||
|
filtersets.json
|
||||||
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"]
|
||||||
30
README.md
Normal file
30
README.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# BalanceBoard
|
||||||
|
|
||||||
|
A Reddit-style content aggregator with ethical design principles and ADHD-friendly features.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Content aggregation from multiple platforms
|
||||||
|
- User authentication and personalization
|
||||||
|
- Content filtering and moderation
|
||||||
|
- Polling service for automatic updates
|
||||||
|
- Docker deployment ready
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Access at http://localhost:5021
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Set environment variables in `.env` or docker-compose.yml:
|
||||||
|
- `POSTGRES_HOST`, `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB`
|
||||||
|
- `SECRET_KEY`
|
||||||
|
- Optional: Auth0 credentials
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Open Source
|
||||||
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()
|
||||||
59
themes/modern-card-ui/card-template.html
Normal file
59
themes/modern-card-ui/card-template.html
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<!-- Modern Card UI - Post Card Template -->
|
||||||
|
<template id="modern-card-template">
|
||||||
|
<article class="post-card" data-post-id="{{id}}" data-platform="{{platform}}">
|
||||||
|
<div class="card-surface">
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="card-header">
|
||||||
|
<div class="header-meta">
|
||||||
|
<span class="platform-badge platform-{{platform}}">{{platform|upper}}</span>
|
||||||
|
{% if source %}
|
||||||
|
<span class="card-source">{{source}}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="vote-indicator">
|
||||||
|
<span class="vote-score">{{score}}</span>
|
||||||
|
<span class="vote-label">pts</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Title -->
|
||||||
|
<div class="card-title-section">
|
||||||
|
<h2 class="card-title">
|
||||||
|
<a href="{{post_url}}" class="title-link">{{title}}</a>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content Preview -->
|
||||||
|
{% if content %}
|
||||||
|
<div class="card-content-preview">
|
||||||
|
<p class="content-text">{{ truncate(content, 150) }}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<footer class="card-footer">
|
||||||
|
<div class="author-info">
|
||||||
|
<span class="author-name">{{author}}</span>
|
||||||
|
<span class="post-time">{{formatTimeAgo(timestamp)}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="engagement-info">
|
||||||
|
<span class="reply-count">{{replies}} replies</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<!-- Tags -->
|
||||||
|
{% if tags %}
|
||||||
|
<div class="card-tags">
|
||||||
|
{% for tag in tags[:3] if tag %}
|
||||||
|
<span class="tag-chip">{{tag}}</span>
|
||||||
|
{% endfor %}
|
||||||
|
{% if tags|length > 3 %}
|
||||||
|
<span class="tag-more">+{{tags|length - 3}} more</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
41
themes/modern-card-ui/comment-template.html
Normal file
41
themes/modern-card-ui/comment-template.html
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<!-- Modern Card UI - Comment Template -->
|
||||||
|
<template id="modern-comment-template">
|
||||||
|
<div class="comment-card" data-comment-id="{{uuid}}" style="margin-left: {{depth * 24}}px">
|
||||||
|
<div class="comment-surface">
|
||||||
|
<!-- Comment Header -->
|
||||||
|
<header class="comment-header">
|
||||||
|
<div class="comment-meta">
|
||||||
|
<span class="comment-author">{{author}}</span>
|
||||||
|
<span class="comment-time">{{formatTimeAgo(timestamp)}}</span>
|
||||||
|
{% if score != 0 %}
|
||||||
|
<div class="comment-score">
|
||||||
|
<span class="score-number">{{score}}</span>
|
||||||
|
<span class="score-label">pts</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Comment Content -->
|
||||||
|
<div class="comment-body">
|
||||||
|
<div class="comment-text">
|
||||||
|
{{ renderMarkdown(content)|safe }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if children_section %}
|
||||||
|
<!-- Nested replies section -->
|
||||||
|
<div class="comment-replies">
|
||||||
|
{{children_section|safe}}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Comment Footer (for actions) -->
|
||||||
|
<footer class="comment-footer">
|
||||||
|
<div class="depth-indicator" data-depth="{{depth}}">
|
||||||
|
<span class="depth-label">Level {{depth + 1}}</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
69
themes/modern-card-ui/detail-template.html
Normal file
69
themes/modern-card-ui/detail-template.html
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
<!-- Modern Card UI - Post Detail Template -->
|
||||||
|
<template id="modern-detail-template">
|
||||||
|
<article class="post-detail" data-post-id="{{id}}" data-platform="{{platform}}">
|
||||||
|
<div class="detail-container">
|
||||||
|
<!-- Header Card -->
|
||||||
|
<header class="detail-header">
|
||||||
|
<div class="header-meta-card">
|
||||||
|
<div class="meta-row">
|
||||||
|
<span class="platform-badge platform-{{platform}}">{{platform|upper}}</span>
|
||||||
|
{% if source %}
|
||||||
|
<span class="detail-source">in {{source}}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="headline-section">
|
||||||
|
<h1 class="detail-title">{{title}}</h1>
|
||||||
|
<div class="byline">
|
||||||
|
<span class="author-link">by {{author}}</span>
|
||||||
|
<span class="publication-time">{{formatDateTime(timestamp)}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats-row">
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-number">{{score}}</span>
|
||||||
|
<span class="stat-label">points</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-number">{{replies}}</span>
|
||||||
|
<span class="stat-label">comments</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if tags %}
|
||||||
|
<div class="detail-tags">
|
||||||
|
{% for tag in tags if tag %}
|
||||||
|
<span class="tag-pill">{{tag}}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Article Body -->
|
||||||
|
{% if content %}
|
||||||
|
<section class="article-body">
|
||||||
|
<div class="article-content">
|
||||||
|
{{ renderMarkdown(content)|safe }}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Action Row -->
|
||||||
|
<div class="article-actions">
|
||||||
|
<a href="{{url}}" target="_blank" class="action-button primary">
|
||||||
|
View Original
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Comments Section -->
|
||||||
|
{% if comments_section %}
|
||||||
|
<section class="comments-section">
|
||||||
|
<h2 class="comments-header">Comments ({{replies}})</h2>
|
||||||
|
{{comments_section|safe}}
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
357
themes/modern-card-ui/index.html
Normal file
357
themes/modern-card-ui/index.html
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>BalanceBoard - Content Feed</title>
|
||||||
|
{% for css_path in theme.css_dependencies %}
|
||||||
|
<link rel="stylesheet" href="{{ css_path }}">
|
||||||
|
{% endfor %}
|
||||||
|
<style>
|
||||||
|
/* Enhanced Navigation Styles */
|
||||||
|
.nav-top {
|
||||||
|
background: var(--surface-color);
|
||||||
|
border-bottom: 1px solid var(--divider-color);
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-top-container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 1rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-logo {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-brand-text {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-balance {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-board {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-user-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-user-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-avatar {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--primary-color);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-username {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-menu {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-toggle {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-toggle:hover {
|
||||||
|
background: var(--hover-overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-line {
|
||||||
|
width: 20px;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--text-primary);
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
right: 0;
|
||||||
|
background: var(--surface-color);
|
||||||
|
border: 1px solid var(--divider-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
min-width: 200px;
|
||||||
|
z-index: 1000;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-dropdown.show {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item {
|
||||||
|
display: block;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
border-bottom: 1px solid var(--divider-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item:hover {
|
||||||
|
background: var(--hover-overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--divider-color);
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-login-prompt {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-nav-login, .btn-nav-signup {
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-nav-login {
|
||||||
|
background: var(--surface-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--divider-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-nav-login:hover {
|
||||||
|
background: var(--hover-overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-nav-signup {
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-nav-signup:hover {
|
||||||
|
background: var(--primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.nav-username {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Enhanced Top Navigation -->
|
||||||
|
<nav class="nav-top">
|
||||||
|
<div class="nav-top-container">
|
||||||
|
<a href="/" class="nav-brand">
|
||||||
|
<img src="/logo.png" alt="BalanceBoard Logo" class="nav-logo">
|
||||||
|
<div class="nav-brand-text">
|
||||||
|
<span class="brand-balance">balance</span><span class="brand-board">Board</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="nav-user-section">
|
||||||
|
<!-- Logged in user state -->
|
||||||
|
<div class="nav-user-info" style="display: none;">
|
||||||
|
<div class="nav-avatar">JD</div>
|
||||||
|
<span class="nav-username">johndoe</span>
|
||||||
|
<div class="hamburger-menu">
|
||||||
|
<button class="hamburger-toggle" onclick="toggleDropdown()">
|
||||||
|
<div class="hamburger-line"></div>
|
||||||
|
<div class="hamburger-line"></div>
|
||||||
|
<div class="hamburger-line"></div>
|
||||||
|
</button>
|
||||||
|
<div class="hamburger-dropdown" id="userDropdown">
|
||||||
|
<a href="/settings" class="dropdown-item">
|
||||||
|
⚙️ Settings
|
||||||
|
</a>
|
||||||
|
<a href="/settings/profile" class="dropdown-item">
|
||||||
|
👤 Profile
|
||||||
|
</a>
|
||||||
|
<a href="/settings/communities" class="dropdown-item">
|
||||||
|
🌐 Communities
|
||||||
|
</a>
|
||||||
|
<a href="/settings/filters" class="dropdown-item">
|
||||||
|
🎛️ Filters
|
||||||
|
</a>
|
||||||
|
<div class="dropdown-divider"></div>
|
||||||
|
<a href="/admin" class="dropdown-item" style="display: none;">
|
||||||
|
🛠️ Admin
|
||||||
|
</a>
|
||||||
|
<div class="dropdown-divider" style="display: none;"></div>
|
||||||
|
<a href="/logout" class="dropdown-item">
|
||||||
|
🚪 Sign Out
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Logged out state -->
|
||||||
|
<div class="nav-login-prompt">
|
||||||
|
<a href="/login" class="btn-nav-login">Log In</a>
|
||||||
|
<a href="/signup" class="btn-nav-signup">Sign Up</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="app-layout">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<aside class="sidebar">
|
||||||
|
<!-- User Card -->
|
||||||
|
<div class="sidebar-section user-card">
|
||||||
|
<div class="login-prompt">
|
||||||
|
<div class="user-avatar">?</div>
|
||||||
|
<p>Join BalanceBoard to customize your feed</p>
|
||||||
|
<a href="/login" class="btn-login">Log In</a>
|
||||||
|
<a href="/signup" class="btn-signup">Sign Up</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Navigation -->
|
||||||
|
<div class="sidebar-section">
|
||||||
|
<h3>Navigation</h3>
|
||||||
|
<ul class="nav-menu">
|
||||||
|
<li><a href="/" class="nav-item active">
|
||||||
|
<span class="nav-icon">🏠</span>
|
||||||
|
<span>Home</span>
|
||||||
|
</a></li>
|
||||||
|
<li><a href="#" class="nav-item">
|
||||||
|
<span class="nav-icon">🔥</span>
|
||||||
|
<span>Popular</span>
|
||||||
|
</a></li>
|
||||||
|
<li><a href="#" class="nav-item">
|
||||||
|
<span class="nav-icon">⭐</span>
|
||||||
|
<span>Saved</span>
|
||||||
|
</a></li>
|
||||||
|
<li><a href="#" class="nav-item">
|
||||||
|
<span class="nav-icon">📊</span>
|
||||||
|
<span>Analytics</span>
|
||||||
|
</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="sidebar-section">
|
||||||
|
<h3>Filter by Platform</h3>
|
||||||
|
<div class="filter-tags">
|
||||||
|
<a href="#" class="filter-tag active">All</a>
|
||||||
|
<a href="#" class="filter-tag">Reddit</a>
|
||||||
|
<a href="#" class="filter-tag">HackerNews</a>
|
||||||
|
<a href="#" class="filter-tag">Lobsters</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- About -->
|
||||||
|
<div class="sidebar-section">
|
||||||
|
<h3>About</h3>
|
||||||
|
<p style="font-size: 0.85rem; color: var(--text-secondary); line-height: 1.5;">
|
||||||
|
BalanceBoard filters and curates content from multiple platforms to help you stay informed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<main class="main-content">
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<h1>{{ filterset_name|title or 'All Posts' }}</h1>
|
||||||
|
<p class="post-count">{{ posts|length }} posts</p>
|
||||||
|
</header>
|
||||||
|
<div id="posts-container">
|
||||||
|
{% for post in posts %}
|
||||||
|
{{ post|safe }}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% for js_path in theme.js_dependencies %}
|
||||||
|
<script src="{{ js_path }}"></script>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Dropdown functionality
|
||||||
|
function toggleDropdown() {
|
||||||
|
const dropdown = document.getElementById('userDropdown');
|
||||||
|
dropdown.classList.toggle('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close dropdown when clicking outside
|
||||||
|
document.addEventListener('click', function(event) {
|
||||||
|
const dropdown = document.getElementById('userDropdown');
|
||||||
|
const toggle = document.querySelector('.hamburger-toggle');
|
||||||
|
|
||||||
|
if (!toggle.contains(event.target) && !dropdown.contains(event.target)) {
|
||||||
|
dropdown.classList.remove('show');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check user authentication state (this would be dynamic in a real app)
|
||||||
|
function checkAuthState() {
|
||||||
|
// This would normally check with the server
|
||||||
|
// For now, we'll show the logged out state
|
||||||
|
document.querySelector('.nav-user-info').style.display = 'none';
|
||||||
|
document.querySelector('.nav-login-prompt').style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize on page load
|
||||||
|
document.addEventListener('DOMContentLoaded', checkAuthState);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
121
themes/modern-card-ui/interactions.js
Normal file
121
themes/modern-card-ui/interactions.js
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
// Modern Card UI Theme Interactions
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Enhanced hover effects
|
||||||
|
function initializeCardHoverEffects() {
|
||||||
|
const cards = document.querySelectorAll('.card-surface, .list-card, .comment-surface');
|
||||||
|
|
||||||
|
cards.forEach(card => {
|
||||||
|
card.addEventListener('mouseenter', function() {
|
||||||
|
// Subtle scale effect on hover
|
||||||
|
this.style.transform = 'translateY(-2px)';
|
||||||
|
this.style.boxShadow = '0 6px 20px rgba(0, 0, 0, 0.15)';
|
||||||
|
});
|
||||||
|
|
||||||
|
card.addEventListener('mouseleave', function() {
|
||||||
|
// Reset transform
|
||||||
|
this.style.transform = '';
|
||||||
|
this.style.boxShadow = '';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lazy loading for performance
|
||||||
|
function initializeLazyLoading() {
|
||||||
|
if ('IntersectionObserver' in window) {
|
||||||
|
const options = {
|
||||||
|
root: null,
|
||||||
|
rootMargin: '50px',
|
||||||
|
threshold: 0.1
|
||||||
|
};
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver((entries, observer) => {
|
||||||
|
entries.forEach(entry => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
// Add visible class for animations
|
||||||
|
entry.target.classList.add('visible');
|
||||||
|
|
||||||
|
// Unobserve after animation
|
||||||
|
observer.unobserve(entry.target);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, options);
|
||||||
|
|
||||||
|
// Observe all cards and comments
|
||||||
|
document.querySelectorAll('.post-card, .comment-card').forEach(card => {
|
||||||
|
observer.observe(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Improved comment thread visibility
|
||||||
|
function initializeCommentThreading() {
|
||||||
|
const toggleButtons = document.querySelectorAll('.comment-toggle');
|
||||||
|
|
||||||
|
toggleButtons.forEach(button => {
|
||||||
|
button.addEventListener('click', function() {
|
||||||
|
const comment = this.closest('.comment-card');
|
||||||
|
const replies = comment.querySelector('.comment-replies');
|
||||||
|
|
||||||
|
if (replies) {
|
||||||
|
replies.classList.toggle('collapsed');
|
||||||
|
this.textContent = replies.classList.contains('collapsed') ? '+' : '-';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add CSS classes for JavaScript-enhanced features
|
||||||
|
function initializeThemeFeatures() {
|
||||||
|
document.documentElement.classList.add('js-enabled');
|
||||||
|
|
||||||
|
// Add platform-specific classes to body
|
||||||
|
const platformElements = document.querySelectorAll('[data-platform]');
|
||||||
|
const platforms = new Set();
|
||||||
|
|
||||||
|
platformElements.forEach(el => {
|
||||||
|
platforms.add(el.dataset.platform);
|
||||||
|
});
|
||||||
|
|
||||||
|
platforms.forEach(platform => {
|
||||||
|
document.body.classList.add(`has-${platform}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keyboard navigation for accessibility
|
||||||
|
function initializeKeyboardNavigation() {
|
||||||
|
const cards = document.querySelectorAll('.post-card, .comment-card');
|
||||||
|
|
||||||
|
cards.forEach(card => {
|
||||||
|
card.setAttribute('tabindex', '0');
|
||||||
|
|
||||||
|
card.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
const link = this.querySelector('a');
|
||||||
|
if (link) {
|
||||||
|
link.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize all features when DOM is ready
|
||||||
|
function initializeTheme() {
|
||||||
|
initializeThemeFeatures();
|
||||||
|
initializeCardHoverEffects();
|
||||||
|
initializeLazyLoading();
|
||||||
|
initializeCommentThreading();
|
||||||
|
initializeKeyboardNavigation();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run initialization after DOM load
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', initializeTheme);
|
||||||
|
} else {
|
||||||
|
initializeTheme();
|
||||||
|
}
|
||||||
|
})();
|
||||||
42
themes/modern-card-ui/list-template.html
Normal file
42
themes/modern-card-ui/list-template.html
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<!-- Modern Card UI - Post List Template -->
|
||||||
|
<template id="modern-list-template">
|
||||||
|
<div class="post-list-item" data-post-id="{{id}}" data-platform="{{platform}}">
|
||||||
|
<div class="list-card">
|
||||||
|
<!-- Platform indicator -->
|
||||||
|
<div class="list-platform">
|
||||||
|
<span class="platform-badge medium platform-{{platform}}">{{platform[:1]|upper}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main content -->
|
||||||
|
<div class="list-content">
|
||||||
|
<div class="list-vote-section">
|
||||||
|
<div class="vote-display">
|
||||||
|
<span class="vote-number">{{score}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="list-meta">
|
||||||
|
<h3 class="list-title">
|
||||||
|
<a href="{{post_url}}" class="title-link">{{title}}</a>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="list-details">
|
||||||
|
<div class="list-attribution">
|
||||||
|
{% if source %}
|
||||||
|
<span class="list-source">{{source}}</span>
|
||||||
|
<span class="separator">•</span>
|
||||||
|
{% endif %}
|
||||||
|
<span class="list-author">{{author}}</span>
|
||||||
|
<span class="separator">•</span>
|
||||||
|
<span class="list-time">{{formatTimeAgo(timestamp)}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="list-engagement">
|
||||||
|
<span class="replies-indicator">{{replies}} replies</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
936
themes/modern-card-ui/styles.css
Normal file
936
themes/modern-card-ui/styles.css
Normal file
@@ -0,0 +1,936 @@
|
|||||||
|
/* BalanceBoard Theme Styles */
|
||||||
|
:root {
|
||||||
|
/* BalanceBoard Color Palette */
|
||||||
|
--primary-color: #4DB6AC;
|
||||||
|
--primary-hover: #26A69A;
|
||||||
|
--primary-dark: #1B3A52;
|
||||||
|
--accent-color: #4DB6AC;
|
||||||
|
--surface-color: #FFFFFF;
|
||||||
|
--background-color: #F5F5F5;
|
||||||
|
--surface-elevation-1: rgba(0, 0, 0, 0.05);
|
||||||
|
--surface-elevation-2: rgba(0, 0, 0, 0.10);
|
||||||
|
--surface-elevation-3: rgba(0, 0, 0, 0.15);
|
||||||
|
--text-primary: #1B3A52;
|
||||||
|
--text-secondary: #757575;
|
||||||
|
--text-accent: #4DB6AC;
|
||||||
|
--border-color: rgba(0, 0, 0, 0.12);
|
||||||
|
--divider-color: rgba(0, 0, 0, 0.08);
|
||||||
|
--hover-overlay: rgba(77, 182, 172, 0.08);
|
||||||
|
--active-overlay: rgba(77, 182, 172, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Global Styles */
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
|
background-color: var(--background-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* BalanceBoard Navigation */
|
||||||
|
.balanceboard-nav {
|
||||||
|
background: linear-gradient(135deg, var(--primary-dark) 0%, #2a5068 100%);
|
||||||
|
box-shadow: 0 2px 8px var(--surface-elevation-2);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
border-bottom: 3px solid var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 16px 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-logo {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: white;
|
||||||
|
padding: 4px;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-brand:hover .nav-logo {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-brand-text {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 300;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-brand-text .brand-balance {
|
||||||
|
color: var(--primary-color);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-brand-text .brand-board {
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-subtitle {
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-top: -4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main Layout */
|
||||||
|
.app-layout {
|
||||||
|
display: flex;
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 24px;
|
||||||
|
min-height: calc(100vh - 80px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar */
|
||||||
|
.sidebar {
|
||||||
|
width: 280px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: sticky;
|
||||||
|
top: 88px;
|
||||||
|
height: fit-content;
|
||||||
|
max-height: calc(100vh - 104px);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-section {
|
||||||
|
background: var(--surface-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
box-shadow: 0 2px 4px var(--surface-elevation-1);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-section h3 {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* User Card */
|
||||||
|
.user-card {
|
||||||
|
background: linear-gradient(135deg, var(--primary-dark) 0%, #2a5068 100%);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
color: white;
|
||||||
|
text-align: center;
|
||||||
|
border: 2px solid var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-avatar {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--primary-color);
|
||||||
|
margin: 0 auto 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-karma {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
opacity: 0.8;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.karma-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-prompt {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-prompt p {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-login {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
text-decoration: none;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-login:hover {
|
||||||
|
background: var(--primary-hover);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(77, 182, 172, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-signup {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: transparent;
|
||||||
|
color: white;
|
||||||
|
border: 2px solid var(--primary-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
text-decoration: none;
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-signup:hover {
|
||||||
|
background: rgba(77, 182, 172, 0.1);
|
||||||
|
border-color: var(--primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Navigation Menu */
|
||||||
|
.nav-menu {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-menu li {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item:hover {
|
||||||
|
background: var(--hover-overlay);
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.active {
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-icon {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
width: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Filter Tags */
|
||||||
|
.filter-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-tag {
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: var(--background-color);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 16px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-tag:hover {
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-tag.active {
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main Content Area */
|
||||||
|
.main-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Platform Colors */
|
||||||
|
.platform-reddit { background: linear-gradient(135deg, #FF4500, #FF6B35); color: white; }
|
||||||
|
.platform-hackernews { background: linear-gradient(135deg, #FF6600, #FF8533); color: white; }
|
||||||
|
.platform-lobsters { background: linear-gradient(135deg, #8B5A3C, #A0695A); color: white; }
|
||||||
|
|
||||||
|
/* Page Header */
|
||||||
|
.container > header {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
box-shadow: 0 2px 4px var(--surface-elevation-1);
|
||||||
|
border-left: 4px solid var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header .post-count {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header .post-count::before {
|
||||||
|
content: "•";
|
||||||
|
color: var(--primary-color);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Post Cards */
|
||||||
|
.post-card {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-surface {
|
||||||
|
background: var(--surface-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 4px var(--surface-elevation-1);
|
||||||
|
padding: 24px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-surface:hover {
|
||||||
|
box-shadow: 0 4px 12px var(--surface-elevation-2);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
background: var(--hover-overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card Header */
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-badge {
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.025em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vote-indicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vote-score {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card Title */
|
||||||
|
.card-title {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.4;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-link {
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-link:hover {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content Preview */
|
||||||
|
.card-content-preview {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-text {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card Footer */
|
||||||
|
.card-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.engagement-info {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tags */
|
||||||
|
.card-tags {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-chip {
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 16px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* List View */
|
||||||
|
.post-list-item {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--surface-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-card:hover {
|
||||||
|
background: var(--hover-overlay);
|
||||||
|
box-shadow: 0 2px 8px var(--surface-elevation-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex: 1;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-vote-section {
|
||||||
|
min-width: 60px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vote-number {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-meta {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-details {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-attribution {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.separator {
|
||||||
|
color: var(--divider-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-engagement {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Detailed View */
|
||||||
|
.post-detail {
|
||||||
|
background: var(--surface-color);
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 4px 12px var(--surface-elevation-1);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-container {
|
||||||
|
padding: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-header {
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-meta-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-source {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.headline-section {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-title {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.byline {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-link {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-number {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: lowercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-tags {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-pill {
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Article Body */
|
||||||
|
.article-body {
|
||||||
|
margin-bottom: 32px;
|
||||||
|
padding: 24px 0;
|
||||||
|
border-top: 1px solid var(--divider-color);
|
||||||
|
border-bottom: 1px solid var(--divider-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content p {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content strong {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px var(--surface-elevation-2);
|
||||||
|
margin: 16px 0;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content a {
|
||||||
|
color: var(--primary-color);
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: 1px solid transparent;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content a:hover {
|
||||||
|
border-bottom-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content em {
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Action Buttons */
|
||||||
|
.article-actions {
|
||||||
|
margin-bottom: 32px;
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button.primary {
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button.primary:hover {
|
||||||
|
background: var(--primary-hover);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(25, 118, 210, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Comments Section */
|
||||||
|
.comments-section {
|
||||||
|
border-top: 1px solid var(--divider-color);
|
||||||
|
padding-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-header {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Comments */
|
||||||
|
.comment-card {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
transition: margin-left 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-surface {
|
||||||
|
background: var(--surface-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
padding: 16px;
|
||||||
|
box-shadow: 0 1px 3px var(--surface-elevation-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-header {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-author {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-time {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-score {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-number {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-body {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-text {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-text p {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-text img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 2px 6px var(--surface-elevation-1);
|
||||||
|
margin: 12px 0;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-text a {
|
||||||
|
color: var(--primary-color);
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: 1px solid transparent;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-text a:hover {
|
||||||
|
border-bottom-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-text strong {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-text em {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-replies {
|
||||||
|
border-left: 3px solid var(--divider-color);
|
||||||
|
margin-left: 16px;
|
||||||
|
padding-left: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-footer {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.depth-indicator {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Design */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.app-layout {
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: 100%;
|
||||||
|
position: static;
|
||||||
|
max-height: none;
|
||||||
|
order: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-section {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-container {
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-logo {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-brand-text {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.app-layout {
|
||||||
|
padding: 12px;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-surface {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-container {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-title {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-row {
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-card {
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-content {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-subtitle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar Responsive */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.app-layout {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: 100%;
|
||||||
|
position: static;
|
||||||
|
max-height: none;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-section {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.sidebar {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
67
themes/modern-card-ui/theme.json
Normal file
67
themes/modern-card-ui/theme.json
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
{
|
||||||
|
"template_id": "modern-card-ui-theme",
|
||||||
|
"template_path": "./themes/modern-card-ui",
|
||||||
|
"template_type": "card",
|
||||||
|
"data_schema": "../../schemas/post_schema.json",
|
||||||
|
"required_fields": [
|
||||||
|
"platform",
|
||||||
|
"id",
|
||||||
|
"title",
|
||||||
|
"author",
|
||||||
|
"timestamp",
|
||||||
|
"score",
|
||||||
|
"replies",
|
||||||
|
"url"
|
||||||
|
],
|
||||||
|
"optional_fields": [
|
||||||
|
"content",
|
||||||
|
"source",
|
||||||
|
"tags",
|
||||||
|
"meta"
|
||||||
|
],
|
||||||
|
"css_dependencies": [
|
||||||
|
"./themes/modern-card-ui/styles.css"
|
||||||
|
],
|
||||||
|
"js_dependencies": [
|
||||||
|
"./themes/modern-card-ui/interactions.js"
|
||||||
|
],
|
||||||
|
"templates": {
|
||||||
|
"card": "./themes/modern-card-ui/card-template.html",
|
||||||
|
"list": "./themes/modern-card-ui/list-template.html",
|
||||||
|
"detail": "./themes/modern-card-ui/detail-template.html",
|
||||||
|
"comment": "./themes/modern-card-ui/comment-template.html"
|
||||||
|
},
|
||||||
|
"render_options": {
|
||||||
|
"container_selector": "#posts-container",
|
||||||
|
"batch_size": 20,
|
||||||
|
"lazy_load": true,
|
||||||
|
"animate": true,
|
||||||
|
"hover_effects": true,
|
||||||
|
"card_elevation": true
|
||||||
|
},
|
||||||
|
"filters": {
|
||||||
|
"platform": true,
|
||||||
|
"date_range": true,
|
||||||
|
"score_threshold": true,
|
||||||
|
"source": true
|
||||||
|
},
|
||||||
|
"sorting": {
|
||||||
|
"default_field": "timestamp",
|
||||||
|
"default_order": "desc",
|
||||||
|
"available_fields": [
|
||||||
|
"timestamp",
|
||||||
|
"score",
|
||||||
|
"replies",
|
||||||
|
"title"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"color_scheme": {
|
||||||
|
"primary": "#1976D2",
|
||||||
|
"secondary": "#FFFFFF",
|
||||||
|
"accent": "#FF5722",
|
||||||
|
"background": "#FAFAFA",
|
||||||
|
"surface": "#FFFFFF",
|
||||||
|
"text_primary": "#212121",
|
||||||
|
"text_secondary": "#757575"
|
||||||
|
}
|
||||||
|
}
|
||||||
42
themes/vanilla-js/card-template.html
Normal file
42
themes/vanilla-js/card-template.html
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<!-- Card Template - Jinja2 template -->
|
||||||
|
<template id="post-card-template">
|
||||||
|
<article class="post-card" data-post-id="{{id}}" data-platform="{{platform}}">
|
||||||
|
<header class="post-header">
|
||||||
|
<div class="post-meta">
|
||||||
|
<span class="platform-badge platform-{{platform}}">{{platform}}</span>
|
||||||
|
{% if source %}<span class="post-source">{{source}}</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
<h2 class="post-title">
|
||||||
|
<a href="{{post_url}}" target="_blank" rel="noopener">{{title}}</a>
|
||||||
|
</h2>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="post-info">
|
||||||
|
<span class="post-author">by {{author}}</span>
|
||||||
|
<time class="post-time" datetime="{{timestamp}}">{{formatTime(timestamp)}}</time>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="post-content">
|
||||||
|
{% if content %}<p class="post-excerpt">{{ renderMarkdown(content)|safe }}</p>{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="post-footer">
|
||||||
|
<div class="post-stats">
|
||||||
|
<span class="stat-score" title="Score">
|
||||||
|
<i class="icon-score">▲</i> {{score}}
|
||||||
|
</span>
|
||||||
|
<span class="stat-replies" title="Replies">
|
||||||
|
<i class="icon-replies">💬</i> {{replies}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if tags %}
|
||||||
|
<div class="post-tags">
|
||||||
|
{% for tag in tags if tag %}
|
||||||
|
<span class="tag">{{tag}}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
21
themes/vanilla-js/comment-template.html
Normal file
21
themes/vanilla-js/comment-template.html
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<!-- Comment Template - Nested comment rendering with unlimited depth -->
|
||||||
|
<template id="comment-template">
|
||||||
|
<div class="comment" data-comment-uuid="{{uuid}}" data-depth="{{depth}}" style="margin-left: {{depth * 20}}px">
|
||||||
|
<div class="comment-header">
|
||||||
|
<span class="comment-author">{{author}}</span>
|
||||||
|
<time class="comment-time" datetime="{{timestamp}}">{{formatTimeAgo(timestamp)}}</time>
|
||||||
|
<span class="comment-score" title="Score">↑ {{score}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="comment-body">
|
||||||
|
<p class="comment-content">{{renderMarkdown(content)|safe}}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="comment-footer">
|
||||||
|
<span class="comment-depth-indicator">Depth: {{depth}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Placeholder for nested children -->
|
||||||
|
{{children_section|safe}}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
52
themes/vanilla-js/detail-template.html
Normal file
52
themes/vanilla-js/detail-template.html
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<!-- Detail Template - Full post view -->
|
||||||
|
<template id="post-detail-template">
|
||||||
|
<article class="post-detail" data-post-id="{{id}}" data-platform="{{platform}}">
|
||||||
|
<header class="detail-header">
|
||||||
|
<div class="breadcrumb">
|
||||||
|
<span class="platform-badge platform-{{platform}}">{{platform}}</span>
|
||||||
|
<span class="separator">/</span>
|
||||||
|
{% if source %}<span class="source-link">{{source}}</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="detail-title">{{title}}</h1>
|
||||||
|
|
||||||
|
<div class="detail-meta">
|
||||||
|
<div class="author-info">
|
||||||
|
<span class="author-name">{{author}}</span>
|
||||||
|
<time class="post-time" datetime="{{timestamp}}">{{formatDateTime(timestamp)}}</time>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="post-stats">
|
||||||
|
<span class="stat-item">
|
||||||
|
<i class="icon-score">▲</i> {{score}} points
|
||||||
|
</span>
|
||||||
|
<span class="stat-item">
|
||||||
|
<i class="icon-replies">💬</i> {{replies}} comments
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{% if content %}
|
||||||
|
<div class="detail-content">
|
||||||
|
{{ renderMarkdown(content)|safe }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if tags %}
|
||||||
|
<div class="detail-tags">
|
||||||
|
{% for tag in tags if tag %}
|
||||||
|
<span class="tag">{{tag}}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{{comments_section|safe}}
|
||||||
|
|
||||||
|
<footer class="detail-footer">
|
||||||
|
<a href="{{url}}" target="_blank" rel="noopener" class="source-link-btn">
|
||||||
|
View on {{platform}}
|
||||||
|
</a>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
357
themes/vanilla-js/index.html
Normal file
357
themes/vanilla-js/index.html
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>BalanceBoard - Content Feed</title>
|
||||||
|
{% for css_path in theme.css_dependencies %}
|
||||||
|
<link rel="stylesheet" href="{{ css_path }}">
|
||||||
|
{% endfor %}
|
||||||
|
<style>
|
||||||
|
/* Enhanced Navigation Styles */
|
||||||
|
.nav-top {
|
||||||
|
background: var(--surface);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-top-container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 1rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-logo {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-brand-text {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-balance {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-board {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-user-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-user-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-avatar {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-username {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-menu {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-toggle {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-toggle:hover {
|
||||||
|
background: var(--surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-line {
|
||||||
|
width: 20px;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--text-primary);
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
right: 0;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
min-width: 200px;
|
||||||
|
z-index: 1000;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-dropdown.show {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item {
|
||||||
|
display: block;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item:hover {
|
||||||
|
background: var(--surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border);
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-login-prompt {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-nav-login, .btn-nav-signup {
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-nav-login {
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-nav-login:hover {
|
||||||
|
background: var(--surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-nav-signup {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-nav-signup:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.nav-username {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Enhanced Top Navigation -->
|
||||||
|
<nav class="nav-top">
|
||||||
|
<div class="nav-top-container">
|
||||||
|
<a href="/" class="nav-brand">
|
||||||
|
<img src="/logo.png" alt="BalanceBoard Logo" class="nav-logo">
|
||||||
|
<div class="nav-brand-text">
|
||||||
|
<span class="brand-balance">balance</span><span class="brand-board">Board</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="nav-user-section">
|
||||||
|
<!-- Logged in user state -->
|
||||||
|
<div class="nav-user-info" style="display: none;">
|
||||||
|
<div class="nav-avatar">JD</div>
|
||||||
|
<span class="nav-username">johndoe</span>
|
||||||
|
<div class="hamburger-menu">
|
||||||
|
<button class="hamburger-toggle" onclick="toggleDropdown()">
|
||||||
|
<div class="hamburger-line"></div>
|
||||||
|
<div class="hamburger-line"></div>
|
||||||
|
<div class="hamburger-line"></div>
|
||||||
|
</button>
|
||||||
|
<div class="hamburger-dropdown" id="userDropdown">
|
||||||
|
<a href="/settings" class="dropdown-item">
|
||||||
|
⚙️ Settings
|
||||||
|
</a>
|
||||||
|
<a href="/settings/profile" class="dropdown-item">
|
||||||
|
👤 Profile
|
||||||
|
</a>
|
||||||
|
<a href="/settings/communities" class="dropdown-item">
|
||||||
|
🌐 Communities
|
||||||
|
</a>
|
||||||
|
<a href="/settings/filters" class="dropdown-item">
|
||||||
|
🎛️ Filters
|
||||||
|
</a>
|
||||||
|
<div class="dropdown-divider"></div>
|
||||||
|
<a href="/admin" class="dropdown-item" style="display: none;">
|
||||||
|
🛠️ Admin
|
||||||
|
</a>
|
||||||
|
<div class="dropdown-divider" style="display: none;"></div>
|
||||||
|
<a href="/logout" class="dropdown-item">
|
||||||
|
🚪 Sign Out
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Logged out state -->
|
||||||
|
<div class="nav-login-prompt">
|
||||||
|
<a href="/login" class="btn-nav-login">Log In</a>
|
||||||
|
<a href="/signup" class="btn-nav-signup">Sign Up</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="app-layout">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<aside class="sidebar">
|
||||||
|
<!-- User Card -->
|
||||||
|
<div class="sidebar-section user-card">
|
||||||
|
<div class="login-prompt">
|
||||||
|
<div class="user-avatar">?</div>
|
||||||
|
<p>Join BalanceBoard to customize your feed</p>
|
||||||
|
<a href="/login" class="btn-login">Log In</a>
|
||||||
|
<a href="/signup" class="btn-signup">Sign Up</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Navigation -->
|
||||||
|
<div class="sidebar-section">
|
||||||
|
<h3>Navigation</h3>
|
||||||
|
<ul class="nav-menu">
|
||||||
|
<li><a href="/" class="nav-item active">
|
||||||
|
<span class="nav-icon">🏠</span>
|
||||||
|
<span>Home</span>
|
||||||
|
</a></li>
|
||||||
|
<li><a href="#" class="nav-item">
|
||||||
|
<span class="nav-icon">🔥</span>
|
||||||
|
<span>Popular</span>
|
||||||
|
</a></li>
|
||||||
|
<li><a href="#" class="nav-item">
|
||||||
|
<span class="nav-icon">⭐</span>
|
||||||
|
<span>Saved</span>
|
||||||
|
</a></li>
|
||||||
|
<li><a href="#" class="nav-item">
|
||||||
|
<span class="nav-icon">📊</span>
|
||||||
|
<span>Analytics</span>
|
||||||
|
</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="sidebar-section">
|
||||||
|
<h3>Filter by Platform</h3>
|
||||||
|
<div class="filter-tags">
|
||||||
|
<a href="#" class="filter-tag active">All</a>
|
||||||
|
<a href="#" class="filter-tag">Reddit</a>
|
||||||
|
<a href="#" class="filter-tag">HackerNews</a>
|
||||||
|
<a href="#" class="filter-tag">Lobsters</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- About -->
|
||||||
|
<div class="sidebar-section">
|
||||||
|
<h3>About</h3>
|
||||||
|
<p style="font-size: 0.85rem; color: var(--text-secondary); line-height: 1.5;">
|
||||||
|
BalanceBoard filters and curates content from multiple platforms to help you stay informed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<main class="main-content">
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<h1>{{ filterset_name|title or 'All Posts' }}</h1>
|
||||||
|
<p class="post-count">{{ posts|length }} posts</p>
|
||||||
|
</header>
|
||||||
|
<div id="posts-container">
|
||||||
|
{% for post in posts %}
|
||||||
|
{{ post|safe }}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% for js_path in theme.js_dependencies %}
|
||||||
|
<script src="{{ js_path }}"></script>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Dropdown functionality
|
||||||
|
function toggleDropdown() {
|
||||||
|
const dropdown = document.getElementById('userDropdown');
|
||||||
|
dropdown.classList.toggle('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close dropdown when clicking outside
|
||||||
|
document.addEventListener('click', function(event) {
|
||||||
|
const dropdown = document.getElementById('userDropdown');
|
||||||
|
const toggle = document.querySelector('.hamburger-toggle');
|
||||||
|
|
||||||
|
if (!toggle.contains(event.target) && !dropdown.contains(event.target)) {
|
||||||
|
dropdown.classList.remove('show');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check user authentication state (this would be dynamic in a real app)
|
||||||
|
function checkAuthState() {
|
||||||
|
// This would normally check with the server
|
||||||
|
// For now, we'll show the logged out state
|
||||||
|
document.querySelector('.nav-user-info').style.display = 'none';
|
||||||
|
document.querySelector('.nav-login-prompt').style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize on page load
|
||||||
|
document.addEventListener('DOMContentLoaded', checkAuthState);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
22
themes/vanilla-js/list-template.html
Normal file
22
themes/vanilla-js/list-template.html
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<!-- List Template - Compact list view -->
|
||||||
|
<template id="post-list-template">
|
||||||
|
<div class="post-list-item" data-post-id="{{id}}" data-platform="{{platform}}">
|
||||||
|
<div class="post-vote">
|
||||||
|
<span class="vote-score">{{score}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="post-main">
|
||||||
|
<h3 class="post-title">
|
||||||
|
<a href="{{post_url}}" target="_blank" rel="noopener">{{title}}</a>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="post-metadata">
|
||||||
|
<span class="platform-badge platform-{{platform}}">{{platform}}</span>
|
||||||
|
{% if source %}<span class="post-source">{{source}}</span>{% endif %}
|
||||||
|
<span class="post-author">u/{{author}}</span>
|
||||||
|
<time class="post-time" datetime="{{timestamp}}">{{formatTimeAgo(timestamp)}}</time>
|
||||||
|
<span class="post-replies">{{replies}} comments</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
127
themes/vanilla-js/renderer.js
Normal file
127
themes/vanilla-js/renderer.js
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
/**
|
||||||
|
* Vanilla JS Template Renderer
|
||||||
|
* Renders posts using HTML template literals
|
||||||
|
*/
|
||||||
|
|
||||||
|
class VanillaRenderer {
|
||||||
|
constructor() {
|
||||||
|
this.templates = new Map();
|
||||||
|
this.formatters = {
|
||||||
|
formatTime: this.formatTime.bind(this),
|
||||||
|
formatTimeAgo: this.formatTimeAgo.bind(this),
|
||||||
|
formatDateTime: this.formatDateTime.bind(this),
|
||||||
|
truncate: this.truncate.bind(this),
|
||||||
|
renderMarkdown: this.renderMarkdown.bind(this)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load a template from HTML file
|
||||||
|
*/
|
||||||
|
async loadTemplate(templateId, templatePath) {
|
||||||
|
const response = await fetch(templatePath);
|
||||||
|
const html = await response.text();
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(html, 'text/html');
|
||||||
|
const template = doc.querySelector('template');
|
||||||
|
|
||||||
|
if (template) {
|
||||||
|
this.templates.set(templateId, template.innerHTML);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a post using a template
|
||||||
|
*/
|
||||||
|
render(templateId, postData) {
|
||||||
|
const template = this.templates.get(templateId);
|
||||||
|
if (!template) {
|
||||||
|
throw new Error(`Template ${templateId} not loaded`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create context with data and helper functions
|
||||||
|
const context = { ...postData, ...this.formatters };
|
||||||
|
|
||||||
|
// Use Function constructor to evaluate template literal
|
||||||
|
const rendered = new Function(...Object.keys(context), `return \`${template}\`;`)(...Object.values(context));
|
||||||
|
|
||||||
|
return rendered;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render multiple posts
|
||||||
|
*/
|
||||||
|
renderBatch(templateId, posts, container) {
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
|
||||||
|
posts.forEach(post => {
|
||||||
|
const html = this.render(templateId, post);
|
||||||
|
const temp = document.createElement('div');
|
||||||
|
temp.innerHTML = html;
|
||||||
|
fragment.appendChild(temp.firstElementChild);
|
||||||
|
});
|
||||||
|
|
||||||
|
container.appendChild(fragment);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions available in templates
|
||||||
|
|
||||||
|
formatTime(timestamp) {
|
||||||
|
const date = new Date(timestamp * 1000);
|
||||||
|
return date.toLocaleTimeString('en-US', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
formatTimeAgo(timestamp) {
|
||||||
|
const seconds = Math.floor(Date.now() / 1000 - timestamp);
|
||||||
|
|
||||||
|
const intervals = {
|
||||||
|
year: 31536000,
|
||||||
|
month: 2592000,
|
||||||
|
week: 604800,
|
||||||
|
day: 86400,
|
||||||
|
hour: 3600,
|
||||||
|
minute: 60
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [unit, secondsInUnit] of Object.entries(intervals)) {
|
||||||
|
const interval = Math.floor(seconds / secondsInUnit);
|
||||||
|
if (interval >= 1) {
|
||||||
|
return `${interval} ${unit}${interval !== 1 ? 's' : ''} ago`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'just now';
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDateTime(timestamp) {
|
||||||
|
const date = new Date(timestamp * 1000);
|
||||||
|
return date.toLocaleString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
truncate(text, maxLength) {
|
||||||
|
if (text.length <= maxLength) return text;
|
||||||
|
return text.substring(0, maxLength).trim() + '...';
|
||||||
|
}
|
||||||
|
|
||||||
|
renderMarkdown(text) {
|
||||||
|
// Basic markdown rendering (expand as needed)
|
||||||
|
return text
|
||||||
|
.replace(/\n\n/g, '</p><p>')
|
||||||
|
.replace(/\n/g, '<br>')
|
||||||
|
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
||||||
|
.replace(/\*(.*?)\*/g, '<em>$1</em>')
|
||||||
|
.replace(/\[(.*?)\]\((.*?)\)/g, '<a href="$2" target="_blank">$1</a>');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export for use
|
||||||
|
export default VanillaRenderer;
|
||||||
336
themes/vanilla-js/styles.css
Normal file
336
themes/vanilla-js/styles.css
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
/* Vanilla JS Theme Styles */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg-primary: #ffffff;
|
||||||
|
--bg-secondary: #f6f7f8;
|
||||||
|
--bg-hover: #f0f1f2;
|
||||||
|
--text-primary: #1c1c1c;
|
||||||
|
--text-secondary: #7c7c7c;
|
||||||
|
--border-color: #e0e0e0;
|
||||||
|
--accent-reddit: #ff4500;
|
||||||
|
--accent-hn: #ff6600;
|
||||||
|
--accent-lobsters: #990000;
|
||||||
|
--accent-se: #0077cc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card Template Styles */
|
||||||
|
.post-card {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
transition: box-shadow 0.2s, transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-card:hover {
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-header {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-reddit { background: var(--accent-reddit); color: white; }
|
||||||
|
.platform-hackernews { background: var(--accent-hn); color: white; }
|
||||||
|
.platform-lobsters { background: var(--accent-lobsters); color: white; }
|
||||||
|
.platform-stackexchange { background: var(--accent-se); color: white; }
|
||||||
|
|
||||||
|
.post-source {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-title {
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-title a {
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-title a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-info {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-content {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-excerpt {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-score,
|
||||||
|
.stat-replies {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-tags {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* List Template Styles */
|
||||||
|
.post-list-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-list-item:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-vote {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vote-score {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-main {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-list-item .post-title {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-metadata {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Detail Template Styles */
|
||||||
|
.post-detail {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.separator {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-title {
|
||||||
|
font-size: 32px;
|
||||||
|
line-height: 1.3;
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 0;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-content {
|
||||||
|
line-height: 1.8;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-content p {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-tags {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-footer {
|
||||||
|
padding-top: 24px;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-link-btn {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 12px 24px;
|
||||||
|
background: var(--accent-reddit);
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-link-btn:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Comment Styles */
|
||||||
|
.comments-section {
|
||||||
|
margin-top: 32px;
|
||||||
|
padding-top: 24px;
|
||||||
|
border-top: 2px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-left: 2px solid var(--border-color);
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-author {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-time {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-score {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-body {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-content {
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-content p {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-footer {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-depth-indicator {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-children {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Depth-based styling */
|
||||||
|
.comment[data-depth="0"] {
|
||||||
|
border-left-color: var(--accent-reddit);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment[data-depth="1"] {
|
||||||
|
border-left-color: var(--accent-hn);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment[data-depth="2"] {
|
||||||
|
border-left-color: var(--accent-lobsters);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment[data-depth="3"] {
|
||||||
|
border-left-color: var(--accent-se);
|
||||||
|
}
|
||||||
56
themes/vanilla-js/theme.json
Normal file
56
themes/vanilla-js/theme.json
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
{
|
||||||
|
"template_id": "vanilla-js-theme",
|
||||||
|
"template_path": "./themes/vanilla-js",
|
||||||
|
"template_type": "card",
|
||||||
|
"data_schema": "../../schemas/post_schema.json",
|
||||||
|
"required_fields": [
|
||||||
|
"platform",
|
||||||
|
"id",
|
||||||
|
"title",
|
||||||
|
"author",
|
||||||
|
"timestamp",
|
||||||
|
"score",
|
||||||
|
"replies",
|
||||||
|
"url"
|
||||||
|
],
|
||||||
|
"optional_fields": [
|
||||||
|
"content",
|
||||||
|
"source",
|
||||||
|
"tags",
|
||||||
|
"meta"
|
||||||
|
],
|
||||||
|
"css_dependencies": [
|
||||||
|
"./themes/vanilla-js/styles.css"
|
||||||
|
],
|
||||||
|
"js_dependencies": [
|
||||||
|
"./themes/vanilla-js/renderer.js"
|
||||||
|
],
|
||||||
|
"templates": {
|
||||||
|
"card": "./themes/vanilla-js/card-template.html",
|
||||||
|
"list": "./themes/vanilla-js/list-template.html",
|
||||||
|
"detail": "./themes/vanilla-js/detail-template.html",
|
||||||
|
"comment": "./themes/vanilla-js/comment-template.html"
|
||||||
|
},
|
||||||
|
"render_options": {
|
||||||
|
"container_selector": "#posts-container",
|
||||||
|
"batch_size": 50,
|
||||||
|
"lazy_load": true,
|
||||||
|
"animate": true
|
||||||
|
},
|
||||||
|
"filters": {
|
||||||
|
"platform": true,
|
||||||
|
"date_range": true,
|
||||||
|
"score_threshold": true,
|
||||||
|
"source": true
|
||||||
|
},
|
||||||
|
"sorting": {
|
||||||
|
"default_field": "timestamp",
|
||||||
|
"default_order": "desc",
|
||||||
|
"available_fields": [
|
||||||
|
"timestamp",
|
||||||
|
"score",
|
||||||
|
"replies",
|
||||||
|
"title"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
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