BalanceBoard - Clean release

- Docker deployment ready
- Content aggregation and filtering
- User authentication
- Polling service for updates

🤖 Generated with Claude Code
This commit is contained in:
2025-10-11 21:24:21 +00:00
commit cb894b2159
53 changed files with 13514 additions and 0 deletions

57
templates/404.html Normal file
View File

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

74
templates/500.html Normal file
View File

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

579
templates/admin.html Normal file
View File

@@ -0,0 +1,579 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Panel - BalanceBoard</title>
<link rel="stylesheet" href="{{ url_for('serve_theme', filename='modern-card-ui/styles.css') }}">
<style>
.admin-container {
max-width: 1400px;
margin: 0 auto;
padding: 24px;
}
.admin-header {
background: linear-gradient(135deg, var(--primary-dark) 0%, #2a5068 100%);
color: white;
padding: 32px;
border-radius: 12px;
margin-bottom: 24px;
border-bottom: 3px solid var(--primary-color);
}
.admin-header h1 {
margin: 0 0 8px 0;
font-size: 2rem;
}
.admin-header p {
margin: 0;
opacity: 0.9;
}
.admin-tabs {
display: flex;
gap: 8px;
margin-bottom: 24px;
border-bottom: 2px solid var(--divider-color);
}
.tab-btn {
padding: 12px 24px;
background: none;
border: none;
border-bottom: 3px solid transparent;
cursor: pointer;
font-weight: 500;
color: var(--text-secondary);
transition: all 0.2s;
}
.tab-btn.active {
color: var(--primary-color);
border-bottom-color: var(--primary-color);
}
.tab-btn:hover {
color: var(--primary-color);
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
.admin-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
background: var(--surface-color);
padding: 20px;
border-radius: 12px;
box-shadow: 0 2px 4px var(--surface-elevation-1);
border-left: 4px solid var(--primary-color);
}
.stat-number {
font-size: 2rem;
font-weight: 700;
color: var(--primary-color);
}
.stat-label {
color: var(--text-secondary);
font-size: 0.9rem;
margin-top: 4px;
}
.admin-section {
background: var(--surface-color);
border-radius: 12px;
padding: 24px;
margin-bottom: 24px;
box-shadow: 0 2px 4px var(--surface-elevation-1);
}
.section-title {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 16px;
color: var(--text-primary);
}
.users-table {
background: var(--surface-color);
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 4px var(--surface-elevation-1);
}
.users-table table {
width: 100%;
border-collapse: collapse;
}
.users-table th {
background: var(--primary-dark);
color: white;
padding: 16px;
text-align: left;
font-weight: 600;
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.users-table td {
padding: 16px;
border-bottom: 1px solid var(--divider-color);
}
.users-table tr:hover {
background: var(--hover-overlay);
}
.badge {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
font-size: 0.8rem;
font-weight: 600;
}
.badge-admin {
background: var(--primary-color);
color: white;
}
.badge-user {
background: var(--background-color);
color: var(--text-secondary);
}
.badge-active {
background: #28a745;
color: white;
}
.badge-inactive {
background: #6c757d;
color: white;
}
.action-btn {
padding: 6px 12px;
border: none;
border-radius: 6px;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
margin-right: 8px;
}
.action-btn-primary {
background: var(--primary-color);
color: white;
}
.action-btn-primary:hover {
background: var(--primary-hover);
}
.action-btn-danger {
background: #dc3545;
color: white;
}
.action-btn-danger:hover {
background: #c82333;
}
.action-btn-warning {
background: #ffc107;
color: #212529;
}
.action-btn-warning:hover {
background: #e0a800;
}
.back-link {
display: inline-block;
margin-bottom: 16px;
color: var(--primary-color);
text-decoration: none;
font-weight: 500;
}
.back-link:hover {
text-decoration: underline;
}
.flash-messages {
margin-bottom: 24px;
}
.flash-message {
padding: 12px 16px;
border-radius: 8px;
margin-bottom: 12px;
}
.flash-message.success {
background: #d4edda;
color: #155724;
}
.flash-message.error {
background: #f8d7da;
color: #721c24;
}
.flash-message.warning {
background: #fff3cd;
color: #856404;
}
.system-info {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 16px;
}
.info-card {
background: var(--background-color);
padding: 16px;
border-radius: 8px;
border-left: 3px solid var(--primary-color);
}
.info-card h4 {
margin: 0 0 8px 0;
color: var(--primary-color);
}
.info-card p {
margin: 4px 0;
font-size: 0.9rem;
}
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
margin-bottom: 4px;
font-weight: 500;
color: var(--text-primary);
}
.form-control {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--divider-color);
border-radius: 6px;
font-size: 0.9rem;
}
.form-control:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);
}
.btn {
padding: 8px 16px;
border: none;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary {
background: var(--primary-color);
color: white;
}
.btn-primary:hover {
background: var(--primary-hover);
}
.user-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--primary-color);
display: inline-flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 600;
font-size: 0.8rem;
margin-right: 8px;
}
.user-info {
display: flex;
align-items: center;
}
@media (max-width: 768px) {
.admin-tabs {
overflow-x: auto;
}
.admin-stats {
grid-template-columns: 1fr;
}
.system-info {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<div class="admin-container">
<a href="{{ url_for('index') }}" class="back-link">← Back to Feed</a>
<div class="admin-header">
<h1>Admin Panel</h1>
<p>Manage users, content, and system settings</p>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flash-messages">
{% for category, message in messages %}
<div class="flash-message {{ category }}">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<div class="admin-tabs">
<button class="tab-btn active" onclick="showTab('overview')">Overview</button>
<button class="tab-btn" onclick="showTab('users')">Users</button>
<button class="tab-btn" onclick="showTab('content')">Content</button>
<button class="tab-btn" onclick="showTab('system')">System</button>
</div>
<!-- Overview Tab -->
<div id="overview" class="tab-content active">
<div class="admin-stats">
<div class="stat-card">
<div class="stat-number">{{ users|length }}</div>
<div class="stat-label">Total Users</div>
</div>
<div class="stat-card">
<div class="stat-number">{{ users|selectattr('3', 'equalto', 1)|list|length }}</div>
<div class="stat-label">Admins</div>
</div>
<div class="stat-card">
<div class="stat-number">{{ users|selectattr('5', 'ne', None)|list|length }}</div>
<div class="stat-label">Active Users</div>
</div>
<div class="stat-card">
<div class="stat-number">73</div>
<div class="stat-label">Total Posts</div>
</div>
<div class="stat-card">
<div class="stat-number">1,299</div>
<div class="stat-label">Total Comments</div>
</div>
<div class="stat-card">
<div class="stat-number">3</div>
<div class="stat-label">Content Sources</div>
</div>
</div>
<div class="admin-section">
<h3 class="section-title">Recent Activity</h3>
<div class="system-info">
<div class="info-card">
<h4>Latest User</h4>
<p><strong>{{ users[-1].username if users else 'None' }}</strong></p>
<p>Joined: {{ users[-1].created_at.strftime('%Y-%m-%d') if users and users[-1].created_at else 'N/A' }}</p>
</div>
<div class="info-card">
<h4>System Status</h4>
<p><strong>🟢 Operational</strong></p>
<p>Last update: Just now</p>
</div>
<div class="info-card">
<h4>Storage Usage</h4>
<p><strong>~50 MB</strong></p>
<p>Posts and comments</p>
</div>
</div>
</div>
</div>
<!-- Users Tab -->
<div id="users" class="tab-content">
<div class="admin-section">
<h3 class="section-title">User Management</h3>
<div class="users-table">
<table>
<thead>
<tr>
<th>User</th>
<th>Email</th>
<th>Role</th>
<th>Status</th>
<th>Created</th>
<th>Last Login</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td>
<div class="user-info">
<div class="user-avatar">{{ user.username[:2].upper() }}</div>
<strong>{{ user.username }}</strong>
</div>
</td>
<td>{{ user.email }}</td>
<td>
{% if user.is_admin %}
<span class="badge badge-admin">Admin</span>
{% else %}
<span class="badge badge-user">User</span>
{% endif %}
</td>
<td>
{% if user.last_login %}
<span class="badge badge-active">Active</span>
{% else %}
<span class="badge badge-inactive">Inactive</span>
{% endif %}
</td>
<td>{{ user.created_at.strftime('%Y-%m-%d') if user.created_at else 'N/A' }}</td>
<td>{{ user.last_login.strftime('%Y-%m-%d') if user.last_login else 'Never' }}</td>
<td>
<form method="POST" action="{{ url_for('admin_toggle_admin', user_id=user.id) }}" style="display: inline;">
<button type="submit" class="action-btn action-btn-primary">
{% if user.is_admin %}Remove Admin{% else %}Make Admin{% endif %}
</button>
</form>
<form method="POST" action="{{ url_for('admin_delete_user', user_id=user.id) }}" style="display: inline;"
onsubmit="return confirm('Are you sure you want to delete this user?');">
<button type="submit" class="action-btn action-btn-danger">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<!-- Content Tab -->
<div id="content" class="tab-content">
<div class="admin-section">
<h3 class="section-title">Content Management</h3>
<div class="system-info">
<div class="info-card">
<h4>Content Sources</h4>
<p>Reddit - Active</p>
<p>Hacker News - Active</p>
<p>Lobsters - Active</p>
</div>
<div class="info-card">
<h4>Filter Sets</h4>
<p>safe_content - Default</p>
<p>no_filter - Unfiltered</p>
</div>
<div class="info-card">
<h4>Content Stats</h4>
<p>Posts today: 12</p>
<p>Comments today: 45</p>
</div>
</div>
</div>
<div class="admin-section">
<h3 class="section-title">Content Actions</h3>
<form method="POST" action="{{ url_for('admin_regenerate_content') }}">
<button type="submit" class="btn btn-primary">Regenerate All Content</button>
<p style="margin-top: 8px; font-size: 0.85rem; color: var(--text-secondary);">
This will regenerate all HTML files with current templates and filters.
</p>
</form>
</div>
</div>
<!-- System Tab -->
<div id="system" class="tab-content">
<div class="admin-section">
<h3 class="section-title">System Information</h3>
<div class="system-info">
<div class="info-card">
<h4>Application</h4>
<p>BalanceBoard v2.0</p>
<p>Python 3.9+</p>
<p>Flask Framework</p>
</div>
<div class="info-card">
<h4>Database</h4>
<p>PostgreSQL</p>
<p>Connection: Active</p>
</div>
<div class="info-card">
<h4>Storage</h4>
<p>Posts: 73 files</p>
<p>Comments: 1,299 files</p>
<p>Themes: 2 available</p>
</div>
</div>
</div>
<div class="admin-section">
<h3 class="section-title">System Maintenance</h3>
<div style="display: flex; gap: 12px; flex-wrap: wrap;">
<a href="{{ url_for('admin_polling') }}" class="action-btn action-btn-primary">📡 Manage Polling</a>
<form method="POST" action="{{ url_for('admin_clear_cache') }}" style="display: inline;">
<button type="submit" class="action-btn action-btn-warning">Clear Cache</button>
</form>
<form method="POST" action="{{ url_for('admin_backup_data') }}" style="display: inline;">
<button type="submit" class="action-btn action-btn-primary">Backup Data</button>
</form>
</div>
</div>
</div>
</div>
<script>
function showTab(tabName) {
// Hide all tabs
const tabs = document.querySelectorAll('.tab-content');
tabs.forEach(tab => tab.classList.remove('active'));
// Remove active class from all buttons
const buttons = document.querySelectorAll('.tab-btn');
buttons.forEach(btn => btn.classList.remove('active'));
// Show selected tab
document.getElementById(tabName).classList.add('active');
// Add active class to clicked button
event.target.classList.add('active');
}
</script>
</body>
</html>

View File

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

View File

@@ -0,0 +1,188 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Polling Logs - {{ source.display_name }} - Admin</title>
<link rel="stylesheet" href="{{ url_for('serve_theme', filename='modern-card-ui/styles.css') }}">
<style>
.admin-container {
max-width: 1400px;
margin: 0 auto;
padding: 24px;
}
.admin-header {
background: linear-gradient(135deg, var(--primary-dark) 0%, #2a5068 100%);
color: white;
padding: 32px;
border-radius: 12px;
margin-bottom: 24px;
}
.log-table {
width: 100%;
border-collapse: collapse;
background: var(--surface-color);
border-radius: 12px;
overflow: hidden;
}
.log-table th {
background: var(--primary-color);
color: white;
padding: 12px;
text-align: left;
font-weight: 600;
}
.log-table td {
padding: 12px;
border-bottom: 1px solid var(--divider-color);
}
.log-table tr:last-child td {
border-bottom: none;
}
.status-badge {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
font-size: 0.85rem;
font-weight: 500;
}
.status-success {
background: #d4edda;
color: #155724;
}
.status-error {
background: #f8d7da;
color: #721c24;
}
.status-running {
background: #fff3cd;
color: #856404;
}
.error-detail {
background: #fff3cd;
padding: 12px;
border-radius: 6px;
margin-top: 8px;
font-size: 0.9rem;
white-space: pre-wrap;
max-height: 200px;
overflow-y: auto;
}
.btn {
padding: 8px 16px;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: 500;
text-decoration: none;
display: inline-block;
transition: all 0.2s;
}
.btn-secondary {
background: var(--divider-color);
color: var(--text-primary);
}
.btn-secondary:hover {
background: #d0d0d0;
}
.no-logs {
text-align: center;
padding: 48px;
color: var(--text-secondary);
}
</style>
</head>
<body>
<div class="admin-container">
<div class="admin-header">
<h1>📋 Polling Logs</h1>
<p>{{ source.display_name }} ({{ source.platform}}:{{ source.source_id }})</p>
</div>
{% if logs %}
<table class="log-table">
<thead>
<tr>
<th>Started</th>
<th>Completed</th>
<th>Duration</th>
<th>Status</th>
<th>Posts Found</th>
<th>New</th>
<th>Updated</th>
<th>Details</th>
</tr>
</thead>
<tbody>
{% for log in logs %}
<tr>
<td>{{ log.started_at.strftime('%Y-%m-%d %H:%M:%S') }}</td>
<td>
{% if log.completed_at %}
{{ log.completed_at.strftime('%Y-%m-%d %H:%M:%S') }}
{% else %}
-
{% endif %}
</td>
<td>
{% if log.completed_at %}
{{ ((log.completed_at - log.started_at).total_seconds())|round(1) }}s
{% else %}
-
{% endif %}
</td>
<td>
{% if log.status == 'success' %}
<span class="status-badge status-success">Success</span>
{% elif log.status == 'error' %}
<span class="status-badge status-error">Error</span>
{% elif log.status == 'running' %}
<span class="status-badge status-running">Running</span>
{% else %}
<span class="status-badge">{{ log.status }}</span>
{% endif %}
</td>
<td>{{ log.posts_found }}</td>
<td>{{ log.posts_new }}</td>
<td>{{ log.posts_updated }}</td>
<td>
{% if log.error_message %}
<details>
<summary style="cursor: pointer; color: var(--primary-color);">View Error</summary>
<div class="error-detail">{{ log.error_message }}</div>
</details>
{% else %}
-
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="no-logs">
<p>No polling logs yet.</p>
<p>Logs will appear here after the first poll.</p>
</div>
{% endif %}
<div style="margin-top: 24px;">
<a href="{{ url_for('admin_polling') }}" class="btn btn-secondary">← Back to Polling Management</a>
</div>
</div>
</body>
</html>

View File

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

251
templates/base.html Normal file
View File

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

1264
templates/dashboard.html Normal file

File diff suppressed because it is too large Load Diff

61
templates/login.html Normal file
View File

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

681
templates/post_detail.html Normal file
View File

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

382
templates/settings.html Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

70
templates/signup.html Normal file
View File

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