Compare commits

...

14 Commits

Author SHA1 Message Date
51911f2c48 Add password reset mechanism (Issue #1)
- Added reset_token and reset_token_expiry fields to User model
- Implemented generate_reset_token(), verify_reset_token(), and clear_reset_token() methods
- Created password reset request form (/password-reset-request)
- Created password reset form (/password-reset/<token>)
- Added "Forgot password?" link to login page
- Reset tokens expire after 1 hour for security
- Created migration script to add new database columns
- Reset links are logged (would be emailed in production)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 18:46:18 -05:00
a1d8c9d373 Implement anonymous account mode (Issue #2)
- Modified index route to allow browsing without login
- Set default user_settings for anonymous users with safe defaults
- Added anonymous flag to dashboard template
- Updated navigation to show Login/Sign Up buttons for anonymous users
- Changed feed header to "Public Feed" for anonymous browsing
- Hide Customize button for anonymous users

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 18:42:28 -05:00
36bb905f99 Add edit modal and diverse polling settings UI
- Add Edit button for each poll source
- Modal dialog for editing all source settings
- Add max_posts, fetch_comments, priority fields to add form
- Display source settings in source list
- JavaScript modal management for editing

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 18:36:53 -05:00
f477a074a2 Add poll source editing and expanded settings
- Update admin_polling_add to accept max_posts, fetch_comments, priority
- Enhance admin_polling_update to modify all configurable fields
- Support editing display_name, interval, max_posts, fetch_comments, priority

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 18:36:43 -05:00
99d51fe14a Use source-specific polling settings in collection
- Read max_posts from source.max_posts (fallback to 100)
- Read fetch_comments from source settings
- Allows customizing collection per source

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 18:36:32 -05:00
5d3b01926c Add polling configuration fields to PollSource model
- Add max_posts field (default 100)
- Add fetch_comments boolean (default true)
- Add priority field (low/medium/high, default medium)
- Enables per-source control of collection settings

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 18:36:17 -05:00
066d90ea53 Remove iframe-like scrolling from feed container
- Remove max-height and overflow-y from feed container
- Allows natural page scrolling instead of nested scroll
- Improves browsing experience

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 18:28:29 -05:00
eead6033e2 Fix polling to use 24-hour window instead of resume feature
- Polling now always collects posts from last 24 hours
- Removes resume feature that created too-narrow time windows
- Fixes issue where polls returned 0 posts due to minutes-wide ranges

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 18:23:43 -05:00
3849da68bd Add custom source input for manually adding communities
- Add 'Custom Source' text input field to polling form
- Allows manual entry of subreddits (r/subreddit) or RSS URLs
- Custom input overrides dropdown selection if filled
- Dropdown becomes optional when custom source is entered
- Backend prioritizes custom_source_id over dropdown source_id

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 18:10:24 -05:00
1ecb0512b0 Fix label and data directory permissions
- Change 'Poll Interval (minutes)' to just 'Poll Interval'
- Create data subdirectories with correct permissions on server

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 18:04:36 -05:00
7084e01aa4 Set poll sources disabled by default with better interval options
- Poll sources now created with enabled=False by default
- Admin must manually enable sources after adding them
- Replace numeric interval input with dropdown: 15min to 24hr options
- Default interval is 1 hour
- Fix avatar upload form with proper multipart/form-data encoding

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 18:00:17 -05:00
2d633bebc6 Fix avatar upload form - use proper multipart form
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 17:57:16 -05:00
278d9c606a Fix 404 when logged out - redirect to login
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 17:46:22 -05:00
62001d08a4 Add themes, static assets, and logo
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 17:38:19 -05:00
28 changed files with 3300 additions and 48 deletions

126
app.py
View File

@@ -273,11 +273,21 @@ def index():
except (json.JSONDecodeError, TypeError) as e:
logger.warning(f"Invalid user settings JSON for user {current_user.id}: {e}")
user_settings = {}
return render_template('dashboard.html', user_settings=user_settings)
else:
# For non-authenticated users, serve static content
return send_from_directory('active_html/no_filter', 'index.html')
# Anonymous mode - allow browsing with default settings
user_settings = {
'filter_set': 'no_filter',
'communities': [],
'experience': {
'infinite_scroll': False,
'auto_refresh': False,
'push_notifications': False,
'dark_patterns_opt_in': False
}
}
return render_template('dashboard.html', user_settings=user_settings, anonymous=True)
@app.route('/feed/<filterset>')
@@ -620,6 +630,78 @@ def login():
return render_template('login.html')
@app.route('/password-reset-request', methods=['GET', 'POST'])
def password_reset_request():
"""Request a password reset"""
if current_user.is_authenticated:
return redirect(url_for('index'))
if request.method == 'POST':
email = request.form.get('email', '').strip().lower()
if not email:
flash('Please enter your email address', 'error')
return render_template('password_reset_request.html')
# Find user by email
user = User.query.filter_by(email=email).first()
# Always show success message for security (don't reveal if email exists)
flash('If an account exists with that email, a password reset link has been sent.', 'success')
if user and user.password_hash: # Only send reset if user has a password (not OAuth only)
# Generate reset token
token = user.generate_reset_token()
# Build reset URL
reset_url = url_for('password_reset', token=token, _external=True)
# Log the reset URL (in production, this would be emailed)
logger.info(f"Password reset requested for {email}. Reset URL: {reset_url}")
# For now, also flash it for development (remove in production)
flash(f'Reset link (development only): {reset_url}', 'info')
return redirect(url_for('login'))
return render_template('password_reset_request.html')
@app.route('/password-reset/<token>', methods=['GET', 'POST'])
def password_reset(token):
"""Reset password with token"""
if current_user.is_authenticated:
return redirect(url_for('index'))
# Find user by token
user = User.query.filter_by(reset_token=token).first()
if not user or not user.verify_reset_token(token):
flash('Invalid or expired reset token', 'error')
return redirect(url_for('login'))
if request.method == 'POST':
password = request.form.get('password', '')
confirm_password = request.form.get('confirm_password', '')
if not password or len(password) < 6:
flash('Password must be at least 6 characters', 'error')
return render_template('password_reset.html')
if password != confirm_password:
flash('Passwords do not match', 'error')
return render_template('password_reset.html')
# Set new password
user.set_password(password)
user.clear_reset_token()
flash('Your password has been reset successfully. You can now log in.', 'success')
return redirect(url_for('login'))
return render_template('password_reset.html')
# Auth0 Routes
@app.route('/auth0/login')
def auth0_login():
@@ -1347,8 +1429,16 @@ def admin_polling_add():
platform = request.form.get('platform')
source_id = request.form.get('source_id')
custom_source_id = request.form.get('custom_source_id')
display_name = request.form.get('display_name')
poll_interval = int(request.form.get('poll_interval', 60))
max_posts = int(request.form.get('max_posts', 100))
fetch_comments = request.form.get('fetch_comments', 'true') == 'true'
priority = request.form.get('priority', 'medium')
# Use custom source if provided, otherwise use dropdown
if custom_source_id and custom_source_id.strip():
source_id = custom_source_id.strip()
if not platform or not source_id or not display_name:
flash('Missing required fields', 'error')
@@ -1360,13 +1450,16 @@ def admin_polling_add():
flash(f'Source {platform}:{source_id} already exists', 'warning')
return redirect(url_for('admin_polling'))
# Create new source
# Create new source (disabled by default)
source = PollSource(
platform=platform,
source_id=source_id,
display_name=display_name,
poll_interval_minutes=poll_interval,
enabled=True,
max_posts=max_posts,
fetch_comments=fetch_comments,
priority=priority,
enabled=False,
created_by=current_user.id
)
@@ -1418,11 +1511,24 @@ def admin_polling_update(source_id):
flash('Source not found', 'error')
return redirect(url_for('admin_polling'))
poll_interval = request.form.get('poll_interval')
if poll_interval:
source.poll_interval_minutes = int(poll_interval)
db.session.commit()
flash(f'Updated interval for {source.display_name}', 'success')
# Update all configurable fields
if request.form.get('poll_interval'):
source.poll_interval_minutes = int(request.form.get('poll_interval'))
if request.form.get('max_posts'):
source.max_posts = int(request.form.get('max_posts'))
if request.form.get('fetch_comments') is not None:
source.fetch_comments = request.form.get('fetch_comments') == 'true'
if request.form.get('priority'):
source.priority = request.form.get('priority')
if request.form.get('display_name'):
source.display_name = request.form.get('display_name')
db.session.commit()
flash(f'Updated settings for {source.display_name}', 'success')
return redirect(url_for('admin_polling'))

BIN
logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 407 KiB

54
migrate_password_reset.py Normal file
View File

@@ -0,0 +1,54 @@
#!/usr/bin/env python3
"""
Database migration to add password reset fields to users table.
Run this once to add the new columns for password reset functionality.
"""
import sys
from app import app, db
def migrate():
"""Add password reset columns to users table"""
with app.app_context():
try:
# Check if columns already exist
from sqlalchemy import inspect
inspector = inspect(db.engine)
columns = [col['name'] for col in inspector.get_columns('users')]
if 'reset_token' in columns and 'reset_token_expiry' in columns:
print("✓ Password reset columns already exist")
return True
# Add the new columns using raw SQL
with db.engine.connect() as conn:
if 'reset_token' not in columns:
print("Adding reset_token column...")
conn.execute(db.text(
"ALTER TABLE users ADD COLUMN reset_token VARCHAR(100) UNIQUE"
))
conn.execute(db.text(
"CREATE INDEX IF NOT EXISTS ix_users_reset_token ON users(reset_token)"
))
conn.commit()
if 'reset_token_expiry' not in columns:
print("Adding reset_token_expiry column...")
conn.execute(db.text(
"ALTER TABLE users ADD COLUMN reset_token_expiry TIMESTAMP"
))
conn.commit()
print("✓ Password reset columns added successfully")
return True
except Exception as e:
print(f"✗ Migration failed: {e}")
import traceback
traceback.print_exc()
return False
if __name__ == '__main__':
print("Running password reset migration...")
success = migrate()
sys.exit(0 if success else 1)

View File

@@ -41,6 +41,10 @@ class User(UserMixin, db.Model):
# User settings (JSON stored as text)
settings = db.Column(db.Text, default='{}')
# Password reset
reset_token = db.Column(db.String(100), nullable=True, unique=True, index=True)
reset_token_expiry = db.Column(db.DateTime, nullable=True)
def __init__(self, username, email, password=None, is_admin=False, auth0_id=None):
"""
Initialize a new user.
@@ -102,6 +106,32 @@ class User(UserMixin, db.Model):
self.last_login = datetime.utcnow()
db.session.commit()
def generate_reset_token(self):
"""Generate a password reset token that expires in 1 hour"""
import secrets
from datetime import timedelta
self.reset_token = secrets.token_urlsafe(32)
self.reset_token_expiry = datetime.utcnow() + timedelta(hours=1)
db.session.commit()
return self.reset_token
def verify_reset_token(self, token):
"""Verify if the provided reset token is valid and not expired"""
if not self.reset_token or not self.reset_token_expiry:
return False
if self.reset_token != token:
return False
if datetime.utcnow() > self.reset_token_expiry:
return False
return True
def clear_reset_token(self):
"""Clear the reset token after use"""
self.reset_token = None
self.reset_token_expiry = None
db.session.commit()
def get_id(self):
"""Required by Flask-Login"""
return self.id
@@ -140,6 +170,9 @@ class PollSource(db.Model):
# 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
max_posts = db.Column(db.Integer, default=100, nullable=False) # Max posts per poll
fetch_comments = db.Column(db.Boolean, default=True, nullable=False) # Whether to fetch comments
priority = db.Column(db.String(20), default='medium', nullable=False) # low, medium, high
# Status tracking
last_poll_time = db.Column(db.DateTime, nullable=True)

View File

@@ -145,25 +145,30 @@ class PollingService:
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
from data_collection import ensure_directories, load_index, save_index, load_state, save_state
from datetime import datetime, timedelta
# 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)
# Calculate date range - always use last 24 hours for polling
# Don't use the resume feature as it can create too narrow windows
end_date = datetime.now()
start_date = end_date - timedelta(hours=24)
start_iso = start_date.isoformat()
end_iso = end_date.isoformat()
try:
# Call the existing collect_platform function
# Call the existing collect_platform function using source settings
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,
max_posts=source.max_posts or 100,
fetch_comments=source.fetch_comments if hasattr(source, 'fetch_comments') else True,
index=index,
dirs=dirs
)

View File

@@ -223,6 +223,13 @@
<select class="form-select" name="source_id" id="source_id" required onchange="updateDisplayName()">
<option value="">Select source...</option>
</select>
<p class="help-text">Or enter custom source below</p>
</div>
<div class="form-group">
<label class="form-label" for="custom_source_id">Custom Source (optional)</label>
<input type="text" class="form-input" name="custom_source_id" id="custom_source_id" placeholder="e.g., r/technology or https://example.com/feed.xml">
<p class="help-text">For Reddit: r/subreddit | For RSS: full URL | Leave blank to use dropdown</p>
</div>
<div class="form-group">
@@ -231,8 +238,46 @@
</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>
<label class="form-label" for="poll_interval">Poll Interval</label>
<select class="form-select" name="poll_interval" id="poll_interval" required>
<option value="15">15 minutes</option>
<option value="30">30 minutes</option>
<option value="60" selected>1 hour</option>
<option value="120">2 hours</option>
<option value="240">4 hours</option>
<option value="360">6 hours</option>
<option value="720">12 hours</option>
<option value="1440">24 hours</option>
</select>
</div>
<div class="form-group">
<label class="form-label" for="max_posts">Max Posts Per Poll</label>
<select class="form-select" name="max_posts" id="max_posts">
<option value="25">25 posts</option>
<option value="50">50 posts</option>
<option value="100" selected>100 posts</option>
<option value="200">200 posts</option>
<option value="500">500 posts</option>
</select>
</div>
<div class="form-group">
<label class="form-label" for="fetch_comments">Fetch Comments</label>
<select class="form-select" name="fetch_comments" id="fetch_comments">
<option value="true" selected>Yes - Fetch comments</option>
<option value="false">No - Posts only</option>
</select>
</div>
<div class="form-group">
<label class="form-label" for="priority">Priority</label>
<select class="form-select" name="priority" id="priority">
<option value="low">Low</option>
<option value="medium" selected>Medium</option>
<option value="high">High</option>
</select>
<p class="help-text">Higher priority sources poll more reliably during load</p>
</div>
<button type="submit" class="btn btn-primary">Add Source</button>
@@ -299,6 +344,8 @@
{% endif %}
<div class="source-actions">
<button onclick="openEditModal('{{ source.id }}', '{{ source.display_name }}', {{ source.poll_interval_minutes }}, {{ source.max_posts or 100 }}, {{ 'true' if source.fetch_comments else 'false' }}, '{{ source.priority or 'medium' }}')" class="btn btn-secondary">⚙️ Edit</button>
<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 %}
@@ -361,6 +408,95 @@
displayNameInput.value = selectedOption.dataset.displayName;
}
}
// Handle custom source input - make dropdown optional when custom is filled
document.getElementById('custom_source_id').addEventListener('input', function() {
const sourceSelect = document.getElementById('source_id');
if (this.value.trim()) {
sourceSelect.removeAttribute('required');
} else {
sourceSelect.setAttribute('required', 'required');
}
});
function openEditModal(sourceId, displayName, interval, maxPosts, fetchComments, priority) {
const modal = document.getElementById('edit-modal');
if (!modal) {
// Create modal HTML
const modalHTML = `
<div id="edit-modal" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 1000;">
<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); background: white; padding: 24px; border-radius: 8px; max-width: 500px; width: 90%;">
<h3>Edit Poll Source</h3>
<form id="edit-form" action="" method="POST">
<div class="form-group">
<label>Display Name</label>
<input type="text" name="display_name" id="edit_display_name" class="form-input" required>
</div>
<div class="form-group">
<label>Poll Interval</label>
<select name="poll_interval" id="edit_interval" class="form-select">
<option value="15">15 minutes</option>
<option value="30">30 minutes</option>
<option value="60">1 hour</option>
<option value="120">2 hours</option>
<option value="240">4 hours</option>
<option value="360">6 hours</option>
<option value="720">12 hours</option>
<option value="1440">24 hours</option>
</select>
</div>
<div class="form-group">
<label>Max Posts</label>
<select name="max_posts" id="edit_max_posts" class="form-select">
<option value="25">25 posts</option>
<option value="50">50 posts</option>
<option value="100">100 posts</option>
<option value="200">200 posts</option>
<option value="500">500 posts</option>
</select>
</div>
<div class="form-group">
<label>Fetch Comments</label>
<select name="fetch_comments" id="edit_fetch_comments" class="form-select">
<option value="true">Yes</option>
<option value="false">No</option>
</select>
</div>
<div class="form-group">
<label>Priority</label>
<select name="priority" id="edit_priority" class="form-select">
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</div>
<div style="display: flex; gap: 8px; margin-top: 16px;">
<button type="submit" class="btn btn-primary">Save Changes</button>
<button type="button" onclick="closeEditModal()" class="btn btn-secondary">Cancel</button>
</div>
</form>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', modalHTML);
}
// Fill form with current values
const modal2 = document.getElementById('edit-modal');
const form = document.getElementById('edit-form');
form.action = `/admin/polling/${sourceId}/update`;
document.getElementById('edit_display_name').value = displayName;
document.getElementById('edit_interval').value = interval;
document.getElementById('edit_max_posts').value = maxPosts;
document.getElementById('edit_fetch_comments').value = fetchComments;
document.getElementById('edit_priority').value = priority;
modal2.style.display = 'block';
}
function closeEditModal() {
document.getElementById('edit-modal').style.display = 'none';
}
</script>
</body>
</html>

View File

@@ -21,25 +21,32 @@
</div>
<div class="nav-right">
<div class="user-menu">
<div class="user-info">
<div class="user-avatar">
{% if current_user.profile_picture_url %}
<img src="{{ current_user.profile_picture_url }}" alt="Avatar">
{% else %}
<div class="avatar-placeholder">{{ current_user.username[:2].upper() }}</div>
{% endif %}
{% if anonymous %}
<div class="anonymous-actions">
<a href="{{ url_for('login') }}" class="login-btn">🔑 Login</a>
<a href="{{ url_for('register') }}" class="register-btn">📝 Sign Up</a>
</div>
{% else %}
<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>
<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>
{% endif %}
</div>
</div>
</nav>
@@ -90,10 +97,12 @@
<!-- Content Feed -->
<section class="content-section">
<div class="content-header">
<h1>Your Feed</h1>
<h1>{% if anonymous %}Public Feed{% else %}Your Feed{% endif %}</h1>
<div class="content-actions">
<button class="refresh-btn" onclick="refreshFeed()">🔄 Refresh</button>
<a href="{{ url_for('settings_filters') }}" class="filter-btn">🔧 Customize</a>
{% if not anonymous %}
<a href="{{ url_for('settings_filters') }}" class="filter-btn">🔧 Customize</a>
{% endif %}
</div>
</div>
@@ -440,8 +449,6 @@
.feed-container {
padding: 0;
max-height: calc(100vh - 200px);
overflow-y: auto;
}
.loading {

View File

@@ -37,6 +37,10 @@
<label for="remember" style="margin-bottom: 0;">Remember me</label>
</div>
<div style="text-align: right; margin-bottom: 16px;">
<a href="{{ url_for('password_reset_request') }}" style="color: var(--primary-color); text-decoration: none; font-size: 14px;">Forgot password?</a>
</div>
<button type="submit">Log In</button>
</form>

View File

@@ -0,0 +1,43 @@
{% extends "base.html" %}
{% block title %}Set New Password - BalanceBoard{% endblock %}
{% block content %}
<div class="auth-container">
<div class="auth-card">
<div class="auth-logo">
<img src="{{ url_for('serve_logo') }}" alt="BalanceBoard Logo">
<h1><span class="balance">balance</span>Board</h1>
<p style="color: var(--text-secondary); margin-top: 8px;">Set a new password</p>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flash-messages">
{% for category, message in messages %}
<div class="flash-message {{ category }}">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<form method="POST" class="auth-form">
<div class="form-group">
<label for="password">New Password</label>
<input type="password" id="password" name="password" required autofocus minlength="6">
</div>
<div class="form-group">
<label for="confirm_password">Confirm New Password</label>
<input type="password" id="confirm_password" name="confirm_password" required minlength="6">
</div>
<button type="submit">Reset Password</button>
</form>
<div class="auth-footer">
<p>Remember your password? <a href="{{ url_for('login') }}">Log in</a></p>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,41 @@
{% extends "base.html" %}
{% block title %}Reset Password - BalanceBoard{% endblock %}
{% block content %}
<div class="auth-container">
<div class="auth-card">
<div class="auth-logo">
<img src="{{ url_for('serve_logo') }}" alt="BalanceBoard Logo">
<h1><span class="balance">balance</span>Board</h1>
<p style="color: var(--text-secondary); margin-top: 8px;">Reset your password</p>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flash-messages">
{% for category, message in messages %}
<div class="flash-message {{ category }}">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<form method="POST" class="auth-form">
<div class="form-group">
<label for="email">Email Address</label>
<input type="email" id="email" name="email" required autofocus>
<small style="color: var(--text-secondary); display: block; margin-top: 4px;">
Enter the email address associated with your account and we'll send you a password reset link.
</small>
</div>
<button type="submit">Send Reset Link</button>
</form>
<div class="auth-footer">
<p>Remember your password? <a href="{{ url_for('login') }}">Log in</a></p>
</div>
</div>
</div>
{% endblock %}

View File

@@ -256,20 +256,18 @@
<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>
<form id="upload-form" method="POST" action="{{ url_for('upload_avatar') }}" enctype="multipart/form-data">
<div class="file-upload">
<input type="file" id="avatar" name="avatar" accept="image/*" onchange="this.form.submit()">
<label for="avatar" class="file-upload-label">Choose New Picture</label>
</div>
<p class="help-text">PNG, JPG, or GIF. Maximum size 2MB.</p>
</form>
</div>
</div>
</div>
</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>

View 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>

View 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>

View 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>

View 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>

View 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();
}
})();

View 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>

View 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;
}
}

View 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"
}
}

120
themes/template_prompt.txt Normal file
View File

@@ -0,0 +1,120 @@
# Template Creation Prompt for AI
This document describes the data structures, helper functions, and conventions an AI needs to create or modify HTML templates for this social media archive system.
## Data Structures Available
### Post Data (when rendering posts)
- **Available in all post templates (card, list, detail):**
- platform: string (e.g., "reddit", "hackernews")
- id: string (unique post identifier)
- title: string
- author: string
- timestamp: integer (unix timestamp)
- score: integer (up/down vote score)
- replies: integer (number of comments)
- url: string (original post URL)
- content: string (optional post body text)
- source: string (optional subreddit/community)
- tags: array of strings (optional tags/flair)
- meta: object (optional platform-specific metadata)
- comments: array (optional nested comment tree - only in detail templates)
- post_url: string (generated: "{uuid}.html" - for local linking to detail pages)
### Comment Data (when rendering comments)
- **Available in comment templates:**
- uuid: string (unique comment identifier)
- id: string (platform-specific identifier)
- author: string (comment author username)
- content: string (comment text)
- timestamp: integer (unix timestamp)
- score: integer (comment score)
- platform: string
- depth: integer (nesting level)
- children: array (nested replies)
- children_section: string (pre-rendered HTML of nested children)
## Template Engine: Jinja2
Templates use Jinja2 syntax (`{{ }}` for variables, `{% %}` for control flow).
### Important Filters:
- `|safe`: Mark content as safe HTML (for already-escaped content)
- Example: `{{ renderMarkdown(content)|safe }}`
### Available Control Structures:
- `{% if variable %}...{% endif %}`
- `{% for item in array %}...{% endfor %}`
- `{% set variable = value %}` (create local variables)
## Helper Functions Available
Call these in templates using `{{ function(arg) }}`:
### Time/Date Formatting:
- `formatTime(timestamp)` -> "HH:MM"
- `formatTimeAgo(timestamp)` -> "2 hours ago"
- `formatDateTime(timestamp)` -> "January 15, 2024 at 14:30"
### Text Processing:
- `truncate(text, max_length)` -> truncated string with "..."
- `escapeHtml(text)` -> HTML-escaped version
### Content Rendering:
- `renderMarkdown(text)` -> Basic HTML from markdown (returns already-escaped HTML)
## Template Types
### Card Template (for index/listing pages)
- Used for summary view of posts
- Links should use `post_url` to point to local detail pages
- Keep concise - truncated content, basic info
### List Template (compact listing)
- Even more compact than cards
- Vote scores, basic metadata, title link
### Detail Template (full post view)
- Full content, meta information
- Source link uses `url` (external)
- Must include `{{comments_section|safe}}` for rendered comments
### Comment Template (nested comments)
- Recursive rendering with depth styling
- Children rendered as flattened HTML in `children_section`
## Convenience Data Added by System
In `generate_html.py`, `post_url` is added to each post before rendering: `{post['uuid']}.html`
This allows templates to link to local detail pages instead of external Reddit.
## CSS Classes Convention
Templates use semantic CSS classes:
- Post cards: `.post-card`, `.post-header`, `.post-meta`, etc.
- Comments: `.comment`, `.comment-header`, `.comment-body`, etc.
- Platform: `.platform-{platform}` for platform-specific styling
## Examples
### Conditional Rendering:
```
{% if content %}
<p class="content">{{ renderMarkdown(content)|safe }}</p>
{% endif %}
```
### Looping Tags:
```
{% for tag in tags if tag %}
<span class="tag">{{ tag }}</span>
{% endfor %}
```
### Styling by Depth (comments):
```
<div class="comment" style="margin-left: {{depth * 20}}px">
```
When creating new templates, follow these patterns and use the available data and helper functions appropriately.

View 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>

View 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>

View 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>

View 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>

View 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>

View 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;

View 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);
}

View 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"
]
}
}