forked from retoor/devplacepy
Working with avatars/
This commit is contained in:
@@ -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
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
@@ -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"]),
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user