Move profile picture section to top of account page

parent 8c62d513
...@@ -50,6 +50,43 @@ ...@@ -50,6 +50,43 @@
{% block content %} {% block content %}
<div class="container"> <div class="container">
<!-- Avatar Management -->
<div class="account-card">
<div class="card-header">
<h3><i class="fas fa-user-circle"></i> Profile Picture</h3>
</div>
<div style="display: flex; align-items: center; gap: 2rem; margin-bottom: 1rem;">
<img src="{{ user.avatar_url }}" alt="Current Avatar" style="width: 80px; height: 80px; border-radius: 50%; border: 2px solid #e5e7eb;">
<div>
<p style="margin-bottom: 0.5rem; color: #6b7280;">Upload a new profile picture or use Gravatar.</p>
<p style="font-size: 0.9rem; color: #6b7280;">Supported formats: JPG, PNG, GIF. Max size: 2MB.</p>
</div>
</div>
<form method="post" action="/account/upload_avatar" enctype="multipart/form-data" style="margin-bottom: 1rem;">
<div class="form-group">
<label for="avatar_file">Choose new avatar:</label>
<input type="file" id="avatar_file" name="avatar" accept="image/*" required>
</div>
<button type="submit" class="btn"><i class="fas fa-upload"></i> Upload Avatar</button>
</form>
{% if user.get('avatar') %}
<form method="post" action="/account/delete_avatar" style="display: inline;">
<button type="submit" class="btn btn-danger" onclick="return confirm('Are you sure you want to delete your custom avatar and use Gravatar instead?')">
<i class="fas fa-trash"></i> Delete Custom Avatar
</button>
</form>
{% endif %}
<div style="margin-top: 1rem; padding: 1rem; background: #f0f9ff; border-radius: 8px; border-left: 4px solid #3b82f6;">
<h4 style="margin: 0 0 0.5rem 0; color: #1e40af;">Gravatar</h4>
<p style="margin: 0; color: #1e40af; font-size: 0.9rem;">If you don't upload a custom avatar, your Gravatar (based on your email) will be used automatically.</p>
<a href="https://gravatar.com" target="_blank" style="color: #2563eb; text-decoration: underline;">Manage your Gravatar</a>
</div>
</div>
{% if user.get('role') != 'admin' %} {% if user.get('role') != 'admin' %}
<!-- Token Balance --> <!-- Token Balance -->
<div class="token-balance"> <div class="token-balance">
......
...@@ -14,9 +14,10 @@ ...@@ -14,9 +14,10 @@
.nav a { text-decoration: none; color: #64748b; font-weight: 500; } .nav a { text-decoration: none; color: #64748b; font-weight: 500; }
.nav a.active { color: #667eea; } .nav a.active { color: #667eea; }
.user-menu { display: flex; align-items: center; gap: 1rem; position: relative; } .user-menu { display: flex; align-items: center; gap: 1rem; position: relative; }
.user-icon { cursor: pointer; padding: 0.5rem; border-radius: 50%; background: #f8fafc; transition: background 0.2s; } .user-icon { cursor: pointer; padding: 0.5rem; border-radius: 8px; background: #f8fafc; transition: background 0.2s; display: flex; align-items: center; gap: 0.5rem; }
.user-icon:hover { background: #e2e8f0; } .user-icon:hover { background: #e2e8f0; }
.user-icon i { font-size: 1.2rem; color: #64748b; } .user-avatar { width: 32px; height: 32px; border-radius: 50%; }
.user-name { font-weight: 500; color: #64748b; }
.user-dropdown { display: none; position: absolute; top: 100%; right: 0; background: white; min-width: 200px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); border-radius: 8px; z-index: 1000; } .user-dropdown { display: none; position: absolute; top: 100%; right: 0; background: white; min-width: 200px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); border-radius: 8px; z-index: 1000; }
.user-dropdown a { display: block; padding: 0.75rem 1rem; text-decoration: none; color: #374151; border-bottom: 1px solid #f1f5f9; } .user-dropdown a { display: block; padding: 0.75rem 1rem; text-decoration: none; color: #374151; border-bottom: 1px solid #f1f5f9; }
.user-dropdown a:last-child { border-bottom: none; color: #dc2626; } .user-dropdown a:last-child { border-bottom: none; color: #dc2626; }
...@@ -111,7 +112,8 @@ ...@@ -111,7 +112,8 @@
</nav> </nav>
<div class="user-menu"> <div class="user-menu">
<div class="user-icon" onclick="toggleUserMenu()"> <div class="user-icon" onclick="toggleUserMenu()">
<i class="fas fa-user"></i> <img src="{{ user.avatar_url }}" alt="Avatar" class="user-avatar">
<span class="user-name">{{ user.username }}</span>
</div> </div>
<div id="userDropdown" class="user-dropdown"> <div id="userDropdown" class="user-dropdown">
<a href="/account">Account</a> <a href="/account">Account</a>
......
...@@ -158,6 +158,7 @@ def init_db(conn) -> None: ...@@ -158,6 +158,7 @@ def init_db(conn) -> None:
email_confirmation_token VARCHAR(255), email_confirmation_token VARCHAR(255),
email_confirmation_expires TIMESTAMP NULL, email_confirmation_expires TIMESTAMP NULL,
tokens INT DEFAULT 100, tokens INT DEFAULT 100,
avatar VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_login TIMESTAMP NULL last_login TIMESTAMP NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
...@@ -175,6 +176,7 @@ def init_db(conn) -> None: ...@@ -175,6 +176,7 @@ def init_db(conn) -> None:
email_confirmation_token TEXT, email_confirmation_token TEXT,
email_confirmation_expires TIMESTAMP, email_confirmation_expires TIMESTAMP,
tokens INTEGER DEFAULT 0, tokens INTEGER DEFAULT 0,
avatar TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_login TIMESTAMP last_login TIMESTAMP
) )
...@@ -521,6 +523,16 @@ def init_db(conn) -> None: ...@@ -521,6 +523,16 @@ def init_db(conn) -> None:
except sqlite3.OperationalError: except sqlite3.OperationalError:
pass # Column already exists pass # Column already exists
# Add avatar column to users table if it doesn't exist
try:
if config['type'] == 'mysql':
cursor.execute('ALTER TABLE users ADD COLUMN avatar VARCHAR(255)')
else:
cursor.execute('ALTER TABLE users ADD COLUMN avatar TEXT')
except:
# Column might already exist
pass
# Insert default admin user if not exist # Insert default admin user if not exist
import hashlib import hashlib
default_password = hashlib.sha256('admin'.encode()).hexdigest() default_password = hashlib.sha256('admin'.encode()).hexdigest()
...@@ -715,7 +727,7 @@ def authenticate_user(username: str, password: str) -> Optional[Dict[str, Any]]: ...@@ -715,7 +727,7 @@ def authenticate_user(username: str, password: str) -> Optional[Dict[str, Any]]:
password_hash = hashlib.sha256(password.encode()).hexdigest() password_hash = hashlib.sha256(password.encode()).hexdigest()
conn = get_db_connection() conn = get_db_connection()
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute('SELECT id, username, email, role, active, email_confirmed, tokens FROM users WHERE username = ? AND password_hash = ? AND active = 1 AND email_confirmed = 1', cursor.execute('SELECT id, username, email, role, active, email_confirmed, tokens, avatar FROM users WHERE username = ? AND password_hash = ? AND active = 1 AND email_confirmed = 1',
(username, password_hash)) (username, password_hash))
row = cursor.fetchone() row = cursor.fetchone()
if row: if row:
...@@ -733,7 +745,7 @@ def get_user_by_id(user_id: int) -> Optional[Dict[str, Any]]: ...@@ -733,7 +745,7 @@ def get_user_by_id(user_id: int) -> Optional[Dict[str, Any]]:
"""Get user by ID.""" """Get user by ID."""
conn = get_db_connection() conn = get_db_connection()
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute('SELECT id, username, email, role, active, created_at, last_login FROM users WHERE id = ?', cursor.execute('SELECT id, username, email, role, active, created_at, last_login, avatar FROM users WHERE id = ?',
(user_id,)) (user_id,))
row = cursor.fetchone() row = cursor.fetchone()
conn.close() conn.close()
...@@ -744,7 +756,7 @@ def get_all_users() -> List[Dict[str, Any]]: ...@@ -744,7 +756,7 @@ def get_all_users() -> List[Dict[str, Any]]:
"""Get all users.""" """Get all users."""
conn = get_db_connection() conn = get_db_connection()
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute('SELECT id, username, email, role, active, created_at, last_login FROM users ORDER BY username') cursor.execute('SELECT id, username, email, role, active, created_at, last_login, avatar FROM users ORDER BY username')
rows = cursor.fetchall() rows = cursor.fetchall()
conn.close() conn.close()
return [dict(row) for row in rows] return [dict(row) for row in rows]
...@@ -1769,6 +1781,17 @@ def get_cluster_client(client_id: str) -> Optional[Dict[str, Any]]: ...@@ -1769,6 +1781,17 @@ def get_cluster_client(client_id: str) -> Optional[Dict[str, Any]]:
# Admin dashboard stats functions # Admin dashboard stats functions
def update_user_avatar(user_id: int, avatar_filename: str = None) -> bool:
"""Update user avatar filename."""
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute('UPDATE users SET avatar = ? WHERE id = ?', (avatar_filename, user_id))
conn.commit()
success = cursor.rowcount > 0
conn.close()
return success
def get_admin_dashboard_stats() -> Dict[str, int]: def get_admin_dashboard_stats() -> Dict[str, int]:
"""Get admin dashboard statistics.""" """Get admin dashboard statistics."""
conn = get_db_connection() conn = get_db_connection()
......
...@@ -26,7 +26,17 @@ def get_current_user_session(): ...@@ -26,7 +26,17 @@ def get_current_user_session():
from flask import session from flask import session
session_id = session.get('session_id') session_id = session.get('session_id')
if session_id: if session_id:
return get_current_user(session_id) user = get_current_user(session_id)
if user and user.get('email'):
import hashlib
email_hash = hashlib.md5(user['email'].lower().encode()).hexdigest()
user['gravatar_url'] = f"https://www.gravatar.com/avatar/{email_hash}?s=32&d=mp"
# If custom avatar, use that instead
if user.get('avatar'):
user['avatar_url'] = f"/static/avatars/{user['avatar']}"
else:
user['avatar_url'] = user['gravatar_url']
return user
return None return None
def login_required(f): def login_required(f):
......
...@@ -25,6 +25,7 @@ import json ...@@ -25,6 +25,7 @@ import json
import uuid import uuid
import time import time
import argparse import argparse
from PIL import Image
from .comm import SocketCommunicator, Message from .comm import SocketCommunicator, Message
from .config import get_all_settings, get_allow_registration from .config import get_all_settings, get_allow_registration
from .auth import login_user, logout_user, get_current_user, register_user, confirm_email, require_auth from .auth import login_user, logout_user, get_current_user, register_user, confirm_email, require_auth
...@@ -37,10 +38,19 @@ app = Flask(__name__, template_folder='../templates') ...@@ -37,10 +38,19 @@ app = Flask(__name__, template_folder='../templates')
app.secret_key = os.environ.get('FLASK_SECRET_KEY', 'dev-secret-key-change-in-production') app.secret_key = os.environ.get('FLASK_SECRET_KEY', 'dev-secret-key-change-in-production')
os.makedirs('../static', exist_ok=True) os.makedirs('../static', exist_ok=True)
# Ensure avatars directory exists
avatars_dir = os.path.join(app.root_path, '..', 'static', 'avatars')
os.makedirs(avatars_dir, exist_ok=True)
# Global configuration # Global configuration
server_dir = None server_dir = None
def allowed_file(filename, allowed_extensions):
"""Check if file has allowed extension."""
return '.' in filename and filename.rsplit('.', 1)[1].lower() in allowed_extensions
@app.before_request @app.before_request
def check_remember_me(): def check_remember_me():
"""Check for remember me cookie and auto-login if valid.""" """Check for remember me cookie and auto-login if valid."""
...@@ -1004,6 +1014,78 @@ def account(): ...@@ -1004,6 +1014,78 @@ def account():
return render_template('account.html', user=user, active_page='account') return render_template('account.html', user=user, active_page='account')
@app.route('/account/upload_avatar', methods=['POST'])
@login_required
def upload_avatar():
"""Upload user avatar."""
user = get_current_user_session()
if 'avatar' not in request.files:
flash('No file selected', 'error')
return redirect(url_for('account'))
file = request.files['avatar']
if file.filename == '':
flash('No file selected', 'error')
return redirect(url_for('account'))
if file and allowed_file(file.filename, {'png', 'jpg', 'jpeg', 'gif'}):
# Generate unique filename
import uuid
filename = f"{user['id']}_{uuid.uuid4().hex}.{file.filename.rsplit('.', 1)[1].lower()}"
filepath = os.path.join(avatars_dir, filename)
# Resize and save image
from PIL import Image
import io
# Read image
image = Image.open(file.stream)
# Convert to RGB if necessary
if image.mode in ("RGBA", "P"):
image = image.convert("RGB")
# Resize to 128x128
image.thumbnail((128, 128), Image.Resampling.LANCZOS)
# Save
image.save(filepath, optimize=True, quality=85)
# Update user avatar in database
from .database import update_user_avatar
update_user_avatar(user['id'], filename)
flash('Avatar uploaded successfully', 'success')
else:
flash('Invalid file type. Please upload PNG, JPG, or GIF.', 'error')
return redirect(url_for('account'))
@app.route('/account/delete_avatar', methods=['POST'])
@login_required
def delete_avatar():
"""Delete user custom avatar."""
user = get_current_user_session()
if user.get('avatar'):
# Delete file
filepath = os.path.join(avatars_dir, user['avatar'])
if os.path.exists(filepath):
os.remove(filepath)
# Update database
from .database import update_user_avatar
update_user_avatar(user['id'], None)
flash('Custom avatar deleted. Using Gravatar now.', 'success')
else:
flash('No custom avatar to delete', 'error')
return redirect(url_for('account'))
@app.route('/account/change_password', methods=['POST']) @app.route('/account/change_password', methods=['POST'])
@login_required @login_required
def change_password(): def change_password():
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment