Working with avatars/

This commit is contained in:
2026-05-10 21:33:53 +02:00
parent 75236eb421
commit e340a8dc42
26 changed files with 745 additions and 456 deletions
+40
View File
@@ -0,0 +1,40 @@
import logging
logger = logging.getLogger(__name__)
AVATAR_STYLES = [
"adventurer",
"adventurer-neutral",
"avataaars",
"big-ears",
"big-smile",
"bottts",
"croodles",
"fun-emoji",
"identicon",
"initials",
"lorelei",
"micah",
"miniavs",
"notionists",
"open-peeps",
"personas",
"pixel-art",
"shapes",
"thumbs",
]
DICEBEAR_BASE = "https://api.dicebear.com/9.x"
def avatar_url(style: str, seed: str, size: int = 128) -> str:
style = style or "initials"
return f"/avatar/{style}/{seed}?size={size}"
def dicebear_proxy_url(style: str, seed: str, size: int = 128) -> str:
return f"{DICEBEAR_BASE}/{style}/svg?seed={seed}&size={size}"
def avatar_styles():
return AVATAR_STYLES
+2 -1
View File
@@ -6,7 +6,7 @@ from devplacepy.config import STATIC_DIR
from devplacepy.database import init_db
from devplacepy.templating import templates
from devplacepy.utils import get_current_user
from devplacepy.routers import auth, feed, posts, comments, projects, profile, messages, notifications, votes
from devplacepy.routers import auth, feed, posts, comments, projects, profile, messages, notifications, votes, avatar
logging.basicConfig(
level=logging.INFO,
@@ -26,6 +26,7 @@ app.include_router(profile.router, prefix="/profile")
app.include_router(messages.router, prefix="/messages")
app.include_router(notifications.router, prefix="/notifications")
app.include_router(votes.router, prefix="/votes")
app.include_router(avatar.router, prefix="/avatar")
@app.on_event("startup")
+4 -1
View File
@@ -59,6 +59,9 @@ async def signup(request: Request):
)
uid = generate_uid()
avatar_style = form.get("avatar_style", "initials")
if avatar_style not in ("adventurer", "adventurer-neutral", "avataaars", "big-ears", "big-smile", "bottts", "croodles", "fun-emoji", "identicon", "initials", "lorelei", "micah", "miniavs", "notionists", "open-peeps", "personas", "pixel-art", "shapes", "thumbs"):
avatar_style = "initials"
users.insert({
"uid": uid,
"username": username,
@@ -68,7 +71,7 @@ async def signup(request: Request):
"location": "",
"git_link": "",
"website": "",
"avatar": "",
"avatar_style": avatar_style,
"role": "Member",
"level": 1,
"xp": 0,
+45
View File
@@ -0,0 +1,45 @@
import logging
import httpx
from fastapi import APIRouter, Request
from fastapi.responses import Response
from devplacepy.avatar import dicebear_proxy_url
logger = logging.getLogger(__name__)
router = APIRouter()
_cache = {}
def _initial_svg(seed: str) -> str:
initial = seed[:1].upper() if seed else "?"
return (
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">'
f'<rect width="100" height="100" rx="50" fill="#ff6b35"/>'
f'<text x="50" y="65" text-anchor="middle" fill="white" font-size="40" font-weight="700" font-family="sans-serif">{initial}</text></svg>'
)
@router.get("/{style}/{seed}")
async def avatar_proxy(request: Request, style: str, seed: str, size: int = 128):
cache_key = f"{style}:{seed}:{size}"
if cache_key in _cache:
return Response(content=_cache[cache_key], media_type="image/svg+xml")
if style == "initials" or not style:
svg = _initial_svg(seed)
_cache[cache_key] = svg
return Response(content=svg, media_type="image/svg+xml")
url = dicebear_proxy_url(style, seed, size)
try:
async with httpx.AsyncClient(timeout=3.0) as client:
resp = await client.get(url, follow_redirects=True)
content_type = resp.headers.get("content-type", "image/svg+xml")
body = resp.content
_cache[cache_key] = body
return Response(content=body, media_type=content_type)
except Exception as e:
logger.warning(f"Avatar proxy failed for {style}/{seed}: {e}")
svg = _initial_svg(seed)
_cache[cache_key] = svg
return Response(content=svg, media_type="image/svg+xml")
+13 -4
View File
@@ -61,10 +61,10 @@ async def view_post(request: Request, post_uid: str):
comments_table = get_table("comments")
raw_comments = list(comments_table.find(post_uid=post_uid, order_by=["created_at"]))
comments = []
comment_map = {}
for c in raw_comments:
commenter = users_table.find_one(uid=c["user_uid"])
comments.append({
comment_map[c["uid"]] = {
"comment": c,
"author": commenter,
"time_ago": time_ago(c["created_at"]),
@@ -72,13 +72,22 @@ async def view_post(request: Request, post_uid: str):
"up": len(list(get_table("votes").find(target_uid=c["uid"], value=1))),
"down": len(list(get_table("votes").find(target_uid=c["uid"], value=-1))),
},
})
"children": [],
}
top_level = []
for item in comment_map.values():
parent_uid = item["comment"].get("parent_uid")
if parent_uid and parent_uid in comment_map:
comment_map[parent_uid]["children"].append(item)
else:
top_level.append(item)
return templates.TemplateResponse("post.html", {
"request": request,
"user": user,
"post": post,
"author": author,
"comments": comments,
"comments": top_level,
"time_ago": time_ago(post["created_at"]),
})
+8 -2
View File
@@ -52,15 +52,21 @@ async def update_profile(request: Request):
location = form.get("location", "").strip()
git_link = form.get("git_link", "").strip()
website = form.get("website", "").strip()
avatar_style = form.get("avatar_style", "").strip()
users = get_table("users")
users.update({
update_data = {
"uid": user["uid"],
"bio": bio,
"location": location,
"git_link": git_link,
"website": website,
}, ["uid"])
}
valid_styles = ("adventurer", "adventurer-neutral", "avataaars", "big-ears", "big-smile", "bottts", "croodles", "fun-emoji", "identicon", "initials", "lorelei", "micah", "miniavs", "notionists", "open-peeps", "personas", "pixel-art", "shapes", "thumbs")
if avatar_style in valid_styles:
update_data["avatar_style"] = avatar_style
users.update(update_data, ["uid"])
logger.info(f"Profile updated for {user['username']}")
return RedirectResponse(url=f"/profile/{user['username']}", status_code=302)
+11
View File
@@ -173,6 +173,7 @@ img {
.avatar-sm { width: 32px; height: 32px; font-size: 0.8125rem; }
.avatar-lg { width: 80px; height: 80px; font-size: 2rem; }
.avatar-img { border-radius: 50%; object-fit: cover; flex-shrink: 0; }
.card {
background: var(--bg-card);
@@ -188,6 +189,16 @@ img {
padding-top: calc(var(--nav-height) + 1rem);
}
.empty-state {
text-align: center;
padding: 3rem 1rem;
color: var(--text-muted);
font-size: 0.9375rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
}
.fade-in {
animation: fadeIn 0.3s ease;
}
+73 -15
View File
@@ -8,6 +8,10 @@
.feed-sidebar {
position: sticky;
top: calc(var(--nav-height) + 1rem);
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1rem;
}
.feed-sidebar h3 {
@@ -22,7 +26,7 @@
.feed-categories {
display: flex;
flex-direction: column;
gap: 0.25rem;
gap: 0.125rem;
}
.feed-category {
@@ -37,7 +41,7 @@
}
.feed-category:hover {
background: var(--bg-card);
background: var(--bg-card-hover);
color: var(--text-primary);
}
@@ -76,6 +80,7 @@
.feed-nav-btn:hover {
color: var(--text-primary);
background: var(--bg-card-hover);
}
.feed-nav-btn.active {
@@ -89,6 +94,19 @@
gap: 0.25rem;
}
.feed-nav-actions button {
color: var(--text-muted);
padding: 0.375rem 0.5rem;
border-radius: var(--radius);
font-size: 0.875rem;
transition: all 0.2s;
}
.feed-nav-actions button:hover {
color: var(--text-primary);
background: var(--bg-card-hover);
}
.feed-posts {
display: flex;
flex-direction: column;
@@ -99,12 +117,13 @@
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1rem;
transition: border-color 0.2s;
padding: 1.25rem;
transition: all 0.2s;
}
.post-card:hover {
border-color: var(--border-light);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.post-header {
@@ -114,21 +133,29 @@
margin-bottom: 0.75rem;
}
.post-author-wrap {
display: flex;
flex-direction: column;
line-height: 1.3;
}
.post-author {
font-weight: 600;
font-size: 0.875rem;
color: var(--text-primary);
}
.post-author-role {
font-size: 0.75rem;
font-size: 0.6875rem;
color: var(--text-muted);
font-weight: 400;
}
.post-time {
font-size: 0.75rem;
font-size: 0.6875rem;
color: var(--text-muted);
margin-left: auto;
white-space: nowrap;
}
.post-topic {
@@ -140,14 +167,19 @@
font-weight: 700;
margin-bottom: 0.5rem;
color: var(--text-primary);
line-height: 1.3;
}
.post-content {
font-size: 0.875rem;
color: var(--text-secondary);
line-height: 1.6;
line-height: 1.65;
margin-bottom: 0.75rem;
word-break: break-word;
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
}
.post-actions {
@@ -159,14 +191,18 @@
}
.post-action-btn {
display: flex;
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.625rem;
padding: 0.375rem 0.75rem;
border-radius: var(--radius);
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-muted);
transition: all 0.2s;
background: none;
border: none;
cursor: pointer;
}
.post-action-btn:hover {
@@ -174,10 +210,15 @@
color: var(--text-secondary);
}
.post-action-btn.voted {
.post-action-btn.voted,
.post-action-btn.vote-up {
color: var(--accent);
}
.post-action-btn.share {
margin-left: auto;
}
.feed-right {
position: sticky;
top: calc(var(--nav-height) + 1rem);
@@ -190,7 +231,7 @@
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1rem;
padding: 1.25rem;
}
.daily-topic-label {
@@ -205,12 +246,14 @@
.daily-topic-card h4 {
font-size: 0.875rem;
margin-bottom: 0.375rem;
line-height: 1.4;
}
.daily-topic-card p {
font-size: 0.8125rem;
color: var(--text-secondary);
margin-bottom: 0.5rem;
line-height: 1.5;
margin-bottom: 0.75rem;
}
.daily-topic-card .topic-links {
@@ -227,7 +270,7 @@
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1rem;
padding: 1.25rem;
}
.community-stats h3 {
@@ -242,16 +285,28 @@
.stat-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.375rem 0;
font-size: 0.8125rem;
}
.stat-row:not(:last-child) {
border-bottom: 1px solid var(--border);
margin-bottom: 0.25rem;
padding-bottom: 0.5rem;
}
.stat-row .label {
color: var(--text-secondary);
display: flex;
align-items: center;
gap: 0.375rem;
}
.stat-row .value {
font-weight: 600;
font-weight: 700;
color: var(--text-primary);
font-size: 0.9375rem;
}
.feed-fab {
@@ -267,14 +322,17 @@
display: flex;
align-items: center;
justify-content: center;
box-shadow: var(--shadow-lg);
box-shadow: 0 4px 16px rgba(255, 107, 53, 0.4);
transition: all 0.2s;
z-index: 100;
border: none;
cursor: pointer;
}
.feed-fab:hover {
background: var(--accent-hover);
transform: scale(1.05);
box-shadow: 0 6px 20px rgba(255, 107, 53, 0.5);
}
@media (max-width: 1024px) {
+2 -10
View File
@@ -28,8 +28,8 @@
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1rem;
transition: border-color 0.2s;
padding: 1.25rem;
transition: all 0.2s;
}
.notification-card.unread {
@@ -73,12 +73,4 @@
color: var(--text-primary);
}
.notifications-empty {
text-align: center;
padding: 3rem 1rem;
color: var(--text-muted);
}
.notifications-empty p {
font-size: 0.9375rem;
}
+6 -4
View File
@@ -72,12 +72,14 @@
.comment {
display: flex;
gap: 0.75rem;
padding: 1rem 0;
border-bottom: 1px solid var(--border);
padding: 0.75rem 0;
position: relative;
}
.comment:last-child {
border-bottom: none;
.comment-replies {
margin-top: 0.75rem;
padding-left: 0.5rem;
border-left: 2px solid var(--border);
}
.comment-votes {
+1 -1
View File
@@ -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>
+10 -13
View File
@@ -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">&middot; {{ 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">
&#x1F4AC; {{ 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">
&#x2197;&#xFE0E; Open
</a>
<button class="post-action-btn">&#x1F517; 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>
+3 -5
View File
@@ -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>
+2 -4
View File
@@ -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>
+18 -5
View File
@@ -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">&#x1F600;</button>
+48 -26
View File
@@ -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">&middot; {{ 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;">&#x1F4AC; {{ item.comment_count }}</span>
<a href="/posts/{{ item.post['uid'] }}" class="post-action-btn" style="margin-left: auto;">&#x2197;&#xFE0E; Open</a>
<a href="/posts/{{ item.post['uid'] }}" class="post-action-btn share">&#x2197;&#xFE0E; 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 %}">
&#x25CF; {{ 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>
+1 -3
View File
@@ -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>
+19
View File
@@ -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 &mdash; 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">
+3
View File
@@ -1,6 +1,7 @@
from fastapi.templating import Jinja2Templates
from devplacepy.config import TEMPLATES_DIR
from devplacepy.database import get_table
from devplacepy.avatar import avatar_url, avatar_styles
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
@@ -17,3 +18,5 @@ def jinja_user_projects(user_uid: str) -> list:
templates.env.globals["get_unread_count"] = jinja_unread_count
templates.env.globals["get_user_projects"] = jinja_user_projects
templates.env.globals["avatar_url"] = avatar_url
templates.env.globals["avatar_styles"] = avatar_styles