Super fast..

This commit is contained in:
2026-05-11 03:14:43 +02:00
parent 120eb740aa
commit 2dc6eb5c5d
37 changed files with 1203 additions and 366 deletions
+14 -31
View File
@@ -2,39 +2,22 @@ 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
def generate_avatar_svg(seed: str) -> str:
try:
from multiavatar import multiavatar
svg = multiavatar(seed)
if svg and svg.strip().startswith("<svg"):
return svg
except Exception as e:
logger.warning(f"Avatar generation failed for {seed}: {e}")
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>'
)
+77 -29
View File
@@ -5,37 +5,55 @@ from devplacepy.config import DATABASE_URL
logger = logging.getLogger(__name__)
db = dataset.connect(DATABASE_URL)
db = dataset.connect(
DATABASE_URL,
engine_kwargs={
"connect_args": {
"timeout": 30,
"check_same_thread": False,
},
},
on_connect_statements=[
"PRAGMA journal_mode=WAL",
"PRAGMA synchronous=NORMAL",
"PRAGMA busy_timeout=30000",
"PRAGMA cache_size=-8000",
"PRAGMA temp_store=MEMORY",
"PRAGMA mmap_size=268435456",
],
)
def _index(db, table, name, columns):
try:
if table in db.tables:
cols = ", ".join(columns)
db.query(f"CREATE INDEX IF NOT EXISTS {name} ON {table} ({cols})")
except Exception as e:
logger.warning(f"Could not create index {name} on {table}: {e}")
def init_db():
tables = db.tables
if "users" in tables:
db.query("CREATE INDEX IF NOT EXISTS idx_users_username ON users (username)")
db.query("CREATE INDEX IF NOT EXISTS idx_users_email ON users (email)")
if "posts" in tables:
db.query("CREATE INDEX IF NOT EXISTS idx_posts_user_uid ON posts (user_uid)")
db.query("CREATE INDEX IF NOT EXISTS idx_posts_created_at ON posts (created_at)")
db.query("CREATE INDEX IF NOT EXISTS idx_posts_topic ON posts (topic)")
if "comments" in tables:
db.query("CREATE INDEX IF NOT EXISTS idx_comments_post_uid ON comments (post_uid)")
db.query("CREATE INDEX IF NOT EXISTS idx_comments_user_uid ON comments (user_uid)")
if "votes" in tables:
db.query("CREATE INDEX IF NOT EXISTS idx_votes_target ON votes (target_uid, target_type)")
if "messages" in tables:
db.query("CREATE INDEX IF NOT EXISTS idx_messages_sender ON messages (sender_uid)")
db.query("CREATE INDEX IF NOT EXISTS idx_messages_receiver ON messages (receiver_uid)")
if "notifications" in tables:
db.query("CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications (user_uid)")
if "sessions" in tables:
db.query("CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions (session_token)")
if "projects" in tables:
db.query("CREATE INDEX IF NOT EXISTS idx_projects_user ON projects (user_uid)")
if "badges" in tables:
db.query("CREATE INDEX IF NOT EXISTS idx_badges_user ON badges (user_uid)")
if "follows" in tables:
db.query("CREATE INDEX IF NOT EXISTS idx_follows_follower ON follows (follower_uid)")
db.query("CREATE INDEX IF NOT EXISTS idx_follows_following ON follows (following_uid)")
_index(db, "users", "idx_users_username", ["username"])
_index(db, "users", "idx_users_email", ["email"])
_index(db, "posts", "idx_posts_user_uid", ["user_uid"])
_index(db, "posts", "idx_posts_created_at", ["created_at"])
_index(db, "posts", "idx_posts_topic", ["topic"])
_index(db, "comments", "idx_comments_post_uid", ["post_uid"])
_index(db, "comments", "idx_comments_user_uid", ["user_uid"])
_index(db, "votes", "idx_votes_target", ["target_uid", "target_type"])
_index(db, "messages", "idx_messages_sender", ["sender_uid"])
_index(db, "messages", "idx_messages_receiver", ["receiver_uid"])
_index(db, "notifications", "idx_notifications_user", ["user_uid"])
_index(db, "notifications", "idx_notifications_user_read", ["user_uid", "read"])
_index(db, "sessions", "idx_sessions_token", ["session_token"])
_index(db, "projects", "idx_projects_user", ["user_uid"])
_index(db, "badges", "idx_badges_user", ["user_uid"])
_index(db, "follows", "idx_follows_follower", ["follower_uid"])
_index(db, "follows", "idx_follows_following", ["following_uid"])
_index(db, "password_resets", "idx_password_resets_token", ["token"])
if "daily_topics" in tables:
existing = db["daily_topics"].find_one()
if not existing:
@@ -45,8 +63,7 @@ def init_db():
"summary": "New techniques in AI safety research show promising results for alignment of large language models with human values.",
"updated_at": datetime.utcnow().isoformat(),
})
if "password_resets" in tables:
db.query("CREATE INDEX IF NOT EXISTS idx_password_resets_token ON password_resets (token)")
logger.info("Database initialized")
@@ -54,6 +71,37 @@ def get_table(name):
return db[name]
def get_users_by_uids(uids):
if not uids:
return {}
seen = set()
unique = [u for u in uids if u not in seen and not seen.add(u)]
return {u["uid"]: u for u in db["users"].find(db["users"].table.columns.uid.in_(unique))}
def get_comment_counts_by_post_uids(post_uids):
if not post_uids or "comments" not in db.tables:
return {}
placeholders = ",".join(f"'{u}'" for u in post_uids)
rows = db.query(f"SELECT post_uid, COUNT(*) as c FROM comments WHERE post_uid IN ({placeholders}) GROUP BY post_uid")
return {r["post_uid"]: r["c"] for r in rows}
def get_vote_counts(target_uids):
if not target_uids or "votes" not in db.tables:
return {}, {}
placeholders = ",".join(f"'{u}'" for u in target_uids)
rows = db.query(f"SELECT target_uid, value, COUNT(*) as c FROM votes WHERE target_uid IN ({placeholders}) GROUP BY target_uid, value")
ups = {}
downs = {}
for r in rows:
if r["value"] == 1:
ups[r["target_uid"]] = r["c"]
else:
downs[r["target_uid"]] = r["c"]
return ups, downs
def get_daily_topic():
topics = db["daily_topics"]
topic = topics.find_one()
+1 -1
View File
@@ -1,4 +1,4 @@
from pydantic import BaseModel, Field, EmailStr
from pydantic import BaseModel, Field
from typing import Optional
from datetime import date
-4
View File
@@ -60,9 +60,6 @@ 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,
@@ -72,7 +69,6 @@ async def signup(request: Request):
"location": "",
"git_link": "",
"website": "",
"avatar_style": avatar_style,
"role": "Member",
"level": 1,
"xp": 0,
+5 -30
View File
@@ -1,8 +1,7 @@
import logging
import httpx
from fastapi import APIRouter, Request
from fastapi.responses import Response
from devplacepy.avatar import dicebear_proxy_url
from devplacepy.avatar import generate_avatar_svg
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -10,36 +9,12 @@ 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}"
cache_key = f"{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")
svg = generate_avatar_svg(seed)
_cache[cache_key] = svg
return Response(content=svg, media_type="image/svg+xml")
+10 -4
View File
@@ -51,7 +51,7 @@ async def create_comment(request: Request):
"user_uid": post["user_uid"],
"type": "comment",
"message": f"{user['username']} commented on your post",
"related_uid": post_uid,
"related_uid": user["uid"],
"read": False,
"created_at": datetime.utcnow().isoformat(),
})
@@ -65,6 +65,12 @@ async def delete_comment(request: Request, comment_uid: str):
user = require_user(request)
comments = get_table("comments")
comment = comments.find_one(uid=comment_uid)
if comment and comment["user_uid"] == user["uid"]:
comments.delete(id=comment["id"])
return RedirectResponse(url=f"/posts/{comment['post_uid']}", status_code=302)
post_uid = None
if comment:
post_uid = comment.get("post_uid")
if comment["user_uid"] == user["uid"]:
comments.delete(id=comment["id"])
logger.info(f"Comment {comment_uid} deleted by {user['username']}")
if post_uid:
return RedirectResponse(url=f"/posts/{post_uid}", status_code=302)
return RedirectResponse(url="/feed", status_code=302)
+39 -31
View File
@@ -1,63 +1,70 @@
import logging
from datetime import datetime, timedelta
from datetime import datetime
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from devplacepy.database import get_table, get_daily_topic
from fastapi.responses import HTMLResponse
from devplacepy.database import get_table, get_daily_topic, get_users_by_uids, get_comment_counts_by_post_uids
from devplacepy.templating import templates
from devplacepy.utils import get_current_user, require_user, time_ago
from devplacepy.utils import require_user, time_ago
logger = logging.getLogger(__name__)
router = APIRouter()
PAGE_SIZE = 25
def get_feed_posts(user, tab: str = "all", topic: str = None):
def get_feed_posts(user, tab: str = "all", topic: str = None, before: str = None):
posts_table = get_table("posts")
users_table = get_table("users")
query = "1=1"
params = {}
filters = {}
if topic:
query += " AND topic = :topic"
params["topic"] = topic
filters["topic"] = topic
if tab == "following":
follows = get_table("follows")
following = [f["following_uid"] for f in follows.find(follower_uid=user["uid"])]
if following:
placeholders = ",".join(f":uid_{i}" for i in range(len(following)))
query += f" AND user_uid IN ({placeholders})"
for i, uid in enumerate(following):
params[f"uid_{i}"] = uid
if not following:
return [], None
posts = list(posts_table.find(posts_table.table.columns.user_uid.in_(following), order_by=["-created_at"], _limit=PAGE_SIZE))
else:
order = ["-created_at"]
if tab == "trending":
order = ["-stars", "-created_at"]
if before:
posts = list(posts_table.find(**filters, order_by=order, _limit=PAGE_SIZE + 1))
posts = [p for p in posts if p["created_at"] < before][:PAGE_SIZE]
else:
return []
posts = list(posts_table.find(**filters, order_by=order, _limit=PAGE_SIZE + 1))
order = "created_at DESC"
if tab == "trending":
order = "stars DESC, created_at DESC"
has_more = len(posts) > PAGE_SIZE
posts = posts[:PAGE_SIZE]
posts = list(posts_table.find(**params, _limit=50))
posts.sort(key=lambda p: p.get("created_at", ""), reverse=True)
if tab == "trending":
posts.sort(key=lambda p: int(p.get("stars", 0)), reverse=True)
next_cursor = None
if has_more and posts:
next_cursor = posts[-1]["created_at"]
if not posts:
return [], next_cursor
uids = [p["user_uid"] for p in posts]
post_uids = [p["uid"] for p in posts]
authors = get_users_by_uids(uids)
counts = get_comment_counts_by_post_uids(post_uids)
result = []
for post in posts:
author = users_table.find_one(uid=post["user_uid"])
comment_count = len(list(get_table("comments").find(post_uid=post["uid"])))
result.append({
"post": post,
"author": author,
"author": authors.get(post["user_uid"]),
"time_ago": time_ago(post["created_at"]),
"comment_count": comment_count,
"comment_count": counts.get(post["uid"], 0),
})
return result
return result, next_cursor
@router.get("", response_class=HTMLResponse)
async def feed_page(request: Request, tab: str = "all", topic: str = None):
async def feed_page(request: Request, tab: str = "all", topic: str = None, before: str = None):
user = require_user(request)
posts = get_feed_posts(user, tab, topic)
posts, next_cursor = get_feed_posts(user, tab, topic, before)
users_table = get_table("users")
total_members = len(list(users_table.all()))
posts_table = get_table("posts")
@@ -78,4 +85,5 @@ async def feed_page(request: Request, tab: str = "all", topic: str = None):
"total_projects": total_projects,
"top_authors": top_authors,
"daily_topic": daily_topic,
"next_cursor": next_cursor,
})
+23 -16
View File
@@ -4,7 +4,7 @@ from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from devplacepy.database import get_table
from devplacepy.templating import templates
from devplacepy.utils import generate_uid, get_current_user, require_user, time_ago
from devplacepy.utils import generate_uid, require_user, time_ago
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -12,24 +12,30 @@ router = APIRouter()
def get_conversations(user_uid: str):
messages_table = get_table("messages")
users_table = get_table("users")
all_messages = list(messages_table.find(
"sender_uid = :uid OR receiver_uid = :uid",
{"uid": user_uid},
))
all_messages = (
list(messages_table.find(sender_uid=user_uid)) +
list(messages_table.find(receiver_uid=user_uid))
)
conversation_map = {}
other_uids = set()
for msg in all_messages:
other_uid = msg["receiver_uid"] if msg["sender_uid"] == user_uid else msg["sender_uid"]
other_uids.add(other_uid)
if other_uid not in conversation_map or msg["created_at"] > conversation_map[other_uid]["last_message_at"]:
other_user = users_table.find_one(uid=other_uid)
conversation_map[other_uid] = {
"other_user": other_user,
"other_user": None,
"last_message": msg["content"],
"last_message_at": msg["created_at"],
"unread": msg["receiver_uid"] == user_uid and not msg["read"],
}
if other_uids:
from devplacepy.database import get_users_by_uids
users_map = get_users_by_uids(list(other_uids))
for uid, conv in conversation_map.items():
conv["other_user"] = users_map.get(uid)
conversations = sorted(
conversation_map.values(),
key=lambda c: c["last_message_at"],
@@ -40,25 +46,26 @@ def get_conversations(user_uid: str):
def get_conversation_messages(user_uid: str, other_uid: str):
messages_table = get_table("messages")
msgs = list(messages_table.find(
"(sender_uid = :me AND receiver_uid = :other) OR (sender_uid = :other AND receiver_uid = :me)",
{"me": user_uid, "other": other_uid},
))
msgs = (
list(messages_table.find(sender_uid=user_uid, receiver_uid=other_uid)) +
list(messages_table.find(sender_uid=other_uid, receiver_uid=user_uid))
)
msgs.sort(key=lambda m: m["created_at"])
for msg in msgs:
if msg["receiver_uid"] == user_uid and not msg["read"]:
messages_table.update({"id": msg["id"], "read": True}, ["id"])
users_table = get_table("users")
other_user = users_table.find_one(uid=other_uid)
from devplacepy.database import get_users_by_uids
user_ids = list({m["sender_uid"] for m in msgs} | {other_uid})
users_map = get_users_by_uids(user_ids)
other_user = users_map.get(other_uid)
result = []
for m in msgs:
sender = users_table.find_one(uid=m["sender_uid"])
result.append({
"message": m,
"sender": sender,
"sender": users_map.get(m["sender_uid"]),
"is_mine": m["sender_uid"] == user_uid,
"time_ago": time_ago(m["created_at"]),
})
+8 -4
View File
@@ -3,7 +3,7 @@ from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from devplacepy.database import get_table
from devplacepy.templating import templates
from devplacepy.utils import get_current_user, require_user, time_ago
from devplacepy.utils import require_user, time_ago
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -17,13 +17,17 @@ async def notifications_page(request: Request):
notifications_table.find(user_uid=user["uid"], order_by=["-created_at"])
)
users_table = get_table("users")
notifications = []
if raw_notifications:
from devplacepy.database import get_users_by_uids
actor_uids = [n.get("related_uid") for n in raw_notifications if n.get("related_uid")]
actors = get_users_by_uids(actor_uids)
else:
actors = {}
for n in raw_notifications:
actor = users_table.find_one(uid=n.get("related_uid")) if n.get("related_uid") else None
notifications.append({
"notification": n,
"actor": actor,
"actor": actors.get(n.get("related_uid")),
"time_ago": time_ago(n["created_at"]),
})
+29 -14
View File
@@ -1,9 +1,8 @@
import os
import logging
import aiofiles
from datetime import datetime
from pathlib import Path
from fastapi import APIRouter, Request, HTTPException, status, UploadFile, Form
from fastapi import APIRouter, Request, HTTPException
from fastapi.responses import RedirectResponse, HTMLResponse
from devplacepy.database import get_table
from devplacepy.templating import templates
@@ -38,15 +37,23 @@ async def create_post(request: Request):
image_file = form.get("image")
if image_file and hasattr(image_file, "filename") and image_file.filename:
try:
upload_dir = STATIC_DIR / "uploads"
upload_dir.mkdir(parents=True, exist_ok=True)
ext = Path(image_file.filename).suffix or ".png"
image_filename = f"{generate_uid()}{ext}"
file_path = upload_dir / image_filename
content_bytes = await image_file.read()
async with aiofiles.open(str(file_path), "wb") as f:
await f.write(content_bytes)
logger.info(f"Image saved: {image_filename}")
if len(content_bytes) > 5 * 1024 * 1024:
logger.warning(f"Image too large: {image_file.filename}")
else:
import imghdr
ext = Path(image_file.filename).suffix.lower()
allowed = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"}
if ext not in allowed:
logger.warning(f"Unsupported image type: {ext}")
else:
upload_dir = STATIC_DIR / "uploads"
upload_dir.mkdir(parents=True, exist_ok=True)
image_filename = f"{generate_uid()}{ext}"
file_path = upload_dir / image_filename
async with aiofiles.open(str(file_path), "wb") as f:
await f.write(content_bytes)
logger.info(f"Image saved: {image_filename}")
except Exception as e:
logger.warning(f"Image upload failed: {e}")
@@ -91,16 +98,24 @@ 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"]))
if raw_comments:
uids = [c["user_uid"] for c in raw_comments]
cids = [c["uid"] for c in raw_comments]
from devplacepy.database import get_users_by_uids, get_vote_counts
comment_users = get_users_by_uids(uids)
ups, downs = get_vote_counts(cids)
else:
comment_users, ups, downs = {}, {}, {}
comment_map = {}
for c in raw_comments:
commenter = users_table.find_one(uid=c["user_uid"])
comment_map[c["uid"]] = {
"comment": c,
"author": commenter,
"author": comment_users.get(c["user_uid"]),
"time_ago": time_ago(c["created_at"]),
"votes": {
"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))),
"up": ups.get(c["uid"], 0),
"down": downs.get(c["uid"], 0),
},
"children": [],
}
+13 -17
View File
@@ -20,29 +20,32 @@ async def profile_page(request: Request, username: str, tab: str = "posts"):
posts = []
if tab == "posts":
posts_table = get_table("posts")
raw_posts = list(posts_table.find(user_uid=profile_user["uid"]))
raw_posts = list(posts_table.find(user_uid=profile_user["uid"], order_by=["-created_at"]))
if raw_posts:
from devplacepy.database import get_comment_counts_by_post_uids
counts = get_comment_counts_by_post_uids([p["uid"] for p in raw_posts])
else:
counts = {}
for p in raw_posts:
comments_count = len(list(get_table("comments").find(post_uid=p["uid"])))
posts.append({
"post": p,
"time_ago": time_ago(p["created_at"]),
"comment_count": comments_count,
"comment_count": counts.get(p["uid"], 0),
})
posts.sort(key=lambda x: x["post"]["created_at"], reverse=True)
badges = list(get_table("badges").find(user_uid=profile_user["uid"]))
projects = list(get_table("projects").find(user_uid=profile_user["uid"]))
posts_count = len(list(get_table("posts").find(user_uid=profile_user["uid"])))
posts_count = len(posts) or len(list(get_table("posts").find(user_uid=profile_user["uid"])))
activities = []
if tab == "activity":
posts_table = get_table("posts")
for p in posts_table.find(user_uid=profile_user["uid"], order_by=["-created_at"], _limit=10):
activities.append({"type": "post", "content": p.get("title") or p["content"][:80], "time_ago": time_ago(p["created_at"]), "uid": p["uid"]})
activities.append({"type": "post", "content": p.get("title") or p["content"][:80], "time_ago": time_ago(p["created_at"]), "created_at": p["created_at"], "uid": p["uid"]})
comments_table = get_table("comments")
for c in comments_table.find(user_uid=profile_user["uid"], order_by=["-created_at"], _limit=10):
activities.append({"type": "comment", "content": c["content"][:80], "time_ago": time_ago(c["created_at"]), "uid": c["post_uid"]})
activities.sort(key=lambda a: a["time_ago"], reverse=True)
activities.append({"type": "comment", "content": c["content"][:80], "time_ago": time_ago(c["created_at"]), "created_at": c["created_at"], "uid": c["post_uid"]})
activities.sort(key=lambda a: a["created_at"], reverse=True)
is_following = False
if current_user:
@@ -71,21 +74,14 @@ 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")
update_data = {
users.update({
"uid": user["uid"],
"bio": bio,
"location": location,
"git_link": git_link,
"website": website,
}
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"])
}, ["uid"])
logger.info(f"Profile updated for {user['username']}")
return RedirectResponse(url=f"/profile/{user['username']}", status_code=302)
+7 -4
View File
@@ -12,7 +12,6 @@ router = APIRouter()
def get_projects_list(tab: str = "recent", search: str = "", user_uid: str = None, project_type: str = None):
projects = get_table("projects")
users = get_table("users")
all_projects = list(projects.all())
if user_uid:
@@ -29,9 +28,13 @@ def get_projects_list(tab: str = "recent", search: str = "", user_uid: str = Non
or search_lower in p.get("description", "").lower()
]
for p in all_projects:
author = users.find_one(uid=p["user_uid"])
p["author_name"] = author["username"] if author else "Unknown"
if all_projects:
from devplacepy.database import get_users_by_uids
uids = [p["user_uid"] for p in all_projects]
users_map = get_users_by_uids(uids)
for p in all_projects:
author = users_map.get(p["user_uid"])
p["author_name"] = author["username"] if author else "Unknown"
if tab == "released":
all_projects = [p for p in all_projects if p.get("status") == "Released"]
+2 -1
View File
@@ -1,4 +1,5 @@
import logging
from datetime import datetime
from fastapi import APIRouter, Request
from fastapi.responses import RedirectResponse
from devplacepy.database import get_table
@@ -32,7 +33,7 @@ async def vote(request: Request, target_type: str, target_uid: str):
"target_uid": target_uid,
"target_type": target_type,
"value": value,
"created_at": __import__("datetime").datetime.utcnow().isoformat(),
"created_at": datetime.utcnow().isoformat(),
})
up = len(list(votes.find(target_uid=target_uid, value=1)))
+20 -2
View File
@@ -9,8 +9,7 @@ class Application {
this.initNotificationDismiss();
this.initProfileEdit();
this.initProjectForm();
this.loadCSS("/static/css/variables.css");
this.loadCSS("/static/css/base.css");
this.initFormDisable();
}
loadCSS(href) {
@@ -83,6 +82,25 @@ class Application {
titleCount.textContent = `${title.value.length}/500`;
});
}
form.addEventListener("submit", () => {
const btn = form.querySelector("button[type='submit']");
if (btn) {
btn.disabled = true;
btn.textContent = "Posting...";
}
});
}
initFormDisable() {
document.querySelectorAll("form").forEach((form) => {
form.addEventListener("submit", () => {
const btn = form.querySelector("button[type='submit']");
if (btn) {
btn.disabled = true;
}
});
});
}
initCommentForms() {
+1 -1
View File
@@ -28,7 +28,7 @@
{% endif %}
</a>
<a href="/profile/{{ user['username'] }}" class="topnav-user">
<img src="{{ avatar_url(user.get('avatar_style', 'initials'), user['username'], 32) }}" class="avatar-img avatar-sm" alt="{{ user['username'] }}">
<img src="{{ avatar_url('multiavatar', 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>
+11 -7
View File
@@ -50,7 +50,7 @@
{% for item in posts %}
<article class="post-card fade-in">
<div class="post-header">
<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 '?' }}">
<img src="{{ avatar_url('multiavatar', 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') %}
@@ -90,6 +90,12 @@
<div class="empty-state">No posts yet. Be the first!</div>
{% endfor %}
</div>
{% if next_cursor %}
<div style="text-align: center; margin-top: 1rem;">
<a href="/feed?before={{ next_cursor }}{% if current_tab %}&tab={{ current_tab }}{% endif %}{% if current_topic %}&topic={{ current_topic }}{% endif %}" class="btn btn-secondary btn-sm">Load More</a>
</div>
{% endif %}
</div>
<aside class="feed-right">
@@ -122,7 +128,7 @@
{% for author in top_authors %}
<div class="stat-row">
<span class="label">
<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'] }}">
<img src="{{ avatar_url('multiavatar', 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>
@@ -175,11 +181,9 @@
</div>
<div class="auth-field" style="margin-bottom: 1rem;">
<label>Add an image (optional)</label>
<div style="border: 1px dashed var(--border); border-radius: var(--radius); padding: 2rem; text-align: center; color: var(--text-muted); font-size: 0.8125rem;">
Drop image here or click to browse
</div>
</div>
<label for="post-image">Add an image (optional)</label>
<input type="file" id="post-image" name="image" accept="image/*" style="font-size: 0.8125rem; color: var(--text-muted); padding: 0.5rem 0;">
</div>
<div style="display: flex; gap: 0.75rem; justify-content: flex-end;">
<button type="button" class="modal-close btn btn-secondary">Cancel</button>
+2 -2
View File
@@ -16,7 +16,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 %}">
<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'] }}">
<img src="{{ avatar_url('multiavatar', 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>
@@ -32,7 +32,7 @@
<div class="messages-main">
{% if other_user %}
<div class="messages-main-header">
<img src="{{ avatar_url(other_user.get('avatar_style', 'initials'), other_user['username'], 32) }}" class="avatar-img avatar-sm" alt="{{ other_user['username'] }}">
<img src="{{ avatar_url('multiavatar', other_user['username'], 32) }}" class="avatar-img avatar-sm" alt="{{ other_user['username'] }}">
<h3>{{ other_user['username'] }}</h3>
</div>
+1 -1
View File
@@ -14,7 +14,7 @@
<div class="notifications-list">
{% for item in notifications %}
<div class="notification-card {% if not item.notification['read'] %}unread{% endif %}">
<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 '?' }}">
<img src="{{ avatar_url('multiavatar', 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>
+3 -3
View File
@@ -8,7 +8,7 @@
<article class="post-detail">
<div class="post-detail-header">
<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 '?' }}">
<img src="{{ avatar_url('multiavatar', 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') %}
@@ -60,7 +60,7 @@
<div class="comment-body">
<div class="comment-header">
<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 '?' }}">
<img src="{{ avatar_url('multiavatar', 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>
@@ -94,7 +94,7 @@
{% if user %}
<form class="comment-form" method="POST" action="/comments/create">
<input type="hidden" name="post_uid" value="{{ post['uid'] }}">
<img src="{{ avatar_url(user.get('avatar_style', 'initials'), user['username'], 32) }}" class="avatar-img avatar-sm" alt="{{ user['username'] }}">
<img src="{{ avatar_url('multiavatar', 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>
+3 -20
View File
@@ -10,8 +10,8 @@
<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 class="profile-avatar-wrap">
<img src="{{ avatar_url('multiavatar', 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') %}
@@ -113,23 +113,6 @@
<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>
@@ -170,7 +153,7 @@
{% 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'] }}">
<img src="{{ avatar_url('multiavatar', 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') %}
-19
View File
@@ -47,26 +47,7 @@
</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">
+8 -3
View File
@@ -1,14 +1,20 @@
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
from devplacepy.avatar import avatar_url
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
_unread_cache = {}
def jinja_unread_count(user_uid: str) -> int:
if user_uid in _unread_cache:
return _unread_cache[user_uid]
notifs = get_table("notifications")
return len(list(notifs.find(user_uid=user_uid, read=False)))
count = len(list(notifs.find(user_uid=user_uid, read=False)))
_unread_cache[user_uid] = count
return count
def jinja_user_projects(user_uid: str) -> list:
@@ -19,4 +25,3 @@ 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
+10
View File
@@ -30,10 +30,18 @@ def create_session(user_uid: str) -> str:
return token
_user_cache = {}
def get_current_user(request: Request):
token = request.cookies.get("session")
if not token:
return None
cached = _user_cache.get(token)
if cached:
return cached
sessions = get_table("sessions")
session = sessions.find_one(session_token=token)
if not session:
@@ -44,6 +52,8 @@ def get_current_user(request: Request):
return None
users = get_table("users")
user = users.find_one(uid=session["user_uid"])
if user:
_user_cache[token] = user
return user