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:
+54
-7
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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"], []),
|
||||
})
|
||||
|
||||
|
||||
@@ -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", "")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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
@@ -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"
|
||||
|
||||
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}")
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -265,10 +265,6 @@
|
||||
transform: translateX(18px);
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">×</button>
|
||||
</div>
|
||||
{{ caller() }}
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
@@ -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">
|
||||
💬 {{ item.comment_count }}
|
||||
</a>
|
||||
<a href="{{ content_url(item.post, 'posts') }}" class="post-action-btn share">
|
||||
↗︎ Open
|
||||
</a>
|
||||
{% if _show_share %}
|
||||
<button type="button" class="post-action-btn" data-share="{{ content_url(item.post, 'posts') }}">🔗 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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">🔔</span> Notifications</a>
|
||||
|
||||
@@ -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;">×</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">📤</span> Submit Report</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -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">
|
||||
💬 {{ item.comment_count }}
|
||||
</a>
|
||||
<a href="/posts/{{ item.post['slug'] or item.post['uid'] }}" class="post-action-btn share">
|
||||
↗︎ Open
|
||||
</a>
|
||||
<button type="button" class="post-action-btn" data-share="/posts/{{ item.post['slug'] or item.post['uid'] }}">🔗 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">×</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 %}
|
||||
|
||||
@@ -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'] }}">🔗 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">☆ <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">✏️</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">×</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">💾</span>Save Changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% endif %}
|
||||
|
||||
{% with target_uid=gist['uid'], target_type="gist" %}
|
||||
|
||||
@@ -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">☆ <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">×</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 %}
|
||||
|
||||
|
||||
@@ -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">★</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'] %} · {{ article['time_ago'] }}{% endif %}</span>
|
||||
</a>
|
||||
{% else %}
|
||||
<div class="no-authors-msg">No featured articles yet</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -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'] }}">🔗 Share</button>
|
||||
{% if user and post['user_uid'] == user['uid'] %}
|
||||
<button class="post-action-btn" data-modal="edit-post-modal"><span class="icon">✏️</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">×</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">💾</span>Save Changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% endif %}
|
||||
|
||||
{% with target_uid=post['uid'], target_type="post" %}
|
||||
|
||||
@@ -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 %}—{% 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">
|
||||
💬 {{ item.comment_count }}
|
||||
</a>
|
||||
<a href="/posts/{{ item.post['slug'] or item.post['uid'] }}" class="post-action-btn share">↗︎ 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 %}
|
||||
|
||||
@@ -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'] }}">🔗 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">☆ <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;">
|
||||
|
||||
@@ -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">☆ <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">×</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 %}
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user