Initial commit: BalanceBoard - Reddit-style content aggregator
- Flask-based web application with PostgreSQL - User authentication and session management - Content moderation and filtering - Docker deployment with docker-compose - Admin interface for content management 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
57
templates/404.html
Normal file
57
templates/404.html
Normal file
@@ -0,0 +1,57 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Page Not Found - BalanceBoard</title>
|
||||
<link rel="stylesheet" href="{{ url_for('serve_theme', filename='modern-card-ui/styles.css') }}">
|
||||
<style>
|
||||
.error-container {
|
||||
max-width: 600px;
|
||||
margin: 100px auto;
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
}
|
||||
.error-code {
|
||||
font-size: 6rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary-color);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.error-message {
|
||||
font-size: 1.5rem;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.error-description {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 40px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.btn-home {
|
||||
display: inline-block;
|
||||
padding: 12px 24px;
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
.btn-home:hover {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="error-container">
|
||||
<div class="error-code">404</div>
|
||||
<h1 class="error-message">Page Not Found</h1>
|
||||
<p class="error-description">
|
||||
Sorry, the page you're looking for doesn't exist or has been moved.
|
||||
The content you're trying to access might not be available yet.
|
||||
</p>
|
||||
<a href="{{ url_for('index') }}" class="btn-home">Go Home</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
74
templates/500.html
Normal file
74
templates/500.html
Normal file
@@ -0,0 +1,74 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Server Error - BalanceBoard</title>
|
||||
<link rel="stylesheet" href="{{ url_for('serve_theme', filename='modern-card-ui/styles.css') }}">
|
||||
<style>
|
||||
.error-container {
|
||||
max-width: 600px;
|
||||
margin: 100px auto;
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
}
|
||||
.error-code {
|
||||
font-size: 6rem;
|
||||
font-weight: 700;
|
||||
color: #dc3545;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.error-message {
|
||||
font-size: 1.5rem;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.error-description {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 40px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.btn-home {
|
||||
display: inline-block;
|
||||
padding: 12px 24px;
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
transition: background-color 0.2s;
|
||||
margin-right: 10px;
|
||||
}
|
||||
.btn-home:hover {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
.btn-retry {
|
||||
display: inline-block;
|
||||
padding: 12px 24px;
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
.btn-retry:hover {
|
||||
background: #5a6268;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="error-container">
|
||||
<div class="error-code">500</div>
|
||||
<h1 class="error-message">Server Error</h1>
|
||||
<p class="error-description">
|
||||
Something went wrong on our end. We're working to fix the issue.
|
||||
Please try again in a few moments.
|
||||
</p>
|
||||
<div>
|
||||
<a href="{{ url_for('index') }}" class="btn-home">Go Home</a>
|
||||
<a href="javascript:history.back()" class="btn-retry">Go Back</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
579
templates/admin.html
Normal file
579
templates/admin.html
Normal file
@@ -0,0 +1,579 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Admin Panel - BalanceBoard</title>
|
||||
<link rel="stylesheet" href="{{ url_for('serve_theme', filename='modern-card-ui/styles.css') }}">
|
||||
<style>
|
||||
.admin-container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
background: linear-gradient(135deg, var(--primary-dark) 0%, #2a5068 100%);
|
||||
color: white;
|
||||
padding: 32px;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 24px;
|
||||
border-bottom: 3px solid var(--primary-color);
|
||||
}
|
||||
|
||||
.admin-header h1 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.admin-header p {
|
||||
margin: 0;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.admin-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 24px;
|
||||
border-bottom: 2px solid var(--divider-color);
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
padding: 12px 24px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 3px solid transparent;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
color: var(--primary-color);
|
||||
border-bottom-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.tab-btn:hover {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.admin-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--surface-color);
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 4px var(--surface-elevation-1);
|
||||
border-left: 4px solid var(--primary-color);
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.admin-section {
|
||||
background: var(--surface-color);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: 0 2px 4px var(--surface-elevation-1);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.users-table {
|
||||
background: var(--surface-color);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 4px var(--surface-elevation-1);
|
||||
}
|
||||
|
||||
.users-table table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.users-table th {
|
||||
background: var(--primary-dark);
|
||||
color: white;
|
||||
padding: 16px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.users-table td {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
|
||||
.users-table tr:hover {
|
||||
background: var(--hover-overlay);
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge-admin {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-user {
|
||||
background: var(--background-color);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.badge-active {
|
||||
background: #28a745;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-inactive {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 6px 12px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.action-btn-primary {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.action-btn-primary:hover {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
.action-btn-danger {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.action-btn-danger:hover {
|
||||
background: #c82333;
|
||||
}
|
||||
|
||||
.action-btn-warning {
|
||||
background: #ffc107;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.action-btn-warning:hover {
|
||||
background: #e0a800;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-block;
|
||||
margin-bottom: 16px;
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.flash-messages {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.flash-message {
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.flash-message.success {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.flash-message.error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.flash-message.warning {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.system-info {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.info-card {
|
||||
background: var(--background-color);
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
border-left: 3px solid var(--primary-color);
|
||||
}
|
||||
|
||||
.info-card h4 {
|
||||
margin: 0 0 8px 0;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.info-card p {
|
||||
margin: 4px 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary-color);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
font-size: 0.8rem;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.admin-tabs {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.admin-stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.system-info {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="admin-container">
|
||||
<a href="{{ url_for('index') }}" class="back-link">← Back to Feed</a>
|
||||
|
||||
<div class="admin-header">
|
||||
<h1>Admin Panel</h1>
|
||||
<p>Manage users, content, and system settings</p>
|
||||
</div>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="flash-messages">
|
||||
{% for category, message in messages %}
|
||||
<div class="flash-message {{ category }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="admin-tabs">
|
||||
<button class="tab-btn active" onclick="showTab('overview')">Overview</button>
|
||||
<button class="tab-btn" onclick="showTab('users')">Users</button>
|
||||
<button class="tab-btn" onclick="showTab('content')">Content</button>
|
||||
<button class="tab-btn" onclick="showTab('system')">System</button>
|
||||
</div>
|
||||
|
||||
<!-- Overview Tab -->
|
||||
<div id="overview" class="tab-content active">
|
||||
<div class="admin-stats">
|
||||
<div class="stat-card">
|
||||
<div class="stat-number">{{ users|length }}</div>
|
||||
<div class="stat-label">Total Users</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-number">{{ users|selectattr('3', 'equalto', 1)|list|length }}</div>
|
||||
<div class="stat-label">Admins</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-number">{{ users|selectattr('5', 'ne', None)|list|length }}</div>
|
||||
<div class="stat-label">Active Users</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-number">73</div>
|
||||
<div class="stat-label">Total Posts</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-number">1,299</div>
|
||||
<div class="stat-label">Total Comments</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-number">3</div>
|
||||
<div class="stat-label">Content Sources</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-section">
|
||||
<h3 class="section-title">Recent Activity</h3>
|
||||
<div class="system-info">
|
||||
<div class="info-card">
|
||||
<h4>Latest User</h4>
|
||||
<p><strong>{{ users[-1].username if users else 'None' }}</strong></p>
|
||||
<p>Joined: {{ users[-1].created_at.strftime('%Y-%m-%d') if users and users[-1].created_at else 'N/A' }}</p>
|
||||
</div>
|
||||
<div class="info-card">
|
||||
<h4>System Status</h4>
|
||||
<p><strong>🟢 Operational</strong></p>
|
||||
<p>Last update: Just now</p>
|
||||
</div>
|
||||
<div class="info-card">
|
||||
<h4>Storage Usage</h4>
|
||||
<p><strong>~50 MB</strong></p>
|
||||
<p>Posts and comments</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Users Tab -->
|
||||
<div id="users" class="tab-content">
|
||||
<div class="admin-section">
|
||||
<h3 class="section-title">User Management</h3>
|
||||
<div class="users-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Email</th>
|
||||
<th>Role</th>
|
||||
<th>Status</th>
|
||||
<th>Created</th>
|
||||
<th>Last Login</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in users %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="user-info">
|
||||
<div class="user-avatar">{{ user.username[:2].upper() }}</div>
|
||||
<strong>{{ user.username }}</strong>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ user.email }}</td>
|
||||
<td>
|
||||
{% if user.is_admin %}
|
||||
<span class="badge badge-admin">Admin</span>
|
||||
{% else %}
|
||||
<span class="badge badge-user">User</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if user.last_login %}
|
||||
<span class="badge badge-active">Active</span>
|
||||
{% else %}
|
||||
<span class="badge badge-inactive">Inactive</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ user.created_at.strftime('%Y-%m-%d') if user.created_at else 'N/A' }}</td>
|
||||
<td>{{ user.last_login.strftime('%Y-%m-%d') if user.last_login else 'Never' }}</td>
|
||||
<td>
|
||||
<form method="POST" action="{{ url_for('admin_toggle_admin', user_id=user.id) }}" style="display: inline;">
|
||||
<button type="submit" class="action-btn action-btn-primary">
|
||||
{% if user.is_admin %}Remove Admin{% else %}Make Admin{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
<form method="POST" action="{{ url_for('admin_delete_user', user_id=user.id) }}" style="display: inline;"
|
||||
onsubmit="return confirm('Are you sure you want to delete this user?');">
|
||||
<button type="submit" class="action-btn action-btn-danger">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content Tab -->
|
||||
<div id="content" class="tab-content">
|
||||
<div class="admin-section">
|
||||
<h3 class="section-title">Content Management</h3>
|
||||
<div class="system-info">
|
||||
<div class="info-card">
|
||||
<h4>Content Sources</h4>
|
||||
<p>Reddit - Active</p>
|
||||
<p>Hacker News - Active</p>
|
||||
<p>Lobsters - Active</p>
|
||||
</div>
|
||||
<div class="info-card">
|
||||
<h4>Filter Sets</h4>
|
||||
<p>safe_content - Default</p>
|
||||
<p>no_filter - Unfiltered</p>
|
||||
</div>
|
||||
<div class="info-card">
|
||||
<h4>Content Stats</h4>
|
||||
<p>Posts today: 12</p>
|
||||
<p>Comments today: 45</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-section">
|
||||
<h3 class="section-title">Content Actions</h3>
|
||||
<form method="POST" action="{{ url_for('admin_regenerate_content') }}">
|
||||
<button type="submit" class="btn btn-primary">Regenerate All Content</button>
|
||||
<p style="margin-top: 8px; font-size: 0.85rem; color: var(--text-secondary);">
|
||||
This will regenerate all HTML files with current templates and filters.
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System Tab -->
|
||||
<div id="system" class="tab-content">
|
||||
<div class="admin-section">
|
||||
<h3 class="section-title">System Information</h3>
|
||||
<div class="system-info">
|
||||
<div class="info-card">
|
||||
<h4>Application</h4>
|
||||
<p>BalanceBoard v2.0</p>
|
||||
<p>Python 3.9+</p>
|
||||
<p>Flask Framework</p>
|
||||
</div>
|
||||
<div class="info-card">
|
||||
<h4>Database</h4>
|
||||
<p>PostgreSQL</p>
|
||||
<p>Connection: Active</p>
|
||||
</div>
|
||||
<div class="info-card">
|
||||
<h4>Storage</h4>
|
||||
<p>Posts: 73 files</p>
|
||||
<p>Comments: 1,299 files</p>
|
||||
<p>Themes: 2 available</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-section">
|
||||
<h3 class="section-title">System Maintenance</h3>
|
||||
<div style="display: flex; gap: 12px; flex-wrap: wrap;">
|
||||
<a href="{{ url_for('admin_polling') }}" class="action-btn action-btn-primary">📡 Manage Polling</a>
|
||||
<form method="POST" action="{{ url_for('admin_clear_cache') }}" style="display: inline;">
|
||||
<button type="submit" class="action-btn action-btn-warning">Clear Cache</button>
|
||||
</form>
|
||||
<form method="POST" action="{{ url_for('admin_backup_data') }}" style="display: inline;">
|
||||
<button type="submit" class="action-btn action-btn-primary">Backup Data</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function showTab(tabName) {
|
||||
// Hide all tabs
|
||||
const tabs = document.querySelectorAll('.tab-content');
|
||||
tabs.forEach(tab => tab.classList.remove('active'));
|
||||
|
||||
// Remove active class from all buttons
|
||||
const buttons = document.querySelectorAll('.tab-btn');
|
||||
buttons.forEach(btn => btn.classList.remove('active'));
|
||||
|
||||
// Show selected tab
|
||||
document.getElementById(tabName).classList.add('active');
|
||||
|
||||
// Add active class to clicked button
|
||||
event.target.classList.add('active');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
366
templates/admin_polling.html
Normal file
366
templates/admin_polling.html
Normal file
@@ -0,0 +1,366 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Polling Management - Admin - BalanceBoard</title>
|
||||
<link rel="stylesheet" href="{{ url_for('serve_theme', filename='modern-card-ui/styles.css') }}">
|
||||
<style>
|
||||
.admin-container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
background: linear-gradient(135deg, var(--primary-dark) 0%, #2a5068 100%);
|
||||
color: white;
|
||||
padding: 32px;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-enabled {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.status-disabled {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.status-success {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.status-error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.source-card {
|
||||
background: var(--surface-color);
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.source-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.source-title {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.source-meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.source-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--primary-dark);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--divider-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #d0d0d0;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #c82333;
|
||||
}
|
||||
|
||||
.add-source-form {
|
||||
background: var(--surface-color);
|
||||
border: 2px dashed var(--divider-color);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.form-input, .form-select {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.scheduler-status {
|
||||
background: var(--surface-color);
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.no-sources {
|
||||
text-align: center;
|
||||
padding: 48px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="admin-container">
|
||||
<div class="admin-header">
|
||||
<h1>📡 Polling Management</h1>
|
||||
<p>Configure automatic data collection from content sources</p>
|
||||
</div>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<!-- Scheduler Status -->
|
||||
<div class="scheduler-status">
|
||||
<h3>Scheduler Status</h3>
|
||||
<p><strong>Status:</strong>
|
||||
{% if scheduler_status.running %}
|
||||
<span class="status-badge status-enabled">Running</span>
|
||||
{% else %}
|
||||
<span class="status-badge status-disabled">Stopped</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
<p><strong>Active Jobs:</strong> {{ scheduler_status.jobs|length }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Add New Source Form -->
|
||||
<div class="add-source-form">
|
||||
<h3>Add New Source</h3>
|
||||
<form action="{{ url_for('admin_polling_add') }}" method="POST">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="platform">Platform</label>
|
||||
<select class="form-select" name="platform" id="platform" required onchange="updateSourceOptions()">
|
||||
<option value="">Select platform...</option>
|
||||
{% for platform_id, platform_data in platform_config.platforms.items() %}
|
||||
<option value="{{ platform_id }}">{{ platform_data.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="source_id">Source</label>
|
||||
<select class="form-select" name="source_id" id="source_id" required onchange="updateDisplayName()">
|
||||
<option value="">Select source...</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="display_name">Display Name</label>
|
||||
<input type="text" class="form-input" name="display_name" id="display_name" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="poll_interval">Poll Interval (minutes)</label>
|
||||
<input type="number" class="form-input" name="poll_interval" id="poll_interval" value="60" min="5" required>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">Add Source</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Existing Sources -->
|
||||
<h3>Configured Sources ({{ sources|length }})</h3>
|
||||
|
||||
{% if sources %}
|
||||
{% for source in sources %}
|
||||
<div class="source-card">
|
||||
<div class="source-header">
|
||||
<div>
|
||||
<div class="source-title">{{ source.display_name }}</div>
|
||||
<small>{{ source.platform }}:{{ source.source_id }}</small>
|
||||
</div>
|
||||
<div>
|
||||
{% if source.enabled %}
|
||||
<span class="status-badge status-enabled">Enabled</span>
|
||||
{% else %}
|
||||
<span class="status-badge status-disabled">Disabled</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="source-meta">
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">Poll Interval</span>
|
||||
<span class="meta-value">{{ source.poll_interval_minutes }} minutes</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">Last Poll</span>
|
||||
<span class="meta-value">
|
||||
{% if source.last_poll_time %}
|
||||
{{ source.last_poll_time.strftime('%Y-%m-%d %H:%M:%S') }}
|
||||
{% else %}
|
||||
Never
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">Status</span>
|
||||
<span class="meta-value">
|
||||
{% if source.last_poll_status == 'success' %}
|
||||
<span class="status-badge status-success">Success</span>
|
||||
{% elif source.last_poll_status == 'error' %}
|
||||
<span class="status-badge status-error">Error</span>
|
||||
{% else %}
|
||||
<span class="status-badge">{{ source.last_poll_status or 'N/A' }}</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">Posts Collected</span>
|
||||
<span class="meta-value">{{ source.posts_collected }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if source.last_poll_error %}
|
||||
<div style="background: #fff3cd; padding: 12px; border-radius: 6px; margin-bottom: 16px;">
|
||||
<strong>Last Error:</strong> {{ source.last_poll_error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="source-actions">
|
||||
<form action="{{ url_for('admin_polling_toggle', source_id=source.id) }}" method="POST" style="display: inline;">
|
||||
<button type="submit" class="btn btn-secondary">
|
||||
{% if source.enabled %}Disable{% else %}Enable{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form action="{{ url_for('admin_polling_poll_now', source_id=source.id) }}" method="POST" style="display: inline;">
|
||||
<button type="submit" class="btn btn-primary">Poll Now</button>
|
||||
</form>
|
||||
|
||||
<a href="{{ url_for('admin_polling_logs', source_id=source.id) }}" class="btn btn-secondary">View Logs</a>
|
||||
|
||||
<form action="{{ url_for('admin_polling_delete', source_id=source.id) }}" method="POST" style="display: inline;" onsubmit="return confirm('Are you sure you want to delete this source?');">
|
||||
<button type="submit" class="btn btn-danger">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="no-sources">
|
||||
<p>No polling sources configured yet.</p>
|
||||
<p>Add your first source above to start collecting content!</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div style="margin-top: 24px;">
|
||||
<a href="{{ url_for('admin_panel') }}" class="btn btn-secondary">← Back to Admin Panel</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const platformConfig = {{ platform_config|tojson }};
|
||||
|
||||
function updateSourceOptions() {
|
||||
const platformSelect = document.getElementById('platform');
|
||||
const sourceSelect = document.getElementById('source_id');
|
||||
const selectedPlatform = platformSelect.value;
|
||||
|
||||
// Clear existing options
|
||||
sourceSelect.innerHTML = '<option value="">Select source...</option>';
|
||||
|
||||
if (selectedPlatform && platformConfig.platforms[selectedPlatform]) {
|
||||
const communities = platformConfig.platforms[selectedPlatform].communities || [];
|
||||
communities.forEach(community => {
|
||||
const option = document.createElement('option');
|
||||
option.value = community.id;
|
||||
option.textContent = community.display_name || community.name;
|
||||
option.dataset.displayName = community.display_name || community.name;
|
||||
sourceSelect.appendChild(option);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function updateDisplayName() {
|
||||
const sourceSelect = document.getElementById('source_id');
|
||||
const displayNameInput = document.getElementById('display_name');
|
||||
const selectedOption = sourceSelect.options[sourceSelect.selectedIndex];
|
||||
|
||||
if (selectedOption && selectedOption.dataset.displayName) {
|
||||
displayNameInput.value = selectedOption.dataset.displayName;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
188
templates/admin_polling_logs.html
Normal file
188
templates/admin_polling_logs.html
Normal file
@@ -0,0 +1,188 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Polling Logs - {{ source.display_name }} - Admin</title>
|
||||
<link rel="stylesheet" href="{{ url_for('serve_theme', filename='modern-card-ui/styles.css') }}">
|
||||
<style>
|
||||
.admin-container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
background: linear-gradient(135deg, var(--primary-dark) 0%, #2a5068 100%);
|
||||
color: white;
|
||||
padding: 32px;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.log-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: var(--surface-color);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.log-table th {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.log-table td {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
|
||||
.log-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-success {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.status-error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.status-running {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.error-detail {
|
||||
background: #fff3cd;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
margin-top: 8px;
|
||||
font-size: 0.9rem;
|
||||
white-space: pre-wrap;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--divider-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #d0d0d0;
|
||||
}
|
||||
|
||||
.no-logs {
|
||||
text-align: center;
|
||||
padding: 48px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="admin-container">
|
||||
<div class="admin-header">
|
||||
<h1>📋 Polling Logs</h1>
|
||||
<p>{{ source.display_name }} ({{ source.platform}}:{{ source.source_id }})</p>
|
||||
</div>
|
||||
|
||||
{% if logs %}
|
||||
<table class="log-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Started</th>
|
||||
<th>Completed</th>
|
||||
<th>Duration</th>
|
||||
<th>Status</th>
|
||||
<th>Posts Found</th>
|
||||
<th>New</th>
|
||||
<th>Updated</th>
|
||||
<th>Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for log in logs %}
|
||||
<tr>
|
||||
<td>{{ log.started_at.strftime('%Y-%m-%d %H:%M:%S') }}</td>
|
||||
<td>
|
||||
{% if log.completed_at %}
|
||||
{{ log.completed_at.strftime('%Y-%m-%d %H:%M:%S') }}
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if log.completed_at %}
|
||||
{{ ((log.completed_at - log.started_at).total_seconds())|round(1) }}s
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if log.status == 'success' %}
|
||||
<span class="status-badge status-success">Success</span>
|
||||
{% elif log.status == 'error' %}
|
||||
<span class="status-badge status-error">Error</span>
|
||||
{% elif log.status == 'running' %}
|
||||
<span class="status-badge status-running">Running</span>
|
||||
{% else %}
|
||||
<span class="status-badge">{{ log.status }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ log.posts_found }}</td>
|
||||
<td>{{ log.posts_new }}</td>
|
||||
<td>{{ log.posts_updated }}</td>
|
||||
<td>
|
||||
{% if log.error_message %}
|
||||
<details>
|
||||
<summary style="cursor: pointer; color: var(--primary-color);">View Error</summary>
|
||||
<div class="error-detail">{{ log.error_message }}</div>
|
||||
</details>
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="no-logs">
|
||||
<p>No polling logs yet.</p>
|
||||
<p>Logs will appear here after the first poll.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div style="margin-top: 24px;">
|
||||
<a href="{{ url_for('admin_polling') }}" class="btn btn-secondary">← Back to Polling Management</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
78
templates/admin_setup.html
Normal file
78
templates/admin_setup.html
Normal file
@@ -0,0 +1,78 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Create Admin Account - BalanceBoard{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="auth-container">
|
||||
<div class="auth-card">
|
||||
<div class="auth-logo">
|
||||
<img src="{{ url_for('serve_logo') }}" alt="BalanceBoard Logo">
|
||||
<h1><span class="balance">balance</span><span class="board">Board</span></h1>
|
||||
<p style="color: var(--text-secondary); margin-top: 8px;">Create Administrator Account</p>
|
||||
</div>
|
||||
|
||||
<div class="flash-messages">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="flash-message {{ category }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
|
||||
<form method="POST" class="auth-form">
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" required
|
||||
placeholder="Choose admin username" autocomplete="username">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="email">Email Address</label>
|
||||
<input type="email" id="email" name="email" required
|
||||
placeholder="admin@example.com" autocomplete="email">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" required
|
||||
placeholder="Create strong password" autocomplete="new-password">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password_confirm">Confirm Password</label>
|
||||
<input type="password" id="password_confirm" name="password_confirm" required
|
||||
placeholder="Confirm your password" autocomplete="new-password">
|
||||
</div>
|
||||
|
||||
<button type="submit">Create Admin Account</button>
|
||||
</form>
|
||||
|
||||
<div class="auth-footer">
|
||||
<p style="color: var(--text-secondary); font-size: 0.9rem; text-align: center;">
|
||||
This will create the first administrator account for BalanceBoard.
|
||||
<br>This user will have full system access.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.auth-container {
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-hover) 100%);
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
border-top: 4px solid var(--primary-color);
|
||||
}
|
||||
|
||||
.balance {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.board {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
251
templates/base.html
Normal file
251
templates/base.html
Normal file
@@ -0,0 +1,251 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}BalanceBoard{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('serve_theme', filename='modern-card-ui/styles.css') }}">
|
||||
<style>
|
||||
/* Auth pages styling */
|
||||
.auth-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--background-color);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
background: var(--surface-color);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 12px var(--surface-elevation-2);
|
||||
padding: 48px;
|
||||
max-width: 450px;
|
||||
width: 100%;
|
||||
border-top: 4px solid var(--primary-color);
|
||||
}
|
||||
|
||||
.auth-logo {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.auth-logo img {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.auth-logo h1 {
|
||||
margin-top: 16px;
|
||||
font-size: 1.75rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.auth-logo h1 .balance {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.auth-form .form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.auth-form label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.auth-form input {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
transition: all 0.2s ease;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.auth-form input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 3px rgba(77, 182, 172, 0.1);
|
||||
}
|
||||
|
||||
.auth-form .checkbox-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.auth-form .checkbox-group input[type="checkbox"] {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.auth-form button {
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.auth-form button:hover {
|
||||
background: var(--primary-hover);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(77, 182, 172, 0.3);
|
||||
}
|
||||
|
||||
.auth-footer {
|
||||
text-align: center;
|
||||
margin-top: 24px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid var(--divider-color);
|
||||
}
|
||||
|
||||
.auth-footer a {
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.auth-footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.flash-messages {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.flash-message {
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.flash-message.success {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
|
||||
.flash-message.error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
|
||||
.flash-message.info {
|
||||
background: #d1ecf1;
|
||||
color: #0c5460;
|
||||
border: 1px solid #bee5eb;
|
||||
}
|
||||
|
||||
.divider {
|
||||
text-align: center;
|
||||
margin: 24px 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.divider::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background: var(--divider-color);
|
||||
}
|
||||
|
||||
.divider span {
|
||||
background: var(--surface-color);
|
||||
padding: 0 16px;
|
||||
position: relative;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Social Authentication Styles */
|
||||
.social-auth-separator {
|
||||
text-align: center;
|
||||
margin: 24px 0 20px 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.social-auth-separator::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
.social-auth-separator span {
|
||||
background: var(--surface-color);
|
||||
padding: 0 16px;
|
||||
position: relative;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.social-auth-buttons {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.social-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-color);
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.social-btn:hover {
|
||||
background: var(--surface-elevation-1);
|
||||
border-color: var(--primary-color);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px var(--surface-elevation-2);
|
||||
}
|
||||
|
||||
.auth0-btn {
|
||||
border-color: #eb5424;
|
||||
color: #eb5424;
|
||||
}
|
||||
|
||||
.auth0-btn:hover {
|
||||
background: #eb5424;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.social-btn svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
{% block extra_css %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
{% block content %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
1264
templates/dashboard.html
Normal file
1264
templates/dashboard.html
Normal file
File diff suppressed because it is too large
Load Diff
61
templates/login.html
Normal file
61
templates/login.html
Normal file
@@ -0,0 +1,61 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Log In - BalanceBoard{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="auth-container">
|
||||
<div class="auth-card">
|
||||
<div class="auth-logo">
|
||||
<img src="{{ url_for('serve_logo') }}" alt="BalanceBoard Logo">
|
||||
<h1><span class="balance">balance</span>Board</h1>
|
||||
<p style="color: var(--text-secondary); margin-top: 8px;">Welcome back!</p>
|
||||
</div>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="flash-messages">
|
||||
{% for category, message in messages %}
|
||||
<div class="flash-message {{ category }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<form method="POST" class="auth-form">
|
||||
<div class="form-group">
|
||||
<label for="username">Username or Email</label>
|
||||
<input type="text" id="username" name="username" required autofocus>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group checkbox-group">
|
||||
<input type="checkbox" id="remember" name="remember">
|
||||
<label for="remember" style="margin-bottom: 0;">Remember me</label>
|
||||
</div>
|
||||
|
||||
<button type="submit">Log In</button>
|
||||
</form>
|
||||
|
||||
<div class="social-auth-separator">
|
||||
<span>or</span>
|
||||
</div>
|
||||
|
||||
<div class="social-auth-buttons">
|
||||
<a href="{{ url_for('auth0_login') }}" class="social-btn auth0-btn">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M21.98 7.448L19.62 0H4.347L2.02 7.448c-1.352 4.312.03 9.206 3.815 12.015L12.007 24l6.157-4.537c3.785-2.809 5.167-7.703 3.815-12.015z"/>
|
||||
</svg>
|
||||
Continue with Auth0
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="auth-footer">
|
||||
<p>Don't have an account? <a href="{{ url_for('signup') }}">Sign up</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
681
templates/post_detail.html
Normal file
681
templates/post_detail.html
Normal file
@@ -0,0 +1,681 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ post.title }} - BalanceBoard{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Modern Top Navigation -->
|
||||
<nav class="top-nav">
|
||||
<div class="nav-content">
|
||||
<div class="nav-left">
|
||||
<div class="logo-section">
|
||||
<img src="{{ url_for('serve_logo') }}" alt="BalanceBoard" class="nav-logo">
|
||||
<span class="brand-text"><span class="brand-balance">balance</span><span class="brand-board">Board</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nav-center">
|
||||
<div class="search-bar">
|
||||
<input type="text" placeholder="Search content..." class="search-input">
|
||||
<button class="search-btn">🔍</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nav-right">
|
||||
{% if current_user.is_authenticated %}
|
||||
<div class="user-menu">
|
||||
<div class="user-info">
|
||||
<div class="user-avatar">
|
||||
{% if current_user.profile_picture_url %}
|
||||
<img src="{{ current_user.profile_picture_url }}" alt="Avatar">
|
||||
{% else %}
|
||||
<div class="avatar-placeholder">{{ current_user.username[:2].upper() }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<span class="username">{{ current_user.username }}</span>
|
||||
</div>
|
||||
<div class="user-dropdown">
|
||||
<a href="{{ url_for('settings') }}" class="dropdown-item">⚙️ Settings</a>
|
||||
{% if current_user.is_admin %}
|
||||
<a href="{{ url_for('admin_panel') }}" class="dropdown-item">👨💼 Admin Panel</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('logout') }}" class="dropdown-item">🚪 Logout</a>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="auth-buttons">
|
||||
<a href="{{ url_for('login') }}" class="auth-btn">Login</a>
|
||||
<a href="{{ url_for('signup') }}" class="auth-btn primary">Sign Up</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<main class="main-content single-post">
|
||||
<!-- Back Button -->
|
||||
<div class="back-section">
|
||||
<button onclick="goBackToFeed()" class="back-btn">← Back to Feed</button>
|
||||
</div>
|
||||
|
||||
<!-- Post Content -->
|
||||
<article class="post-detail">
|
||||
<div class="post-header">
|
||||
<div class="platform-badge platform-{{ post.platform }}">
|
||||
{{ post.platform.title()[:1] }}
|
||||
</div>
|
||||
<div class="post-meta">
|
||||
<span class="post-author">{{ post.author }}</span>
|
||||
<span class="post-separator">•</span>
|
||||
{% if post.source %}
|
||||
<span class="post-source">{{ post.source_display if post.source_display else ('r/' + post.source if post.platform == 'reddit' else post.source) }}</span>
|
||||
<span class="post-separator">•</span>
|
||||
{% endif %}
|
||||
<span class="post-time">{{ moment(post.timestamp).fromNow() if moment else 'Recently' }}</span>
|
||||
{% if post.url and not post.url.startswith('/') %}
|
||||
<span class="external-link-indicator">🔗</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if post.url and not post.url.startswith('/') %}
|
||||
<h1 class="post-title">
|
||||
<a href="{{ post.url }}" target="_blank" class="post-title-link">{{ post.title }}</a>
|
||||
</h1>
|
||||
{% else %}
|
||||
<h1 class="post-title">{{ post.title }}</h1>
|
||||
{% endif %}
|
||||
|
||||
{% if post.content %}
|
||||
<div class="post-content">
|
||||
{{ post.content | safe | nl2br }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if post.url and not post.url.startswith('/') %}
|
||||
<div class="external-link">
|
||||
<a href="{{ post.url }}" target="_blank" class="external-btn">
|
||||
{% if post.platform == 'reddit' %}
|
||||
🔺 View on Reddit
|
||||
{% elif post.platform == 'hackernews' %}
|
||||
🧮 View on Hacker News
|
||||
{% elif post.platform == 'lobsters' %}
|
||||
🦞 View on Lobsters
|
||||
{% elif post.platform == 'github' %}
|
||||
🐙 View on GitHub
|
||||
{% elif post.platform == 'devto' %}
|
||||
📝 View on Dev.to
|
||||
{% elif post.platform == 'stackoverflow' %}
|
||||
📚 View on Stack Overflow
|
||||
{% else %}
|
||||
🔗 View Original Source
|
||||
{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="post-footer">
|
||||
<div class="post-stats">
|
||||
<div class="post-score">
|
||||
<span>▲</span>
|
||||
<span>{{ post.score }}</span>
|
||||
</div>
|
||||
<div class="post-comments">
|
||||
<span>💬</span>
|
||||
<span>{{ comments|length }} comments</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="post-actions">
|
||||
<button class="post-action" onclick="sharePost()">Share</button>
|
||||
<button class="post-action" onclick="savePost()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- Comments Section -->
|
||||
<section class="comments-section">
|
||||
<h2>Comments ({{ comments|length }})</h2>
|
||||
|
||||
{% if comments %}
|
||||
<div class="comments-list">
|
||||
{% for comment in comments %}
|
||||
<div class="comment">
|
||||
<div class="comment-header">
|
||||
<span class="comment-author">{{ comment.author }}</span>
|
||||
<span class="comment-separator">•</span>
|
||||
<span class="comment-time">{{ moment(comment.timestamp).fromNow() if moment else 'Recently' }}</span>
|
||||
</div>
|
||||
<div class="comment-content">
|
||||
{{ comment.content | safe | nl2br }}
|
||||
</div>
|
||||
<div class="comment-footer">
|
||||
<div class="comment-score">
|
||||
<span>▲ {{ comment.score or 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="no-comments">
|
||||
<p>No comments yet. Be the first to share your thoughts!</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
/* Inherit styles from dashboard */
|
||||
/* Top Navigation */
|
||||
.top-nav {
|
||||
background: white;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1000;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.nav-content {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 24px;
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.nav-left .logo-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.nav-logo {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.brand-text {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.brand-balance {
|
||||
color: #4db6ac;
|
||||
}
|
||||
|
||||
.brand-board {
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: #f8fafc;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
padding: 8px 16px;
|
||||
min-width: 400px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.search-bar:focus-within {
|
||||
border-color: #4db6ac;
|
||||
box-shadow: 0 0 0 3px rgba(77, 182, 172, 0.1);
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: none;
|
||||
outline: none;
|
||||
font-size: 14px;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.search-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.user-menu {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
padding: 8px 16px;
|
||||
border-radius: 12px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.user-info:hover {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.user-avatar img, .avatar-placeholder {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
background: linear-gradient(135deg, #4db6ac, #26a69a);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.username {
|
||||
font-weight: 500;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.user-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
|
||||
padding: 8px 0;
|
||||
min-width: 180px;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateY(-10px);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.user-menu:hover .user-dropdown {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
display: block;
|
||||
padding: 12px 20px;
|
||||
color: #2c3e50;
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
background: #f8fafc;
|
||||
color: #4db6ac;
|
||||
}
|
||||
|
||||
.auth-buttons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.auth-btn {
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.auth-btn:not(.primary) {
|
||||
color: #2c3e50;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.auth-btn.primary {
|
||||
background: #4db6ac;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.auth-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Main Content */
|
||||
.main-content.single-post {
|
||||
max-width: 1200px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
min-height: calc(100vh - 64px);
|
||||
}
|
||||
|
||||
.back-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: #2c3e50;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
/* Post Detail */
|
||||
.post-detail {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 32px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05);
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.post-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.platform-badge {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.platform-reddit {
|
||||
background: #ff4500;
|
||||
}
|
||||
|
||||
.platform-hackernews {
|
||||
background: #ff6600;
|
||||
}
|
||||
|
||||
.platform-unknown {
|
||||
background: #64748b;
|
||||
}
|
||||
|
||||
.post-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.post-author {
|
||||
font-weight: 500;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.post-source {
|
||||
color: #4db6ac;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.post-separator {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.post-time {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.external-link-indicator {
|
||||
color: #4db6ac;
|
||||
}
|
||||
|
||||
.post-title {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 700;
|
||||
color: #2c3e50;
|
||||
margin: 0 0 24px 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.post-title-link {
|
||||
color: #2c3e50;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.post-title-link:hover {
|
||||
color: #4db6ac;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.post-content {
|
||||
color: #374151;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.external-link {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.external-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: #4db6ac;
|
||||
color: white;
|
||||
padding: 12px 20px;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.external-btn:hover {
|
||||
background: #26a69a;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.post-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.post-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.post-score, .post-comments {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.post-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.post-action {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
background: none;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.post-action:hover {
|
||||
border-color: #4db6ac;
|
||||
color: #4db6ac;
|
||||
}
|
||||
|
||||
/* Comments Section */
|
||||
.comments-section {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 32px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.comments-section h2 {
|
||||
margin: 0 0 24px 0;
|
||||
font-size: 1.3rem;
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.comment {
|
||||
padding: 20px 0;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.comment:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.comment-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.comment-author {
|
||||
font-weight: 500;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.comment-separator {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.comment-time {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.comment-content {
|
||||
color: #374151;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.comment-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.comment-score {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.no-comments {
|
||||
text-align: center;
|
||||
color: #64748b;
|
||||
font-style: italic;
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.nav-center {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.main-content.single-post {
|
||||
padding: 16px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.post-detail, .comments-section {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
min-width: 250px;
|
||||
}
|
||||
|
||||
.post-title {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.external-btn {
|
||||
font-size: 14px;
|
||||
padding: 10px 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
function goBackToFeed() {
|
||||
// Try to go back to the dashboard if possible
|
||||
if (document.referrer && document.referrer.includes(window.location.origin)) {
|
||||
window.history.back();
|
||||
} else {
|
||||
// Fallback to dashboard
|
||||
window.location.href = '/';
|
||||
}
|
||||
}
|
||||
|
||||
function sharePost() {
|
||||
const url = window.location.href;
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
alert('Link copied to clipboard!');
|
||||
});
|
||||
}
|
||||
|
||||
function savePost() {
|
||||
alert('Save functionality coming soon!');
|
||||
}
|
||||
|
||||
// Moment.js replacement for timestamp formatting
|
||||
function formatTimeAgo(timestamp) {
|
||||
const now = Date.now() / 1000;
|
||||
const diff = now - timestamp;
|
||||
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||
if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`;
|
||||
return new Date(timestamp * 1000).toLocaleDateString();
|
||||
}
|
||||
|
||||
// Update timestamps on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.querySelectorAll('.post-time, .comment-time').forEach(el => {
|
||||
const timestamp = parseInt(el.dataset.timestamp);
|
||||
if (timestamp) {
|
||||
el.textContent = formatTimeAgo(timestamp);
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
382
templates/settings.html
Normal file
382
templates/settings.html
Normal file
@@ -0,0 +1,382 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Settings - BalanceBoard{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.settings-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.settings-header h1 {
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.settings-header p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 250px 1fr;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.settings-sidebar {
|
||||
background: var(--surface-color);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
height: fit-content;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.settings-nav {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.settings-nav li {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.settings-nav a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.settings-nav a:hover {
|
||||
background: var(--surface-elevation-1);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.settings-nav a.active {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.settings-nav .nav-icon {
|
||||
margin-right: 12px;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
background: var(--surface-color);
|
||||
border-radius: 12px;
|
||||
padding: 32px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.settings-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.settings-section h2 {
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 16px;
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.settings-section p {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 24px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.setting-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
|
||||
.setting-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.setting-info h3 {
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 4px;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.setting-info p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.setting-value {
|
||||
color: var(--primary-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-settings {
|
||||
padding: 8px 16px;
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-settings:hover {
|
||||
background: var(--primary-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--surface-elevation-1);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--surface-elevation-2);
|
||||
}
|
||||
|
||||
.user-profile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
background: var(--surface-elevation-1);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.user-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.user-info h3 {
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.user-info p {
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.current-filter {
|
||||
padding: 12px 16px;
|
||||
background: var(--surface-elevation-1);
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid var(--primary-color);
|
||||
}
|
||||
|
||||
.current-filter strong {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.settings-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.settings-sidebar {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.setting-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="settings-container">
|
||||
<div class="settings-header">
|
||||
<h1>Settings</h1>
|
||||
<p>Manage your BalanceBoard preferences and account settings</p>
|
||||
</div>
|
||||
|
||||
<div class="settings-grid">
|
||||
<aside class="settings-sidebar">
|
||||
<ul class="settings-nav">
|
||||
<li>
|
||||
<a href="{{ url_for('settings') }}" class="active">
|
||||
<span class="nav-icon">⚙️</span>
|
||||
<span>Overview</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('settings_profile') }}">
|
||||
<span class="nav-icon">👤</span>
|
||||
<span>Profile</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('settings_communities') }}">
|
||||
<span class="nav-icon">🌐</span>
|
||||
<span>Communities</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('settings_filters') }}">
|
||||
<span class="nav-icon">🔍</span>
|
||||
<span>Filters</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('settings_experience') }}">
|
||||
<span class="nav-icon">🎯</span>
|
||||
<span>Experience</span>
|
||||
</a>
|
||||
</li>
|
||||
{% if current_user.is_admin %}
|
||||
<li>
|
||||
<a href="{{ url_for('admin_panel') }}">
|
||||
<span class="nav-icon">🛡️</span>
|
||||
<span>Admin Panel</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
<main class="settings-content">
|
||||
<div class="user-profile">
|
||||
<div class="user-avatar">
|
||||
{% if user.profile_picture_url %}
|
||||
<img src="{{ user.profile_picture_url }}" alt="{{ user.username }}">
|
||||
{% else %}
|
||||
{{ user.username[0]|upper }}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="user-info">
|
||||
<h3>{{ user.username }}</h3>
|
||||
<p>{{ user.email }}</p>
|
||||
{% if user.is_admin %}
|
||||
<p style="color: var(--primary-color); font-weight: 500;">🛡️ Administrator</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h2>Profile Settings</h2>
|
||||
<p>Manage your account information and profile picture</p>
|
||||
<div class="setting-item">
|
||||
<div class="setting-info">
|
||||
<h3>Profile Information</h3>
|
||||
<p>Update your username, email, and profile picture</p>
|
||||
</div>
|
||||
<a href="{{ url_for('settings_profile') }}" class="btn-settings">Edit Profile</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h2>Content Preferences</h2>
|
||||
<p>Customize your content sources and filtering preferences</p>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-info">
|
||||
<h3>Communities</h3>
|
||||
<p>Select which subreddits, websites, and sources to follow</p>
|
||||
</div>
|
||||
<a href="{{ url_for('settings_communities') }}" class="btn-settings">Manage</a>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-info">
|
||||
<h3>Content Filters</h3>
|
||||
<p>Configure content filtering and safety preferences</p>
|
||||
</div>
|
||||
<a href="{{ url_for('settings_filters') }}" class="btn-settings">Configure</a>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-info">
|
||||
<h3>Experience Settings</h3>
|
||||
<p>Manage potentially addictive features like infinite scroll</p>
|
||||
</div>
|
||||
<a href="{{ url_for('settings_experience') }}" class="btn-settings">Configure</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h2>Current Configuration</h2>
|
||||
<p>Review your current settings and preferences</p>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-info">
|
||||
<h3>Active Filter</h3>
|
||||
<p>The content filter currently applied to your feed</p>
|
||||
</div>
|
||||
<div class="current-filter">
|
||||
<strong>{{ filter_sets[user_settings.get('filter_set', 'no_filter')].description or 'No Filter' }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-info">
|
||||
<h3>Selected Communities</h3>
|
||||
<p>Communities and sources you're currently following</p>
|
||||
</div>
|
||||
<div class="setting-value">
|
||||
{{ user_settings.get('communities', [])|length or 0 }} communities selected
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h2>Account Actions</h2>
|
||||
<p>Manage your account access and security</p>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-info">
|
||||
<h3>Sign Out</h3>
|
||||
<p>Sign out of your current session</p>
|
||||
</div>
|
||||
<a href="{{ url_for('logout') }}" class="btn-settings btn-secondary">Sign Out</a>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
357
templates/settings_communities.html
Normal file
357
templates/settings_communities.html
Normal file
@@ -0,0 +1,357 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Community Settings - BalanceBoard{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.settings-container {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.settings-header h1 {
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.settings-header p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
background: var(--surface-color);
|
||||
border-radius: 12px;
|
||||
padding: 32px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.community-section {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.community-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.community-section h2 {
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 16px;
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.community-section p {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 24px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.platform-group {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.platform-group h3 {
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 16px;
|
||||
font-size: 1.1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.platform-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.platform-icon.reddit { background: #ff4500; }
|
||||
.platform-icon.hackernews { background: #ff6600; }
|
||||
.platform-icon.lobsters { background: #ac130d; }
|
||||
.platform-icon.stackoverflow { background: #f48024; }
|
||||
|
||||
.community-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.community-item {
|
||||
background: var(--surface-elevation-1);
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.community-item:hover {
|
||||
border-color: var(--primary-color);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.community-item.selected {
|
||||
border-color: var(--primary-color);
|
||||
background: rgba(77, 182, 172, 0.1);
|
||||
}
|
||||
|
||||
.community-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.community-checkbox {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
accent-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.community-info h4 {
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 4px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.community-info p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.community-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-top: 8px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.community-meta span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid var(--divider-color);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 12px 24px;
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--primary-hover);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(77, 182, 172, 0.3);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--surface-elevation-1);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--surface-elevation-2);
|
||||
}
|
||||
|
||||
.selected-summary {
|
||||
background: var(--surface-elevation-1);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 24px;
|
||||
border-left: 4px solid var(--primary-color);
|
||||
}
|
||||
|
||||
.selected-summary h3 {
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.selected-summary p {
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.flash-messages {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.flash-message {
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.flash-message.success {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
|
||||
.flash-message.error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.community-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="settings-container">
|
||||
<div class="settings-header">
|
||||
<h1>Community Settings</h1>
|
||||
<p>Select which communities, subreddits, and sources to include in your feed</p>
|
||||
</div>
|
||||
|
||||
<div class="settings-content">
|
||||
<div class="flash-messages">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="flash-message {{ category }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
|
||||
<form method="POST">
|
||||
<div class="selected-summary">
|
||||
<h3>Current Selection</h3>
|
||||
<p>You have selected <strong>{{ selected_communities|length }}</strong> communities out of <strong>{{ available_communities|length }}</strong> available.</p>
|
||||
</div>
|
||||
|
||||
<div class="community-section">
|
||||
<h2>Available Communities</h2>
|
||||
<p>Choose the communities you want to follow. Content from these sources will appear in your feed.</p>
|
||||
|
||||
{% set platforms = available_communities|groupby('platform') %}
|
||||
|
||||
{% for platform, communities in platforms %}
|
||||
<div class="platform-group">
|
||||
<h3>
|
||||
<span class="platform-icon {{ platform }}">
|
||||
{% if platform == 'reddit' %}R{% elif platform == 'hackernews' %}H{% elif platform == 'lobsters' %}L{% elif platform == 'stackoverflow' %}S{% endif %}
|
||||
</span>
|
||||
{{ platform|title }}
|
||||
</h3>
|
||||
|
||||
<div class="community-grid">
|
||||
{% for community in communities %}
|
||||
<div class="community-item {% if community.id in selected_communities %}selected{% endif %}"
|
||||
onclick="toggleCommunity(this, '{{ community.id }}')">
|
||||
<div class="community-header">
|
||||
<input type="checkbox"
|
||||
name="communities"
|
||||
value="{{ community.id }}"
|
||||
class="community-checkbox"
|
||||
{% if community.id in selected_communities %}checked{% endif %}
|
||||
onclick="event.stopPropagation()">
|
||||
<div class="community-info">
|
||||
<h4>{{ community.name }}</h4>
|
||||
<p>{{ community.platform|title }} community</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="community-meta">
|
||||
<span>📊 {{ community.platform|title }}</span>
|
||||
<span>🔗 {{ community.id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn-primary">Save Community Preferences</button>
|
||||
<a href="{{ url_for('settings') }}" class="btn-primary btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function toggleCommunity(element, communityId) {
|
||||
const checkbox = element.querySelector('.community-checkbox');
|
||||
checkbox.checked = !checkbox.checked;
|
||||
|
||||
if (checkbox.checked) {
|
||||
element.classList.add('selected');
|
||||
} else {
|
||||
element.classList.remove('selected');
|
||||
}
|
||||
|
||||
updateSummary();
|
||||
}
|
||||
|
||||
function updateSummary() {
|
||||
const checkedBoxes = document.querySelectorAll('.community-checkbox:checked');
|
||||
const totalBoxes = document.querySelectorAll('.community-checkbox');
|
||||
|
||||
const summary = document.querySelector('.selected-summary p');
|
||||
summary.innerHTML = `You have selected <strong>${checkedBoxes.length}</strong> communities out of <strong>${totalBoxes.length}</strong> available.`;
|
||||
}
|
||||
|
||||
// Prevent form submission when clicking on community items
|
||||
document.querySelectorAll('.community-item').forEach(item => {
|
||||
item.addEventListener('click', function(e) {
|
||||
if (e.target.type !== 'checkbox') {
|
||||
const checkbox = this.querySelector('.community-checkbox');
|
||||
const communityId = checkbox.value;
|
||||
toggleCommunity(this, communityId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Update summary on checkbox change
|
||||
document.querySelectorAll('.community-checkbox').forEach(checkbox => {
|
||||
checkbox.addEventListener('change', function() {
|
||||
const item = this.closest('.community-item');
|
||||
if (this.checked) {
|
||||
item.classList.add('selected');
|
||||
} else {
|
||||
item.classList.remove('selected');
|
||||
}
|
||||
updateSummary();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
341
templates/settings_experience.html
Normal file
341
templates/settings_experience.html
Normal file
@@ -0,0 +1,341 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Experience Settings - BalanceBoard{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.experience-settings {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.experience-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.experience-header h1 {
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.experience-header p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.1rem;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.warning-banner {
|
||||
background: linear-gradient(135deg, #fff3cd 0%, #ffeaa7 100%);
|
||||
border: 1px solid #f39c12;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
margin-bottom: 32px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.warning-banner h3 {
|
||||
color: #d68910;
|
||||
margin-bottom: 8px;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.warning-banner p {
|
||||
color: #8b4513;
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.experience-section {
|
||||
background: var(--surface-color);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.experience-section h2 {
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 16px;
|
||||
font-size: 1.3rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.experience-section p {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 24px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.feature-toggle {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px 0;
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
|
||||
.feature-toggle:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.feature-info {
|
||||
flex: 1;
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
.feature-info h3 {
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 4px;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.feature-info p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.feature-warning {
|
||||
color: #e74c3c;
|
||||
font-weight: 500;
|
||||
font-size: 0.85rem;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 60px;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.toggle-switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.toggle-slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: var(--surface-elevation-1);
|
||||
transition: .4s;
|
||||
border-radius: 34px;
|
||||
border: 2px solid var(--border-color);
|
||||
}
|
||||
|
||||
.toggle-slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 26px;
|
||||
width: 26px;
|
||||
left: 2px;
|
||||
bottom: 2px;
|
||||
background-color: white;
|
||||
transition: .4s;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
input:checked + .toggle-slider {
|
||||
background-color: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
input:checked + .toggle-slider:before {
|
||||
transform: translateX(26px);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid var(--divider-color);
|
||||
}
|
||||
|
||||
.btn-save {
|
||||
padding: 12px 24px;
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-save:hover {
|
||||
background: var(--primary-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
padding: 12px 24px;
|
||||
background: var(--surface-elevation-1);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-cancel:hover {
|
||||
background: var(--surface-elevation-2);
|
||||
}
|
||||
|
||||
.addiction-notice {
|
||||
background: linear-gradient(135deg, #ffebee 0%, #ffcdd2 100%);
|
||||
border: 1px solid #e57373;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.addiction-notice h4 {
|
||||
color: #c62828;
|
||||
margin-bottom: 8px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.addiction-notice p {
|
||||
color: #b71c1c;
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.experience-settings {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.feature-toggle {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.btn-save, .btn-cancel {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="experience-settings">
|
||||
<div class="experience-header">
|
||||
<h1>Experience Settings</h1>
|
||||
<p>Configure features that may affect your browsing habits. All features below are <strong>opt-in only</strong> and disabled by default.</p>
|
||||
</div>
|
||||
|
||||
<div class="warning-banner">
|
||||
<h3>⚠️ Conscious Choice Required</h3>
|
||||
<p>These features are designed to enhance engagement but may contribute to addictive browsing patterns. Please consider your digital well-being before enabling them.</p>
|
||||
</div>
|
||||
|
||||
<form method="POST">
|
||||
<div class="experience-section">
|
||||
<h2>📜 Content Loading</h2>
|
||||
<p>Control how content is loaded and displayed in your feed.</p>
|
||||
|
||||
<div class="feature-toggle">
|
||||
<div class="feature-info">
|
||||
<h3>Infinite Scroll</h3>
|
||||
<p>Automatically load more content as you scroll, eliminating the need to click "next page".</p>
|
||||
<div class="feature-warning">⚠️ May increase time spent browsing</div>
|
||||
<div class="addiction-notice">
|
||||
<h4>Why this matters:</h4>
|
||||
<p>Infinite scroll removes natural stopping points, potentially leading to extended browsing sessions. Studies show it can increase content consumption by 20-50%.</p>
|
||||
</div>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" name="infinite_scroll" {% if experience_settings.infinite_scroll %}checked{% endif %}>
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="feature-toggle">
|
||||
<div class="feature-info">
|
||||
<h3>Auto-Refresh Content</h3>
|
||||
<p>Automatically check for new content once per day (when browsing the main feed).</p>
|
||||
<div class="feature-warning">⚠️ May create FOMO and compulsive checking</div>
|
||||
<div class="addiction-notice">
|
||||
<h4>Why this matters:</h4>
|
||||
<p>Even with daily refreshes, auto-updating content can create expectation patterns that encourage habitual checking behaviors.</p>
|
||||
</div>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" name="auto_refresh" {% if experience_settings.auto_refresh %}checked{% endif %}>
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="experience-section">
|
||||
<h2>🔔 Notifications & Alerts</h2>
|
||||
<p>Manage notifications that might interrupt your workflow or create urgency.</p>
|
||||
|
||||
<div class="feature-toggle">
|
||||
<div class="feature-info">
|
||||
<h3>Push Notifications</h3>
|
||||
<p>Receive browser notifications for new content and updates.</p>
|
||||
<div class="feature-warning">⚠️ May interrupt focus and create urgency</div>
|
||||
<div class="addiction-notice">
|
||||
<h4>Why this matters:</h4>
|
||||
<p>Push notifications exploit the brain's reward system, creating dopamine responses that encourage app checking habits.</p>
|
||||
</div>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" name="push_notifications" {% if experience_settings.push_notifications %}checked{% endif %}>
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="experience-section">
|
||||
<h2>🛡️ Behavioral Opt-in</h2>
|
||||
<p>Acknowledgment and consent for potentially addictive features.</p>
|
||||
|
||||
<div class="feature-toggle">
|
||||
<div class="feature-info">
|
||||
<h3>Dark Patterns Awareness</h3>
|
||||
<p>I understand that the features above may contribute to addictive browsing patterns and I choose to enable them consciously.</p>
|
||||
<div class="feature-warning">⚠️ Required for enabling any addictive features</div>
|
||||
<div class="addiction-notice">
|
||||
<h4>Why this matters:</h4>
|
||||
<p>This serves as a conscious acknowledgment that you're making an informed choice about features that may affect your digital well-being.</p>
|
||||
</div>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" name="dark_patterns_opt_in" {% if experience_settings.dark_patterns_opt_in %}checked{% endif %}>
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<a href="{{ url_for('settings') }}" class="btn-cancel">Cancel</a>
|
||||
<button type="submit" class="btn-save">Save Settings</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
418
templates/settings_filters.html
Normal file
418
templates/settings_filters.html
Normal file
@@ -0,0 +1,418 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Filter Settings - BalanceBoard{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.settings-container {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.settings-header h1 {
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.settings-header p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
background: var(--surface-color);
|
||||
border-radius: 12px;
|
||||
padding: 32px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.current-filter {
|
||||
background: var(--surface-elevation-1);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 32px;
|
||||
border-left: 4px solid var(--primary-color);
|
||||
}
|
||||
|
||||
.current-filter h3 {
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.current-filter p {
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.filter-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
|
||||
gap: 24px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.filter-card {
|
||||
background: var(--surface-color);
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.filter-card:hover {
|
||||
border-color: var(--primary-color);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.filter-card.selected {
|
||||
border-color: var(--primary-color);
|
||||
background: rgba(77, 182, 172, 0.05);
|
||||
}
|
||||
|
||||
.filter-card.selected::before {
|
||||
content: '✓';
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.filter-header {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.filter-header h3 {
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 8px;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.filter-header .filter-id {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
font-family: monospace;
|
||||
background: var(--surface-elevation-1);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.filter-description {
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.filter-details {
|
||||
border-top: 1px solid var(--divider-color);
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.filter-detail {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.filter-detail:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.filter-detail-label {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.filter-detail-value {
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.filter-rules {
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--divider-color);
|
||||
}
|
||||
|
||||
.filter-rules h4 {
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 12px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.rule-item {
|
||||
background: var(--surface-elevation-1);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.rule-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.rule-type {
|
||||
color: var(--primary-color);
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.rule-details {
|
||||
color: var(--text-secondary);
|
||||
font-family: monospace;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid var(--divider-color);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 12px 24px;
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--primary-hover);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(77, 182, 172, 0.3);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--surface-elevation-1);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--surface-elevation-2);
|
||||
}
|
||||
|
||||
.flash-messages {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.flash-message {
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.flash-message.success {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
|
||||
.flash-message.error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
|
||||
.no-filters {
|
||||
text-align: center;
|
||||
padding: 48px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.no-filters h3 {
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.filter-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="settings-container">
|
||||
<div class="settings-header">
|
||||
<h1>Filter Settings</h1>
|
||||
<p>Configure content filtering and safety preferences for your feed</p>
|
||||
</div>
|
||||
|
||||
<div class="settings-content">
|
||||
<div class="flash-messages">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="flash-message {{ category }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
|
||||
{% if filter_sets %}
|
||||
<div class="current-filter">
|
||||
<h3>Currently Active Filter</h3>
|
||||
<p>
|
||||
<strong>{{ filter_sets[current_filter].description or 'No Filter' }}</strong>
|
||||
{% if current_filter != 'no_filter' %}
|
||||
<br><small>Filter ID: <code>{{ current_filter }}</code></small>
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form method="POST">
|
||||
<div class="filter-section">
|
||||
<h2>Available Filters</h2>
|
||||
<p>Select a content filter to apply to your feed. Filters help control what type of content you see.</p>
|
||||
|
||||
<div class="filter-grid">
|
||||
{% for filter_id, filter_config in filter_sets.items() %}
|
||||
<div class="filter-card {% if filter_id == current_filter %}selected{% endif %}"
|
||||
onclick="selectFilter(this, '{{ filter_id }}')">
|
||||
<input type="radio"
|
||||
name="filter_set"
|
||||
value="{{ filter_id }}"
|
||||
{% if filter_id == current_filter %}checked{% endif %}
|
||||
style="display: none;">
|
||||
|
||||
<div class="filter-header">
|
||||
<h3>{{ filter_config.description or filter_id|title }}</h3>
|
||||
<span class="filter-id">{{ filter_id }}</span>
|
||||
</div>
|
||||
|
||||
<div class="filter-description">
|
||||
{{ filter_config.description or 'No description available' }}
|
||||
</div>
|
||||
|
||||
{% if filter_config.post_rules or filter_config.comment_rules %}
|
||||
<div class="filter-details">
|
||||
{% if filter_config.post_rules %}
|
||||
<div class="filter-detail">
|
||||
<span class="filter-detail-label">Post Rules:</span>
|
||||
<span class="filter-detail-value">{{ filter_config.post_rules|length }} rules</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if filter_config.comment_rules %}
|
||||
<div class="filter-detail">
|
||||
<span class="filter-detail-label">Comment Rules:</span>
|
||||
<span class="filter-detail-value">{{ filter_config.comment_rules|length }} rules</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if filter_config.comment_filter_mode %}
|
||||
<div class="filter-detail">
|
||||
<span class="filter-detail-label">Comment Mode:</span>
|
||||
<span class="filter-detail-value">{{ filter_config.comment_filter_mode }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if filter_id != 'no_filter' and (filter_config.post_rules or filter_config.comment_rules) %}
|
||||
<div class="filter-rules">
|
||||
<h4>Filter Rules Preview</h4>
|
||||
|
||||
{% if filter_config.post_rules %}
|
||||
<div class="rule-item">
|
||||
<div class="rule-type">Post Rules</div>
|
||||
<div class="rule-details">
|
||||
{% for rule, condition in filter_config.post_rules.items() %}
|
||||
{{ rule }}: {{ condition }}<br>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if filter_config.comment_rules %}
|
||||
<div class="rule-item">
|
||||
<div class="rule-type">Comment Rules</div>
|
||||
<div class="rule-details">
|
||||
{% for rule, condition in filter_config.comment_rules.items() %}
|
||||
{{ rule }}: {{ condition }}<br>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn-primary">Save Filter Preferences</button>
|
||||
<a href="{{ url_for('settings') }}" class="btn-primary btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="no-filters">
|
||||
<h3>No Filters Available</h3>
|
||||
<p>There are currently no filter sets configured. Please contact an administrator to set up content filters.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function selectFilter(element, filterId) {
|
||||
// Remove selected class from all cards
|
||||
document.querySelectorAll('.filter-card').forEach(card => {
|
||||
card.classList.remove('selected');
|
||||
});
|
||||
|
||||
// Add selected class to clicked card
|
||||
element.classList.add('selected');
|
||||
|
||||
// Check the radio button
|
||||
const radio = element.querySelector('input[type="radio"]');
|
||||
radio.checked = true;
|
||||
|
||||
// Update current filter display
|
||||
const currentFilterDiv = document.querySelector('.current-filter p');
|
||||
const filterTitle = element.querySelector('h3').textContent;
|
||||
currentFilterDiv.innerHTML = `<strong>${filterTitle}</strong><br><small>Filter ID: <code>${filterId}</code></small>`;
|
||||
}
|
||||
|
||||
// Handle click on filter cards
|
||||
document.querySelectorAll('.filter-card').forEach(card => {
|
||||
card.addEventListener('click', function(e) {
|
||||
const radio = this.querySelector('input[type="radio"]');
|
||||
const filterId = radio.value;
|
||||
selectFilter(this, filterId);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
349
templates/settings_profile.html
Normal file
349
templates/settings_profile.html
Normal file
@@ -0,0 +1,349 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Profile Settings - BalanceBoard{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.settings-container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.settings-header h1 {
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.settings-header p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
background: var(--surface-color);
|
||||
border-radius: 12px;
|
||||
padding: 32px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.profile-section {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.profile-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.profile-section h2 {
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 16px;
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
margin-bottom: 32px;
|
||||
padding: 24px;
|
||||
background: var(--surface-elevation-1);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.avatar-preview {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 2.5rem;
|
||||
font-weight: bold;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.avatar-preview img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.avatar-info h3 {
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.avatar-info p {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
transition: all 0.2s ease;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 3px rgba(77, 182, 172, 0.1);
|
||||
}
|
||||
|
||||
.file-upload {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.file-upload input[type="file"] {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-upload-label {
|
||||
display: inline-block;
|
||||
padding: 10px 20px;
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.file-upload-label:hover {
|
||||
background: var(--primary-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 12px 24px;
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--primary-hover);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(77, 182, 172, 0.3);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--surface-elevation-1);
|
||||
color: var(--text-primary);
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--surface-elevation-2);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid var(--divider-color);
|
||||
}
|
||||
|
||||
.help-text {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.flash-messages {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.flash-message {
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.flash-message.success {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
|
||||
.flash-message.error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.profile-avatar {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="settings-container">
|
||||
<div class="settings-header">
|
||||
<h1>Profile Settings</h1>
|
||||
<p>Manage your account information and profile picture</p>
|
||||
</div>
|
||||
|
||||
<div class="settings-content">
|
||||
<div class="flash-messages">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="flash-message {{ category }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
|
||||
<form method="POST">
|
||||
<div class="profile-section">
|
||||
<h2>Profile Picture</h2>
|
||||
<div class="profile-avatar">
|
||||
<div class="avatar-preview">
|
||||
{% if user.profile_picture_url %}
|
||||
<img src="{{ user.profile_picture_url }}" alt="{{ user.username }}">
|
||||
{% else %}
|
||||
{{ user.username[0]|upper }}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="avatar-info">
|
||||
<h3>Current Avatar</h3>
|
||||
<p>Upload a new profile picture to personalize your account</p>
|
||||
<div class="file-upload">
|
||||
<input type="file" id="avatar" name="avatar" accept="image/*" onchange="document.getElementById('upload-form').submit()">
|
||||
<label for="avatar" class="file-upload-label">Choose New Picture</label>
|
||||
</div>
|
||||
<p class="help-text">PNG, JPG, or GIF. Maximum size 2MB.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form id="upload-form" method="POST" action="{{ url_for('upload_avatar') }}" enctype="multipart/form-data" style="display: none;">
|
||||
<input type="hidden" name="avatar" id="avatar-hidden">
|
||||
</form>
|
||||
|
||||
<form method="POST">
|
||||
<div class="profile-section">
|
||||
<h2>Account Information</h2>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" value="{{ user.username }}" required>
|
||||
<p class="help-text">This is how other users will see you on BalanceBoard</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="email">Email Address</label>
|
||||
<input type="email" id="email" name="email" value="{{ user.email }}" required>
|
||||
<p class="help-text">We'll use this for account notifications and password recovery</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="profile-section">
|
||||
<h2>Account Details</h2>
|
||||
<div style="padding: 20px; background: var(--surface-elevation-1); border-radius: 8px;">
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 12px;">
|
||||
<span style="color: var(--text-secondary);">Account Type:</span>
|
||||
<span style="color: var(--text-primary); font-weight: 500;">
|
||||
{% if user.is_admin %}Administrator{% else %}User{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 12px;">
|
||||
<span style="color: var(--text-secondary);">Member Since:</span>
|
||||
<span style="color: var(--text-primary); font-weight: 500;">
|
||||
{{ user.created_at.strftime('%B %d, %Y') }}
|
||||
</span>
|
||||
</div>
|
||||
{% if user.last_login %}
|
||||
<div style="display: flex; justify-content: space-between;">
|
||||
<span style="color: var(--text-secondary);">Last Login:</span>
|
||||
<span style="color: var(--text-primary); font-weight: 500;">
|
||||
{{ user.last_login.strftime('%B %d, %Y at %I:%M %p') }}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn-primary">Save Changes</button>
|
||||
<a href="{{ url_for('settings') }}" class="btn-primary btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Handle file upload
|
||||
document.getElementById('avatar').addEventListener('change', function(e) {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
// Check file size (2MB limit)
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
alert('File size must be less than 2MB');
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Check file type
|
||||
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif'];
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
alert('Please upload a valid image file (PNG, JPG, or GIF)');
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Submit the upload form
|
||||
document.getElementById('upload-form').submit();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
70
templates/signup.html
Normal file
70
templates/signup.html
Normal file
@@ -0,0 +1,70 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Sign Up - BalanceBoard{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="auth-container">
|
||||
<div class="auth-card">
|
||||
<div class="auth-logo">
|
||||
<img src="{{ url_for('serve_logo') }}" alt="BalanceBoard Logo">
|
||||
<h1><span class="balance">balance</span>Board</h1>
|
||||
<p style="color: var(--text-secondary); margin-top: 8px;">Create your account</p>
|
||||
</div>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="flash-messages">
|
||||
{% for category, message in messages %}
|
||||
<div class="flash-message {{ category }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<form method="POST" class="auth-form">
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" required autofocus
|
||||
pattern="[a-zA-Z0-9_]{3,20}"
|
||||
title="Username must be 3-20 characters, letters, numbers and underscores only">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="email">Email</label>
|
||||
<input type="email" id="email" name="email" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" required
|
||||
minlength="8"
|
||||
title="Password must be at least 8 characters">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password_confirm">Confirm Password</label>
|
||||
<input type="password" id="password_confirm" name="password_confirm" required>
|
||||
</div>
|
||||
|
||||
<button type="submit">Sign Up</button>
|
||||
</form>
|
||||
|
||||
<div class="social-auth-separator">
|
||||
<span>or</span>
|
||||
</div>
|
||||
|
||||
<div class="social-auth-buttons">
|
||||
<a href="{{ url_for('auth0_login') }}" class="social-btn auth0-btn">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M21.98 7.448L19.62 0H4.347L2.02 7.448c-1.352 4.312.03 9.206 3.815 12.015L12.007 24l6.157-4.537c3.785-2.809 5.167-7.703 3.815-12.015z"/>
|
||||
</svg>
|
||||
Sign up with Auth0
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="auth-footer">
|
||||
<p>Already have an account? <a href="{{ url_for('login') }}">Log in</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user