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.
251 lines
15 KiB
HTML
251 lines
15 KiB
HTML
{% 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;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
height: var(--nav-height);
|
|
background: var(--bg-secondary);
|
|
border-bottom: 1px solid var(--border);
|
|
z-index: 1000;
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
.topnav-inner {
|
|
max-width: var(--max-content);
|
|
margin: 0 auto;
|
|
padding: 0 1rem;
|
|
width: 100%;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 2rem;
|
|
}
|
|
.topnav-logo { font-size: 1.25rem; font-weight: 800; color: var(--text-primary); letter-spacing: -0.02em; }
|
|
.topnav-logo span { color: var(--accent); }
|
|
.topnav-links { display: flex; gap: 0.25rem; }
|
|
.topnav-link { padding: 0.5rem 0.75rem; border-radius: var(--radius); font-size: 0.875rem; font-weight: 500; color: var(--text-secondary); transition: all 0.2s; }
|
|
.topnav-link:hover { color: var(--text-primary); background: var(--bg-card); }
|
|
.topnav-link.active { color: var(--accent); background: var(--accent-light); }
|
|
.topnav-right { margin-left: auto; display: flex; align-items: center; gap: 1rem; }
|
|
.topnav-icon { position: relative; padding: 0.375rem; color: var(--text-secondary); font-size: 1.25rem; transition: color 0.2s; }
|
|
.topnav-icon:hover { color: var(--text-primary); }
|
|
.nav-badge { position: absolute; top: 0; right: 0; min-width: 16px; height: 16px; padding: 0 4px; border-radius: 8px; background: var(--accent); color: #fff; font-size: 0.6875rem; font-weight: 700; display: flex; align-items: center; justify-content: center; }
|
|
.topnav-user { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem; border-radius: var(--radius); transition: background 0.2s; }
|
|
.topnav-user:hover { background: var(--bg-card); }
|
|
.topnav-user-info { display: flex; flex-direction: column; line-height: 1.2; }
|
|
.topnav-user-name { font-size: 0.8125rem; font-weight: 600; color: var(--text-primary); }
|
|
.topnav-user-role { font-size: 0.6875rem; color: var(--text-muted); }
|
|
.hidden { display: none !important; }
|
|
</style>
|
|
{% endblock %}
|
|
{% block content %}
|
|
<div class="profile-layout">
|
|
<aside class="profile-sidebar">
|
|
<div class="profile-card">
|
|
<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') %}
|
|
<div class="profile-role">{{ profile_user['role'] }}</div>
|
|
{% endif %}
|
|
|
|
<div class="profile-stats">
|
|
<div class="profile-stat">
|
|
<span class="profile-stat-value">{{ profile_user.get('posts_count', 0) }}</span>
|
|
<span class="profile-stat-label">Posts</span>
|
|
</div>
|
|
<div class="profile-stat">
|
|
<span class="profile-stat-value">{{ profile_user.get('level', 1) }}</span>
|
|
<span class="profile-stat-label">Level</span>
|
|
</div>
|
|
<div class="profile-stat">
|
|
<span class="profile-stat-value">{{ profile_user.get('stars', 0) }}</span>
|
|
<span class="profile-stat-label">Stars</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="profile-level-bar">
|
|
<div class="level-label">
|
|
<span>Progress to next level</span>
|
|
<span>{{ profile_user.get('xp', 0) % 100 }}%</span>
|
|
</div>
|
|
<div class="bar">
|
|
<div class="bar-fill" style="width: {{ profile_user.get('xp', 0) % 100 }}%"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="profile-badges">
|
|
{% for badge in badges %}
|
|
<span class="profile-badge">{{ badge['badge_name'] }}</span>
|
|
{% else %}
|
|
<span class="profile-badge">Member</span>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
|
|
{% if user and user['uid'] == profile_user['uid'] %}
|
|
<div class="profile-info">
|
|
<form method="POST" action="/profile/update">
|
|
<div class="profile-info-row">
|
|
<span class="profile-info-label">Bio</span>
|
|
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
|
<span class="profile-info-value bio-display" id="display-bio">{{ profile_user.get('bio', '') or 'Not set' }}</span>
|
|
<button type="button" class="profile-info-edit" data-edit-field="bio">✎</button>
|
|
</div>
|
|
</div>
|
|
<div class="auth-field hidden" id="input-bio" style="margin-bottom: 0.5rem;">
|
|
<textarea name="bio" maxlength="500" rows="2" style="font-size: 0.8125rem;">{{ profile_user.get('bio', '') }}</textarea>
|
|
</div>
|
|
|
|
<div class="profile-info-row">
|
|
<span class="profile-info-label">Location</span>
|
|
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
|
<span class="profile-info-value" id="display-location">{{ profile_user.get('location', '') or 'Not set' }}</span>
|
|
<button type="button" class="profile-info-edit" data-edit-field="location">✎</button>
|
|
</div>
|
|
</div>
|
|
<div class="auth-field hidden" id="input-location" style="margin-bottom: 0.5rem;">
|
|
<input type="text" name="location" maxlength="200" value="{{ profile_user.get('location', '') }}" style="font-size: 0.8125rem;">
|
|
</div>
|
|
|
|
<div class="profile-info-row">
|
|
<span class="profile-info-label">Git Link</span>
|
|
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
|
<span class="profile-info-value" id="display-git_link">{{ profile_user.get('git_link', '') or 'Not set' }}</span>
|
|
<button type="button" class="profile-info-edit" data-edit-field="git_link">✎</button>
|
|
</div>
|
|
</div>
|
|
<div class="auth-field hidden" id="input-git_link" style="margin-bottom: 0.5rem;">
|
|
<input type="url" name="git_link" maxlength="500" value="{{ profile_user.get('git_link', '') }}" style="font-size: 0.8125rem;">
|
|
</div>
|
|
|
|
<div class="profile-info-row">
|
|
<span class="profile-info-label">Website</span>
|
|
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
|
<span class="profile-info-value" id="display-website">{{ profile_user.get('website', '') or 'Not set' }}</span>
|
|
<button type="button" class="profile-info-edit" data-edit-field="website">✎</button>
|
|
</div>
|
|
</div>
|
|
<div class="auth-field hidden" id="input-website" style="margin-bottom: 0.5rem;">
|
|
<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>
|
|
{% else %}
|
|
<div class="profile-info">
|
|
<div class="profile-info-row">
|
|
<span class="profile-info-label">Bio</span>
|
|
<span class="profile-info-value">{{ profile_user.get('bio', '') or 'Not set' }}</span>
|
|
</div>
|
|
<div class="profile-info-row">
|
|
<span class="profile-info-label">Location</span>
|
|
<span class="profile-info-value">{{ profile_user.get('location', '') or 'Not set' }}</span>
|
|
</div>
|
|
<div class="profile-info-row">
|
|
<span class="profile-info-label">Git Link</span>
|
|
<span class="profile-info-value">{{ profile_user.get('git_link', '') or 'Not set' }}</span>
|
|
</div>
|
|
<div class="profile-info-row">
|
|
<span class="profile-info-label">Website</span>
|
|
<span class="profile-info-value">{{ profile_user.get('website', '') or 'Not set' }}</span>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
</aside>
|
|
|
|
<div class="profile-content">
|
|
<a href="/feed" style="display: inline-flex; align-items: center; gap: 0.375rem; color: var(--text-secondary); font-size: 0.875rem; margin-bottom: 1rem;">← Back</a>
|
|
|
|
<div class="profile-tabs">
|
|
<a href="/profile/{{ profile_user['username'] }}?tab=posts" class="profile-tab {% if current_tab == 'posts' %}active{% endif %}">Posts</a>
|
|
<a href="/profile/{{ profile_user['username'] }}?tab=projects" class="profile-tab {% if current_tab == 'projects' %}active{% endif %}">Projects</a>
|
|
<a href="/profile/{{ profile_user['username'] }}?tab=activity" class="profile-tab {% if current_tab == 'activity' %}active{% endif %}">Activity</a>
|
|
<button class="btn-ghost btn-icon" style="margin-left: auto; font-size: 0.8125rem;">△</button>
|
|
</div>
|
|
|
|
<div class="profile-posts">
|
|
{% if current_tab == 'posts' %}
|
|
{% for item in posts %}
|
|
<article class="post-card fade-in">
|
|
<div class="post-header">
|
|
<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>
|
|
<div class="post-topic">
|
|
<span class="badge badge-{{ item.post['topic'] }}">{{ item.post['topic'] }}</span>
|
|
</div>
|
|
{% if item.post.get('title') %}
|
|
<h3 class="post-title">{{ item.post['title'] }}</h3>
|
|
{% endif %}
|
|
<p class="post-content">{{ item.post['content'][:200] }}{% if item.post['content']|length > 200 %}...{% endif %}</p>
|
|
<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 share">↗︎ Open</a>
|
|
</div>
|
|
</article>
|
|
{% else %}
|
|
<div class="empty-state">No posts yet.</div>
|
|
{% endfor %}
|
|
{% elif current_tab == 'projects' %}
|
|
{% for p in projects %}
|
|
<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="empty-state">No projects yet.</div>
|
|
{% endfor %}
|
|
{% else %}
|
|
<div class="empty-state">Activity coming soon.</div>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|