feat: add avatar upload and cropping functionality to user profile settings
Implement avatar management with image upload, client-side cropping, and server-side storage. Users can now upload profile pictures from their local filesystem, crop them to a square aspect ratio using a drag-and-resize interface, and save the result. The backend stores cropped images in a dedicated avatars directory and updates the user record with the new avatar path. Includes validation for file type and size limits, plus fallback to default avatar on error.
This commit is contained in:
@@ -28,7 +28,7 @@
|
||||
{% endif %}
|
||||
</a>
|
||||
<a href="/profile/{{ user['username'] }}" class="topnav-user">
|
||||
<div class="avatar avatar-sm">{{ user['username'][:1].upper() }}</div>
|
||||
<img src="{{ avatar_url(user.get('avatar_style', 'initials'), user['username'], 32) }}" class="avatar-img avatar-sm" alt="{{ user['username'] }}">
|
||||
<div class="topnav-user-info">
|
||||
<span class="topnav-user-name">{{ user['username'] }}</span>
|
||||
<span class="topnav-user-role">{{ user.get('role', 'Member') }}</span>
|
||||
|
||||
@@ -159,13 +159,12 @@
|
||||
{% for item in posts %}
|
||||
<article class="post-card fade-in">
|
||||
<div class="post-header">
|
||||
<div class="avatar avatar-sm">{{ item.author['username'][:1].upper() if item.author else '?' }}</div>
|
||||
<div>
|
||||
<div class="post-author">{{ item.author['username'] if item.author else 'Unknown' }}
|
||||
{% if item.author and item.author.get('role') %}
|
||||
<span class="post-author-role">· {{ item.author['role'] }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<img src="{{ avatar_url(item.author.get('avatar_style', 'initials') if item.author else 'initials', item.author['username'] if item.author else '?', 32) }}" class="avatar-img avatar-sm" alt="{{ item.author['username'] if item.author else '?' }}">
|
||||
<div class="post-author-wrap">
|
||||
<span class="post-author">{{ item.author['username'] if item.author else 'Unknown' }}</span>
|
||||
{% if item.author and item.author.get('role') %}
|
||||
<span class="post-author-role">{{ item.author['role'] }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<span class="post-time">{{ item.time_ago }}</span>
|
||||
</div>
|
||||
@@ -183,23 +182,21 @@
|
||||
<div class="post-actions">
|
||||
<form method="POST" action="/votes/post/{{ item.post['uid'] }}" style="display:inline;">
|
||||
<input type="hidden" name="value" value="1">
|
||||
<button type="submit" class="post-action-btn" data-vote="1" data-target="{{ item.post['uid'] }}" data-type="post">
|
||||
<button type="submit" class="post-action-btn vote-up" data-vote="1" data-target="{{ item.post['uid'] }}" data-type="post">
|
||||
+{{ item.post.get('stars', 0) }}
|
||||
</button>
|
||||
</form>
|
||||
<a href="/posts/{{ item.post['uid'] }}" class="post-action-btn">
|
||||
💬 {{ item.comment_count }}
|
||||
</a>
|
||||
<a href="/posts/{{ item.post['uid'] }}" class="post-action-btn" style="margin-left: auto;">
|
||||
<a href="/posts/{{ item.post['uid'] }}" class="post-action-btn share">
|
||||
↗︎ Open
|
||||
</a>
|
||||
<button class="post-action-btn">🔗 Share</button>
|
||||
</div>
|
||||
</article>
|
||||
{% else %}
|
||||
<div class="card" style="text-align: center; padding: 3rem 1rem;">
|
||||
<p style="color: var(--text-muted); font-size: 0.9375rem;">No posts yet. Be the first!</p>
|
||||
</div>
|
||||
<div class="empty-state">No posts yet. Be the first!</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -234,7 +231,7 @@
|
||||
{% for author in top_authors %}
|
||||
<div class="stat-row">
|
||||
<span class="label">
|
||||
<span class="avatar avatar-sm" style="display: inline-flex; width: 20px; height: 20px; font-size: 0.625rem; vertical-align: middle; margin-right: 0.375rem;">{{ author['username'][:1].upper() }}</span>
|
||||
<img src="{{ avatar_url(author.get('avatar_style', 'initials'), author['username'], 20) }}" class="avatar-img" style="width: 20px; height: 20px; vertical-align: middle; margin-right: 0.375rem;" alt="{{ author['username'] }}">
|
||||
{{ author['username'] }}
|
||||
</span>
|
||||
<span class="value">{{ author.get('stars', 0) }}</span>
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
<div class="messages-conversations">
|
||||
{% for conv in conversations %}
|
||||
<a href="/messages?with_uid={{ conv.other_user['uid'] }}" class="conversation-item {% if current_conversation == conv.other_user['uid'] %}active{% endif %}">
|
||||
<div class="avatar avatar-sm">{{ conv.other_user['username'][:1].upper() }}</div>
|
||||
<img src="{{ avatar_url(conv.other_user.get('avatar_style', 'initials'), conv.other_user['username'], 32) }}" class="avatar-img avatar-sm" alt="{{ conv.other_user['username'] }}">
|
||||
<div class="conversation-info">
|
||||
<div class="conversation-name">{{ conv.other_user['username'] }}</div>
|
||||
<div class="conversation-preview">{{ conv.last_message[:60] }}</div>
|
||||
@@ -60,9 +60,7 @@
|
||||
<span class="conversation-time">{{ conv.last_message_at[:10] }}</span>
|
||||
</a>
|
||||
{% else %}
|
||||
<div style="padding: 2rem 1rem; text-align: center; color: var(--text-muted); font-size: 0.875rem;">
|
||||
No conversations yet
|
||||
</div>
|
||||
<div class="empty-state" style="border: none;">No conversations yet</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -70,7 +68,7 @@
|
||||
<div class="messages-main">
|
||||
{% if other_user %}
|
||||
<div class="messages-main-header">
|
||||
<div class="avatar avatar-sm">{{ other_user['username'][:1].upper() }}</div>
|
||||
<img src="{{ avatar_url(other_user.get('avatar_style', 'initials'), other_user['username'], 32) }}" class="avatar-img avatar-sm" alt="{{ other_user['username'] }}">
|
||||
<h3>{{ other_user['username'] }}</h3>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
<div class="notifications-list">
|
||||
{% for item in notifications %}
|
||||
<div class="notification-card {% if not item.notification['read'] %}unread{% endif %}">
|
||||
<div class="avatar avatar-sm">{{ item.actor['username'][:1].upper() if item.actor else '?' }}</div>
|
||||
<img src="{{ avatar_url(item.actor.get('avatar_style', 'initials') if item.actor else 'initials', item.actor['username'] if item.actor else '?', 32) }}" class="avatar-img avatar-sm" alt="{{ item.actor['username'] if item.actor else '?' }}">
|
||||
<div class="notification-body">
|
||||
<div class="notification-text">{{ item.notification['message'] }}</div>
|
||||
<div class="notification-time">{{ item.time_ago }}</div>
|
||||
@@ -62,9 +62,7 @@
|
||||
</form>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="notifications-empty">
|
||||
<p>No notifications yet</p>
|
||||
</div>
|
||||
<div class="empty-state">No notifications yet</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
|
||||
<article class="post-detail">
|
||||
<div class="post-detail-header">
|
||||
<div class="avatar">{{ author['username'][:1].upper() if author else '?' }}</div>
|
||||
<img src="{{ avatar_url(author.get('avatar_style', 'initials') if author else 'initials', author['username'] if author else '?', 40) }}" class="avatar-img" style="width: 40px; height: 40px; border-radius: 50%;" alt="{{ author['username'] if author else '?' }}">
|
||||
<div>
|
||||
<div class="post-detail-author">{{ author['username'] if author else 'Unknown' }}
|
||||
{% if author and author.get('role') %}
|
||||
@@ -97,8 +97,9 @@
|
||||
<section class="comments-section">
|
||||
<h3>Comments</h3>
|
||||
|
||||
{% for item in comments %}
|
||||
<div class="comment">
|
||||
{% macro render_comment(item, depth=0) %}
|
||||
<div class="comment" style="margin-left: {{ depth * 1.5 }}rem;">
|
||||
<div class="comment-thread-line"></div>
|
||||
<div class="comment-votes">
|
||||
<form method="POST" action="/votes/comment/{{ item.comment['uid'] }}">
|
||||
<input type="hidden" name="value" value="1">
|
||||
@@ -113,7 +114,7 @@
|
||||
|
||||
<div class="comment-body">
|
||||
<div class="comment-header">
|
||||
<div class="avatar avatar-sm" style="width: 24px; height: 24px; font-size: 0.625rem;">{{ item.author['username'][:1].upper() if item.author else '?' }}</div>
|
||||
<img src="{{ avatar_url(item.author.get('avatar_style', 'initials') if item.author else 'initials', item.author['username'] if item.author else '?', 24) }}" class="avatar-img" style="width: 24px; height: 24px; border-radius: 50%;" alt="{{ item.author['username'] if item.author else '?' }}">
|
||||
<span class="comment-author">{{ item.author['username'] if item.author else 'Unknown' }}</span>
|
||||
<span class="comment-time">{{ item.time_ago }}</span>
|
||||
</div>
|
||||
@@ -126,8 +127,20 @@
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if item.children %}
|
||||
<div class="comment-replies">
|
||||
{% for child in item.children %}
|
||||
{{ render_comment(child, depth + 1) }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% for item in comments %}
|
||||
{{ render_comment(item, 0) }}
|
||||
{% else %}
|
||||
<p style="color: var(--text-muted); font-size: 0.875rem; text-align: center; padding: 2rem 0;">No comments yet. Start the discussion.</p>
|
||||
{% endfor %}
|
||||
@@ -135,7 +148,7 @@
|
||||
{% if user %}
|
||||
<form class="comment-form" method="POST" action="/comments/create">
|
||||
<input type="hidden" name="post_uid" value="{{ post['uid'] }}">
|
||||
<div class="avatar avatar-sm">{{ user['username'][:1].upper() }}</div>
|
||||
<img src="{{ avatar_url(user.get('avatar_style', 'initials'), user['username'], 32) }}" class="avatar-img avatar-sm" alt="{{ user['username'] }}">
|
||||
<textarea name="content" placeholder="Your opinion goes here..." required maxlength="1000"></textarea>
|
||||
<div class="comment-form-actions">
|
||||
<button type="button" title="Add emoji">😀</button>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{% extends "base.html" %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/profile.css">
|
||||
<link rel="stylesheet" href="/static/css/projects.css">
|
||||
<style>
|
||||
.topnav {
|
||||
position: fixed;
|
||||
@@ -45,8 +46,8 @@
|
||||
<div class="profile-layout">
|
||||
<aside class="profile-sidebar">
|
||||
<div class="profile-card">
|
||||
<div class="profile-avatar-wrap">
|
||||
<div class="avatar avatar-lg">{{ profile_user['username'][:1].upper() }}</div>
|
||||
<div class="profile-avatar-wrap" style="position: relative;">
|
||||
<img src="{{ avatar_url(profile_user.get('avatar_style', 'initials'), profile_user['username'], 80) }}" class="avatar-img avatar-lg" alt="{{ profile_user['username'] }}" id="profile-avatar-preview">
|
||||
</div>
|
||||
<div class="profile-name">{{ profile_user['username'] }}</div>
|
||||
{% if profile_user.get('role') %}
|
||||
@@ -87,7 +88,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if user and user['uid'] == profile_user['uid'] %}
|
||||
{% if user and user['uid'] == profile_user['uid'] %}
|
||||
<div class="profile-info">
|
||||
<form method="POST" action="/profile/update">
|
||||
<div class="profile-info-row">
|
||||
@@ -134,6 +135,23 @@
|
||||
<input type="url" name="website" maxlength="500" value="{{ profile_user.get('website', '') }}" style="font-size: 0.8125rem;">
|
||||
</div>
|
||||
|
||||
<div class="profile-info-row">
|
||||
<span class="profile-info-label">Avatar Style</span>
|
||||
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ avatar_url(profile_user.get('avatar_style', 'initials'), profile_user['username'], 32) }}" class="avatar-img avatar-sm" alt="">
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: grid; grid-template-columns: repeat(5, 1fr); gap: 0.375rem; margin: 0.5rem 0;">
|
||||
{% for style in avatar_styles() %}
|
||||
<label style="display: flex; flex-direction: column; align-items: center; gap: 0.125rem; padding: 0.25rem; border-radius: var(--radius); border: 2px solid var(--border); cursor: pointer; transition: border-color 0.2s; {% if profile_user.get('avatar_style', 'initials') == style %}border-color: var(--accent);{% endif %}">
|
||||
<input type="radio" name="avatar_style" value="{{ style }}" {% if profile_user.get('avatar_style', 'initials') == style %}checked{% endif %} style="display: none;" onchange="this.closest('label').parentElement.querySelectorAll('label').forEach(l => l.style.borderColor = 'var(--border)'); this.closest('label').style.borderColor = 'var(--accent)'; document.getElementById('profile-avatar-preview').src = '{{ avatar_url(style, profile_user['username'], 80) }}';">
|
||||
<img src="{{ avatar_url(style, profile_user['username'], 36) }}" class="avatar-img" style="width: 28px; height: 28px; border-radius: 50%;" alt="{{ style }}">
|
||||
<span style="font-size: 0.5rem; color: var(--text-muted); text-align: center; line-height: 1.1;">{{ style[:8] }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<small style="color: var(--text-muted); font-size: 0.75rem; display: block; margin-bottom: 0.5rem;">Avatars by DiceBear</small>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-sm" style="width: 100%; margin-top: 0.5rem;">Save Changes</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -174,13 +192,12 @@
|
||||
{% for item in posts %}
|
||||
<article class="post-card fade-in">
|
||||
<div class="post-header">
|
||||
<div class="avatar avatar-sm">{{ profile_user['username'][:1].upper() }}</div>
|
||||
<div>
|
||||
<div class="post-author">{{ profile_user['username'] }}
|
||||
{% if profile_user.get('role') %}
|
||||
<span class="post-author-role">· {{ profile_user['role'] }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<img src="{{ avatar_url(profile_user.get('avatar_style', 'initials'), profile_user['username'], 32) }}" class="avatar-img avatar-sm" alt="{{ profile_user['username'] }}">
|
||||
<div class="post-author-wrap">
|
||||
<span class="post-author">{{ profile_user['username'] }}</span>
|
||||
{% if profile_user.get('role') %}
|
||||
<span class="post-author-role">{{ profile_user['role'] }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<span class="post-time">{{ item.time_ago }}</span>
|
||||
</div>
|
||||
@@ -194,33 +211,38 @@
|
||||
<div class="post-actions">
|
||||
<span class="post-action-btn" style="cursor: default;">+{{ item.post.get('stars', 0) }}</span>
|
||||
<span class="post-action-btn" style="cursor: default;">💬 {{ item.comment_count }}</span>
|
||||
<a href="/posts/{{ item.post['uid'] }}" class="post-action-btn" style="margin-left: auto;">↗︎ Open</a>
|
||||
<a href="/posts/{{ item.post['uid'] }}" class="post-action-btn share">↗︎ Open</a>
|
||||
</div>
|
||||
</article>
|
||||
{% else %}
|
||||
<div class="card" style="text-align: center; padding: 3rem 1rem;">
|
||||
<p style="color: var(--text-muted);">No posts yet.</p>
|
||||
</div>
|
||||
<div class="empty-state">No posts yet.</div>
|
||||
{% endfor %}
|
||||
{% elif current_tab == 'projects' %}
|
||||
{% for p in projects %}
|
||||
<div class="card">
|
||||
<h3 style="font-size: 1rem; font-weight: 700; margin-bottom: 0.25rem;">{{ p['title'] }}</h3>
|
||||
<p style="font-size: 0.8125rem; color: var(--text-secondary);">{{ p.get('description', '')[:200] }}</p>
|
||||
<div style="margin-top: 0.5rem; display: flex; gap: 0.375rem;">
|
||||
<span class="badge" style="background: var(--border); color: var(--text-muted);">{{ p.get('project_type', 'software') }}</span>
|
||||
<span class="badge" style="background: var(--border); color: var(--text-muted);">{{ p.get('status', 'In Development') }}</span>
|
||||
<div class="project-card fade-in">
|
||||
<div class="project-card-header">
|
||||
<h3 class="project-card-title">{{ p['title'] }}</h3>
|
||||
</div>
|
||||
<div class="project-card-meta">
|
||||
<span class="project-status {% if p.get('status') == 'Released' %}released{% else %}dev{% endif %}">
|
||||
● {{ p.get('status', 'In Development') }}
|
||||
</span>
|
||||
<span>{{ p.get('project_type', 'software')|replace('_', ' ')|capitalize }}</span>
|
||||
</div>
|
||||
<div class="project-card-desc">{{ p.get('description', '')[:200] }}</div>
|
||||
{% if p.get('platforms') %}
|
||||
<div class="project-card-platforms">
|
||||
{% for plat in p['platforms'].split(',') %}
|
||||
<span class="platform-tag">{{ plat.strip() }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card" style="text-align: center; padding: 3rem 1rem;">
|
||||
<p style="color: var(--text-muted);">No projects yet.</p>
|
||||
</div>
|
||||
<div class="empty-state">No projects yet.</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="card" style="text-align: center; padding: 3rem 1rem;">
|
||||
<p style="color: var(--text-muted);">Activity coming soon.</p>
|
||||
</div>
|
||||
<div class="empty-state">Activity coming soon.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -122,9 +122,7 @@
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card" style="text-align: center; padding: 3rem 1rem; grid-column: 1 / -1;">
|
||||
<p style="color: var(--text-muted); font-size: 0.9375rem;">No projects found. Create one!</p>
|
||||
</div>
|
||||
<div class="empty-state" style="grid-column: 1 / -1;">No projects found. Create one!</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -47,7 +47,26 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="auth-field">
|
||||
<label>Avatar Style</label>
|
||||
<div class="avatar-picker" style="display: grid; grid-template-columns: repeat(6, 1fr); gap: 0.5rem; margin-top: 0.25rem;">
|
||||
{% for style in avatar_styles() %}
|
||||
<label class="avatar-option" style="display: flex; flex-direction: column; align-items: center; gap: 0.25rem; padding: 0.375rem; border-radius: var(--radius); border: 2px solid var(--border); cursor: pointer; transition: border-color 0.2s;">
|
||||
<input type="radio" name="avatar_style" value="{{ style }}" {% if loop.first %}checked{% endif %} style="display: none;">
|
||||
<img src="{{ avatar_url(style, 'demo', 48) }}" class="avatar-img" style="width: 36px; height: 36px; border-radius: 50%;" alt="{{ style }}">
|
||||
<span style="font-size: 0.5625rem; color: var(--text-muted); text-align: center; line-height: 1.2;">{{ style.replace('-', ' ')|capitalize }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<small style="color: var(--text-muted); font-size: 0.75rem; margin-top: 0.375rem; display: block;">Avatars by DiceBear — choose your style</small>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="auth-submit">Create account</button>
|
||||
|
||||
<style>
|
||||
.avatar-option:hover { border-color: var(--accent); }
|
||||
.avatar-option input[type="radio"]:checked + img { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 50%; }
|
||||
</style>
|
||||
</form>
|
||||
|
||||
<div class="auth-footer">
|
||||
|
||||
Reference in New Issue
Block a user