Compare commits
14 Commits
5c00a99523
...
51911f2c48
| Author | SHA1 | Date | |
|---|---|---|---|
| 51911f2c48 | |||
| a1d8c9d373 | |||
| 36bb905f99 | |||
| f477a074a2 | |||
| 99d51fe14a | |||
| 5d3b01926c | |||
| 066d90ea53 | |||
| eead6033e2 | |||
| 3849da68bd | |||
| 1ecb0512b0 | |||
| 7084e01aa4 | |||
| 2d633bebc6 | |||
| 278d9c606a | |||
| 62001d08a4 |
126
app.py
126
app.py
@@ -273,11 +273,21 @@ def index():
|
|||||||
except (json.JSONDecodeError, TypeError) as e:
|
except (json.JSONDecodeError, TypeError) as e:
|
||||||
logger.warning(f"Invalid user settings JSON for user {current_user.id}: {e}")
|
logger.warning(f"Invalid user settings JSON for user {current_user.id}: {e}")
|
||||||
user_settings = {}
|
user_settings = {}
|
||||||
|
|
||||||
return render_template('dashboard.html', user_settings=user_settings)
|
return render_template('dashboard.html', user_settings=user_settings)
|
||||||
else:
|
else:
|
||||||
# For non-authenticated users, serve static content
|
# Anonymous mode - allow browsing with default settings
|
||||||
return send_from_directory('active_html/no_filter', 'index.html')
|
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>')
|
@app.route('/feed/<filterset>')
|
||||||
@@ -620,6 +630,78 @@ def login():
|
|||||||
return render_template('login.html')
|
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
|
# Auth0 Routes
|
||||||
@app.route('/auth0/login')
|
@app.route('/auth0/login')
|
||||||
def auth0_login():
|
def auth0_login():
|
||||||
@@ -1347,8 +1429,16 @@ def admin_polling_add():
|
|||||||
|
|
||||||
platform = request.form.get('platform')
|
platform = request.form.get('platform')
|
||||||
source_id = request.form.get('source_id')
|
source_id = request.form.get('source_id')
|
||||||
|
custom_source_id = request.form.get('custom_source_id')
|
||||||
display_name = request.form.get('display_name')
|
display_name = request.form.get('display_name')
|
||||||
poll_interval = int(request.form.get('poll_interval', 60))
|
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:
|
if not platform or not source_id or not display_name:
|
||||||
flash('Missing required fields', 'error')
|
flash('Missing required fields', 'error')
|
||||||
@@ -1360,13 +1450,16 @@ def admin_polling_add():
|
|||||||
flash(f'Source {platform}:{source_id} already exists', 'warning')
|
flash(f'Source {platform}:{source_id} already exists', 'warning')
|
||||||
return redirect(url_for('admin_polling'))
|
return redirect(url_for('admin_polling'))
|
||||||
|
|
||||||
# Create new source
|
# Create new source (disabled by default)
|
||||||
source = PollSource(
|
source = PollSource(
|
||||||
platform=platform,
|
platform=platform,
|
||||||
source_id=source_id,
|
source_id=source_id,
|
||||||
display_name=display_name,
|
display_name=display_name,
|
||||||
poll_interval_minutes=poll_interval,
|
poll_interval_minutes=poll_interval,
|
||||||
enabled=True,
|
max_posts=max_posts,
|
||||||
|
fetch_comments=fetch_comments,
|
||||||
|
priority=priority,
|
||||||
|
enabled=False,
|
||||||
created_by=current_user.id
|
created_by=current_user.id
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1418,11 +1511,24 @@ def admin_polling_update(source_id):
|
|||||||
flash('Source not found', 'error')
|
flash('Source not found', 'error')
|
||||||
return redirect(url_for('admin_polling'))
|
return redirect(url_for('admin_polling'))
|
||||||
|
|
||||||
poll_interval = request.form.get('poll_interval')
|
# Update all configurable fields
|
||||||
if poll_interval:
|
if request.form.get('poll_interval'):
|
||||||
source.poll_interval_minutes = int(poll_interval)
|
source.poll_interval_minutes = int(request.form.get('poll_interval'))
|
||||||
db.session.commit()
|
|
||||||
flash(f'Updated interval for {source.display_name}', 'success')
|
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'))
|
return redirect(url_for('admin_polling'))
|
||||||
|
|
||||||
|
|||||||
54
migrate_password_reset.py
Normal file
54
migrate_password_reset.py
Normal 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)
|
||||||
33
models.py
33
models.py
@@ -41,6 +41,10 @@ class User(UserMixin, db.Model):
|
|||||||
# User settings (JSON stored as text)
|
# User settings (JSON stored as text)
|
||||||
settings = db.Column(db.Text, default='{}')
|
settings = db.Column(db.Text, default='{}')
|
||||||
|
|
||||||
|
# Password reset
|
||||||
|
reset_token = db.Column(db.String(100), nullable=True, unique=True, index=True)
|
||||||
|
reset_token_expiry = db.Column(db.DateTime, nullable=True)
|
||||||
|
|
||||||
def __init__(self, username, email, password=None, is_admin=False, auth0_id=None):
|
def __init__(self, username, email, password=None, is_admin=False, auth0_id=None):
|
||||||
"""
|
"""
|
||||||
Initialize a new user.
|
Initialize a new user.
|
||||||
@@ -102,6 +106,32 @@ class User(UserMixin, db.Model):
|
|||||||
self.last_login = datetime.utcnow()
|
self.last_login = datetime.utcnow()
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
def generate_reset_token(self):
|
||||||
|
"""Generate a password reset token that expires in 1 hour"""
|
||||||
|
import secrets
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
self.reset_token = secrets.token_urlsafe(32)
|
||||||
|
self.reset_token_expiry = datetime.utcnow() + timedelta(hours=1)
|
||||||
|
db.session.commit()
|
||||||
|
return self.reset_token
|
||||||
|
|
||||||
|
def verify_reset_token(self, token):
|
||||||
|
"""Verify if the provided reset token is valid and not expired"""
|
||||||
|
if not self.reset_token or not self.reset_token_expiry:
|
||||||
|
return False
|
||||||
|
if self.reset_token != token:
|
||||||
|
return False
|
||||||
|
if datetime.utcnow() > self.reset_token_expiry:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def clear_reset_token(self):
|
||||||
|
"""Clear the reset token after use"""
|
||||||
|
self.reset_token = None
|
||||||
|
self.reset_token_expiry = None
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
def get_id(self):
|
def get_id(self):
|
||||||
"""Required by Flask-Login"""
|
"""Required by Flask-Login"""
|
||||||
return self.id
|
return self.id
|
||||||
@@ -140,6 +170,9 @@ class PollSource(db.Model):
|
|||||||
# Polling configuration
|
# Polling configuration
|
||||||
enabled = db.Column(db.Boolean, default=True, nullable=False)
|
enabled = db.Column(db.Boolean, default=True, nullable=False)
|
||||||
poll_interval_minutes = db.Column(db.Integer, default=60, nullable=False) # How often to poll
|
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
|
# Status tracking
|
||||||
last_poll_time = db.Column(db.DateTime, nullable=True)
|
last_poll_time = db.Column(db.DateTime, nullable=True)
|
||||||
|
|||||||
@@ -145,25 +145,30 @@ class PollingService:
|
|||||||
Collect data from a source.
|
Collect data from a source.
|
||||||
Wraps the existing data_collection.py functionality.
|
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
|
# Setup directories and load state
|
||||||
dirs = ensure_directories(self.storage_dir)
|
dirs = ensure_directories(self.storage_dir)
|
||||||
index = load_index(self.storage_dir)
|
index = load_index(self.storage_dir)
|
||||||
state = load_state(self.storage_dir)
|
state = load_state(self.storage_dir)
|
||||||
|
|
||||||
# Calculate date range (collect last 1 day)
|
# Calculate date range - always use last 24 hours for polling
|
||||||
start_iso, end_iso = calculate_date_range(1, state)
|
# 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:
|
try:
|
||||||
# Call the existing collect_platform function
|
# Call the existing collect_platform function using source settings
|
||||||
posts_collected = collect_platform(
|
posts_collected = collect_platform(
|
||||||
platform=source.platform,
|
platform=source.platform,
|
||||||
community=source.source_id,
|
community=source.source_id,
|
||||||
start_date=start_iso,
|
start_date=start_iso,
|
||||||
end_date=end_iso,
|
end_date=end_iso,
|
||||||
max_posts=100, # Default limit
|
max_posts=source.max_posts or 100,
|
||||||
fetch_comments=True,
|
fetch_comments=source.fetch_comments if hasattr(source, 'fetch_comments') else True,
|
||||||
index=index,
|
index=index,
|
||||||
dirs=dirs
|
dirs=dirs
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -223,6 +223,13 @@
|
|||||||
<select class="form-select" name="source_id" id="source_id" required onchange="updateDisplayName()">
|
<select class="form-select" name="source_id" id="source_id" required onchange="updateDisplayName()">
|
||||||
<option value="">Select source...</option>
|
<option value="">Select source...</option>
|
||||||
</select>
|
</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>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -231,8 +238,46 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="poll_interval">Poll Interval (minutes)</label>
|
<label class="form-label" for="poll_interval">Poll Interval</label>
|
||||||
<input type="number" class="form-input" name="poll_interval" id="poll_interval" value="60" min="5" required>
|
<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>
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary">Add Source</button>
|
<button type="submit" class="btn btn-primary">Add Source</button>
|
||||||
@@ -299,6 +344,8 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="source-actions">
|
<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;">
|
<form action="{{ url_for('admin_polling_toggle', source_id=source.id) }}" method="POST" style="display: inline;">
|
||||||
<button type="submit" class="btn btn-secondary">
|
<button type="submit" class="btn btn-secondary">
|
||||||
{% if source.enabled %}Disable{% else %}Enable{% endif %}
|
{% if source.enabled %}Disable{% else %}Enable{% endif %}
|
||||||
@@ -361,6 +408,95 @@
|
|||||||
displayNameInput.value = selectedOption.dataset.displayName;
|
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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -21,25 +21,32 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="nav-right">
|
<div class="nav-right">
|
||||||
<div class="user-menu">
|
{% if anonymous %}
|
||||||
<div class="user-info">
|
<div class="anonymous-actions">
|
||||||
<div class="user-avatar">
|
<a href="{{ url_for('login') }}" class="login-btn">🔑 Login</a>
|
||||||
{% if current_user.profile_picture_url %}
|
<a href="{{ url_for('register') }}" class="register-btn">📝 Sign Up</a>
|
||||||
<img src="{{ current_user.profile_picture_url }}" alt="Avatar">
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="avatar-placeholder">{{ current_user.username[:2].upper() }}</div>
|
<div class="user-menu">
|
||||||
{% endif %}
|
<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>
|
||||||
<span class="username">{{ current_user.username }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="user-dropdown">
|
{% endif %}
|
||||||
<a href="{{ url_for('settings') }}" class="dropdown-item">⚙️ Settings</a>
|
|
||||||
{% if current_user.is_admin %}
|
|
||||||
<a href="{{ url_for('admin_panel') }}" class="dropdown-item">👨💼 Admin Panel</a>
|
|
||||||
{% endif %}
|
|
||||||
<a href="{{ url_for('logout') }}" class="dropdown-item">🚪 Logout</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -90,10 +97,12 @@
|
|||||||
<!-- Content Feed -->
|
<!-- Content Feed -->
|
||||||
<section class="content-section">
|
<section class="content-section">
|
||||||
<div class="content-header">
|
<div class="content-header">
|
||||||
<h1>Your Feed</h1>
|
<h1>{% if anonymous %}Public Feed{% else %}Your Feed{% endif %}</h1>
|
||||||
<div class="content-actions">
|
<div class="content-actions">
|
||||||
<button class="refresh-btn" onclick="refreshFeed()">🔄 Refresh</button>
|
<button class="refresh-btn" onclick="refreshFeed()">🔄 Refresh</button>
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -440,8 +449,6 @@
|
|||||||
|
|
||||||
.feed-container {
|
.feed-container {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
max-height: calc(100vh - 200px);
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading {
|
.loading {
|
||||||
|
|||||||
@@ -37,6 +37,10 @@
|
|||||||
<label for="remember" style="margin-bottom: 0;">Remember me</label>
|
<label for="remember" style="margin-bottom: 0;">Remember me</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align: right; margin-bottom: 16px;">
|
||||||
|
<a href="{{ url_for('password_reset_request') }}" style="color: var(--primary-color); text-decoration: none; font-size: 14px;">Forgot password?</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button type="submit">Log In</button>
|
<button type="submit">Log In</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|||||||
43
templates/password_reset.html
Normal file
43
templates/password_reset.html
Normal 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 %}
|
||||||
41
templates/password_reset_request.html
Normal file
41
templates/password_reset_request.html
Normal 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 %}
|
||||||
@@ -256,20 +256,18 @@
|
|||||||
<div class="avatar-info">
|
<div class="avatar-info">
|
||||||
<h3>Current Avatar</h3>
|
<h3>Current Avatar</h3>
|
||||||
<p>Upload a new profile picture to personalize your account</p>
|
<p>Upload a new profile picture to personalize your account</p>
|
||||||
<div class="file-upload">
|
<form id="upload-form" method="POST" action="{{ url_for('upload_avatar') }}" enctype="multipart/form-data">
|
||||||
<input type="file" id="avatar" name="avatar" accept="image/*" onchange="document.getElementById('upload-form').submit()">
|
<div class="file-upload">
|
||||||
<label for="avatar" class="file-upload-label">Choose New Picture</label>
|
<input type="file" id="avatar" name="avatar" accept="image/*" onchange="this.form.submit()">
|
||||||
</div>
|
<label for="avatar" class="file-upload-label">Choose New Picture</label>
|
||||||
<p class="help-text">PNG, JPG, or GIF. Maximum size 2MB.</p>
|
</div>
|
||||||
|
<p class="help-text">PNG, JPG, or GIF. Maximum size 2MB.</p>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</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">
|
<form method="POST">
|
||||||
<div class="profile-section">
|
<div class="profile-section">
|
||||||
<h2>Account Information</h2>
|
<h2>Account Information</h2>
|
||||||
|
|||||||
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
120
themes/template_prompt.txt
Normal file
120
themes/template_prompt.txt
Normal 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.
|
||||||
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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user