feat: add user_id index to profiles table for faster lookups

The new index on the `user_id` column in the `profiles` table improves query performance for user-specific profile retrieval operations, reducing full table scans during authentication and profile loading workflows.
This commit is contained in:
2026-05-30 18:16:39 +00:00
parent b65e2920de
commit 247b7d738e
57 changed files with 1310 additions and 674 deletions
+54 -7
View File
@@ -1,25 +1,69 @@
# retoor <retoor@molodetz.nl>
import logging
from typing import Any
from datetime import datetime, timezone
from fastapi.responses import RedirectResponse
from devplacepy.database import (
get_table,
resolve_by_slug,
get_users_by_uids,
get_vote_counts,
get_user_votes,
load_comments,
db,
)
from devplacepy.attachments import delete_target_attachments, delete_inline_image, get_attachments
from devplacepy.utils import time_ago
from devplacepy.attachments import delete_target_attachments, delete_inline_image, get_attachments, link_attachments
from devplacepy.utils import time_ago, generate_uid, make_combined_slug, award_rewards, create_mention_notifications
logger = logging.getLogger(__name__)
def is_owner(item: dict | None, user: dict | None) -> bool:
return bool(item and user and item["user_uid"] == user["uid"])
def create_content_item(table_name: str, target_type: str, user: dict, fields: dict, slug_source: str, xp: int, badge: str, mention_text: str, attachment_uids: list | None) -> tuple[str, str]:
uid = generate_uid()
slug = make_combined_slug(slug_source, uid)
get_table(table_name).insert({
"uid": uid,
"user_uid": user["uid"],
"slug": slug,
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
**fields,
})
award_rewards(user["uid"], xp, badge)
if attachment_uids:
link_attachments(attachment_uids, target_type, uid)
create_mention_notifications(mention_text, user["uid"], f"/{table_name}/{slug}")
logger.info(f"{target_type} {uid} created by {user['username']}")
return uid, slug
def detail_context(request, user: dict | None, detail: dict, key: str, seo_ctx: dict, extra: dict | None = None) -> dict:
context = {
**seo_ctx,
"request": request,
"user": user,
key: detail["item"],
"author": detail["author"],
"is_owner": detail["is_owner"],
"star_count": detail["star_count"],
"my_vote": detail["my_vote"],
"time_ago": detail["time_ago"],
"comments": detail["comments"],
"attachments": detail["attachments"],
}
if extra:
context.update(extra)
return context
def edit_content_item(table_name: str, user: dict, slug: str, update_fields: dict, redirect_fail: str) -> RedirectResponse:
table = get_table(table_name)
item = resolve_by_slug(table, slug)
if not item or item["user_uid"] != user["uid"]:
if not is_owner(item, user):
return RedirectResponse(url=redirect_fail, status_code=302)
table.update({"uid": item["uid"], **update_fields}, ["uid"])
logger.info(f"{table_name} {item['uid']} edited by {user['username']}")
@@ -29,7 +73,7 @@ def edit_content_item(table_name: str, user: dict, slug: str, update_fields: dic
def delete_content_item(table_name: str, target_type: str, user: dict, slug: str, redirect_url: str, inline_image_field: str | None = None) -> RedirectResponse:
table = get_table(table_name)
item = resolve_by_slug(table, slug)
if item and item["user_uid"] == user["uid"]:
if is_owner(item, user):
delete_target_attachments(target_type, item["uid"])
if "comments" in db.tables:
comments = get_table("comments")
@@ -56,22 +100,25 @@ def load_detail(table_name: str, target_type: str, slug: str, user: dict | None)
"author": author,
"is_owner": bool(user and user["uid"] == item["user_uid"]),
"star_count": ups.get(item["uid"], 0) - downs.get(item["uid"], 0),
"comments": load_comments(target_type, item["uid"]),
"my_vote": get_user_votes(user["uid"], [item["uid"]]).get(item["uid"], 0) if user else 0,
"comments": load_comments(target_type, item["uid"], user),
"attachments": get_attachments(target_type, item["uid"]),
"time_ago": time_ago(item["created_at"]),
}
def enrich_items(items: list, key: str, authors: dict, extra_maps: dict[str, Any] | None = None, ts_field: str = "created_at") -> list:
def enrich_items(items: list, key: str, authors: dict, extra_maps: dict[str, Any] | None = None, ts_field: str = "created_at", user: dict | None = None) -> list:
extra_maps = extra_maps or {}
my_votes = get_user_votes(user["uid"], [item["uid"] for item in items]) if user else {}
enriched = []
for item in items:
entry = {
key: item,
"author": authors.get(item["user_uid"]),
"time_ago": time_ago(item[ts_field]),
"my_vote": my_votes.get(item["uid"], 0),
}
for name, source in extra_maps.items():
entry[name] = source(item) if callable(source) else source.get(item["uid"])
entry[name] = source(item) if callable(source) else source.get(item["uid"], 0)
enriched.append(entry)
return enriched
+124 -13
View File
@@ -1,5 +1,6 @@
import dataset
import logging
from collections import defaultdict
from datetime import datetime, timezone
from devplacepy.cache import TTLCache
from devplacepy.config import DATABASE_URL
@@ -126,13 +127,74 @@ def init_db():
if not existing:
db["site_settings"].insert({"uid": f"default_{key}", "key": key, "value": value})
_backfill_gamification()
logger.info("Database initialized")
def _backfill_gamification():
if "users" not in db.tables:
return
from devplacepy.utils import (
level_for_xp, check_milestone_badges,
XP_POST, XP_COMMENT, XP_PROJECT, XP_GIST, XP_UPVOTE, XP_FOLLOW,
)
pending = list(db["users"].find(xp=0))
if not pending:
return
xp_by_user = defaultdict(int)
def add_counts(table, column, points):
if table not in db.tables:
return
for row in db.query(f"SELECT {column} AS uid, COUNT(*) AS c FROM {table} GROUP BY {column}"):
if row["uid"]:
xp_by_user[row["uid"]] += row["c"] * points
add_counts("posts", "user_uid", XP_POST)
add_counts("comments", "user_uid", XP_COMMENT)
add_counts("projects", "user_uid", XP_PROJECT)
add_counts("gists", "user_uid", XP_GIST)
add_counts("follows", "following_uid", XP_FOLLOW)
if "votes" in db.tables:
for content_table, target_type in (("posts", "post"), ("projects", "project"), ("gists", "gist"), ("comments", "comment")):
if content_table not in db.tables:
continue
rows = db.query(
f"SELECT c.user_uid AS uid, COUNT(*) AS c "
f"FROM votes v JOIN {content_table} c ON v.target_uid = c.uid "
f"WHERE v.target_type = :t AND v.value = 1 GROUP BY c.user_uid",
t=target_type,
)
for row in rows:
if row["uid"]:
xp_by_user[row["uid"]] += row["c"] * XP_UPVOTE
for user in pending:
xp = xp_by_user.get(user["uid"], 0)
if xp <= 0:
continue
db["users"].update({"uid": user["uid"], "xp": xp, "level": level_for_xp(xp)}, ["uid"])
_authors_cache.clear()
for user in pending:
check_milestone_badges(user["uid"])
logger.info(f"Gamification backfill processed {len(pending)} users")
def get_table(name):
return db[name]
def _in_clause(uids, prefix="p"):
placeholders = ", ".join(f":{prefix}{i}" for i in range(len(uids)))
params = {f"{prefix}{i}": uid for i, uid in enumerate(uids)}
return placeholders, params
def get_users_by_uids(uids):
if not uids:
return {}
@@ -144,8 +206,7 @@ def get_users_by_uids(uids):
def get_comment_counts_by_post_uids(post_uids):
if not post_uids or "comments" not in db.tables:
return {}
placeholders = ", ".join(f":p{i}" for i in range(len(post_uids)))
params = {f"p{i}": u for i, u in enumerate(post_uids)}
placeholders, params = _in_clause(post_uids)
rows = db.query(f"SELECT target_uid, COUNT(*) as c FROM comments WHERE target_type='post' AND target_uid IN ({placeholders}) GROUP BY target_uid", **params)
return {r["target_uid"]: r["c"] for r in rows}
@@ -153,8 +214,7 @@ def get_comment_counts_by_post_uids(post_uids):
def get_post_counts_by_user_uids(user_uids):
if not user_uids or "posts" not in db.tables:
return {}
placeholders = ", ".join(f":p{i}" for i in range(len(user_uids)))
params = {f"p{i}": u for i, u in enumerate(user_uids)}
placeholders, params = _in_clause(user_uids)
rows = db.query(f"SELECT user_uid, COUNT(*) as c FROM posts WHERE user_uid IN ({placeholders}) GROUP BY user_uid", **params)
return {r["user_uid"]: r["c"] for r in rows}
@@ -162,8 +222,7 @@ def get_post_counts_by_user_uids(user_uids):
def get_vote_counts(target_uids):
if not target_uids or "votes" not in db.tables:
return {}, {}
placeholders = ", ".join(f":p{i}" for i in range(len(target_uids)))
params = {f"p{i}": u for i, u in enumerate(target_uids)}
placeholders, params = _in_clause(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", **params)
ups = {}
downs = {}
@@ -175,7 +234,16 @@ def get_vote_counts(target_uids):
return ups, downs
def load_comments(target_type, target_uid):
def get_user_votes(user_uid, target_uids):
if not user_uid or not target_uids or "votes" not in db.tables:
return {}
placeholders, params = _in_clause(target_uids)
params["uid"] = user_uid
rows = db.query(f"SELECT target_uid, value FROM votes WHERE user_uid = :uid AND target_uid IN ({placeholders})", **params)
return {r["target_uid"]: r["value"] for r in rows}
def load_comments(target_type, target_uid, user=None):
if "comments" not in db.tables:
return []
comments_table = db["comments"]
@@ -188,6 +256,7 @@ def load_comments(target_type, target_uid):
cids = [c["uid"] for c in raw]
users = get_users_by_uids(uids)
ups, downs = get_vote_counts(cids)
my_votes = get_user_votes(user["uid"], cids) if user else {}
from devplacepy.utils import time_ago
from devplacepy.attachments import get_attachments_batch as _gab
atts_map = _gab("comment", cids) if "attachments" in db.tables else {}
@@ -198,6 +267,7 @@ def load_comments(target_type, target_uid):
"author": users.get(c["user_uid"]),
"time_ago": time_ago(c["created_at"]),
"votes": {"up": ups.get(c["uid"], 0), "down": downs.get(c["uid"], 0)},
"my_vote": my_votes.get(c["uid"], 0),
"children": [],
"attachments": atts_map.get(c["uid"], []),
}
@@ -340,21 +410,21 @@ def get_gist_languages() -> set[str]:
_STARRED_CONTENT_TABLES = ("posts", "projects", "gists")
_top_authors_cache = TTLCache(ttl=60)
_authors_cache = TTLCache(ttl=60)
def get_top_authors(limit: int = 5) -> list:
cached = _top_authors_cache.get("top")
def _ranked_authors() -> list:
cached = _authors_cache.get("ranked")
if cached is not None:
return cached
sources = [table for table in _STARRED_CONTENT_TABLES if table in db.tables]
if not sources:
_top_authors_cache.set("top", [])
_authors_cache.set("ranked", [])
return []
union = " UNION ALL ".join(f"SELECT user_uid, stars FROM {table}" for table in sources)
rows = db.query(
f"SELECT user_uid, SUM(stars) AS total FROM ({union}) "
f"GROUP BY user_uid HAVING total > 0 ORDER BY total DESC LIMIT {int(limit)}"
f"GROUP BY user_uid HAVING total > 0 ORDER BY total DESC"
)
ranked = [(row["user_uid"], row["total"]) for row in rows]
users_map = get_users_by_uids([uid for uid, _ in ranked])
@@ -365,10 +435,31 @@ def get_top_authors(limit: int = 5) -> list:
author = dict(user)
author["stars"] = total
authors.append(author)
_top_authors_cache.set("top", authors)
_authors_cache.set("ranked", authors)
return authors
def get_top_authors(limit: int = 5) -> list:
return _ranked_authors()[:limit]
def get_leaderboard(limit: int = 50, offset: int = 0) -> list:
sliced = _ranked_authors()[offset:offset + limit]
leaderboard = []
for position, author in enumerate(sliced, start=offset + 1):
entry = dict(author)
entry["rank"] = position
leaderboard.append(entry)
return leaderboard
def get_user_rank(user_uid: str):
for position, author in enumerate(_ranked_authors(), start=1):
if author["uid"] == user_uid:
return position
return None
def get_user_stars(user_uid: str) -> int:
total = 0
for table in _STARRED_CONTENT_TABLES:
@@ -426,6 +517,7 @@ def update_target_stars(target_type: str, target_uid: str, net_stars: int) -> No
if not table_name or target_type not in STAR_TARGETS:
return
get_table(table_name).update({"uid": target_uid, "stars": net_stars}, ["uid"])
_authors_cache.clear()
def get_target_owner_uid(target_type: str, target_uid: str) -> str | None:
@@ -463,3 +555,22 @@ def get_daily_topic():
"url": article.get("url", ""),
}
return {"title": "Welcome to DevPlace", "summary": "Stay tuned for the latest dev news."}
def get_featured_news(limit=5):
if "news" not in db.tables:
return []
from devplacepy.utils import time_ago
rows = list(db["news"].find(show_on_landing=1, order_by=["-synced_at"], _limit=limit))
articles = []
for article in rows:
summary = (article.get("description") or "")[:120] or (article.get("content") or "")[:120]
articles.append({
"title": article.get("title", ""),
"summary": summary,
"slug": article.get("slug", ""),
"url": article.get("url", ""),
"source_name": article.get("source_name", ""),
"time_ago": time_ago(article["synced_at"]) if article.get("synced_at") else "",
})
return articles
+2 -1
View File
@@ -12,7 +12,7 @@ from devplacepy.database import init_db, get_table, db, get_users_by_uids, get_c
from devplacepy.templating import templates
from devplacepy.utils import get_current_user, time_ago
from devplacepy.seo import base_seo_context, site_url, website_schema
from devplacepy.routers import auth, feed, posts, comments, projects, profile, messages, notifications, votes, avatar, follow, admin, seo, bugs, news, gists, services as services_router, uploads, push
from devplacepy.routers import auth, feed, posts, comments, projects, profile, messages, notifications, votes, avatar, follow, admin, seo, bugs, news, gists, services as services_router, uploads, push, leaderboard
from devplacepy.services.manager import service_manager
from devplacepy.services.news import NewsService
@@ -106,6 +106,7 @@ app.include_router(notifications.router, prefix="/notifications")
app.include_router(votes.router, prefix="/votes")
app.include_router(avatar.router, prefix="/avatar")
app.include_router(follow.router, prefix="/follow")
app.include_router(leaderboard.router, prefix="/leaderboard")
app.include_router(admin.router, prefix="/admin")
app.include_router(seo.router)
app.include_router(push.router)
+1 -1
View File
@@ -32,7 +32,7 @@ async def bugs_page(request: Request):
"bug": b,
"author": users_map.get(b["user_uid"]),
"time_ago": time_ago(b["created_at"]),
"comments": load_comments("bug", b["uid"]),
"comments": load_comments("bug", b["uid"], user),
"attachments": attachments_map.get(b["uid"], []),
})
+4 -5
View File
@@ -5,7 +5,8 @@ from fastapi import APIRouter, Request, Form
from fastapi.responses import RedirectResponse
from devplacepy.database import get_table, resolve_object_url
from devplacepy.attachments import link_attachments, delete_target_attachments
from devplacepy.utils import generate_uid, require_user, create_mention_notifications, create_notification, award_badge
from devplacepy.content import is_owner
from devplacepy.utils import generate_uid, require_user, create_mention_notifications, create_notification, award_rewards, XP_COMMENT
from devplacepy.models import CommentForm
logger = logging.getLogger(__name__)
@@ -39,7 +40,7 @@ async def create_comment(request: Request, data: Annotated[CommentForm, Form()])
if data.attachment_uids:
link_attachments(data.attachment_uids, "comment", comment_uid)
award_badge(user["uid"], "First Comment")
award_rewards(user["uid"], XP_COMMENT, "First Comment")
comment_url = f"{redirect_url}#comment-{comment_uid}"
@@ -66,9 +67,7 @@ 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 not comment:
return RedirectResponse(url="/feed", status_code=302)
if comment["user_uid"] != user["uid"]:
if not is_owner(comment, user):
return RedirectResponse(url="/feed", status_code=302)
target_type = comment.get("target_type", "post")
target_uid = comment.get("target_uid") or comment.get("post_uid", "")
+3 -5
View File
@@ -6,7 +6,7 @@ from devplacepy.attachments import get_attachments_batch
from devplacepy.content import enrich_items
from devplacepy.templating import templates
from devplacepy.utils import get_current_user
from devplacepy.seo import base_seo_context, site_url, website_schema
from devplacepy.seo import list_page_seo
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -48,7 +48,7 @@ def get_feed_posts(user, tab: str = "all", topic: str = None, before: str = None
authors = get_users_by_uids([p["user_uid"] for p in posts])
counts = get_comment_counts_by_post_uids([p["uid"] for p in posts])
result = enrich_items(posts, "post", authors, {"comment_count": counts})
result = enrich_items(posts, "post", authors, {"comment_count": counts}, user=user)
return result, next_cursor
@@ -65,13 +65,11 @@ async def feed_page(request: Request, tab: str = "all", topic: str = None, befor
for item in posts:
item["attachments"] = attachments_map.get(item["post"]["uid"], [])
base = site_url(request)
seo_ctx = base_seo_context(
seo_ctx = list_page_seo(
request,
title="Feed",
description="Discover the latest developer discussions, projects, and community activity on DevPlace.",
breadcrumbs=[{"name": "Home", "url": "/feed"}, {"name": "Feed", "url": "/feed"}],
schemas=[website_schema(base)],
)
return templates.TemplateResponse(request, "feed.html", {
**seo_ctx,
+2 -1
View File
@@ -3,7 +3,7 @@ from datetime import datetime, timezone
from fastapi import APIRouter, Request
from fastapi.responses import RedirectResponse
from devplacepy.database import get_table
from devplacepy.utils import generate_uid, require_user, create_notification
from devplacepy.utils import generate_uid, require_user, create_notification, award_rewards, XP_FOLLOW
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -30,6 +30,7 @@ async def follow_user(request: Request, username: str):
})
create_notification(target["uid"], "follow", f"{user['username']} started following you", user["uid"], f"/profile/{user['username']}")
award_rewards(target["uid"], XP_FOLLOW)
logger.info(f"{user['username']} followed {username}")
return RedirectResponse(url=f"/profile/{username}", status_code=302)
+13 -41
View File
@@ -1,14 +1,13 @@
import logging
from typing import Annotated
from datetime import datetime, timezone
from fastapi import APIRouter, Request, HTTPException, Form
from fastapi import APIRouter, Request, Form
from devplacepy.models import GistForm, GistEditForm
from fastapi.responses import HTMLResponse, RedirectResponse
from devplacepy.database import get_table, get_users_by_uids, get_gist_languages
from devplacepy.content import load_detail, edit_content_item, delete_content_item, enrich_items
from devplacepy.content import load_detail, edit_content_item, delete_content_item, enrich_items, create_content_item, detail_context
from devplacepy.templating import templates
from devplacepy.utils import generate_uid, get_current_user, require_user, make_combined_slug, create_mention_notifications
from devplacepy.seo import base_seo_context, site_url, website_schema, software_source_code_schema
from devplacepy.utils import get_current_user, require_user, not_found, XP_GIST
from devplacepy.seo import base_seo_context, site_url, website_schema, software_source_code_schema, list_page_seo
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -24,7 +23,7 @@ LANGUAGES = [
]
def get_gists_list(user_uid=None, language=None):
def get_gists_list(user_uid=None, language=None, viewer=None):
gists_table = get_table("gists")
filters = {}
if user_uid:
@@ -38,16 +37,15 @@ def get_gists_list(user_uid=None, language=None):
return []
users_map = get_users_by_uids([g["user_uid"] for g in all_gists])
return enrich_items(all_gists, "gist", users_map)
return enrich_items(all_gists, "gist", users_map, user=viewer)
@router.get("", response_class=HTMLResponse)
async def gists_page(request: Request, language: str = None, user_uid: str = None):
user = get_current_user(request)
gists_data = get_gists_list(user_uid, language)
gists_data = get_gists_list(user_uid, language, viewer=user)
total_count = len(gists_data)
base = site_url(request)
seo_ctx = base_seo_context(
seo_ctx = list_page_seo(
request,
title="Gists",
description=f"Browse {total_count} code snippets on DevPlace.",
@@ -55,7 +53,6 @@ async def gists_page(request: Request, language: str = None, user_uid: str = Non
{"name": "Home", "url": "/feed"},
{"name": "Gists", "url": "/gists"},
],
schemas=[website_schema(base)],
)
return templates.TemplateResponse(request, "gists.html", {
**seo_ctx,
@@ -74,7 +71,7 @@ async def gist_detail(request: Request, gist_slug: str):
user = get_current_user(request)
detail = load_detail("gists", "gist", gist_slug, user)
if not detail:
raise HTTPException(status_code=404, detail="Gist not found")
raise not_found("Gist not found")
gist = detail["item"]
base = site_url(request)
@@ -90,19 +87,9 @@ async def gist_detail(request: Request, gist_slug: str):
],
schemas=[website_schema(base), software_source_code_schema(gist, base)],
)
return templates.TemplateResponse(request, "gist_detail.html", {
**seo_ctx,
"request": request,
"user": user,
"gist": gist,
"author": detail["author"],
"is_owner": detail["is_owner"],
"star_count": detail["star_count"],
"time_ago": detail["time_ago"],
"comments": detail["comments"],
return templates.TemplateResponse(request, "gist_detail.html", detail_context(request, user, detail, "gist", seo_ctx, {
"languages": LANGUAGES,
"attachments": detail["attachments"],
})
}))
@router.post("/create")
@@ -117,27 +104,12 @@ async def create_gist(request: Request, data: Annotated[GistForm, Form()]):
if language not in valid_languages:
language = "plaintext"
gists = get_table("gists")
uid = generate_uid()
gist_slug = make_combined_slug(title, uid)
gists.insert({
"uid": uid,
"user_uid": user["uid"],
uid, gist_slug = create_content_item("gists", "gist", user, {
"title": title,
"slug": gist_slug,
"description": description or None,
"source_code": source_code,
"language": language,
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
})
from devplacepy.attachments import link_attachments
link_attachments(data.attachment_uids, "gist", uid)
create_mention_notifications(description or "", user["uid"], f"/gists/{gist_slug}")
logger.info(f"Gist {uid} created by {user['username']}")
}, title, XP_GIST, "First Gist", description or "", data.attachment_uids)
return RedirectResponse(url=f"/gists/{gist_slug}", status_code=302)
+44
View File
@@ -0,0 +1,44 @@
import logging
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from devplacepy.database import get_leaderboard, get_user_rank, get_site_stats, get_top_authors, get_featured_news
from devplacepy.templating import templates
from devplacepy.utils import get_current_user
from devplacepy.seo import list_page_seo
logger = logging.getLogger(__name__)
router = APIRouter()
TOP_LIMIT = 50
@router.get("", response_class=HTMLResponse)
async def leaderboard_page(request: Request):
entries = get_leaderboard(TOP_LIMIT, 0)
user = get_current_user(request)
user_rank = get_user_rank(user["uid"]) if user else None
stats = get_site_stats()
top_authors = get_top_authors(5)
featured_news = get_featured_news(5)
seo_ctx = list_page_seo(
request,
title="Leaderboard",
description="Top contributors on DevPlace ranked by the stars their posts, projects, and gists have earned.",
breadcrumbs=[{"name": "Home", "url": "/feed"}, {"name": "Leaderboard", "url": "/leaderboard"}],
)
return templates.TemplateResponse(request, "leaderboard.html", {
**seo_ctx,
"request": request,
"user": user,
"entries": entries,
"user_rank": user_rank,
"total_members": stats["total_members"],
"posts_today": stats["posts_today"],
"total_projects": stats["total_projects"],
"total_gists": stats["total_gists"],
"top_authors": top_authors,
"featured_news": featured_news,
})
+5 -7
View File
@@ -4,8 +4,8 @@ from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from devplacepy.database import get_table, db, load_comments, resolve_by_slug, get_news_images_by_uids
from devplacepy.templating import templates
from devplacepy.utils import get_current_user, time_ago
from devplacepy.seo import base_seo_context, website_schema, site_url, news_article_schema
from devplacepy.utils import get_current_user, time_ago, not_found
from devplacepy.seo import base_seo_context, website_schema, site_url, news_article_schema, list_page_seo
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -37,13 +37,11 @@ async def news_page(request: Request):
"grade": a.get("grade", 0),
})
base = site_url(request)
seo_ctx = base_seo_context(
seo_ctx = list_page_seo(
request,
title="Developer News",
description="Curated developer news and industry signals. Stay ahead with hand-picked articles.",
breadcrumbs=[{"name": "Home", "url": "/feed"}, {"name": "News", "url": "/news"}],
schemas=[website_schema(base)],
)
return templates.TemplateResponse(request, "news.html", {
@@ -60,7 +58,7 @@ async def news_detail_page(request: Request, news_slug: str):
news_table = get_table("news")
article = resolve_by_slug(news_table, news_slug)
if not article:
return HTMLResponse("News article not found", status_code=404)
raise not_found("News article not found")
image_url = ""
if "news_images" in db.tables:
@@ -69,7 +67,7 @@ async def news_detail_page(request: Request, news_slug: str):
image_url = img["url"]
canonical_slug = article.get("slug", "") or article["uid"]
comments = load_comments("news", article["uid"])
comments = load_comments("news", article["uid"], user)
base = site_url(request)
page_url = f"{base}/news/{canonical_slug}"
+16 -47
View File
@@ -1,15 +1,14 @@
import logging
from typing import Annotated
from datetime import datetime, timezone
from fastapi import APIRouter, Request, HTTPException, Form
from fastapi import APIRouter, Request, Form
from fastapi.responses import RedirectResponse, HTMLResponse
from devplacepy.constants import TOPICS
from devplacepy.database import get_table, load_comments, db, resolve_by_slug
from devplacepy.database import db
from devplacepy.templating import templates
from devplacepy.utils import generate_uid, get_current_user, require_user, time_ago, make_combined_slug, create_mention_notifications, award_badge
from devplacepy.content import edit_content_item, delete_content_item
from devplacepy.utils import get_current_user, require_user, time_ago, not_found, XP_POST
from devplacepy.content import load_detail, edit_content_item, delete_content_item, create_content_item, detail_context
from devplacepy.seo import base_seo_context, site_url, website_schema, discussion_forum_posting, truncate
from devplacepy.attachments import get_attachments, link_attachments, save_inline_image
from devplacepy.attachments import save_inline_image
from devplacepy.models import PostForm, PostEditForm
logger = logging.getLogger(__name__)
@@ -32,45 +31,26 @@ async def create_post(request: Request, data: Annotated[PostForm, Form()]):
if image_filename:
content += f"\n\n![](/static/uploads/{image_filename})"
posts = get_table("posts")
uid = generate_uid()
slug_text = title if title else content[:50]
post_slug = make_combined_slug(slug_text, uid)
posts.insert({
"uid": uid,
"user_uid": user["uid"],
uid, post_slug = create_content_item("posts", "post", user, {
"title": title or None,
"slug": post_slug,
"content": content,
"topic": topic,
"project_uid": project_uid or None,
"image": image_filename,
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
})
award_badge(user["uid"], "First Post")
if data.attachment_uids:
link_attachments(data.attachment_uids, "post", uid)
create_mention_notifications(content, user["uid"], f"/posts/{post_slug}")
logger.info(f"Post {uid} created by {user['username']}")
}, slug_text, XP_POST, "First Post", content, data.attachment_uids)
return RedirectResponse(url=f"/posts/{post_slug}", status_code=302)
@router.get("/{post_slug}", response_class=HTMLResponse)
async def view_post(request: Request, post_slug: str):
user = get_current_user(request)
posts = get_table("posts")
post = resolve_by_slug(posts, post_slug)
if not post:
raise HTTPException(status_code=404, detail="Post not found")
users_table = get_table("users")
author = users_table.find_one(uid=post["user_uid"])
top_level = load_comments("post", post["uid"])
detail = load_detail("posts", "post", post_slug, user)
if not detail:
raise not_found("Post not found")
post = detail["item"]
author = detail["author"]
top_level = detail["comments"]
def count_all(items):
total = len(items)
@@ -78,7 +58,6 @@ async def view_post(request: Request, post_slug: str):
total += count_all(item.get("children", []))
return total
comment_count = count_all(top_level)
star_count = post.get("stars", 0)
base = site_url(request)
seo_ctx = base_seo_context(
request,
@@ -92,7 +71,7 @@ async def view_post(request: Request, post_slug: str):
og_type="article",
schemas=[
website_schema(base),
discussion_forum_posting(post, author, comment_count, star_count, base),
discussion_forum_posting(post, author, comment_count, detail["star_count"], base),
],
)
@@ -109,21 +88,11 @@ async def view_post(request: Request, post_slug: str):
"time_ago": time_ago(r["created_at"]),
})
post_attachments = get_attachments("post", post["uid"])
return templates.TemplateResponse(request, "post.html", {
**seo_ctx,
"request": request,
"user": user,
"post": post,
"author": author,
"comments": top_level,
"time_ago": time_ago(post["created_at"]),
return templates.TemplateResponse(request, "post.html", detail_context(request, user, detail, "post", seo_ctx, {
"comment_count": comment_count,
"related_posts": related_posts,
"topics": list(TOPICS),
"attachments": post_attachments,
})
}))
@router.post("/edit/{post_slug}")
+7 -12
View File
@@ -3,7 +3,8 @@ from typing import Annotated
from fastapi import APIRouter, Request, Form
from devplacepy.models import ProfileForm
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
from devplacepy.database import get_table, db, get_user_stars
from devplacepy.database import get_table, db, get_user_stars, get_user_rank, get_comment_counts_by_post_uids
from devplacepy.content import enrich_items
from devplacepy.templating import templates
from devplacepy.utils import get_current_user, require_user, require_user_api, time_ago, clear_user_cache
from devplacepy.seo import base_seo_context, site_url, website_schema, profile_page_schema
@@ -36,22 +37,15 @@ async def profile_page(request: Request, username: str, tab: str = "posts"):
if not profile_user:
return RedirectResponse(url="/feed", status_code=302)
profile_user["stars"] = get_user_stars(profile_user["uid"])
rank = get_user_rank(profile_user["uid"])
posts = []
if tab == "posts":
posts_table = get_table("posts")
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:
posts.append({
"post": p,
"time_ago": time_ago(p["created_at"]),
"comment_count": counts.get(p["uid"], 0),
})
counts = get_comment_counts_by_post_uids([p["uid"] for p in raw_posts]) if raw_posts else {}
authors = {profile_user["uid"]: profile_user}
posts = enrich_items(raw_posts, "post", authors, {"comment_count": counts}, user=current_user)
badges = list(get_table("badges").find(user_uid=profile_user["uid"]))
projects = list(get_table("projects").find(user_uid=profile_user["uid"]))
@@ -111,6 +105,7 @@ async def profile_page(request: Request, username: str, tab: str = "posts"):
"posts_count": posts_count,
"is_following": is_following,
"activities": activities,
"rank": rank,
})
+15 -40
View File
@@ -1,22 +1,20 @@
import logging
from typing import Annotated
from datetime import datetime, timezone
from sqlalchemy import or_
from fastapi import APIRouter, Request, HTTPException, Form
from fastapi import APIRouter, Request, Form
from devplacepy.models import ProjectForm
from fastapi.responses import HTMLResponse, RedirectResponse
from devplacepy.database import get_table, get_users_by_uids, get_site_stats
from devplacepy.content import load_detail, delete_content_item
from devplacepy.attachments import link_attachments
from devplacepy.database import get_table, get_users_by_uids, get_site_stats, get_user_votes
from devplacepy.content import load_detail, delete_content_item, create_content_item, detail_context
from devplacepy.templating import templates
from devplacepy.utils import generate_uid, get_current_user, require_user, make_combined_slug, create_mention_notifications
from devplacepy.seo import base_seo_context, site_url, website_schema, software_application_schema
from devplacepy.utils import get_current_user, require_user, not_found, XP_PROJECT
from devplacepy.seo import base_seo_context, site_url, website_schema, software_application_schema, list_page_seo
logger = logging.getLogger(__name__)
router = APIRouter()
def get_projects_list(tab: str = "recent", search: str = "", user_uid: str = None, project_type: str = None):
def get_projects_list(tab: str = "recent", search: str = "", user_uid: str = None, project_type: str = None, viewer: dict = None):
projects = get_table("projects")
filters = {}
@@ -37,9 +35,11 @@ def get_projects_list(tab: str = "recent", search: str = "", user_uid: str = Non
if all_projects:
users_map = get_users_by_uids([p["user_uid"] for p in all_projects])
my_votes = get_user_votes(viewer["uid"], [p["uid"] for p in all_projects]) if viewer else {}
for p in all_projects:
author = users_map.get(p["user_uid"])
p["author_name"] = author["username"] if author else "Unknown"
p["my_vote"] = my_votes.get(p["uid"], 0)
return all_projects
@@ -53,11 +53,10 @@ async def projects_page(
project_type: str = None,
):
user = get_current_user(request)
projects = get_projects_list(tab, search, user_uid, project_type)
projects = get_projects_list(tab, search, user_uid, project_type, viewer=user)
total_members = get_site_stats()["total_members"]
base = site_url(request)
seo_ctx = base_seo_context(
seo_ctx = list_page_seo(
request,
title="Projects",
description=f"Explore {len(projects)} developer projects on DevPlace. Games, software, mobile apps and more.",
@@ -65,7 +64,6 @@ async def projects_page(
{"name": "Home", "url": "/feed"},
{"name": "Projects", "url": "/projects"},
],
schemas=[website_schema(base)],
)
return templates.TemplateResponse(request, "projects.html", {
**seo_ctx,
@@ -85,7 +83,7 @@ async def project_detail(request: Request, project_slug: str):
user = get_current_user(request)
detail = load_detail("projects", "project", project_slug, user)
if not detail:
raise HTTPException(status_code=404, detail="Project not found")
raise not_found("Project not found")
project = detail["item"]
base = site_url(request)
@@ -100,18 +98,9 @@ async def project_detail(request: Request, project_slug: str):
],
schemas=[website_schema(base), software_application_schema(project, base)],
)
return templates.TemplateResponse(request, "project_detail.html", {
**seo_ctx,
"request": request,
"user": user,
"project": project,
"author": detail["author"],
"is_owner": detail["is_owner"],
"star_count": detail["star_count"],
return templates.TemplateResponse(request, "project_detail.html", detail_context(request, user, detail, "project", seo_ctx, {
"platforms": project.get("platforms", "").split(",") if project.get("platforms") else [],
"comments": detail["comments"],
"attachments": detail["attachments"],
})
}))
@router.post("/delete/{project_slug}")
@@ -126,27 +115,13 @@ async def create_project(request: Request, data: Annotated[ProjectForm, Form()])
title = data.title.strip()
description = data.description.strip()
projects = get_table("projects")
uid = generate_uid()
project_slug = make_combined_slug(title, uid)
projects.insert({
"uid": uid,
"user_uid": user["uid"],
uid, project_slug = create_content_item("projects", "project", user, {
"title": title,
"slug": project_slug,
"description": description,
"release_date": data.release_date or None,
"demo_date": data.demo_date or None,
"project_type": data.project_type,
"platforms": data.platforms.strip(),
"status": data.status,
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
})
if data.attachment_uids:
link_attachments(data.attachment_uids, "project", uid)
create_mention_notifications(description, user["uid"], f"/projects/{project_slug}")
logger.info(f"Project {uid} created by {user['username']}")
}, title, XP_PROJECT, "First Project", description, data.attachment_uids)
return RedirectResponse(url=f"/projects/{project_slug}", status_code=302)
+2 -1
View File
@@ -4,7 +4,7 @@ from datetime import datetime, timezone
from fastapi import APIRouter, Request, Form
from fastapi.responses import RedirectResponse, JSONResponse
from devplacepy.database import get_table, update_target_stars, get_target_owner_uid, resolve_object_url
from devplacepy.utils import generate_uid, require_user, create_notification
from devplacepy.utils import generate_uid, require_user, create_notification, award_rewards, XP_UPVOTE
from devplacepy.models import VoteForm
logger = logging.getLogger(__name__)
@@ -50,6 +50,7 @@ async def vote(request: Request, target_type: str, target_uid: str, data: Annota
if owner_uid and owner_uid != user["uid"]:
target_url = resolve_object_url(target_type, target_uid)
create_notification(owner_uid, "vote", f"{user['username']} ++'d your {target_type}", user["uid"], target_url)
award_rewards(owner_uid, XP_UPVOTE)
if request.headers.get("x-requested-with") == "fetch":
current = votes.find_one(user_uid=user["uid"], target_uid=target_uid, target_type=target_type)
+12
View File
@@ -206,6 +206,17 @@ def base_seo_context(request, title="", description="", robots="index,follow", o
}
def list_page_seo(request, title="", description="", breadcrumbs=None):
base = site_url(request)
return base_seo_context(
request,
title=title,
description=description,
breadcrumbs=breadcrumbs,
schemas=[website_schema(base)],
)
def make_sitemap(base_url):
from devplacepy.database import get_table, db
@@ -236,6 +247,7 @@ def make_sitemap(base_url):
urlset.append(url_element(f"{base_url}/news", changefreq="hourly", priority="0.9"))
urlset.append(url_element(f"{base_url}/projects", changefreq="daily", priority="0.8"))
urlset.append(url_element(f"{base_url}/gists", changefreq="daily", priority="0.8"))
urlset.append(url_element(f"{base_url}/leaderboard", changefreq="daily", priority="0.7"))
if "posts" in db.tables:
posts = list(get_table("posts").find(order_by=["-created_at"], _limit=500))
+2 -1
View File
@@ -15,6 +15,7 @@ NEWS_API_URL_DEFAULT = "https://news.app.molodetz.nl/api"
AI_URL_DEFAULT = "https://openai.app.molodetz.nl/v1/chat/completions"
AI_MODEL_DEFAULT = "molodetz"
GRADE_THRESHOLD_DEFAULT = 7
GRADE_MAX_TOKENS = 2000
def _get_ai_key() -> str:
@@ -216,7 +217,7 @@ class NewsService(BaseService):
payload = {
"model": ai_model,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 10,
"max_tokens": GRADE_MAX_TOKENS,
"temperature": 0.0,
}
-4
View File
@@ -265,10 +265,6 @@
transform: translateX(18px);
}
.hidden {
display: none !important;
}
.pagination {
display: flex;
align-items: center;
+30
View File
@@ -81,6 +81,9 @@
.inline-form {
display: inline;
}
.hidden {
display: none !important;
}
.text-muted {
color: var(--text-muted);
}
@@ -115,6 +118,14 @@
color: var(--text-muted);
padding: 0.5rem 0;
}
.top-authors-link {
display: block;
margin-top: 0.5rem;
font-size: 0.75rem;
font-weight: 600;
color: var(--accent);
text-align: center;
}
.no-comments-msg {
color: var(--text-muted);
font-size: 0.875rem;
@@ -986,3 +997,22 @@ img {
position: relative;
z-index: 2;
}
.vote-star {
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.vote-star::before {
content: "\2606";
}
.vote-star.voted::before {
content: "\2605";
}
.vote-star.voted {
color: var(--warning);
font-weight: 700;
}
+58
View File
@@ -0,0 +1,58 @@
.bugs-layout {
max-width: 720px;
margin: 0 auto;
}
.bugs-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.bugs-header h1 {
font-size: 1.5rem;
font-weight: 700;
}
.bug-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1.25rem;
margin-bottom: 1rem;
}
.bug-card-header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.bug-title {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
}
.bug-status {
font-size: 0.6875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.125rem 0.5rem;
border-radius: 999px;
}
.bug-status.open {
background: var(--accent-light);
color: var(--accent);
}
.bug-status.closed {
background: rgba(76, 175, 80, 0.1);
color: var(--success);
}
.bug-desc {
font-size: 0.875rem;
color: var(--text-secondary);
line-height: 1.5;
margin-bottom: 0.5rem;
}
.bug-meta {
font-size: 0.75rem;
color: var(--text-muted);
}
+174 -6
View File
@@ -17,7 +17,9 @@
border: 1px solid var(--border);
}
.feed-nav-btn {
.feed-nav-btn,
.projects-tab,
.profile-tab {
padding: 0.5rem 1rem;
border-radius: var(--radius);
font-size: 0.8125rem;
@@ -178,15 +180,24 @@
color: var(--text-secondary);
}
.post-action-btn.voted,
.post-action-btn.vote-up {
.post-action-btn.vote-up:hover {
color: var(--accent);
}
.post-action-btn.vote-down {
.post-action-btn.vote-down:hover {
color: var(--danger);
}
.post-action-btn.vote-up.voted {
color: var(--accent);
font-weight: 700;
}
.post-action-btn.vote-down.voted {
color: var(--danger);
font-weight: 700;
}
.post-votes {
display: inline-flex;
align-items: center;
@@ -287,6 +298,14 @@
display: flex;
align-items: center;
gap: 0.375rem;
min-width: 0;
}
.top-author-name {
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.stat-row .value {
@@ -302,8 +321,8 @@
width: 56px;
height: 56px;
border-radius: 50%;
background: #e53935;
color: #fff;
background: var(--danger);
color: var(--white);
font-size: 1.5rem;
display: flex;
align-items: center;
@@ -355,6 +374,12 @@
.feed-right {
display: none;
}
.leaderboard-page {
grid-template-columns: 1fr;
}
.leaderboard-page > aside {
display: none;
}
}
@media (max-width: 768px) {
@@ -428,3 +453,146 @@
width: 100%;
}
}
.leaderboard-page {
display: grid;
grid-template-columns: 280px 1fr 280px;
gap: 1.5rem;
align-items: start;
}
.leaderboard-page > aside {
position: sticky;
top: calc(var(--nav-height) + 1rem);
}
.leaderboard-main {
min-width: 0;
}
.featured-news {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1.25rem;
}
.featured-news h3 {
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-muted);
margin-bottom: 0.75rem;
}
.featured-news-item {
display: flex;
flex-direction: column;
gap: 0.125rem;
padding: 0.5rem 0;
}
.featured-news-item:not(:last-child) {
border-bottom: 1px solid var(--border);
}
.featured-news-title {
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-primary);
line-height: 1.4;
}
.featured-news-meta {
font-size: 0.6875rem;
color: var(--text-muted);
}
.leaderboard-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.25rem;
}
.leaderboard-header h1 {
font-size: 1.5rem;
font-weight: 700;
}
.leaderboard-you {
font-size: 0.8125rem;
font-weight: 600;
color: var(--accent);
background: var(--accent-light);
padding: 0.25rem 0.625rem;
border-radius: 999px;
}
.leaderboard-intro {
font-size: 0.8125rem;
color: var(--text-muted);
margin-bottom: 1rem;
}
.leaderboard-list {
list-style: none;
margin: 0;
padding: 0;
}
.leaderboard-row {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: 0.5rem;
}
.leaderboard-row-self {
border-color: var(--accent);
background: var(--accent-light);
}
.leaderboard-rank {
font-weight: 700;
font-size: 0.9375rem;
color: var(--text-muted);
min-width: 2.5rem;
}
.leaderboard-name {
flex: 1;
min-width: 0;
font-weight: 600;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.leaderboard-level {
font-size: 0.75rem;
color: var(--text-muted);
}
.leaderboard-stars {
font-weight: 700;
color: var(--text-primary);
}
.leaderboard-star-icon {
color: var(--accent);
}
.leaderboard-empty {
list-style: none;
padding: 2rem 1rem;
text-align: center;
color: var(--text-muted);
font-size: 0.875rem;
}
+15 -44
View File
@@ -100,10 +100,24 @@
transition: color 0.2s;
}
.comment-vote-btn:hover {
.comment-vote-btn.vote-up:hover {
color: var(--accent);
}
.comment-vote-btn.vote-down:hover {
color: var(--danger);
}
.comment-vote-btn.vote-up.voted {
color: var(--accent);
font-weight: 700;
}
.comment-vote-btn.vote-down.voted {
color: var(--danger);
font-weight: 700;
}
.comment-vote-count {
font-size: 0.75rem;
font-weight: 700;
@@ -230,49 +244,6 @@ button.comment-form-submit:hover {
background: var(--accent-hover);
}
.post-action-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
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;
line-height: 1;
}
.post-action-btn:hover {
background: var(--bg-card-hover);
color: var(--text-secondary);
}
.post-action-btn.vote-up {
color: var(--accent);
}
.post-action-btn.vote-down {
color: var(--danger);
}
.post-votes {
display: inline-flex;
align-items: center;
gap: 0.125rem;
}
.post-vote-count {
font-weight: 600;
font-size: 0.8125rem;
color: var(--text-secondary);
min-width: 1.25rem;
text-align: center;
}
.comment-thread-line {
position: absolute;
left: 16px;
+14 -9
View File
@@ -56,6 +56,15 @@
display: block;
}
a.profile-stat-value {
color: var(--text-primary);
text-decoration: none;
}
a.profile-stat-value:hover {
color: var(--accent);
}
.profile-stat-label {
font-size: 0.75rem;
color: var(--text-muted);
@@ -109,6 +118,11 @@
color: var(--accent);
}
.profile-badge-icon {
font-size: 0.875rem;
line-height: 1;
}
.profile-info {
background: var(--bg-card);
border: 1px solid var(--border);
@@ -161,15 +175,6 @@
border: 1px solid var(--border);
}
.profile-tab {
padding: 0.5rem 1rem;
border-radius: var(--radius);
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-secondary);
transition: all 0.2s;
}
.profile-tab:hover {
color: var(--text-primary);
}
+78 -9
View File
@@ -16,15 +16,6 @@
flex-wrap: wrap;
}
.projects-tab {
padding: 0.5rem 1rem;
border-radius: var(--radius);
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-secondary);
transition: all 0.2s;
}
.projects-tab:hover {
color: var(--text-primary);
}
@@ -202,3 +193,81 @@
font-size: 1rem;
}
}
.project-detail-page {
max-width: 720px;
margin: 0 auto;
}
.project-detail {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1.5rem;
}
.project-detail-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 1rem;
}
.project-detail-title {
font-size: 1.5rem;
font-weight: 700;
}
.project-detail-meta {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-bottom: 1rem;
font-size: 0.8125rem;
color: var(--text-muted);
}
.project-detail-meta span {
display: flex;
align-items: center;
gap: 0.25rem;
}
.project-detail-author {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border);
}
.project-detail-author a {
font-weight: 600;
font-size: 0.875rem;
color: var(--text-primary);
}
.project-detail-desc {
font-size: 0.9375rem;
color: var(--text-secondary);
line-height: 1.7;
margin-bottom: 1.5rem;
}
.project-detail-actions {
display: flex;
align-items: center;
gap: 0.5rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
}
.project-star-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border-radius: var(--radius);
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-muted);
background: none;
border: none;
cursor: pointer;
transition: all 0.2s;
}
.project-star-btn:hover {
background: var(--bg-card-hover);
color: var(--warning);
}
+45 -31
View File
@@ -8,49 +8,63 @@ export class DomUtils {
this.initStopPropagation();
}
static onDataAttr(attr, event, handler) {
document.querySelectorAll(`[data-${attr}]`).forEach((el) => {
el.addEventListener(event, (e) => handler(el, e));
});
}
static show(el) {
el.style.display = "block";
}
static hide(el) {
el.style.display = "none";
}
static toggle(el) {
el.style.display = el.style.display === "none" ? "block" : "none";
}
static isShown(el) {
return el.style.display === "block";
}
initClipboardCopy() {
document.querySelectorAll("[data-copy]").forEach((btn) => {
btn.addEventListener("click", async () => {
const source = document.getElementById(btn.dataset.copy);
if (!source) return;
try {
await navigator.clipboard.writeText(source.textContent);
Toast.flash(btn, "Copied!", 2000);
} catch {
// silently fail
}
});
DomUtils.onDataAttr("copy", "click", async (btn) => {
const source = document.getElementById(btn.dataset.copy);
if (!source) return;
try {
await navigator.clipboard.writeText(source.textContent);
Toast.flash(btn, "Copied!", 2000);
} catch {
// silently fail
}
});
}
initShareButtons() {
document.querySelectorAll("[data-share]").forEach((btn) => {
btn.addEventListener("click", async (e) => {
e.preventDefault();
e.stopPropagation();
const url = new URL(btn.dataset.share || window.location.href, window.location.href).href;
try {
await navigator.clipboard.writeText(url);
Toast.flash(btn, "Copied!", 1000);
} catch {
// silently fail
}
});
DomUtils.onDataAttr("share", "click", async (btn, e) => {
e.preventDefault();
e.stopPropagation();
const url = new URL(btn.dataset.share || window.location.href, window.location.href).href;
try {
await navigator.clipboard.writeText(url);
Toast.flash(btn, "Copied!", 1000);
} catch {
// silently fail
}
});
}
initTogglers() {
document.querySelectorAll("[data-toggle]").forEach((btn) => {
btn.addEventListener("click", () => {
const target = document.getElementById(btn.dataset.toggle);
if (target) target.classList.toggle("hidden");
});
DomUtils.onDataAttr("toggle", "click", (btn) => {
const target = document.getElementById(btn.dataset.toggle);
if (target) target.classList.toggle("hidden");
});
}
initStopPropagation() {
document.querySelectorAll("[data-stop-propagation]").forEach((el) => {
el.addEventListener("click", (e) => e.stopPropagation());
});
DomUtils.onDataAttr("stop-propagation", "click", (el, e) => e.stopPropagation());
}
}
+3 -6
View File
@@ -1,4 +1,5 @@
import { TextInput } from "./TextInput.js";
import { DomUtils } from "./DomUtils.js";
export class EmojiPicker {
constructor(textarea) {
@@ -41,15 +42,11 @@ export class EmojiPicker {
}
toggle() {
if (this.wrapper.style.display === "none") {
this.wrapper.style.display = "block";
} else {
this.wrapper.style.display = "none";
}
DomUtils.toggle(this.wrapper);
}
hide() {
this.wrapper.style.display = "none";
DomUtils.hide(this.wrapper);
}
}
window.EmojiPicker = EmojiPicker;
+27
View File
@@ -4,6 +4,33 @@ export class Http {
return response.json();
}
static async sendForm(url, params = {}) {
const response = await fetch(url, {
method: "POST",
headers: {
"X-Requested-With": "fetch",
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams(params),
});
if (!response.ok) {
throw new Error(`request failed with status ${response.status}`);
}
return response.json();
}
static async postJson(url, body) {
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!response.ok) {
throw new Error(`request failed with status ${response.status}`);
}
return response.json();
}
static postForm(action, data = {}) {
const form = document.createElement("form");
form.method = "POST";
+10 -9
View File
@@ -1,6 +1,7 @@
import { Http } from "./Http.js";
import { Avatar } from "./Avatar.js";
import { TextInput } from "./TextInput.js";
import { DomUtils } from "./DomUtils.js";
export class MentionInput {
constructor(element) {
@@ -25,7 +26,7 @@ export class MentionInput {
this.input.addEventListener("input", () => this.onInput());
this.input.addEventListener("keydown", (e) => this.onKeydown(e));
this.input.addEventListener("blur", () => {
setTimeout(() => { this.dropdown.style.display = "none"; }, 200);
setTimeout(() => DomUtils.hide(this.dropdown), 200);
});
}
@@ -36,7 +37,7 @@ export class MentionInput {
let match = text.match(/(?:^|\s|\x28)@([a-zA-Z0-9_-]*)$/);
if (!match) {
this.dropdown.style.display = "none";
DomUtils.hide(this.dropdown);
this.lastMatch = null;
return;
}
@@ -45,7 +46,7 @@ export class MentionInput {
this.lastMatch = { query, index: match.index + (text[match.index] === "@" ? 0 : 1) };
if (query.length < 1) {
this.dropdown.style.display = "none";
DomUtils.hide(this.dropdown);
return;
}
@@ -57,12 +58,12 @@ export class MentionInput {
const data = await Http.getJson("/profile/search?q=" + encodeURIComponent(query));
const results = data.results || [];
if (results.length === 0) {
this.dropdown.style.display = "none";
DomUtils.hide(this.dropdown);
return;
}
this.render(results);
} catch (e) {
this.dropdown.style.display = "none";
DomUtils.hide(this.dropdown);
}
}
@@ -81,12 +82,12 @@ export class MentionInput {
});
this.dropdown.appendChild(item);
}
this.dropdown.style.display = "block";
DomUtils.show(this.dropdown);
}
onKeydown(e) {
const items = this.dropdown.querySelectorAll(".mention-dropdown-item");
if (this.dropdown.style.display !== "block" || items.length === 0) {
if (!DomUtils.isShown(this.dropdown) || items.length === 0) {
return;
}
@@ -104,7 +105,7 @@ export class MentionInput {
this.insert(items[this.selectedIndex].dataset.username);
}
} else if (e.key === "Escape") {
this.dropdown.style.display = "none";
DomUtils.hide(this.dropdown);
}
}
@@ -125,7 +126,7 @@ export class MentionInput {
before = before.replace(/@+$/, "");
after = after.replace(/^@+/, "");
TextInput.applyValue(this.input, before + "@" + username + " " + after, before.length + username.length + 2);
this.dropdown.style.display = "none";
DomUtils.hide(this.dropdown);
this.lastMatch = null;
}
}
+5 -4
View File
@@ -1,5 +1,6 @@
import { Http } from "./Http.js";
import { Avatar } from "./Avatar.js";
import { DomUtils } from "./DomUtils.js";
export class MessageSearch {
constructor() {
@@ -24,7 +25,7 @@ export class MessageSearch {
const q = searchInput.value.trim();
if (q.length < 1) {
dropdown.innerHTML = "";
dropdown.style.display = "none";
DomUtils.hide(dropdown);
return;
}
debounceTimer = setTimeout(async () => {
@@ -32,7 +33,7 @@ export class MessageSearch {
const data = await Http.getJson(`/messages/search?q=${encodeURIComponent(q)}`);
const results = data.results || [];
if (results.length === 0) {
dropdown.style.display = "none";
DomUtils.hide(dropdown);
return;
}
dropdown.innerHTML = "";
@@ -43,7 +44,7 @@ export class MessageSearch {
item.innerHTML = `${Avatar.imgHtml(r.username)}<span>${r.username}</span>`;
dropdown.appendChild(item);
}
dropdown.style.display = "block";
DomUtils.show(dropdown);
} catch (e) {
// silently fail - no suggestions
}
@@ -61,7 +62,7 @@ export class MessageSearch {
document.addEventListener("click", (e) => {
if (!wrap.contains(e.target)) {
dropdown.style.display = "none";
DomUtils.hide(dropdown);
}
});
}
+4 -11
View File
@@ -1,5 +1,7 @@
// retoor <retoor@molodetz.nl>
import { Http } from "./Http.js";
export class PushManager {
constructor() {
this.supported = "serviceWorker" in navigator && "PushManager" in window && "Notification" in window;
@@ -46,8 +48,7 @@ export class PushManager {
return;
}
const keyResponse = await fetch("/push.json");
const keyData = await keyResponse.json();
const keyData = await Http.getJson("/push.json");
const applicationServerKey = Uint8Array.from(atob(keyData.publicKey), (c) => c.charCodeAt(0));
const subscription = await registration.pushManager.subscribe({
@@ -55,15 +56,7 @@ export class PushManager {
applicationServerKey: applicationServerKey,
});
const response = await fetch("/push.json", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(subscription.toJSON()),
});
if (!response.ok) {
throw new Error("Bad status code from server.");
}
await Http.postJson("/push.json", subscription.toJSON());
this.refreshTriggerVisibility();
} catch (error) {
+2 -12
View File
@@ -1,4 +1,5 @@
import { Toast } from "./Toast.js";
import { Http } from "./Http.js";
export class VoteManager {
constructor() {
@@ -20,18 +21,7 @@ export class VoteManager {
const action = form.getAttribute("action");
const value = form.querySelector('input[name="value"]').value;
try {
const response = await fetch(action, {
method: "POST",
headers: {
"X-Requested-With": "fetch",
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({ value }),
});
if (!response.ok) {
throw new Error(`vote failed with status ${response.status}`);
}
const result = await response.json();
const result = await Http.sendForm(action, { value });
this.render(action, result);
} catch (error) {
console.error("vote failed", error);
+2 -2
View File
@@ -6,12 +6,12 @@
<div class="comment-votes">
<form method="POST" action="/votes/comment/{{ item.comment['uid'] }}">
<input type="hidden" name="value" value="1">
<button type="submit" class="comment-vote-btn">+</button>
<button type="submit" class="comment-vote-btn vote-up{% if item.my_vote == 1 %} voted{% endif %}">+</button>
</form>
<span class="comment-vote-count" data-vote-count="{{ item.comment['uid'] }}">{{ item.votes.up - item.votes.down }}</span>
<form method="POST" action="/votes/comment/{{ item.comment['uid'] }}">
<input type="hidden" name="value" value="-1">
<button type="submit" class="comment-vote-btn">-</button>
<button type="submit" class="comment-vote-btn vote-down{% if item.my_vote == -1 %} voted{% endif %}">-</button>
</form>
</div>
+13
View File
@@ -0,0 +1,13 @@
{% macro content_url(item, kind) %}/{{ kind }}/{{ item['slug'] or item['uid'] }}{% endmacro %}
{% macro modal(modal_id, title, wide=false) %}
<div class="modal-overlay" id="{{ modal_id }}">
<div class="card modal-card{% if wide %} modal-card-wide{% endif %}">
<div class="modal-header">
<h3>{{ title }}</h3>
<button type="button" class="modal-close btn-ghost btn-icon modal-close-btn">&times;</button>
</div>
{{ caller() }}
</div>
</div>
{% endmacro %}
+42
View File
@@ -0,0 +1,42 @@
{% from "_macros.html" import content_url %}
<article class="post-card fade-in">
{% include "_post_header.html" %}
<div class="post-topic">
<span class="badge badge-{{ item.post['topic'] }}">{{ item.post['topic'] }}</span>
</div>
{% if item.post.get('title') %}
<a href="{{ content_url(item.post, 'posts') }}" class="post-title-link">
<h3 class="post-title">{{ item.post['title'] }}</h3>
</a>
{% endif %}
<div class="post-content rendered-content" data-render onclick="if (!event.target.closest('a')) window.location.href='{{ content_url(item.post, 'posts') }}'">{{ item.post['content'][:300] }}{% if item.post['content']|length > 300 %}...{% endif %}</div>
{% if item.attachments %}
{% include "_attachment_display.html" %}
{% endif %}
<div class="post-actions">
{% set _uid = item.post['uid'] %}{% set _my_vote = item.my_vote %}{% set _count = item.post.get('stars', 0) %}{% include "_post_votes.html" %}
<a href="{{ content_url(item.post, 'posts') }}" class="post-action-btn">
&#x1F4AC; {{ item.comment_count }}
</a>
<a href="{{ content_url(item.post, 'posts') }}" class="post-action-btn share">
&#x2197;&#xFE0E; Open
</a>
{% if _show_share %}
<button type="button" class="post-action-btn" data-share="{{ content_url(item.post, 'posts') }}">&#x1F517; Share</button>
{% endif %}
</div>
{% if _show_comment_form and user %}
<form class="feed-comment-form" method="POST" action="/comments/create">
<input type="hidden" name="post_uid" value="{{ item.post['uid'] }}">
<img src="{{ avatar_url('multiavatar', user['username'], 24) }}" class="avatar-img avatar-24" alt="" loading="lazy">
<input type="text" name="content" placeholder="Your opinion goes here..." maxlength="1000" autocomplete="off">
<button type="submit" class="feed-comment-submit">Post</button>
</form>
{% endif %}
</article>
+10
View File
@@ -0,0 +1,10 @@
<div class="post-header">
{% set _user = _author %}{% set _size = 32 %}{% set _size_class = "sm" %}{% include "_avatar_link.html" %}
<div class="post-author-wrap">
<a href="/profile/{{ _author['username'] if _author else '#' }}" class="post-author-link">{{ _author['username'] if _author else 'Unknown' }}</a>
{% if _author and _author.get('role') %}
<span class="post-author-role">{{ _author['role'] }}</span>
{% endif %}
</div>
<span class="post-time">{{ _time }}</span>
</div>
+11
View File
@@ -0,0 +1,11 @@
<div class="post-votes">
<form method="POST" action="/votes/post/{{ _uid }}" class="inline-form">
<input type="hidden" name="value" value="1">
<button type="submit" class="post-action-btn vote-up{% if _my_vote == 1 %} voted{% endif %}">+</button>
</form>
<span class="post-vote-count" data-vote-count="{{ _uid }}">{{ _count }}</span>
<form method="POST" action="/votes/post/{{ _uid }}" class="inline-form">
<input type="hidden" name="value" value="-1">
<button type="submit" class="post-action-btn vote-down{% if _my_vote == -1 %} voted{% endif %}">−</button>
</form>
</div>
+4
View File
@@ -0,0 +1,4 @@
<form method="POST" action="/votes/{{ _type }}/{{ _uid }}" class="inline-form"{% if _stop %} data-stop-propagation{% endif %}>
<input type="hidden" name="value" value="1">
<button type="submit" class="{{ _btn_class }} vote-star{% if _my_vote == 1 %} voted{% endif %}"><span class="vote-count-value" data-vote-count="{{ _uid }}">{{ _count }}</span></button>
</form>
@@ -0,0 +1,8 @@
<div class="form-row">
{% for t in _topics %}
<label class="topic-label">
<input type="radio" name="topic" value="{{ t }}" {% if t == _selected %}checked{% endif %} class="topic-radio">
{{ t|capitalize }}
</label>
{% endfor %}
</div>
+2
View File
@@ -54,6 +54,7 @@
<a href="/news" class="topnav-link {% if 'news' in request.url.path %}active{% endif %}"><span class="icon">📰</span> News</a>
<a href="/gists" class="topnav-link {% if 'gists' in request.url.path %}active{% endif %}"><span class="icon">📄</span> Gists</a>
<a href="/projects" class="topnav-link {% if 'projects' in request.url.path %}active{% endif %}"><span class="icon">🚀</span> Projects</a>
<a href="/leaderboard" class="topnav-link {% if 'leaderboard' in request.url.path %}active{% endif %}"><span class="icon">🏆</span> Leaderboard</a>
{% if user %}
<a href="/messages" class="topnav-link {% if 'messages' in request.url.path %}active{% endif %}"><span class="icon">✉️</span> Messages</a>
{% if user.get('role') == 'Admin' %}
@@ -105,6 +106,7 @@
<a href="/news" class="topnav-mobile-link {% if 'news' in request.url.path %}active{% endif %}"><span class="icon">📰</span> News</a>
<a href="/gists" class="topnav-mobile-link {% if 'gists' in request.url.path %}active{% endif %}"><span class="icon">📄</span> Gists</a>
<a href="/projects" class="topnav-mobile-link {% if 'projects' in request.url.path %}active{% endif %}"><span class="icon">🚀</span> Projects</a>
<a href="/leaderboard" class="topnav-mobile-link {% if 'leaderboard' in request.url.path %}active{% endif %}"><span class="icon">🏆</span> Leaderboard</a>
{% if user %}
<a href="/messages" class="topnav-mobile-link {% if 'messages' in request.url.path %}active{% endif %}"><span class="icon">✉️</span> Messages</a>
<a href="/notifications" class="topnav-mobile-link {% if 'notifications' in request.url.path %}active{% endif %}"><span class="icon">&#x1F514;</span> Notifications</a>
+8 -72
View File
@@ -1,67 +1,9 @@
{% extends "base.html" %}
{% from "_macros.html" import modal %}
{% block extra_head %}
<link rel="stylesheet" href="/static/css/feed.css">
<link rel="stylesheet" href="/static/css/post.css">
<style>
.bugs-layout {
max-width: 720px;
margin: 0 auto;
}
.bugs-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.bugs-header h1 {
font-size: 1.5rem;
font-weight: 700;
}
.bug-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1.25rem;
margin-bottom: 1rem;
}
.bug-card-header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.bug-title {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
}
.bug-status {
font-size: 0.6875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.125rem 0.5rem;
border-radius: 999px;
}
.bug-status.open {
background: var(--accent-light);
color: var(--accent);
}
.bug-status.closed {
background: rgba(76, 175, 80, 0.1);
color: var(--success);
}
.bug-desc {
font-size: 0.875rem;
color: var(--text-secondary);
line-height: 1.5;
margin-bottom: 0.5rem;
}
.bug-meta {
font-size: 0.75rem;
color: var(--text-muted);
}
</style>
<link rel="stylesheet" href="/static/css/bugs.css">
{% endblock %}
{% block content %}
<div class="bugs-layout">
@@ -101,28 +43,22 @@
</div>
{% if user %}
<div class="modal-overlay" id="create-bug-modal">
<div class="card" style="width: 100%; max-width: 560px; margin: 1rem;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h3 style="font-size: 1.125rem; font-weight: 700;">Report a Bug</h3>
<button type="button" class="modal-close btn-ghost btn-icon" style="font-size: 1.25rem;">&times;</button>
</div>
{% call modal('create-bug-modal', 'Report a Bug') %}
<form method="POST" action="/bugs/create">
<div class="auth-field" style="margin-bottom: 0.75rem;">
<div class="auth-field auth-field-gap">
<label for="bug-title">Title</label>
<input type="text" id="bug-title" name="title" required maxlength="200" placeholder="Brief description of the issue">
</div>
<div class="auth-field" style="margin-bottom: 1rem;">
<div class="auth-field auth-field-gap">
<label for="bug-description">Description</label>
<textarea id="bug-description" name="description" required maxlength="5000" placeholder="Detailed steps to reproduce..." style="min-height: 120px;" data-mention></textarea>
<textarea id="bug-description" name="description" required maxlength="5000" placeholder="Detailed steps to reproduce..." class="min-h-120" data-mention></textarea>
</div>
{% include "_attachment_form.html" %}
<div style="display: flex; gap: 0.75rem; justify-content: flex-end;">
<div class="modal-footer">
<button type="button" class="modal-close btn btn-secondary">Cancel</button>
<button type="submit" class="btn btn-primary"><span class="icon">&#x1F4E4;</span> Submit Report</button>
</div>
</form>
</div>
</div>
{% endcall %}
{% endif %}
{% endblock %}
+8 -78
View File
@@ -1,4 +1,5 @@
{% extends "base.html" %}
{% from "_macros.html" import modal %}
{% block extra_head %}
<link rel="stylesheet" href="/static/css/feed.css">
<link rel="stylesheet" href="/static/css/sidebar.css">
@@ -54,64 +55,7 @@
<div class="feed-posts">
{% for item in posts %}
<article class="post-card fade-in">
<div class="post-header">
{% set _user = item.author %}{% set _size = 32 %}{% set _size_class = "sm" %}{% include "_avatar_link.html" %}
<div class="post-author-wrap">
<a href="/profile/{{ item.author['username'] if item.author else '#' }}" class="post-author-link">{{ item.author['username'] if item.author else 'Unknown' }}</a>
{% 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>
<div class="post-topic">
<span class="badge badge-{{ item.post['topic'] }}">{{ item.post['topic'] }}</span>
</div>
{% if item.post.get('title') %}
<a href="/posts/{{ item.post['slug'] or item.post['uid'] }}" class="post-title-link">
<h3 class="post-title">{{ item.post['title'] }}</h3>
</a>
{% endif %}
<div class="post-content rendered-content" data-render onclick="if (!event.target.closest('a')) window.location.href='/posts/{{ item.post['slug'] or item.post['uid'] }}'">{{ item.post['content'][:300] }}{% if item.post['content']|length > 300 %}...{% endif %}</div>
{% if item.attachments %}
{% include "_attachment_display.html" %}
{% endif %}
<div class="post-actions">
<div class="post-votes">
<form method="POST" action="/votes/post/{{ item.post['uid'] }}" class="inline-form">
<input type="hidden" name="value" value="1">
<button type="submit" class="post-action-btn vote-up">+</button>
</form>
<span class="post-vote-count" data-vote-count="{{ item.post['uid'] }}">{{ item.post.get('stars', 0) }}</span>
<form method="POST" action="/votes/post/{{ item.post['uid'] }}" class="inline-form">
<input type="hidden" name="value" value="-1">
<button type="submit" class="post-action-btn vote-down">−</button>
</form>
</div>
<a href="/posts/{{ item.post['slug'] or item.post['uid'] }}" class="post-action-btn">
&#x1F4AC; {{ item.comment_count }}
</a>
<a href="/posts/{{ item.post['slug'] or item.post['uid'] }}" class="post-action-btn share">
&#x2197;&#xFE0E; Open
</a>
<button type="button" class="post-action-btn" data-share="/posts/{{ item.post['slug'] or item.post['uid'] }}">&#x1F517; Share</button>
</div>
{% if user %}
<form class="feed-comment-form" method="POST" action="/comments/create">
<input type="hidden" name="post_uid" value="{{ item.post['uid'] }}">
<img src="{{ avatar_url('multiavatar', user['username'], 24) }}" class="avatar-img avatar-24" alt="" loading="lazy">
<input type="text" name="content" placeholder="Your opinion goes here..." maxlength="1000" autocomplete="off">
<button type="submit" class="feed-comment-submit">Post</button>
</form>
{% endif %}
</article>
{% set _author = item.author %}{% set _time = item.time_ago %}{% set _show_share = true %}{% set _show_comment_form = true %}{% include "_post_card.html" %}
{% else %}
<div class="empty-state">No posts yet. Be the first!</div>
{% endfor %}
@@ -160,15 +104,15 @@
{% for author in top_authors %}
<div class="stat-row">
<span class="label">
<a href="/profile/{{ author['username'] }}">
<img src="{{ avatar_url('multiavatar', author['username'], 20) }}" class="avatar-img avatar-xs" alt="{{ author['username'] }}" loading="lazy">
{{ author['username'] }}</a>
{% set _user = author %}{% set _size = 20 %}{% set _size_class = "xs" %}{% include "_avatar_link.html" %}
<a href="/profile/{{ author['username'] }}" class="top-author-name">{{ author['username'] }}</a>
</span>
<span class="value">{{ author.get('stars', 0) }}</span>
</div>
{% else %}
<div class="no-authors-msg">No top authors yet</div>
{% endfor %}
<a href="/leaderboard" class="top-authors-link">View full leaderboard</a>
</div>
</div>
</aside>
@@ -177,22 +121,9 @@
{% if user %}
<a href="#" class="feed-fab" data-modal="create-post-modal" title="Create New Post">+</a>
<div class="modal-overlay" id="create-post-modal">
<div class="card modal-card">
<div class="modal-header">
<h3>Create New Post</h3>
<button type="button" class="modal-close btn-ghost btn-icon modal-close-btn">&times;</button>
</div>
{% call modal('create-post-modal', 'Create New Post') %}
<form id="create-post-form" method="POST" action="/posts/create" enctype="multipart/form-data">
<div class="form-row">
{% for t in TOPICS %}
<label class="topic-label">
<input type="radio" name="topic" value="{{ t }}" {% if t == 'random' %}checked{% endif %} class="topic-radio">
{{ t|capitalize }}
</label>
{% endfor %}
</div>
{% set _topics = TOPICS %}{% set _selected = 'random' %}{% include "_topic_selector.html" %}
<div class="auth-field auth-field-gap">
<label for="post-content">What are you sharing?</label>
@@ -226,8 +157,7 @@
<button type="submit" class="btn btn-primary">Post</button>
</div>
</form>
</div>
</div>
{% endcall %}
{% endif %}
{% endblock %}
+4 -12
View File
@@ -1,4 +1,5 @@
{% extends "base.html" %}
{% from "_macros.html" import modal %}
{% block extra_head %}
<link rel="stylesheet" href="/static/css/gists.css">
<link rel="stylesheet" href="/static/css/post.css">
@@ -45,10 +46,7 @@
<div class="gist-detail-actions">
<button type="button" class="gist-star-btn" data-share="/gists/{{ gist['slug'] or gist['uid'] }}">&#x1F517; Share</button>
{% if user %}
<form method="POST" action="/votes/gist/{{ gist['uid'] }}" style="display:inline;">
<input type="hidden" name="value" value="1">
<button type="submit" class="gist-star-btn">&#x2606; <span class="vote-count-value" data-vote-count="{{ gist['uid'] }}">{{ star_count }}</span></button>
</form>
{% set _type = "gist" %}{% set _uid = gist['uid'] %}{% set _my_vote = my_vote %}{% set _count = star_count %}{% set _btn_class = "gist-star-btn" %}{% include "_star_vote.html" %}
{% endif %}
{% if is_owner %}
<button class="gist-star-btn" data-modal="edit-gist-modal"><span class="icon">&#x270F;&#xFE0F;</span>Edit</button>
@@ -60,12 +58,7 @@
</article>
{% if is_owner %}
<div class="modal-overlay" id="edit-gist-modal">
<div class="card modal-card modal-card-wide">
<div class="modal-header">
<h3>Edit Gist</h3>
<button type="button" class="modal-close btn-ghost btn-icon modal-close-btn">&times;</button>
</div>
{% call modal('edit-gist-modal', 'Edit Gist', wide=true) %}
<form method="POST" action="/gists/edit/{{ gist['slug'] or gist['uid'] }}">
<div class="auth-field auth-field-gap">
<label for="edit-gist-title">Title</label>
@@ -93,8 +86,7 @@
<button type="submit" class="btn btn-primary"><span class="icon">&#x1F4BE;</span>Save Changes</button>
</div>
</form>
</div>
</div>
{% endcall %}
{% endif %}
{% with target_uid=gist['uid'], target_type="gist" %}
+4 -13
View File
@@ -1,4 +1,5 @@
{% extends "base.html" %}
{% from "_macros.html" import modal %}
{% block extra_head %}
<link rel="stylesheet" href="/static/css/feed.css">
<link rel="stylesheet" href="/static/css/gists.css">
@@ -55,10 +56,7 @@
{% include "_card_link.html" %}
<div class="gist-card-header">
<h3 class="gist-card-title">{{ item.gist['title'] }}</h3>
<form method="POST" action="/votes/gist/{{ item.gist['uid'] }}" class="inline-form" data-stop-propagation>
<input type="hidden" name="value" value="1">
<button type="submit" class="gist-card-star">&#x2606; <span class="vote-count-value" data-vote-count="{{ item.gist['uid'] }}">{{ item.gist.get('stars', 0) }}</span></button>
</form>
{% set _type = "gist" %}{% set _uid = item.gist['uid'] %}{% set _my_vote = item.my_vote %}{% set _count = item.gist.get('stars', 0) %}{% set _btn_class = "gist-card-star" %}{% set _stop = True %}{% include "_star_vote.html" %}
</div>
<div class="gist-card-meta">
@@ -87,13 +85,7 @@
{% if user %}
<button class="feed-fab" id="create-gist-btn" title="Create Gist" data-modal="create-gist-modal">+</button>
<div class="modal-overlay" id="create-gist-modal">
<div class="card modal-card modal-card-wide">
<div class="modal-header">
<h3>Create Gist</h3>
<button type="button" class="modal-close btn-ghost btn-icon modal-close-btn">&times;</button>
</div>
{% call modal('create-gist-modal', 'Create Gist', wide=true) %}
<form method="POST" action="/gists/create">
<div class="auth-field auth-field-gap">
<label for="gist-title">Title</label>
@@ -126,8 +118,7 @@
<button type="submit" class="btn btn-primary">Create Gist</button>
</div>
</form>
</div>
</div>
{% endcall %}
{% endif %}
{% endblock %}
+79
View File
@@ -0,0 +1,79 @@
{% extends "base.html" %}
{% block extra_head %}
<link rel="stylesheet" href="/static/css/feed.css">
{% endblock %}
{% block content %}
<div class="leaderboard-page">
<aside class="community-stats">
<h3>Community</h3>
<div class="stat-row">
<span class="label">Total Members</span>
<span class="value">{{ total_members }}</span>
</div>
<div class="stat-row">
<span class="label">Posts Today</span>
<span class="value">{{ posts_today }}</span>
</div>
<div class="stat-row">
<span class="label">Total Projects</span>
<span class="value">{{ total_projects }}</span>
</div>
<div class="stat-row">
<span class="label">Total Gists</span>
<span class="value">{{ total_gists }}</span>
</div>
<div class="top-authors-section">
<h3 class="top-authors-title">Top Authors</h3>
{% for author in top_authors %}
<div class="stat-row">
<span class="label">
{% set _user = author %}{% set _size = 20 %}{% set _size_class = "xs" %}{% include "_avatar_link.html" %}
<a href="/profile/{{ author['username'] }}" class="top-author-name">{{ author['username'] }}</a>
</span>
<span class="value">{{ author.get('stars', 0) }}</span>
</div>
{% else %}
<div class="no-authors-msg">No top authors yet</div>
{% endfor %}
</div>
</aside>
<div class="leaderboard-main">
<div class="leaderboard-header">
<h1>Leaderboard</h1>
{% if user_rank %}
<span class="leaderboard-you">Your rank: #{{ user_rank }}</span>
{% endif %}
</div>
<p class="leaderboard-intro">Ranked by total stars earned across posts, projects, and gists.</p>
<ol class="leaderboard-list">
{% for entry in entries %}
<li class="leaderboard-row{% if user and entry['uid'] == user['uid'] %} leaderboard-row-self{% endif %}">
<span class="leaderboard-rank">#{{ entry['rank'] }}</span>
{% set _user = entry %}{% set _size = 32 %}{% set _size_class = "sm" %}{% include "_avatar_link.html" %}
<a href="/profile/{{ entry['username'] }}" class="leaderboard-name">{{ entry['username'] }}</a>
<span class="leaderboard-level">Level {{ entry.get('level', 1) }}</span>
<span class="leaderboard-stars">{{ entry['stars'] }} <span class="leaderboard-star-icon">&#x2605;</span></span>
</li>
{% else %}
<li class="leaderboard-empty">No ranked contributors yet. Earn stars on your posts, projects, and gists to appear here.</li>
{% endfor %}
</ol>
</div>
<aside class="feed-right">
<div class="featured-news">
<h3>Featured</h3>
{% for article in featured_news %}
<a class="featured-news-item" href="/news/{{ article['slug'] }}">
<span class="featured-news-title">{{ article['title'] }}</span>
<span class="featured-news-meta">{{ article['source_name'] }}{% if article['time_ago'] %} &middot; {{ article['time_ago'] }}{% endif %}</span>
</a>
{% else %}
<div class="no-authors-msg">No featured articles yet</div>
{% endfor %}
</div>
</aside>
</div>
{% endblock %}
+5 -27
View File
@@ -1,4 +1,5 @@
{% extends "base.html" %}
{% from "_macros.html" import modal %}
{% block extra_head %}
<link rel="stylesheet" href="/static/css/feed.css">
<link rel="stylesheet" href="/static/css/post.css">
@@ -36,17 +37,7 @@
{% endif %}
<div class="post-detail-actions">
<div class="post-votes">
<form method="POST" action="/votes/post/{{ post['uid'] }}" class="inline-form">
<input type="hidden" name="value" value="1">
<button type="submit" class="post-action-btn vote-up">+</button>
</form>
<span class="post-vote-count" data-vote-count="{{ post['uid'] }}">{{ post.get('stars', 0) }}</span>
<form method="POST" action="/votes/post/{{ post['uid'] }}" class="inline-form">
<input type="hidden" name="value" value="-1">
<button type="submit" class="post-action-btn vote-down">−</button>
</form>
</div>
{% set _uid = post['uid'] %}{% set _my_vote = my_vote %}{% set _count = post.get('stars', 0) %}{% include "_post_votes.html" %}
<button type="button" class="post-action-btn" data-share="/posts/{{ post['slug'] or post['uid'] }}">&#x1F517; Share</button>
{% if user and post['user_uid'] == user['uid'] %}
<button class="post-action-btn" data-modal="edit-post-modal"><span class="icon">&#x270F;&#xFE0F;</span>Edit</button>
@@ -58,21 +49,9 @@
</article>
{% if user and post['user_uid'] == user['uid'] %}
<div class="modal-overlay" id="edit-post-modal">
<div class="card modal-card">
<div class="modal-header">
<h3>Edit Post</h3>
<button type="button" class="modal-close btn-ghost btn-icon modal-close-btn">&times;</button>
</div>
{% call modal('edit-post-modal', 'Edit Post') %}
<form id="edit-post-form" method="POST" action="/posts/edit/{{ post['slug'] or post['uid'] }}">
<div class="form-row">
{% for t in topics %}
<label class="topic-label">
<input type="radio" name="topic" value="{{ t }}" {% if t == post['topic'] %}checked{% endif %} class="topic-radio">
{{ t|capitalize }}
</label>
{% endfor %}
</div>
{% set _topics = topics %}{% set _selected = post['topic'] %}{% include "_topic_selector.html" %}
<div class="auth-field auth-field-gap">
<label for="edit-title">Title</label>
<input type="text" id="edit-title" name="title" maxlength="500" value="{{ post.get('title', '') }}">
@@ -87,8 +66,7 @@
<button type="submit" class="btn btn-primary"><span class="icon">&#x1F4BE;</span>Save Changes</button>
</div>
</form>
</div>
</div>
{% endcall %}
{% endif %}
{% with target_uid=post['uid'], target_type="post" %}
+9 -45
View File
@@ -4,9 +4,6 @@
<link rel="stylesheet" href="/static/css/profile.css">
<link rel="stylesheet" href="/static/css/projects.css">
<link rel="stylesheet" href="/static/css/gists.css">
<style>
.hidden { display: none !important; }
</style>
{% endblock %}
{% block content %}
<div class="profile-layout">
@@ -33,6 +30,10 @@
<span class="profile-stat-value">{{ profile_user.get('stars', 0) }}</span>
<span class="profile-stat-label">Stars</span>
</div>
<div class="profile-stat">
<a href="/leaderboard" class="profile-stat-value">{% if rank %}#{{ rank }}{% else %}&mdash;{% endif %}</a>
<span class="profile-stat-label">Rank</span>
</div>
</div>
<div class="profile-level-bar">
@@ -47,9 +48,11 @@
<div class="profile-badges">
{% for badge in badges %}
<span class="profile-badge">{{ badge['badge_name'] }}</span>
{% set meta = badge_info(badge['badge_name']) %}
<span class="profile-badge" title="{{ meta['description'] }}"><span class="profile-badge-icon">{{ meta['icon'] }}</span>{{ badge['badge_name'] }}</span>
{% else %}
<span class="profile-badge">Member</span>
{% set meta = badge_info('Member') %}
<span class="profile-badge" title="{{ meta['description'] }}"><span class="profile-badge-icon">{{ meta['icon'] }}</span>Member</span>
{% endfor %}
</div>
</div>
@@ -155,46 +158,7 @@
<div class="profile-posts">
{% if current_tab == 'posts' %}
{% for item in posts %}
<article class="post-card fade-in">
<div class="post-header">
<a href="/profile/{{ profile_user['username'] }}">
<img src="{{ avatar_url('multiavatar', profile_user['username'], 32) }}" class="avatar-img avatar-sm" alt="{{ profile_user['username'] }}" loading="lazy">
</a>
<div class="post-author-wrap">
<a href="/profile/{{ profile_user['username'] }}" class="post-author-link">{{ profile_user['username'] }}</a>
{% if profile_user.get('role') %}
<span class="post-author-role">{{ profile_user['role'] }}</span>
{% endif %}
</div>
<span class="post-time">{{ item.time_ago }}</span>
</div>
<div class="post-topic">
<span class="badge badge-{{ item.post['topic'] }}">{{ item.post['topic'] }}</span>
</div>
{% if item.post.get('title') %}
<a href="/posts/{{ item.post['slug'] or item.post['uid'] }}" class="post-title-link">
<h3 class="post-title">{{ item.post['title'] }}</h3>
</a>
{% endif %}
<div class="post-content rendered-content" data-render onclick="if (!event.target.closest('a')) window.location.href='/posts/{{ item.post['slug'] or item.post['uid'] }}'">{{ item.post['content'][:300] }}{% if item.post['content']|length > 300 %}...{% endif %}</div>
<div class="post-actions">
<div class="post-votes">
<form method="POST" action="/votes/post/{{ item.post['uid'] }}" class="inline-form">
<input type="hidden" name="value" value="1">
<button type="submit" class="post-action-btn vote-up">+</button>
</form>
<span class="post-vote-count" data-vote-count="{{ item.post['uid'] }}">{{ item.post.get('stars', 0) }}</span>
<form method="POST" action="/votes/post/{{ item.post['uid'] }}" class="inline-form">
<input type="hidden" name="value" value="-1">
<button type="submit" class="post-action-btn vote-down">−</button>
</form>
</div>
<a href="/posts/{{ item.post['slug'] or item.post['uid'] }}" class="post-action-btn">
&#x1F4AC; {{ item.comment_count }}
</a>
<a href="/posts/{{ item.post['slug'] or item.post['uid'] }}" class="post-action-btn share">&#x2197;&#xFE0E; Open</a>
</div>
</article>
{% set _author = profile_user %}{% set _time = item.time_ago %}{% set _show_share = false %}{% set _show_comment_form = false %}{% include "_post_card.html" %}
{% else %}
<div class="empty-state">No posts yet.</div>
{% endfor %}
+1 -83
View File
@@ -2,85 +2,6 @@
{% block extra_head %}
<link rel="stylesheet" href="/static/css/projects.css">
<link rel="stylesheet" href="/static/css/post.css">
<style>
.project-detail-page {
max-width: 720px;
margin: 0 auto;
}
.project-detail {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1.5rem;
}
.project-detail-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 1rem;
}
.project-detail-title {
font-size: 1.5rem;
font-weight: 700;
}
.project-detail-meta {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-bottom: 1rem;
font-size: 0.8125rem;
color: var(--text-muted);
}
.project-detail-meta span {
display: flex;
align-items: center;
gap: 0.25rem;
}
.project-detail-author {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border);
}
.project-detail-author a {
font-weight: 600;
font-size: 0.875rem;
color: var(--text-primary);
}
.project-detail-desc {
font-size: 0.9375rem;
color: var(--text-secondary);
line-height: 1.7;
margin-bottom: 1.5rem;
}
.project-detail-actions {
display: flex;
align-items: center;
gap: 0.5rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
}
.project-star-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border-radius: var(--radius);
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-muted);
background: none;
border: none;
cursor: pointer;
transition: all 0.2s;
}
.project-star-btn:hover {
background: var(--bg-card-hover);
color: var(--warning);
}
</style>
{% endblock %}
{% block content %}
<div class="project-detail-page">
@@ -134,10 +55,7 @@
<div class="project-detail-actions">
<button type="button" class="project-star-btn" data-share="/projects/{{ project['slug'] or project['uid'] }}">&#x1F517; Share</button>
{% if user %}
<form method="POST" action="/votes/project/{{ project['uid'] }}" style="display:inline;">
<input type="hidden" name="value" value="1">
<button type="submit" class="project-star-btn">&#x2606; <span class="vote-count-value" data-vote-count="{{ project['uid'] }}">{{ star_count }}</span></button>
</form>
{% set _type = "project" %}{% set _uid = project['uid'] %}{% set _my_vote = my_vote %}{% set _count = star_count %}{% set _btn_class = "project-star-btn" %}{% include "_star_vote.html" %}
{% endif %}
{% if is_owner %}
<form method="POST" action="/projects/delete/{{ project['slug'] or project['uid'] }}" style="display:inline;">
+4 -13
View File
@@ -1,4 +1,5 @@
{% extends "base.html" %}
{% from "_macros.html" import modal %}
{% block extra_head %}
<link rel="stylesheet" href="/static/css/feed.css">
<link rel="stylesheet" href="/static/css/projects.css">
@@ -59,10 +60,7 @@
{% include "_card_link.html" %}
<div class="project-card-header">
<h3 class="project-card-title">{{ project['title'] }}</h3>
<form method="POST" action="/votes/project/{{ project['uid'] }}" class="inline-form">
<input type="hidden" name="value" value="1">
<button type="submit" class="project-card-star">&#x2606; <span class="vote-count-value" data-vote-count="{{ project['uid'] }}">{{ project.get('stars', 0) }}</span></button>
</form>
{% set _type = "project" %}{% set _uid = project['uid'] %}{% set _my_vote = project.my_vote %}{% set _count = project.get('stars', 0) %}{% set _btn_class = "project-card-star" %}{% include "_star_vote.html" %}
</div>
<div class="project-card-meta">
@@ -98,13 +96,7 @@
{% if user %}
<button class="feed-fab" id="create-project-btn" title="Add Project" data-modal="create-project-modal">+</button>
<div class="modal-overlay" id="create-project-modal">
<div class="card modal-card">
<div class="modal-header">
<h3>Create Project</h3>
<button type="button" class="modal-close btn-ghost btn-icon modal-close-btn">&times;</button>
</div>
{% call modal('create-project-modal', 'Create Project') %}
<form method="POST" action="/projects/create">
<div class="auth-field auth-field-gap">
<label for="title">Title</label>
@@ -170,7 +162,6 @@
<button type="submit" class="btn btn-primary">Create Project</button>
</div>
</form>
</div>
</div>
{% endcall %}
{% endif %}
{% endblock %}
+2
View File
@@ -5,6 +5,7 @@ from devplacepy.constants import TOPICS
from devplacepy.database import get_table
from devplacepy.avatar import avatar_url
from devplacepy.utils import format_date as _format_date
from devplacepy.utils import badge_info
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
_unread_cache = TTLCache(ttl=60)
@@ -30,6 +31,7 @@ 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["format_date"] = _format_date
templates.env.globals["badge_info"] = badge_info
templates.env.globals["TOPICS"] = TOPICS
def jinja_max_upload_size_mb():
from devplacepy.database import get_int_setting
+90 -1
View File
@@ -7,7 +7,7 @@ from datetime import datetime, timedelta, timezone
from passlib.hash import pbkdf2_sha256
from fastapi import Request, HTTPException, status
from devplacepy.cache import TTLCache
from devplacepy.database import get_table
from devplacepy.database import get_table, get_user_stars
from devplacepy.config import SESSION_MAX_AGE
logger = logging.getLogger(__name__)
@@ -87,6 +87,10 @@ def require_admin(request: Request):
return user
def not_found(detail: str = "Not found") -> HTTPException:
return HTTPException(status_code=404, detail=detail)
def require_user_api(request: Request):
user = get_current_user(request)
if not user:
@@ -207,6 +211,91 @@ def award_badge(user_uid: str, badge_name: str) -> bool:
return True
LEVEL_XP = 100
XP_POST = 10
XP_COMMENT = 2
XP_PROJECT = 15
XP_GIST = 5
XP_UPVOTE = 5
XP_FOLLOW = 5
LEVEL_BADGES = {5: "Level 5", 10: "Level 10"}
BADGE_CATALOG = {
"Member": {"icon": "✦", "description": "Joined DevPlace"},
"First Post": {"icon": "✎", "description": "Published a first post"},
"First Comment": {"icon": "❝", "description": "Wrote a first comment"},
"First Project": {"icon": "⬢", "description": "Shared a first project"},
"First Gist": {"icon": "❡", "description": "Shared a first gist"},
"Prolific": {"icon": "✺", "description": "Published 10 posts"},
"Rising Star": {"icon": "☆", "description": "Earned 25 stars"},
"Star Author": {"icon": "★", "description": "Earned 100 stars"},
"Popular": {"icon": "◎", "description": "Reached 10 followers"},
"Level 5": {"icon": "❖", "description": "Reached level 5"},
"Level 10": {"icon": "❖", "description": "Reached level 10"},
}
def badge_info(badge_name: str) -> dict:
return BADGE_CATALOG.get(badge_name, {"icon": "✦", "description": badge_name})
def level_for_xp(xp: int) -> int:
return 1 + max(0, xp) // LEVEL_XP
def notify_badge(user_uid: str, badge_name: str) -> None:
user = get_table("users").find_one(uid=user_uid)
if not user:
return
create_notification(user_uid, "badge", f"You earned the {badge_name} badge", user_uid, f"/profile/{user['username']}")
def award_xp(user_uid: str, amount: int) -> dict:
users = get_table("users")
user = users.find_one(uid=user_uid)
if not user or amount <= 0:
return {"xp": user.get("xp", 0) if user else 0, "level": user.get("level", 1) if user else 1, "leveled_up": False}
current_xp = user.get("xp", 0) or 0
current_level = user.get("level", 1) or 1
new_xp = max(0, current_xp + amount)
new_level = level_for_xp(new_xp)
users.update({"uid": user_uid, "xp": new_xp, "level": new_level}, ["uid"])
clear_user_cache(user_uid)
leveled_up = new_level > current_level
if leveled_up:
create_notification(user_uid, "level", f"You reached level {new_level}", user_uid, f"/profile/{user['username']}")
for level in range(current_level + 1, new_level + 1):
badge_name = LEVEL_BADGES.get(level)
if badge_name and award_badge(user_uid, badge_name):
notify_badge(user_uid, badge_name)
return {"xp": new_xp, "level": new_level, "leveled_up": leveled_up}
def check_milestone_badges(user_uid: str) -> list:
awarded = []
if get_table("posts").count(user_uid=user_uid) >= 10 and award_badge(user_uid, "Prolific"):
awarded.append("Prolific")
stars = get_user_stars(user_uid)
if stars >= 100 and award_badge(user_uid, "Star Author"):
awarded.append("Star Author")
if stars >= 25 and award_badge(user_uid, "Rising Star"):
awarded.append("Rising Star")
if get_table("follows").count(following_uid=user_uid) >= 10 and award_badge(user_uid, "Popular"):
awarded.append("Popular")
for badge_name in awarded:
notify_badge(user_uid, badge_name)
return awarded
def award_rewards(user_uid: str, amount: int, first_badge: str | None = None) -> None:
if first_badge:
award_badge(user_uid, first_badge)
award_xp(user_uid, amount)
check_milestone_badges(user_uid)
def create_mention_notifications(content: str, actor_uid: str, target_url: str) -> None:
usernames = extract_mentions(content)
if not usernames: