Move profile picture section to top of account page

parent 8c62d513
......@@ -50,6 +50,43 @@
{% block content %}
<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' %}
<!-- Token Balance -->
<div class="token-balance">
......
......@@ -14,9 +14,10 @@
.nav a { text-decoration: none; color: #64748b; font-weight: 500; }
.nav a.active { color: #667eea; }
.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 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 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; }
......@@ -111,7 +112,8 @@
</nav>
<div class="user-menu">
<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 id="userDropdown" class="user-dropdown">
<a href="/account">Account</a>
......
......@@ -158,6 +158,7 @@ def init_db(conn) -> None:
email_confirmation_token VARCHAR(255),
email_confirmation_expires TIMESTAMP NULL,
tokens INT DEFAULT 100,
avatar VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_login TIMESTAMP NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
......@@ -175,6 +176,7 @@ def init_db(conn) -> None:
email_confirmation_token TEXT,
email_confirmation_expires TIMESTAMP,
tokens INTEGER DEFAULT 0,
avatar TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_login TIMESTAMP
)
......@@ -521,6 +523,16 @@ def init_db(conn) -> None:
except sqlite3.OperationalError:
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
import hashlib
default_password = hashlib.sha256('admin'.encode()).hexdigest()
......@@ -715,7 +727,7 @@ def authenticate_user(username: str, password: str) -> Optional[Dict[str, Any]]:
password_hash = hashlib.sha256(password.encode()).hexdigest()
conn = get_db_connection()
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))
row = cursor.fetchone()
if row:
......@@ -733,7 +745,7 @@ def get_user_by_id(user_id: int) -> Optional[Dict[str, Any]]:
"""Get user by ID."""
conn = get_db_connection()
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,))
row = cursor.fetchone()
conn.close()
......@@ -744,7 +756,7 @@ def get_all_users() -> List[Dict[str, Any]]:
"""Get all users."""
conn = get_db_connection()
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()
conn.close()
return [dict(row) for row in rows]
......@@ -1769,6 +1781,17 @@ def get_cluster_client(client_id: str) -> Optional[Dict[str, Any]]:
# 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]:
"""Get admin dashboard statistics."""
conn = get_db_connection()
......
......@@ -26,7 +26,17 @@ def get_current_user_session():
from flask import session
session_id = session.get('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
def login_required(f):
......
......@@ -25,6 +25,7 @@ import json
import uuid
import time
import argparse
from PIL import Image
from .comm import SocketCommunicator, Message
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
......@@ -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')
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
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
def check_remember_me():
"""Check for remember me cookie and auto-login if valid."""
......@@ -1004,6 +1014,78 @@ def 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'])
@login_required
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