iUpdate
All checks were successful
DevPlace CI / test (push) Successful in 6m46s

This commit is contained in:
retoor 2026-05-30 20:16:39 +02:00
parent 3f900b4002
commit c3a8347cc0
57 changed files with 1310 additions and 674 deletions

View File

@ -42,6 +42,7 @@ make locust-headless # Locust in headless CLI mode (for CI)
| `/votes` | `routers/votes.py` |
| `/avatar` | `routers/avatar.py` |
| `/follow` | `routers/follow.py` |
| `/leaderboard` | `routers/leaderboard.py` |
| `/admin` | `routers/admin.py` |
| `/bugs` | `routers/bugs.py` |
| `/gists` | `routers/gists.py` |
@ -110,6 +111,26 @@ trigger.addEventListener("click", (e) => {
The `modal-close` class is handled by `Application.js` - no inline JS needed in templates for basic modals.
Do NOT hand-write the overlay/header markup. Use the shared macro in `templates/_macros.html`:
```jinja
{% from "_macros.html" import modal %}
{% call modal('create-post-modal', 'Create New Post') %}{# wide=true for modal-card-wide #}
<form ...> ... <div class="modal-footer">...</div> </form>
{% endcall %}
```
## Shared template partials
Reuse these via `{% set _x = ... %}{% include %}` (the `_avatar_link.html` convention) instead of copy-pasting markup:
- `_post_votes.html` — post +/- vote bar. Locals: `_uid`, `_my_vote`, `_count`.
- `_star_vote.html` — project/gist star button. Locals: `_type` (`project`|`gist`), `_uid`, `_my_vote`, `_count`, `_btn_class`, optional `_stop` (adds `data-stop-propagation`). The star glyph (`☆`→`★` when `.voted`) comes from the `vote-star` CSS class via `::before` (`base.css`) — do not put a literal star in markup.
- `_post_header.html` — post author/avatar/time header (`.post-header`). Locals: `_author`, `_time`.
- `_topic_selector.html` — topic radio group. Locals: `_topics`, `_selected`.
Vote button styles live ONCE in `feed.css` (`.post-action-btn`) — never redefine them in `post.css`. Page-specific CSS goes in a `static/css/*.css` file referenced from `{% block extra_head %}`, never an inline `<style>` block.
## Database
SQLite via `dataset` with these pragmas on every connection:
@ -178,6 +199,8 @@ if "comments" not in db.tables:
- **Never create your own `Jinja2Templates` instance.** Import the shared one: `from devplacepy.templating import templates`.
- **Register new routers in `main.py`:** `app.include_router(router_instance, prefix="/{path}")`
- **`require_user(request)` raises 303 redirect to `/`** if not authenticated. Only post/comment/vote/etc. routes use this - the feed is public.
- **For a missing detail resource, `raise not_found("X not found")`** (`utils.py`) — it returns an `HTTPException(404)` that the global handler renders as `error.html`. Do not return a bare `HTMLResponse(..., status_code=404)`.
- **Detail pages reuse `load_detail(table, target_type, slug, user)`** (`content.py`) for item+author+comments+attachments+`star_count`+`my_vote`; list pages reuse `enrich_items(items, key, authors, extra_maps, user=...)`. Prefer these over manual per-row loading (posts/projects/gists detail and feed/gists/profile lists already do).
- **`get_current_user(request)` is cached** in `_user_cache` dict by session token (per-process, no TTL). Use this for pages viewable by both auth guests (feed, news detail, projects).
- **Post deletion must cascade:** delete comments and votes first, then the post. Always check ownership: `post["user_uid"] == user["uid"]`.
- **Message deduplication needed** when `sender_uid == receiver_uid` (messaging yourself): `seen = set()` of message UIDs before appending to result list.
@ -312,6 +335,10 @@ Notifications are created server-side in the route handlers and stored in the `n
| `vote` | Upvote on post or comment | `votes.py` | `value == 1` AND voter != owner |
| `follow` | Follow another user | `follow.py` | Always (self-follow blocked upstream) |
| `message` | Send a message | `messages.py` | Always (different user) |
| `level` | Reach a new level | `utils.py` `award_xp` | `new_level > current_level` |
| `badge` | Earn any badge | `utils.py` `notify_badge` | First grant only (`award_badge` returns `True`) |
`level`/`badge` notifications have `related_uid == user_uid` (self), so the notifications template renders them with the recipient's own avatar; the template is type-agnostic (driven by `message`/`actor`), so no per-type handling is needed.
### Time-grouped display
@ -330,6 +357,33 @@ f"{user['username']} ++'d your post"
f"{user['username']} ++'d your comment"
```
## Gamification (XP, levels, badges, leaderboard)
The progression engine lives in `utils.py` and is wired into the existing content-creation, vote, and follow hooks. Do NOT scatter XP/badge logic — go through these helpers.
### XP and levels (`utils.py`)
- `award_xp(user_uid, amount)` — adds XP (clamped to `>= 0`), recomputes and stores `level`, invalidates the per-process user cache (`clear_user_cache`), and on level-up fires a `level` notification plus any level-milestone badge. Returns `{"xp", "level", "leveled_up"}`.
- `level_for_xp(xp)``1 + max(0, xp) // LEVEL_XP` (`LEVEL_XP = 100`). `level` is stored on the user (not derived in the template) so `profile.html`'s `xp % 100` progress bar keeps working.
- XP amounts are constants in `utils.py`: `XP_POST=10`, `XP_COMMENT=2`, `XP_PROJECT=15`, `XP_GIST=5`, `XP_UPVOTE=5`, `XP_FOLLOW=5`. Awards for received upvotes/followers go to the content owner / followed user and live **inside** the existing `owner_uid != user["uid"]` guards (`votes.py`, `follow.py`).
### Badges (`utils.py`)
- `award_badge(user_uid, name)` is idempotent (returns `True` only on first grant) — reuse it; never insert into `badges` directly.
- `check_milestone_badges(user_uid)` recomputes count/star thresholds and awards + notifies any newly crossed badge. Call it after content creation and after an upvote/follow that changes the recipient's totals.
- `award_rewards(user_uid, amount, first_badge=None)` is the single entry point for the create/upvote/follow reward sequence: it awards the optional first-time badge, calls `award_xp`, then `check_milestone_badges`. Use it instead of calling the three separately (posts/projects/gists/comments/follow/votes all go through it) so milestone checks are never skipped.
- Badge catalog + display metadata is `BADGE_CATALOG` in `utils.py`, exposed to templates as the `badge_info(name)` global. Names: Member, First Post, First Comment, First Project, First Gist, Prolific (10 posts), Rising Star (25 stars), Star Author (100 stars), Popular (10 followers), Level 5, Level 10. Unknown names fall back to a default icon and the name as description.
### Rank and leaderboard (`database.py`)
- `_ranked_authors()` builds (and 60s-caches in `_authors_cache`) the full list of authors with positive total stars, ordered desc, enriched via `get_users_by_uids`. `get_top_authors(limit)`, `get_leaderboard(limit, offset)` (adds a 1-based `rank`), and `get_user_rank(user_uid)` all slice/scan this one list — keep them consistent.
- `update_target_stars()` clears `_authors_cache` so a vote is reflected on the leaderboard and profile rank immediately (also keeps integration tests deterministic).
- The leaderboard page (`/leaderboard`, `routers/leaderboard.py`) shows the **top 50 only — no pagination** (`TOP_LIMIT = 50`). It is public (`get_current_user`), highlights the current user's row (`.leaderboard-row-self`), and is listed in `sitemap.xml`.
### Backfill
`_backfill_gamification()` runs at the end of `init_db()`. It computes XP from prior activity (same amounts as above) for users still at the default `xp=0`, sets `level`, then runs `check_milestone_badges` per user. Guarded on `xp=0` so it is idempotent across restarts.
## Inline Comment on Feed Cards
Every post card on the feed now has an inline comment form (`.feed-comment-form`) beneath the post actions. It posts to `/comments/create` like the detail page comment form. Tests must scope Post button clicks to `#create-post-modal button.btn-primary:has-text('Post')` to avoid matching the inline comment's Post button.

View File

@ -62,6 +62,7 @@ devplacepy/
| `/notifications` | Notification list, mark read |
| `/votes` | Upvote/downvote on posts, comments, projects |
| `/follow` | Follow/unfollow users |
| `/leaderboard` | Contributor ranking by total stars earned |
| `/avatar` | Multiavatar proxy with in-memory cache |
| `/bugs` | Bug reports listing, creation |
| `/services` | Background service monitoring (status, logs) |
@ -69,6 +70,18 @@ devplacepy/
| `(none)` | `/robots.txt`, `/sitemap.xml` (SEO) |
| `(none)` | `/push.json` (VAPID key + subscribe), `/service-worker.js`, `/manifest.json` (push + PWA) |
## Gamification
Member progression is driven by activity and peer recognition.
- **Stars** are the net vote score (`upvotes - downvotes`) on a post, project, or gist. A member's total stars is the sum across all their content and is the basis for ranking.
- **XP and levels.** Members earn XP for contributing: posting (10), commenting (2), publishing a project (15) or gist (5), receiving an upvote (5), and gaining a follower (5). Each level requires 100 XP (`level = 1 + xp // 100`). The profile shows the current level and progress to the next.
- **Badges** are awarded once per milestone: first post/comment/project/gist, 10 posts (Prolific), 25 stars (Rising Star), 100 stars (Star Author), 10 followers (Popular), and reaching levels 5 and 10. Badges render with an icon and description on the profile.
- **Leaderboard** (`/leaderboard`) ranks the top 50 members by total stars (single page, no pagination); a member's own rank is shown on their profile.
- **Reward notifications** fire when a member levels up or earns a badge.
XP awards are wired at the existing content-creation, vote, and follow hook points in the routers and centralized in `award_xp()` / `check_milestone_badges()` (`devplacepy/utils.py`). Existing accounts have their XP and levels backfilled once from prior activity at startup (`init_db()`).
## Configuration
| Env var | Default | Purpose |

View File

@ -1,25 +1,69 @@
# retoor <retoor@molodetz.nl>
import logging
from typing import Any
from datetime import datetime, timezone
from fastapi.responses import RedirectResponse
from devplacepy.database import (
get_table,
resolve_by_slug,
get_users_by_uids,
get_vote_counts,
get_user_votes,
load_comments,
db,
)
from devplacepy.attachments import delete_target_attachments, delete_inline_image, get_attachments
from devplacepy.utils import time_ago
from devplacepy.attachments import delete_target_attachments, delete_inline_image, get_attachments, link_attachments
from devplacepy.utils import time_ago, generate_uid, make_combined_slug, award_rewards, create_mention_notifications
logger = logging.getLogger(__name__)
def is_owner(item: dict | None, user: dict | None) -> bool:
return bool(item and user and item["user_uid"] == user["uid"])
def create_content_item(table_name: str, target_type: str, user: dict, fields: dict, slug_source: str, xp: int, badge: str, mention_text: str, attachment_uids: list | None) -> tuple[str, str]:
uid = generate_uid()
slug = make_combined_slug(slug_source, uid)
get_table(table_name).insert({
"uid": uid,
"user_uid": user["uid"],
"slug": slug,
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
**fields,
})
award_rewards(user["uid"], xp, badge)
if attachment_uids:
link_attachments(attachment_uids, target_type, uid)
create_mention_notifications(mention_text, user["uid"], f"/{table_name}/{slug}")
logger.info(f"{target_type} {uid} created by {user['username']}")
return uid, slug
def detail_context(request, user: dict | None, detail: dict, key: str, seo_ctx: dict, extra: dict | None = None) -> dict:
context = {
**seo_ctx,
"request": request,
"user": user,
key: detail["item"],
"author": detail["author"],
"is_owner": detail["is_owner"],
"star_count": detail["star_count"],
"my_vote": detail["my_vote"],
"time_ago": detail["time_ago"],
"comments": detail["comments"],
"attachments": detail["attachments"],
}
if extra:
context.update(extra)
return context
def edit_content_item(table_name: str, user: dict, slug: str, update_fields: dict, redirect_fail: str) -> RedirectResponse:
table = get_table(table_name)
item = resolve_by_slug(table, slug)
if not item or item["user_uid"] != user["uid"]:
if not is_owner(item, user):
return RedirectResponse(url=redirect_fail, status_code=302)
table.update({"uid": item["uid"], **update_fields}, ["uid"])
logger.info(f"{table_name} {item['uid']} edited by {user['username']}")
@ -29,7 +73,7 @@ def edit_content_item(table_name: str, user: dict, slug: str, update_fields: dic
def delete_content_item(table_name: str, target_type: str, user: dict, slug: str, redirect_url: str, inline_image_field: str | None = None) -> RedirectResponse:
table = get_table(table_name)
item = resolve_by_slug(table, slug)
if item and item["user_uid"] == user["uid"]:
if is_owner(item, user):
delete_target_attachments(target_type, item["uid"])
if "comments" in db.tables:
comments = get_table("comments")
@ -56,22 +100,25 @@ def load_detail(table_name: str, target_type: str, slug: str, user: dict | None)
"author": author,
"is_owner": bool(user and user["uid"] == item["user_uid"]),
"star_count": ups.get(item["uid"], 0) - downs.get(item["uid"], 0),
"comments": load_comments(target_type, item["uid"]),
"my_vote": get_user_votes(user["uid"], [item["uid"]]).get(item["uid"], 0) if user else 0,
"comments": load_comments(target_type, item["uid"], user),
"attachments": get_attachments(target_type, item["uid"]),
"time_ago": time_ago(item["created_at"]),
}
def enrich_items(items: list, key: str, authors: dict, extra_maps: dict[str, Any] | None = None, ts_field: str = "created_at") -> list:
def enrich_items(items: list, key: str, authors: dict, extra_maps: dict[str, Any] | None = None, ts_field: str = "created_at", user: dict | None = None) -> list:
extra_maps = extra_maps or {}
my_votes = get_user_votes(user["uid"], [item["uid"] for item in items]) if user else {}
enriched = []
for item in items:
entry = {
key: item,
"author": authors.get(item["user_uid"]),
"time_ago": time_ago(item[ts_field]),
"my_vote": my_votes.get(item["uid"], 0),
}
for name, source in extra_maps.items():
entry[name] = source(item) if callable(source) else source.get(item["uid"])
entry[name] = source(item) if callable(source) else source.get(item["uid"], 0)
enriched.append(entry)
return enriched

View File

@ -1,5 +1,6 @@
import dataset
import logging
from collections import defaultdict
from datetime import datetime, timezone
from devplacepy.cache import TTLCache
from devplacepy.config import DATABASE_URL
@ -126,13 +127,74 @@ def init_db():
if not existing:
db["site_settings"].insert({"uid": f"default_{key}", "key": key, "value": value})
_backfill_gamification()
logger.info("Database initialized")
def _backfill_gamification():
if "users" not in db.tables:
return
from devplacepy.utils import (
level_for_xp, check_milestone_badges,
XP_POST, XP_COMMENT, XP_PROJECT, XP_GIST, XP_UPVOTE, XP_FOLLOW,
)
pending = list(db["users"].find(xp=0))
if not pending:
return
xp_by_user = defaultdict(int)
def add_counts(table, column, points):
if table not in db.tables:
return
for row in db.query(f"SELECT {column} AS uid, COUNT(*) AS c FROM {table} GROUP BY {column}"):
if row["uid"]:
xp_by_user[row["uid"]] += row["c"] * points
add_counts("posts", "user_uid", XP_POST)
add_counts("comments", "user_uid", XP_COMMENT)
add_counts("projects", "user_uid", XP_PROJECT)
add_counts("gists", "user_uid", XP_GIST)
add_counts("follows", "following_uid", XP_FOLLOW)
if "votes" in db.tables:
for content_table, target_type in (("posts", "post"), ("projects", "project"), ("gists", "gist"), ("comments", "comment")):
if content_table not in db.tables:
continue
rows = db.query(
f"SELECT c.user_uid AS uid, COUNT(*) AS c "
f"FROM votes v JOIN {content_table} c ON v.target_uid = c.uid "
f"WHERE v.target_type = :t AND v.value = 1 GROUP BY c.user_uid",
t=target_type,
)
for row in rows:
if row["uid"]:
xp_by_user[row["uid"]] += row["c"] * XP_UPVOTE
for user in pending:
xp = xp_by_user.get(user["uid"], 0)
if xp <= 0:
continue
db["users"].update({"uid": user["uid"], "xp": xp, "level": level_for_xp(xp)}, ["uid"])
_authors_cache.clear()
for user in pending:
check_milestone_badges(user["uid"])
logger.info(f"Gamification backfill processed {len(pending)} users")
def get_table(name):
return db[name]
def _in_clause(uids, prefix="p"):
placeholders = ", ".join(f":{prefix}{i}" for i in range(len(uids)))
params = {f"{prefix}{i}": uid for i, uid in enumerate(uids)}
return placeholders, params
def get_users_by_uids(uids):
if not uids:
return {}
@ -144,8 +206,7 @@ def get_users_by_uids(uids):
def get_comment_counts_by_post_uids(post_uids):
if not post_uids or "comments" not in db.tables:
return {}
placeholders = ", ".join(f":p{i}" for i in range(len(post_uids)))
params = {f"p{i}": u for i, u in enumerate(post_uids)}
placeholders, params = _in_clause(post_uids)
rows = db.query(f"SELECT target_uid, COUNT(*) as c FROM comments WHERE target_type='post' AND target_uid IN ({placeholders}) GROUP BY target_uid", **params)
return {r["target_uid"]: r["c"] for r in rows}
@ -153,8 +214,7 @@ def get_comment_counts_by_post_uids(post_uids):
def get_post_counts_by_user_uids(user_uids):
if not user_uids or "posts" not in db.tables:
return {}
placeholders = ", ".join(f":p{i}" for i in range(len(user_uids)))
params = {f"p{i}": u for i, u in enumerate(user_uids)}
placeholders, params = _in_clause(user_uids)
rows = db.query(f"SELECT user_uid, COUNT(*) as c FROM posts WHERE user_uid IN ({placeholders}) GROUP BY user_uid", **params)
return {r["user_uid"]: r["c"] for r in rows}
@ -162,8 +222,7 @@ def get_post_counts_by_user_uids(user_uids):
def get_vote_counts(target_uids):
if not target_uids or "votes" not in db.tables:
return {}, {}
placeholders = ", ".join(f":p{i}" for i in range(len(target_uids)))
params = {f"p{i}": u for i, u in enumerate(target_uids)}
placeholders, params = _in_clause(target_uids)
rows = db.query(f"SELECT target_uid, value, COUNT(*) as c FROM votes WHERE target_uid IN ({placeholders}) GROUP BY target_uid, value", **params)
ups = {}
downs = {}
@ -175,7 +234,16 @@ def get_vote_counts(target_uids):
return ups, downs
def load_comments(target_type, target_uid):
def get_user_votes(user_uid, target_uids):
if not user_uid or not target_uids or "votes" not in db.tables:
return {}
placeholders, params = _in_clause(target_uids)
params["uid"] = user_uid
rows = db.query(f"SELECT target_uid, value FROM votes WHERE user_uid = :uid AND target_uid IN ({placeholders})", **params)
return {r["target_uid"]: r["value"] for r in rows}
def load_comments(target_type, target_uid, user=None):
if "comments" not in db.tables:
return []
comments_table = db["comments"]
@ -188,6 +256,7 @@ def load_comments(target_type, target_uid):
cids = [c["uid"] for c in raw]
users = get_users_by_uids(uids)
ups, downs = get_vote_counts(cids)
my_votes = get_user_votes(user["uid"], cids) if user else {}
from devplacepy.utils import time_ago
from devplacepy.attachments import get_attachments_batch as _gab
atts_map = _gab("comment", cids) if "attachments" in db.tables else {}
@ -198,6 +267,7 @@ def load_comments(target_type, target_uid):
"author": users.get(c["user_uid"]),
"time_ago": time_ago(c["created_at"]),
"votes": {"up": ups.get(c["uid"], 0), "down": downs.get(c["uid"], 0)},
"my_vote": my_votes.get(c["uid"], 0),
"children": [],
"attachments": atts_map.get(c["uid"], []),
}
@ -340,21 +410,21 @@ def get_gist_languages() -> set[str]:
_STARRED_CONTENT_TABLES = ("posts", "projects", "gists")
_top_authors_cache = TTLCache(ttl=60)
_authors_cache = TTLCache(ttl=60)
def get_top_authors(limit: int = 5) -> list:
cached = _top_authors_cache.get("top")
def _ranked_authors() -> list:
cached = _authors_cache.get("ranked")
if cached is not None:
return cached
sources = [table for table in _STARRED_CONTENT_TABLES if table in db.tables]
if not sources:
_top_authors_cache.set("top", [])
_authors_cache.set("ranked", [])
return []
union = " UNION ALL ".join(f"SELECT user_uid, stars FROM {table}" for table in sources)
rows = db.query(
f"SELECT user_uid, SUM(stars) AS total FROM ({union}) "
f"GROUP BY user_uid HAVING total > 0 ORDER BY total DESC LIMIT {int(limit)}"
f"GROUP BY user_uid HAVING total > 0 ORDER BY total DESC"
)
ranked = [(row["user_uid"], row["total"]) for row in rows]
users_map = get_users_by_uids([uid for uid, _ in ranked])
@ -365,10 +435,31 @@ def get_top_authors(limit: int = 5) -> list:
author = dict(user)
author["stars"] = total
authors.append(author)
_top_authors_cache.set("top", authors)
_authors_cache.set("ranked", authors)
return authors
def get_top_authors(limit: int = 5) -> list:
return _ranked_authors()[:limit]
def get_leaderboard(limit: int = 50, offset: int = 0) -> list:
sliced = _ranked_authors()[offset:offset + limit]
leaderboard = []
for position, author in enumerate(sliced, start=offset + 1):
entry = dict(author)
entry["rank"] = position
leaderboard.append(entry)
return leaderboard
def get_user_rank(user_uid: str):
for position, author in enumerate(_ranked_authors(), start=1):
if author["uid"] == user_uid:
return position
return None
def get_user_stars(user_uid: str) -> int:
total = 0
for table in _STARRED_CONTENT_TABLES:
@ -426,6 +517,7 @@ def update_target_stars(target_type: str, target_uid: str, net_stars: int) -> No
if not table_name or target_type not in STAR_TARGETS:
return
get_table(table_name).update({"uid": target_uid, "stars": net_stars}, ["uid"])
_authors_cache.clear()
def get_target_owner_uid(target_type: str, target_uid: str) -> str | None:
@ -463,3 +555,22 @@ def get_daily_topic():
"url": article.get("url", ""),
}
return {"title": "Welcome to DevPlace", "summary": "Stay tuned for the latest dev news."}
def get_featured_news(limit=5):
if "news" not in db.tables:
return []
from devplacepy.utils import time_ago
rows = list(db["news"].find(show_on_landing=1, order_by=["-synced_at"], _limit=limit))
articles = []
for article in rows:
summary = (article.get("description") or "")[:120] or (article.get("content") or "")[:120]
articles.append({
"title": article.get("title", ""),
"summary": summary,
"slug": article.get("slug", ""),
"url": article.get("url", ""),
"source_name": article.get("source_name", ""),
"time_ago": time_ago(article["synced_at"]) if article.get("synced_at") else "",
})
return articles

View File

@ -12,7 +12,7 @@ from devplacepy.database import init_db, get_table, db, get_users_by_uids, get_c
from devplacepy.templating import templates
from devplacepy.utils import get_current_user, time_ago
from devplacepy.seo import base_seo_context, site_url, website_schema
from devplacepy.routers import auth, feed, posts, comments, projects, profile, messages, notifications, votes, avatar, follow, admin, seo, bugs, news, gists, services as services_router, uploads, push
from devplacepy.routers import auth, feed, posts, comments, projects, profile, messages, notifications, votes, avatar, follow, admin, seo, bugs, news, gists, services as services_router, uploads, push, leaderboard
from devplacepy.services.manager import service_manager
from devplacepy.services.news import NewsService
@ -106,6 +106,7 @@ app.include_router(notifications.router, prefix="/notifications")
app.include_router(votes.router, prefix="/votes")
app.include_router(avatar.router, prefix="/avatar")
app.include_router(follow.router, prefix="/follow")
app.include_router(leaderboard.router, prefix="/leaderboard")
app.include_router(admin.router, prefix="/admin")
app.include_router(seo.router)
app.include_router(push.router)

View File

@ -32,7 +32,7 @@ async def bugs_page(request: Request):
"bug": b,
"author": users_map.get(b["user_uid"]),
"time_ago": time_ago(b["created_at"]),
"comments": load_comments("bug", b["uid"]),
"comments": load_comments("bug", b["uid"], user),
"attachments": attachments_map.get(b["uid"], []),
})

View File

@ -5,7 +5,8 @@ from fastapi import APIRouter, Request, Form
from fastapi.responses import RedirectResponse
from devplacepy.database import get_table, resolve_object_url
from devplacepy.attachments import link_attachments, delete_target_attachments
from devplacepy.utils import generate_uid, require_user, create_mention_notifications, create_notification, award_badge
from devplacepy.content import is_owner
from devplacepy.utils import generate_uid, require_user, create_mention_notifications, create_notification, award_rewards, XP_COMMENT
from devplacepy.models import CommentForm
logger = logging.getLogger(__name__)
@ -39,7 +40,7 @@ async def create_comment(request: Request, data: Annotated[CommentForm, Form()])
if data.attachment_uids:
link_attachments(data.attachment_uids, "comment", comment_uid)
award_badge(user["uid"], "First Comment")
award_rewards(user["uid"], XP_COMMENT, "First Comment")
comment_url = f"{redirect_url}#comment-{comment_uid}"
@ -66,9 +67,7 @@ async def delete_comment(request: Request, comment_uid: str):
user = require_user(request)
comments = get_table("comments")
comment = comments.find_one(uid=comment_uid)
if not comment:
return RedirectResponse(url="/feed", status_code=302)
if comment["user_uid"] != user["uid"]:
if not is_owner(comment, user):
return RedirectResponse(url="/feed", status_code=302)
target_type = comment.get("target_type", "post")
target_uid = comment.get("target_uid") or comment.get("post_uid", "")

View File

@ -6,7 +6,7 @@ from devplacepy.attachments import get_attachments_batch
from devplacepy.content import enrich_items
from devplacepy.templating import templates
from devplacepy.utils import get_current_user
from devplacepy.seo import base_seo_context, site_url, website_schema
from devplacepy.seo import list_page_seo
logger = logging.getLogger(__name__)
router = APIRouter()
@ -48,7 +48,7 @@ def get_feed_posts(user, tab: str = "all", topic: str = None, before: str = None
authors = get_users_by_uids([p["user_uid"] for p in posts])
counts = get_comment_counts_by_post_uids([p["uid"] for p in posts])
result = enrich_items(posts, "post", authors, {"comment_count": counts})
result = enrich_items(posts, "post", authors, {"comment_count": counts}, user=user)
return result, next_cursor
@ -65,13 +65,11 @@ async def feed_page(request: Request, tab: str = "all", topic: str = None, befor
for item in posts:
item["attachments"] = attachments_map.get(item["post"]["uid"], [])
base = site_url(request)
seo_ctx = base_seo_context(
seo_ctx = list_page_seo(
request,
title="Feed",
description="Discover the latest developer discussions, projects, and community activity on DevPlace.",
breadcrumbs=[{"name": "Home", "url": "/feed"}, {"name": "Feed", "url": "/feed"}],
schemas=[website_schema(base)],
)
return templates.TemplateResponse(request, "feed.html", {
**seo_ctx,

View File

@ -3,7 +3,7 @@ from datetime import datetime, timezone
from fastapi import APIRouter, Request
from fastapi.responses import RedirectResponse
from devplacepy.database import get_table
from devplacepy.utils import generate_uid, require_user, create_notification
from devplacepy.utils import generate_uid, require_user, create_notification, award_rewards, XP_FOLLOW
logger = logging.getLogger(__name__)
router = APIRouter()
@ -30,6 +30,7 @@ async def follow_user(request: Request, username: str):
})
create_notification(target["uid"], "follow", f"{user['username']} started following you", user["uid"], f"/profile/{user['username']}")
award_rewards(target["uid"], XP_FOLLOW)
logger.info(f"{user['username']} followed {username}")
return RedirectResponse(url=f"/profile/{username}", status_code=302)

View File

@ -1,14 +1,13 @@
import logging
from typing import Annotated
from datetime import datetime, timezone
from fastapi import APIRouter, Request, HTTPException, Form
from fastapi import APIRouter, Request, Form
from devplacepy.models import GistForm, GistEditForm
from fastapi.responses import HTMLResponse, RedirectResponse
from devplacepy.database import get_table, get_users_by_uids, get_gist_languages
from devplacepy.content import load_detail, edit_content_item, delete_content_item, enrich_items
from devplacepy.content import load_detail, edit_content_item, delete_content_item, enrich_items, create_content_item, detail_context
from devplacepy.templating import templates
from devplacepy.utils import generate_uid, get_current_user, require_user, make_combined_slug, create_mention_notifications
from devplacepy.seo import base_seo_context, site_url, website_schema, software_source_code_schema
from devplacepy.utils import get_current_user, require_user, not_found, XP_GIST
from devplacepy.seo import base_seo_context, site_url, website_schema, software_source_code_schema, list_page_seo
logger = logging.getLogger(__name__)
router = APIRouter()
@ -24,7 +23,7 @@ LANGUAGES = [
]
def get_gists_list(user_uid=None, language=None):
def get_gists_list(user_uid=None, language=None, viewer=None):
gists_table = get_table("gists")
filters = {}
if user_uid:
@ -38,16 +37,15 @@ def get_gists_list(user_uid=None, language=None):
return []
users_map = get_users_by_uids([g["user_uid"] for g in all_gists])
return enrich_items(all_gists, "gist", users_map)
return enrich_items(all_gists, "gist", users_map, user=viewer)
@router.get("", response_class=HTMLResponse)
async def gists_page(request: Request, language: str = None, user_uid: str = None):
user = get_current_user(request)
gists_data = get_gists_list(user_uid, language)
gists_data = get_gists_list(user_uid, language, viewer=user)
total_count = len(gists_data)
base = site_url(request)
seo_ctx = base_seo_context(
seo_ctx = list_page_seo(
request,
title="Gists",
description=f"Browse {total_count} code snippets on DevPlace.",
@ -55,7 +53,6 @@ async def gists_page(request: Request, language: str = None, user_uid: str = Non
{"name": "Home", "url": "/feed"},
{"name": "Gists", "url": "/gists"},
],
schemas=[website_schema(base)],
)
return templates.TemplateResponse(request, "gists.html", {
**seo_ctx,
@ -74,7 +71,7 @@ async def gist_detail(request: Request, gist_slug: str):
user = get_current_user(request)
detail = load_detail("gists", "gist", gist_slug, user)
if not detail:
raise HTTPException(status_code=404, detail="Gist not found")
raise not_found("Gist not found")
gist = detail["item"]
base = site_url(request)
@ -90,19 +87,9 @@ async def gist_detail(request: Request, gist_slug: str):
],
schemas=[website_schema(base), software_source_code_schema(gist, base)],
)
return templates.TemplateResponse(request, "gist_detail.html", {
**seo_ctx,
"request": request,
"user": user,
"gist": gist,
"author": detail["author"],
"is_owner": detail["is_owner"],
"star_count": detail["star_count"],
"time_ago": detail["time_ago"],
"comments": detail["comments"],
return templates.TemplateResponse(request, "gist_detail.html", detail_context(request, user, detail, "gist", seo_ctx, {
"languages": LANGUAGES,
"attachments": detail["attachments"],
})
}))
@router.post("/create")
@ -117,27 +104,12 @@ async def create_gist(request: Request, data: Annotated[GistForm, Form()]):
if language not in valid_languages:
language = "plaintext"
gists = get_table("gists")
uid = generate_uid()
gist_slug = make_combined_slug(title, uid)
gists.insert({
"uid": uid,
"user_uid": user["uid"],
uid, gist_slug = create_content_item("gists", "gist", user, {
"title": title,
"slug": gist_slug,
"description": description or None,
"source_code": source_code,
"language": language,
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
})
from devplacepy.attachments import link_attachments
link_attachments(data.attachment_uids, "gist", uid)
create_mention_notifications(description or "", user["uid"], f"/gists/{gist_slug}")
logger.info(f"Gist {uid} created by {user['username']}")
}, title, XP_GIST, "First Gist", description or "", data.attachment_uids)
return RedirectResponse(url=f"/gists/{gist_slug}", status_code=302)

View File

@ -0,0 +1,44 @@
import logging
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from devplacepy.database import get_leaderboard, get_user_rank, get_site_stats, get_top_authors, get_featured_news
from devplacepy.templating import templates
from devplacepy.utils import get_current_user
from devplacepy.seo import list_page_seo
logger = logging.getLogger(__name__)
router = APIRouter()
TOP_LIMIT = 50
@router.get("", response_class=HTMLResponse)
async def leaderboard_page(request: Request):
entries = get_leaderboard(TOP_LIMIT, 0)
user = get_current_user(request)
user_rank = get_user_rank(user["uid"]) if user else None
stats = get_site_stats()
top_authors = get_top_authors(5)
featured_news = get_featured_news(5)
seo_ctx = list_page_seo(
request,
title="Leaderboard",
description="Top contributors on DevPlace ranked by the stars their posts, projects, and gists have earned.",
breadcrumbs=[{"name": "Home", "url": "/feed"}, {"name": "Leaderboard", "url": "/leaderboard"}],
)
return templates.TemplateResponse(request, "leaderboard.html", {
**seo_ctx,
"request": request,
"user": user,
"entries": entries,
"user_rank": user_rank,
"total_members": stats["total_members"],
"posts_today": stats["posts_today"],
"total_projects": stats["total_projects"],
"total_gists": stats["total_gists"],
"top_authors": top_authors,
"featured_news": featured_news,
})

View File

@ -4,8 +4,8 @@ from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from devplacepy.database import get_table, db, load_comments, resolve_by_slug, get_news_images_by_uids
from devplacepy.templating import templates
from devplacepy.utils import get_current_user, time_ago
from devplacepy.seo import base_seo_context, website_schema, site_url, news_article_schema
from devplacepy.utils import get_current_user, time_ago, not_found
from devplacepy.seo import base_seo_context, website_schema, site_url, news_article_schema, list_page_seo
logger = logging.getLogger(__name__)
router = APIRouter()
@ -37,13 +37,11 @@ async def news_page(request: Request):
"grade": a.get("grade", 0),
})
base = site_url(request)
seo_ctx = base_seo_context(
seo_ctx = list_page_seo(
request,
title="Developer News",
description="Curated developer news and industry signals. Stay ahead with hand-picked articles.",
breadcrumbs=[{"name": "Home", "url": "/feed"}, {"name": "News", "url": "/news"}],
schemas=[website_schema(base)],
)
return templates.TemplateResponse(request, "news.html", {
@ -60,7 +58,7 @@ async def news_detail_page(request: Request, news_slug: str):
news_table = get_table("news")
article = resolve_by_slug(news_table, news_slug)
if not article:
return HTMLResponse("News article not found", status_code=404)
raise not_found("News article not found")
image_url = ""
if "news_images" in db.tables:
@ -69,7 +67,7 @@ async def news_detail_page(request: Request, news_slug: str):
image_url = img["url"]
canonical_slug = article.get("slug", "") or article["uid"]
comments = load_comments("news", article["uid"])
comments = load_comments("news", article["uid"], user)
base = site_url(request)
page_url = f"{base}/news/{canonical_slug}"

View File

@ -1,15 +1,14 @@
import logging
from typing import Annotated
from datetime import datetime, timezone
from fastapi import APIRouter, Request, HTTPException, Form
from fastapi import APIRouter, Request, Form
from fastapi.responses import RedirectResponse, HTMLResponse
from devplacepy.constants import TOPICS
from devplacepy.database import get_table, load_comments, db, resolve_by_slug
from devplacepy.database import db
from devplacepy.templating import templates
from devplacepy.utils import generate_uid, get_current_user, require_user, time_ago, make_combined_slug, create_mention_notifications, award_badge
from devplacepy.content import edit_content_item, delete_content_item
from devplacepy.utils import get_current_user, require_user, time_ago, not_found, XP_POST
from devplacepy.content import load_detail, edit_content_item, delete_content_item, create_content_item, detail_context
from devplacepy.seo import base_seo_context, site_url, website_schema, discussion_forum_posting, truncate
from devplacepy.attachments import get_attachments, link_attachments, save_inline_image
from devplacepy.attachments import save_inline_image
from devplacepy.models import PostForm, PostEditForm
logger = logging.getLogger(__name__)
@ -32,45 +31,26 @@ async def create_post(request: Request, data: Annotated[PostForm, Form()]):
if image_filename:
content += f"\n\n![](/static/uploads/{image_filename})"
posts = get_table("posts")
uid = generate_uid()
slug_text = title if title else content[:50]
post_slug = make_combined_slug(slug_text, uid)
posts.insert({
"uid": uid,
"user_uid": user["uid"],
uid, post_slug = create_content_item("posts", "post", user, {
"title": title or None,
"slug": post_slug,
"content": content,
"topic": topic,
"project_uid": project_uid or None,
"image": image_filename,
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
})
award_badge(user["uid"], "First Post")
if data.attachment_uids:
link_attachments(data.attachment_uids, "post", uid)
create_mention_notifications(content, user["uid"], f"/posts/{post_slug}")
logger.info(f"Post {uid} created by {user['username']}")
}, slug_text, XP_POST, "First Post", content, data.attachment_uids)
return RedirectResponse(url=f"/posts/{post_slug}", status_code=302)
@router.get("/{post_slug}", response_class=HTMLResponse)
async def view_post(request: Request, post_slug: str):
user = get_current_user(request)
posts = get_table("posts")
post = resolve_by_slug(posts, post_slug)
if not post:
raise HTTPException(status_code=404, detail="Post not found")
users_table = get_table("users")
author = users_table.find_one(uid=post["user_uid"])
top_level = load_comments("post", post["uid"])
detail = load_detail("posts", "post", post_slug, user)
if not detail:
raise not_found("Post not found")
post = detail["item"]
author = detail["author"]
top_level = detail["comments"]
def count_all(items):
total = len(items)
@ -78,7 +58,6 @@ async def view_post(request: Request, post_slug: str):
total += count_all(item.get("children", []))
return total
comment_count = count_all(top_level)
star_count = post.get("stars", 0)
base = site_url(request)
seo_ctx = base_seo_context(
request,
@ -92,7 +71,7 @@ async def view_post(request: Request, post_slug: str):
og_type="article",
schemas=[
website_schema(base),
discussion_forum_posting(post, author, comment_count, star_count, base),
discussion_forum_posting(post, author, comment_count, detail["star_count"], base),
],
)
@ -109,21 +88,11 @@ async def view_post(request: Request, post_slug: str):
"time_ago": time_ago(r["created_at"]),
})
post_attachments = get_attachments("post", post["uid"])
return templates.TemplateResponse(request, "post.html", {
**seo_ctx,
"request": request,
"user": user,
"post": post,
"author": author,
"comments": top_level,
"time_ago": time_ago(post["created_at"]),
return templates.TemplateResponse(request, "post.html", detail_context(request, user, detail, "post", seo_ctx, {
"comment_count": comment_count,
"related_posts": related_posts,
"topics": list(TOPICS),
"attachments": post_attachments,
})
}))
@router.post("/edit/{post_slug}")

View File

@ -3,7 +3,8 @@ from typing import Annotated
from fastapi import APIRouter, Request, Form
from devplacepy.models import ProfileForm
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
from devplacepy.database import get_table, db, get_user_stars
from devplacepy.database import get_table, db, get_user_stars, get_user_rank, get_comment_counts_by_post_uids
from devplacepy.content import enrich_items
from devplacepy.templating import templates
from devplacepy.utils import get_current_user, require_user, require_user_api, time_ago, clear_user_cache
from devplacepy.seo import base_seo_context, site_url, website_schema, profile_page_schema
@ -36,22 +37,15 @@ async def profile_page(request: Request, username: str, tab: str = "posts"):
if not profile_user:
return RedirectResponse(url="/feed", status_code=302)
profile_user["stars"] = get_user_stars(profile_user["uid"])
rank = get_user_rank(profile_user["uid"])
posts = []
if tab == "posts":
posts_table = get_table("posts")
raw_posts = list(posts_table.find(user_uid=profile_user["uid"], order_by=["-created_at"]))
if raw_posts:
from devplacepy.database import get_comment_counts_by_post_uids
counts = get_comment_counts_by_post_uids([p["uid"] for p in raw_posts])
else:
counts = {}
for p in raw_posts:
posts.append({
"post": p,
"time_ago": time_ago(p["created_at"]),
"comment_count": counts.get(p["uid"], 0),
})
counts = get_comment_counts_by_post_uids([p["uid"] for p in raw_posts]) if raw_posts else {}
authors = {profile_user["uid"]: profile_user}
posts = enrich_items(raw_posts, "post", authors, {"comment_count": counts}, user=current_user)
badges = list(get_table("badges").find(user_uid=profile_user["uid"]))
projects = list(get_table("projects").find(user_uid=profile_user["uid"]))
@ -111,6 +105,7 @@ async def profile_page(request: Request, username: str, tab: str = "posts"):
"posts_count": posts_count,
"is_following": is_following,
"activities": activities,
"rank": rank,
})

View File

@ -1,22 +1,20 @@
import logging
from typing import Annotated
from datetime import datetime, timezone
from sqlalchemy import or_
from fastapi import APIRouter, Request, HTTPException, Form
from fastapi import APIRouter, Request, Form
from devplacepy.models import ProjectForm
from fastapi.responses import HTMLResponse, RedirectResponse
from devplacepy.database import get_table, get_users_by_uids, get_site_stats
from devplacepy.content import load_detail, delete_content_item
from devplacepy.attachments import link_attachments
from devplacepy.database import get_table, get_users_by_uids, get_site_stats, get_user_votes
from devplacepy.content import load_detail, delete_content_item, create_content_item, detail_context
from devplacepy.templating import templates
from devplacepy.utils import generate_uid, get_current_user, require_user, make_combined_slug, create_mention_notifications
from devplacepy.seo import base_seo_context, site_url, website_schema, software_application_schema
from devplacepy.utils import get_current_user, require_user, not_found, XP_PROJECT
from devplacepy.seo import base_seo_context, site_url, website_schema, software_application_schema, list_page_seo
logger = logging.getLogger(__name__)
router = APIRouter()
def get_projects_list(tab: str = "recent", search: str = "", user_uid: str = None, project_type: str = None):
def get_projects_list(tab: str = "recent", search: str = "", user_uid: str = None, project_type: str = None, viewer: dict = None):
projects = get_table("projects")
filters = {}
@ -37,9 +35,11 @@ def get_projects_list(tab: str = "recent", search: str = "", user_uid: str = Non
if all_projects:
users_map = get_users_by_uids([p["user_uid"] for p in all_projects])
my_votes = get_user_votes(viewer["uid"], [p["uid"] for p in all_projects]) if viewer else {}
for p in all_projects:
author = users_map.get(p["user_uid"])
p["author_name"] = author["username"] if author else "Unknown"
p["my_vote"] = my_votes.get(p["uid"], 0)
return all_projects
@ -53,11 +53,10 @@ async def projects_page(
project_type: str = None,
):
user = get_current_user(request)
projects = get_projects_list(tab, search, user_uid, project_type)
projects = get_projects_list(tab, search, user_uid, project_type, viewer=user)
total_members = get_site_stats()["total_members"]
base = site_url(request)
seo_ctx = base_seo_context(
seo_ctx = list_page_seo(
request,
title="Projects",
description=f"Explore {len(projects)} developer projects on DevPlace. Games, software, mobile apps and more.",
@ -65,7 +64,6 @@ async def projects_page(
{"name": "Home", "url": "/feed"},
{"name": "Projects", "url": "/projects"},
],
schemas=[website_schema(base)],
)
return templates.TemplateResponse(request, "projects.html", {
**seo_ctx,
@ -85,7 +83,7 @@ async def project_detail(request: Request, project_slug: str):
user = get_current_user(request)
detail = load_detail("projects", "project", project_slug, user)
if not detail:
raise HTTPException(status_code=404, detail="Project not found")
raise not_found("Project not found")
project = detail["item"]
base = site_url(request)
@ -100,18 +98,9 @@ async def project_detail(request: Request, project_slug: str):
],
schemas=[website_schema(base), software_application_schema(project, base)],
)
return templates.TemplateResponse(request, "project_detail.html", {
**seo_ctx,
"request": request,
"user": user,
"project": project,
"author": detail["author"],
"is_owner": detail["is_owner"],
"star_count": detail["star_count"],
return templates.TemplateResponse(request, "project_detail.html", detail_context(request, user, detail, "project", seo_ctx, {
"platforms": project.get("platforms", "").split(",") if project.get("platforms") else [],
"comments": detail["comments"],
"attachments": detail["attachments"],
})
}))
@router.post("/delete/{project_slug}")
@ -126,27 +115,13 @@ async def create_project(request: Request, data: Annotated[ProjectForm, Form()])
title = data.title.strip()
description = data.description.strip()
projects = get_table("projects")
uid = generate_uid()
project_slug = make_combined_slug(title, uid)
projects.insert({
"uid": uid,
"user_uid": user["uid"],
uid, project_slug = create_content_item("projects", "project", user, {
"title": title,
"slug": project_slug,
"description": description,
"release_date": data.release_date or None,
"demo_date": data.demo_date or None,
"project_type": data.project_type,
"platforms": data.platforms.strip(),
"status": data.status,
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
})
if data.attachment_uids:
link_attachments(data.attachment_uids, "project", uid)
create_mention_notifications(description, user["uid"], f"/projects/{project_slug}")
logger.info(f"Project {uid} created by {user['username']}")
}, title, XP_PROJECT, "First Project", description, data.attachment_uids)
return RedirectResponse(url=f"/projects/{project_slug}", status_code=302)

View File

@ -4,7 +4,7 @@ from datetime import datetime, timezone
from fastapi import APIRouter, Request, Form
from fastapi.responses import RedirectResponse, JSONResponse
from devplacepy.database import get_table, update_target_stars, get_target_owner_uid, resolve_object_url
from devplacepy.utils import generate_uid, require_user, create_notification
from devplacepy.utils import generate_uid, require_user, create_notification, award_rewards, XP_UPVOTE
from devplacepy.models import VoteForm
logger = logging.getLogger(__name__)
@ -50,6 +50,7 @@ async def vote(request: Request, target_type: str, target_uid: str, data: Annota
if owner_uid and owner_uid != user["uid"]:
target_url = resolve_object_url(target_type, target_uid)
create_notification(owner_uid, "vote", f"{user['username']} ++'d your {target_type}", user["uid"], target_url)
award_rewards(owner_uid, XP_UPVOTE)
if request.headers.get("x-requested-with") == "fetch":
current = votes.find_one(user_uid=user["uid"], target_uid=target_uid, target_type=target_type)

View File

@ -206,6 +206,17 @@ def base_seo_context(request, title="", description="", robots="index,follow", o
}
def list_page_seo(request, title="", description="", breadcrumbs=None):
base = site_url(request)
return base_seo_context(
request,
title=title,
description=description,
breadcrumbs=breadcrumbs,
schemas=[website_schema(base)],
)
def make_sitemap(base_url):
from devplacepy.database import get_table, db
@ -236,6 +247,7 @@ def make_sitemap(base_url):
urlset.append(url_element(f"{base_url}/news", changefreq="hourly", priority="0.9"))
urlset.append(url_element(f"{base_url}/projects", changefreq="daily", priority="0.8"))
urlset.append(url_element(f"{base_url}/gists", changefreq="daily", priority="0.8"))
urlset.append(url_element(f"{base_url}/leaderboard", changefreq="daily", priority="0.7"))
if "posts" in db.tables:
posts = list(get_table("posts").find(order_by=["-created_at"], _limit=500))

View File

@ -15,6 +15,7 @@ NEWS_API_URL_DEFAULT = "https://news.app.molodetz.nl/api"
AI_URL_DEFAULT = "https://openai.app.molodetz.nl/v1/chat/completions"
AI_MODEL_DEFAULT = "molodetz"
GRADE_THRESHOLD_DEFAULT = 7
GRADE_MAX_TOKENS = 2000
def _get_ai_key() -> str:
@ -216,7 +217,7 @@ class NewsService(BaseService):
payload = {
"model": ai_model,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 10,
"max_tokens": GRADE_MAX_TOKENS,
"temperature": 0.0,
}

View File

@ -265,10 +265,6 @@
transform: translateX(18px);
}
.hidden {
display: none !important;
}
.pagination {
display: flex;
align-items: center;

View File

@ -81,6 +81,9 @@
.inline-form {
display: inline;
}
.hidden {
display: none !important;
}
.text-muted {
color: var(--text-muted);
}
@ -115,6 +118,14 @@
color: var(--text-muted);
padding: 0.5rem 0;
}
.top-authors-link {
display: block;
margin-top: 0.5rem;
font-size: 0.75rem;
font-weight: 600;
color: var(--accent);
text-align: center;
}
.no-comments-msg {
color: var(--text-muted);
font-size: 0.875rem;
@ -986,3 +997,22 @@ img {
position: relative;
z-index: 2;
}
.vote-star {
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.vote-star::before {
content: "\2606";
}
.vote-star.voted::before {
content: "\2605";
}
.vote-star.voted {
color: var(--warning);
font-weight: 700;
}

View File

@ -0,0 +1,58 @@
.bugs-layout {
max-width: 720px;
margin: 0 auto;
}
.bugs-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.bugs-header h1 {
font-size: 1.5rem;
font-weight: 700;
}
.bug-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1.25rem;
margin-bottom: 1rem;
}
.bug-card-header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.bug-title {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
}
.bug-status {
font-size: 0.6875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.125rem 0.5rem;
border-radius: 999px;
}
.bug-status.open {
background: var(--accent-light);
color: var(--accent);
}
.bug-status.closed {
background: rgba(76, 175, 80, 0.1);
color: var(--success);
}
.bug-desc {
font-size: 0.875rem;
color: var(--text-secondary);
line-height: 1.5;
margin-bottom: 0.5rem;
}
.bug-meta {
font-size: 0.75rem;
color: var(--text-muted);
}

View File

@ -17,7 +17,9 @@
border: 1px solid var(--border);
}
.feed-nav-btn {
.feed-nav-btn,
.projects-tab,
.profile-tab {
padding: 0.5rem 1rem;
border-radius: var(--radius);
font-size: 0.8125rem;
@ -178,15 +180,24 @@
color: var(--text-secondary);
}
.post-action-btn.voted,
.post-action-btn.vote-up {
.post-action-btn.vote-up:hover {
color: var(--accent);
}
.post-action-btn.vote-down {
.post-action-btn.vote-down:hover {
color: var(--danger);
}
.post-action-btn.vote-up.voted {
color: var(--accent);
font-weight: 700;
}
.post-action-btn.vote-down.voted {
color: var(--danger);
font-weight: 700;
}
.post-votes {
display: inline-flex;
align-items: center;
@ -287,6 +298,14 @@
display: flex;
align-items: center;
gap: 0.375rem;
min-width: 0;
}
.top-author-name {
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.stat-row .value {
@ -302,8 +321,8 @@
width: 56px;
height: 56px;
border-radius: 50%;
background: #e53935;
color: #fff;
background: var(--danger);
color: var(--white);
font-size: 1.5rem;
display: flex;
align-items: center;
@ -355,6 +374,12 @@
.feed-right {
display: none;
}
.leaderboard-page {
grid-template-columns: 1fr;
}
.leaderboard-page > aside {
display: none;
}
}
@media (max-width: 768px) {
@ -428,3 +453,146 @@
width: 100%;
}
}
.leaderboard-page {
display: grid;
grid-template-columns: 280px 1fr 280px;
gap: 1.5rem;
align-items: start;
}
.leaderboard-page > aside {
position: sticky;
top: calc(var(--nav-height) + 1rem);
}
.leaderboard-main {
min-width: 0;
}
.featured-news {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1.25rem;
}
.featured-news h3 {
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-muted);
margin-bottom: 0.75rem;
}
.featured-news-item {
display: flex;
flex-direction: column;
gap: 0.125rem;
padding: 0.5rem 0;
}
.featured-news-item:not(:last-child) {
border-bottom: 1px solid var(--border);
}
.featured-news-title {
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-primary);
line-height: 1.4;
}
.featured-news-meta {
font-size: 0.6875rem;
color: var(--text-muted);
}
.leaderboard-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.25rem;
}
.leaderboard-header h1 {
font-size: 1.5rem;
font-weight: 700;
}
.leaderboard-you {
font-size: 0.8125rem;
font-weight: 600;
color: var(--accent);
background: var(--accent-light);
padding: 0.25rem 0.625rem;
border-radius: 999px;
}
.leaderboard-intro {
font-size: 0.8125rem;
color: var(--text-muted);
margin-bottom: 1rem;
}
.leaderboard-list {
list-style: none;
margin: 0;
padding: 0;
}
.leaderboard-row {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: 0.5rem;
}
.leaderboard-row-self {
border-color: var(--accent);
background: var(--accent-light);
}
.leaderboard-rank {
font-weight: 700;
font-size: 0.9375rem;
color: var(--text-muted);
min-width: 2.5rem;
}
.leaderboard-name {
flex: 1;
min-width: 0;
font-weight: 600;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.leaderboard-level {
font-size: 0.75rem;
color: var(--text-muted);
}
.leaderboard-stars {
font-weight: 700;
color: var(--text-primary);
}
.leaderboard-star-icon {
color: var(--accent);
}
.leaderboard-empty {
list-style: none;
padding: 2rem 1rem;
text-align: center;
color: var(--text-muted);
font-size: 0.875rem;
}

View File

@ -100,10 +100,24 @@
transition: color 0.2s;
}
.comment-vote-btn:hover {
.comment-vote-btn.vote-up:hover {
color: var(--accent);
}
.comment-vote-btn.vote-down:hover {
color: var(--danger);
}
.comment-vote-btn.vote-up.voted {
color: var(--accent);
font-weight: 700;
}
.comment-vote-btn.vote-down.voted {
color: var(--danger);
font-weight: 700;
}
.comment-vote-count {
font-size: 0.75rem;
font-weight: 700;
@ -230,49 +244,6 @@ button.comment-form-submit:hover {
background: var(--accent-hover);
}
.post-action-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border-radius: var(--radius);
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-muted);
transition: all 0.2s;
background: none;
border: none;
cursor: pointer;
line-height: 1;
}
.post-action-btn:hover {
background: var(--bg-card-hover);
color: var(--text-secondary);
}
.post-action-btn.vote-up {
color: var(--accent);
}
.post-action-btn.vote-down {
color: var(--danger);
}
.post-votes {
display: inline-flex;
align-items: center;
gap: 0.125rem;
}
.post-vote-count {
font-weight: 600;
font-size: 0.8125rem;
color: var(--text-secondary);
min-width: 1.25rem;
text-align: center;
}
.comment-thread-line {
position: absolute;
left: 16px;

View File

@ -56,6 +56,15 @@
display: block;
}
a.profile-stat-value {
color: var(--text-primary);
text-decoration: none;
}
a.profile-stat-value:hover {
color: var(--accent);
}
.profile-stat-label {
font-size: 0.75rem;
color: var(--text-muted);
@ -109,6 +118,11 @@
color: var(--accent);
}
.profile-badge-icon {
font-size: 0.875rem;
line-height: 1;
}
.profile-info {
background: var(--bg-card);
border: 1px solid var(--border);
@ -161,15 +175,6 @@
border: 1px solid var(--border);
}
.profile-tab {
padding: 0.5rem 1rem;
border-radius: var(--radius);
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-secondary);
transition: all 0.2s;
}
.profile-tab:hover {
color: var(--text-primary);
}

View File

@ -16,15 +16,6 @@
flex-wrap: wrap;
}
.projects-tab {
padding: 0.5rem 1rem;
border-radius: var(--radius);
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-secondary);
transition: all 0.2s;
}
.projects-tab:hover {
color: var(--text-primary);
}
@ -202,3 +193,81 @@
font-size: 1rem;
}
}
.project-detail-page {
max-width: 720px;
margin: 0 auto;
}
.project-detail {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1.5rem;
}
.project-detail-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 1rem;
}
.project-detail-title {
font-size: 1.5rem;
font-weight: 700;
}
.project-detail-meta {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-bottom: 1rem;
font-size: 0.8125rem;
color: var(--text-muted);
}
.project-detail-meta span {
display: flex;
align-items: center;
gap: 0.25rem;
}
.project-detail-author {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border);
}
.project-detail-author a {
font-weight: 600;
font-size: 0.875rem;
color: var(--text-primary);
}
.project-detail-desc {
font-size: 0.9375rem;
color: var(--text-secondary);
line-height: 1.7;
margin-bottom: 1.5rem;
}
.project-detail-actions {
display: flex;
align-items: center;
gap: 0.5rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
}
.project-star-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border-radius: var(--radius);
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-muted);
background: none;
border: none;
cursor: pointer;
transition: all 0.2s;
}
.project-star-btn:hover {
background: var(--bg-card-hover);
color: var(--warning);
}

View File

@ -8,49 +8,63 @@ export class DomUtils {
this.initStopPropagation();
}
static onDataAttr(attr, event, handler) {
document.querySelectorAll(`[data-${attr}]`).forEach((el) => {
el.addEventListener(event, (e) => handler(el, e));
});
}
static show(el) {
el.style.display = "block";
}
static hide(el) {
el.style.display = "none";
}
static toggle(el) {
el.style.display = el.style.display === "none" ? "block" : "none";
}
static isShown(el) {
return el.style.display === "block";
}
initClipboardCopy() {
document.querySelectorAll("[data-copy]").forEach((btn) => {
btn.addEventListener("click", async () => {
const source = document.getElementById(btn.dataset.copy);
if (!source) return;
try {
await navigator.clipboard.writeText(source.textContent);
Toast.flash(btn, "Copied!", 2000);
} catch {
// silently fail
}
});
DomUtils.onDataAttr("copy", "click", async (btn) => {
const source = document.getElementById(btn.dataset.copy);
if (!source) return;
try {
await navigator.clipboard.writeText(source.textContent);
Toast.flash(btn, "Copied!", 2000);
} catch {
// silently fail
}
});
}
initShareButtons() {
document.querySelectorAll("[data-share]").forEach((btn) => {
btn.addEventListener("click", async (e) => {
e.preventDefault();
e.stopPropagation();
const url = new URL(btn.dataset.share || window.location.href, window.location.href).href;
try {
await navigator.clipboard.writeText(url);
Toast.flash(btn, "Copied!", 1000);
} catch {
// silently fail
}
});
DomUtils.onDataAttr("share", "click", async (btn, e) => {
e.preventDefault();
e.stopPropagation();
const url = new URL(btn.dataset.share || window.location.href, window.location.href).href;
try {
await navigator.clipboard.writeText(url);
Toast.flash(btn, "Copied!", 1000);
} catch {
// silently fail
}
});
}
initTogglers() {
document.querySelectorAll("[data-toggle]").forEach((btn) => {
btn.addEventListener("click", () => {
const target = document.getElementById(btn.dataset.toggle);
if (target) target.classList.toggle("hidden");
});
DomUtils.onDataAttr("toggle", "click", (btn) => {
const target = document.getElementById(btn.dataset.toggle);
if (target) target.classList.toggle("hidden");
});
}
initStopPropagation() {
document.querySelectorAll("[data-stop-propagation]").forEach((el) => {
el.addEventListener("click", (e) => e.stopPropagation());
});
DomUtils.onDataAttr("stop-propagation", "click", (el, e) => e.stopPropagation());
}
}

View File

@ -1,4 +1,5 @@
import { TextInput } from "./TextInput.js";
import { DomUtils } from "./DomUtils.js";
export class EmojiPicker {
constructor(textarea) {
@ -41,15 +42,11 @@ export class EmojiPicker {
}
toggle() {
if (this.wrapper.style.display === "none") {
this.wrapper.style.display = "block";
} else {
this.wrapper.style.display = "none";
}
DomUtils.toggle(this.wrapper);
}
hide() {
this.wrapper.style.display = "none";
DomUtils.hide(this.wrapper);
}
}
window.EmojiPicker = EmojiPicker;

View File

@ -4,6 +4,33 @@ export class Http {
return response.json();
}
static async sendForm(url, params = {}) {
const response = await fetch(url, {
method: "POST",
headers: {
"X-Requested-With": "fetch",
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams(params),
});
if (!response.ok) {
throw new Error(`request failed with status ${response.status}`);
}
return response.json();
}
static async postJson(url, body) {
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!response.ok) {
throw new Error(`request failed with status ${response.status}`);
}
return response.json();
}
static postForm(action, data = {}) {
const form = document.createElement("form");
form.method = "POST";

View File

@ -1,6 +1,7 @@
import { Http } from "./Http.js";
import { Avatar } from "./Avatar.js";
import { TextInput } from "./TextInput.js";
import { DomUtils } from "./DomUtils.js";
export class MentionInput {
constructor(element) {
@ -25,7 +26,7 @@ export class MentionInput {
this.input.addEventListener("input", () => this.onInput());
this.input.addEventListener("keydown", (e) => this.onKeydown(e));
this.input.addEventListener("blur", () => {
setTimeout(() => { this.dropdown.style.display = "none"; }, 200);
setTimeout(() => DomUtils.hide(this.dropdown), 200);
});
}
@ -36,7 +37,7 @@ export class MentionInput {
let match = text.match(/(?:^|\s|\x28)@([a-zA-Z0-9_-]*)$/);
if (!match) {
this.dropdown.style.display = "none";
DomUtils.hide(this.dropdown);
this.lastMatch = null;
return;
}
@ -45,7 +46,7 @@ export class MentionInput {
this.lastMatch = { query, index: match.index + (text[match.index] === "@" ? 0 : 1) };
if (query.length < 1) {
this.dropdown.style.display = "none";
DomUtils.hide(this.dropdown);
return;
}
@ -57,12 +58,12 @@ export class MentionInput {
const data = await Http.getJson("/profile/search?q=" + encodeURIComponent(query));
const results = data.results || [];
if (results.length === 0) {
this.dropdown.style.display = "none";
DomUtils.hide(this.dropdown);
return;
}
this.render(results);
} catch (e) {
this.dropdown.style.display = "none";
DomUtils.hide(this.dropdown);
}
}
@ -81,12 +82,12 @@ export class MentionInput {
});
this.dropdown.appendChild(item);
}
this.dropdown.style.display = "block";
DomUtils.show(this.dropdown);
}
onKeydown(e) {
const items = this.dropdown.querySelectorAll(".mention-dropdown-item");
if (this.dropdown.style.display !== "block" || items.length === 0) {
if (!DomUtils.isShown(this.dropdown) || items.length === 0) {
return;
}
@ -104,7 +105,7 @@ export class MentionInput {
this.insert(items[this.selectedIndex].dataset.username);
}
} else if (e.key === "Escape") {
this.dropdown.style.display = "none";
DomUtils.hide(this.dropdown);
}
}
@ -125,7 +126,7 @@ export class MentionInput {
before = before.replace(/@+$/, "");
after = after.replace(/^@+/, "");
TextInput.applyValue(this.input, before + "@" + username + " " + after, before.length + username.length + 2);
this.dropdown.style.display = "none";
DomUtils.hide(this.dropdown);
this.lastMatch = null;
}
}

View File

@ -1,5 +1,6 @@
import { Http } from "./Http.js";
import { Avatar } from "./Avatar.js";
import { DomUtils } from "./DomUtils.js";
export class MessageSearch {
constructor() {
@ -24,7 +25,7 @@ export class MessageSearch {
const q = searchInput.value.trim();
if (q.length < 1) {
dropdown.innerHTML = "";
dropdown.style.display = "none";
DomUtils.hide(dropdown);
return;
}
debounceTimer = setTimeout(async () => {
@ -32,7 +33,7 @@ export class MessageSearch {
const data = await Http.getJson(`/messages/search?q=${encodeURIComponent(q)}`);
const results = data.results || [];
if (results.length === 0) {
dropdown.style.display = "none";
DomUtils.hide(dropdown);
return;
}
dropdown.innerHTML = "";
@ -43,7 +44,7 @@ export class MessageSearch {
item.innerHTML = `${Avatar.imgHtml(r.username)}<span>${r.username}</span>`;
dropdown.appendChild(item);
}
dropdown.style.display = "block";
DomUtils.show(dropdown);
} catch (e) {
// silently fail - no suggestions
}
@ -61,7 +62,7 @@ export class MessageSearch {
document.addEventListener("click", (e) => {
if (!wrap.contains(e.target)) {
dropdown.style.display = "none";
DomUtils.hide(dropdown);
}
});
}

View File

@ -1,5 +1,7 @@
// retoor <retoor@molodetz.nl>
import { Http } from "./Http.js";
export class PushManager {
constructor() {
this.supported = "serviceWorker" in navigator && "PushManager" in window && "Notification" in window;
@ -46,8 +48,7 @@ export class PushManager {
return;
}
const keyResponse = await fetch("/push.json");
const keyData = await keyResponse.json();
const keyData = await Http.getJson("/push.json");
const applicationServerKey = Uint8Array.from(atob(keyData.publicKey), (c) => c.charCodeAt(0));
const subscription = await registration.pushManager.subscribe({
@ -55,15 +56,7 @@ export class PushManager {
applicationServerKey: applicationServerKey,
});
const response = await fetch("/push.json", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(subscription.toJSON()),
});
if (!response.ok) {
throw new Error("Bad status code from server.");
}
await Http.postJson("/push.json", subscription.toJSON());
this.refreshTriggerVisibility();
} catch (error) {

View File

@ -1,4 +1,5 @@
import { Toast } from "./Toast.js";
import { Http } from "./Http.js";
export class VoteManager {
constructor() {
@ -20,18 +21,7 @@ export class VoteManager {
const action = form.getAttribute("action");
const value = form.querySelector('input[name="value"]').value;
try {
const response = await fetch(action, {
method: "POST",
headers: {
"X-Requested-With": "fetch",
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({ value }),
});
if (!response.ok) {
throw new Error(`vote failed with status ${response.status}`);
}
const result = await response.json();
const result = await Http.sendForm(action, { value });
this.render(action, result);
} catch (error) {
console.error("vote failed", error);

View File

@ -6,12 +6,12 @@
<div class="comment-votes">
<form method="POST" action="/votes/comment/{{ item.comment['uid'] }}">
<input type="hidden" name="value" value="1">
<button type="submit" class="comment-vote-btn">+</button>
<button type="submit" class="comment-vote-btn vote-up{% if item.my_vote == 1 %} voted{% endif %}">+</button>
</form>
<span class="comment-vote-count" data-vote-count="{{ item.comment['uid'] }}">{{ item.votes.up - item.votes.down }}</span>
<form method="POST" action="/votes/comment/{{ item.comment['uid'] }}">
<input type="hidden" name="value" value="-1">
<button type="submit" class="comment-vote-btn">-</button>
<button type="submit" class="comment-vote-btn vote-down{% if item.my_vote == -1 %} voted{% endif %}">-</button>
</form>
</div>

View File

@ -0,0 +1,13 @@
{% macro content_url(item, kind) %}/{{ kind }}/{{ item['slug'] or item['uid'] }}{% endmacro %}
{% macro modal(modal_id, title, wide=false) %}
<div class="modal-overlay" id="{{ modal_id }}">
<div class="card modal-card{% if wide %} modal-card-wide{% endif %}">
<div class="modal-header">
<h3>{{ title }}</h3>
<button type="button" class="modal-close btn-ghost btn-icon modal-close-btn">&times;</button>
</div>
{{ caller() }}
</div>
</div>
{% endmacro %}

View File

@ -0,0 +1,42 @@
{% from "_macros.html" import content_url %}
<article class="post-card fade-in">
{% include "_post_header.html" %}
<div class="post-topic">
<span class="badge badge-{{ item.post['topic'] }}">{{ item.post['topic'] }}</span>
</div>
{% if item.post.get('title') %}
<a href="{{ content_url(item.post, 'posts') }}" class="post-title-link">
<h3 class="post-title">{{ item.post['title'] }}</h3>
</a>
{% endif %}
<div class="post-content rendered-content" data-render onclick="if (!event.target.closest('a')) window.location.href='{{ content_url(item.post, 'posts') }}'">{{ item.post['content'][:300] }}{% if item.post['content']|length > 300 %}...{% endif %}</div>
{% if item.attachments %}
{% include "_attachment_display.html" %}
{% endif %}
<div class="post-actions">
{% set _uid = item.post['uid'] %}{% set _my_vote = item.my_vote %}{% set _count = item.post.get('stars', 0) %}{% include "_post_votes.html" %}
<a href="{{ content_url(item.post, 'posts') }}" class="post-action-btn">
&#x1F4AC; {{ item.comment_count }}
</a>
<a href="{{ content_url(item.post, 'posts') }}" class="post-action-btn share">
&#x2197;&#xFE0E; Open
</a>
{% if _show_share %}
<button type="button" class="post-action-btn" data-share="{{ content_url(item.post, 'posts') }}">&#x1F517; Share</button>
{% endif %}
</div>
{% if _show_comment_form and user %}
<form class="feed-comment-form" method="POST" action="/comments/create">
<input type="hidden" name="post_uid" value="{{ item.post['uid'] }}">
<img src="{{ avatar_url('multiavatar', user['username'], 24) }}" class="avatar-img avatar-24" alt="" loading="lazy">
<input type="text" name="content" placeholder="Your opinion goes here..." maxlength="1000" autocomplete="off">
<button type="submit" class="feed-comment-submit">Post</button>
</form>
{% endif %}
</article>

View File

@ -0,0 +1,10 @@
<div class="post-header">
{% set _user = _author %}{% set _size = 32 %}{% set _size_class = "sm" %}{% include "_avatar_link.html" %}
<div class="post-author-wrap">
<a href="/profile/{{ _author['username'] if _author else '#' }}" class="post-author-link">{{ _author['username'] if _author else 'Unknown' }}</a>
{% if _author and _author.get('role') %}
<span class="post-author-role">{{ _author['role'] }}</span>
{% endif %}
</div>
<span class="post-time">{{ _time }}</span>
</div>

View File

@ -0,0 +1,11 @@
<div class="post-votes">
<form method="POST" action="/votes/post/{{ _uid }}" class="inline-form">
<input type="hidden" name="value" value="1">
<button type="submit" class="post-action-btn vote-up{% if _my_vote == 1 %} voted{% endif %}">+</button>
</form>
<span class="post-vote-count" data-vote-count="{{ _uid }}">{{ _count }}</span>
<form method="POST" action="/votes/post/{{ _uid }}" class="inline-form">
<input type="hidden" name="value" value="-1">
<button type="submit" class="post-action-btn vote-down{% if _my_vote == -1 %} voted{% endif %}"></button>
</form>
</div>

View File

@ -0,0 +1,4 @@
<form method="POST" action="/votes/{{ _type }}/{{ _uid }}" class="inline-form"{% if _stop %} data-stop-propagation{% endif %}>
<input type="hidden" name="value" value="1">
<button type="submit" class="{{ _btn_class }} vote-star{% if _my_vote == 1 %} voted{% endif %}"><span class="vote-count-value" data-vote-count="{{ _uid }}">{{ _count }}</span></button>
</form>

View File

@ -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>

View File

@ -54,6 +54,7 @@
<a href="/news" class="topnav-link {% if 'news' in request.url.path %}active{% endif %}"><span class="icon">📰</span> News</a>
<a href="/gists" class="topnav-link {% if 'gists' in request.url.path %}active{% endif %}"><span class="icon">📄</span> Gists</a>
<a href="/projects" class="topnav-link {% if 'projects' in request.url.path %}active{% endif %}"><span class="icon">🚀</span> Projects</a>
<a href="/leaderboard" class="topnav-link {% if 'leaderboard' in request.url.path %}active{% endif %}"><span class="icon">🏆</span> Leaderboard</a>
{% if user %}
<a href="/messages" class="topnav-link {% if 'messages' in request.url.path %}active{% endif %}"><span class="icon">✉️</span> Messages</a>
{% if user.get('role') == 'Admin' %}
@ -105,6 +106,7 @@
<a href="/news" class="topnav-mobile-link {% if 'news' in request.url.path %}active{% endif %}"><span class="icon">📰</span> News</a>
<a href="/gists" class="topnav-mobile-link {% if 'gists' in request.url.path %}active{% endif %}"><span class="icon">📄</span> Gists</a>
<a href="/projects" class="topnav-mobile-link {% if 'projects' in request.url.path %}active{% endif %}"><span class="icon">🚀</span> Projects</a>
<a href="/leaderboard" class="topnav-mobile-link {% if 'leaderboard' in request.url.path %}active{% endif %}"><span class="icon">🏆</span> Leaderboard</a>
{% if user %}
<a href="/messages" class="topnav-mobile-link {% if 'messages' in request.url.path %}active{% endif %}"><span class="icon">✉️</span> Messages</a>
<a href="/notifications" class="topnav-mobile-link {% if 'notifications' in request.url.path %}active{% endif %}"><span class="icon">&#x1F514;</span> Notifications</a>

View File

@ -1,67 +1,9 @@
{% extends "base.html" %}
{% from "_macros.html" import modal %}
{% block extra_head %}
<link rel="stylesheet" href="/static/css/feed.css">
<link rel="stylesheet" href="/static/css/post.css">
<style>
.bugs-layout {
max-width: 720px;
margin: 0 auto;
}
.bugs-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.bugs-header h1 {
font-size: 1.5rem;
font-weight: 700;
}
.bug-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1.25rem;
margin-bottom: 1rem;
}
.bug-card-header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.bug-title {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
}
.bug-status {
font-size: 0.6875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.125rem 0.5rem;
border-radius: 999px;
}
.bug-status.open {
background: var(--accent-light);
color: var(--accent);
}
.bug-status.closed {
background: rgba(76, 175, 80, 0.1);
color: var(--success);
}
.bug-desc {
font-size: 0.875rem;
color: var(--text-secondary);
line-height: 1.5;
margin-bottom: 0.5rem;
}
.bug-meta {
font-size: 0.75rem;
color: var(--text-muted);
}
</style>
<link rel="stylesheet" href="/static/css/bugs.css">
{% endblock %}
{% block content %}
<div class="bugs-layout">
@ -101,28 +43,22 @@
</div>
{% if user %}
<div class="modal-overlay" id="create-bug-modal">
<div class="card" style="width: 100%; max-width: 560px; margin: 1rem;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h3 style="font-size: 1.125rem; font-weight: 700;">Report a Bug</h3>
<button type="button" class="modal-close btn-ghost btn-icon" style="font-size: 1.25rem;">&times;</button>
</div>
{% call modal('create-bug-modal', 'Report a Bug') %}
<form method="POST" action="/bugs/create">
<div class="auth-field" style="margin-bottom: 0.75rem;">
<div class="auth-field auth-field-gap">
<label for="bug-title">Title</label>
<input type="text" id="bug-title" name="title" required maxlength="200" placeholder="Brief description of the issue">
</div>
<div class="auth-field" style="margin-bottom: 1rem;">
<div class="auth-field auth-field-gap">
<label for="bug-description">Description</label>
<textarea id="bug-description" name="description" required maxlength="5000" placeholder="Detailed steps to reproduce..." style="min-height: 120px;" data-mention></textarea>
<textarea id="bug-description" name="description" required maxlength="5000" placeholder="Detailed steps to reproduce..." class="min-h-120" data-mention></textarea>
</div>
{% include "_attachment_form.html" %}
<div style="display: flex; gap: 0.75rem; justify-content: flex-end;">
<div class="modal-footer">
<button type="button" class="modal-close btn btn-secondary">Cancel</button>
<button type="submit" class="btn btn-primary"><span class="icon">&#x1F4E4;</span> Submit Report</button>
</div>
</form>
</div>
</div>
{% endcall %}
{% endif %}
{% endblock %}

View File

@ -1,4 +1,5 @@
{% extends "base.html" %}
{% from "_macros.html" import modal %}
{% block extra_head %}
<link rel="stylesheet" href="/static/css/feed.css">
<link rel="stylesheet" href="/static/css/sidebar.css">
@ -54,64 +55,7 @@
<div class="feed-posts">
{% for item in posts %}
<article class="post-card fade-in">
<div class="post-header">
{% set _user = item.author %}{% set _size = 32 %}{% set _size_class = "sm" %}{% include "_avatar_link.html" %}
<div class="post-author-wrap">
<a href="/profile/{{ item.author['username'] if item.author else '#' }}" class="post-author-link">{{ item.author['username'] if item.author else 'Unknown' }}</a>
{% if item.author and item.author.get('role') %}
<span class="post-author-role">{{ item.author['role'] }}</span>
{% endif %}
</div>
<span class="post-time">{{ item.time_ago }}</span>
</div>
<div class="post-topic">
<span class="badge badge-{{ item.post['topic'] }}">{{ item.post['topic'] }}</span>
</div>
{% if item.post.get('title') %}
<a href="/posts/{{ item.post['slug'] or item.post['uid'] }}" class="post-title-link">
<h3 class="post-title">{{ item.post['title'] }}</h3>
</a>
{% endif %}
<div class="post-content rendered-content" data-render onclick="if (!event.target.closest('a')) window.location.href='/posts/{{ item.post['slug'] or item.post['uid'] }}'">{{ item.post['content'][:300] }}{% if item.post['content']|length > 300 %}...{% endif %}</div>
{% if item.attachments %}
{% include "_attachment_display.html" %}
{% endif %}
<div class="post-actions">
<div class="post-votes">
<form method="POST" action="/votes/post/{{ item.post['uid'] }}" class="inline-form">
<input type="hidden" name="value" value="1">
<button type="submit" class="post-action-btn vote-up">+</button>
</form>
<span class="post-vote-count" data-vote-count="{{ item.post['uid'] }}">{{ item.post.get('stars', 0) }}</span>
<form method="POST" action="/votes/post/{{ item.post['uid'] }}" class="inline-form">
<input type="hidden" name="value" value="-1">
<button type="submit" class="post-action-btn vote-down"></button>
</form>
</div>
<a href="/posts/{{ item.post['slug'] or item.post['uid'] }}" class="post-action-btn">
&#x1F4AC; {{ item.comment_count }}
</a>
<a href="/posts/{{ item.post['slug'] or item.post['uid'] }}" class="post-action-btn share">
&#x2197;&#xFE0E; Open
</a>
<button type="button" class="post-action-btn" data-share="/posts/{{ item.post['slug'] or item.post['uid'] }}">&#x1F517; Share</button>
</div>
{% if user %}
<form class="feed-comment-form" method="POST" action="/comments/create">
<input type="hidden" name="post_uid" value="{{ item.post['uid'] }}">
<img src="{{ avatar_url('multiavatar', user['username'], 24) }}" class="avatar-img avatar-24" alt="" loading="lazy">
<input type="text" name="content" placeholder="Your opinion goes here..." maxlength="1000" autocomplete="off">
<button type="submit" class="feed-comment-submit">Post</button>
</form>
{% endif %}
</article>
{% set _author = item.author %}{% set _time = item.time_ago %}{% set _show_share = true %}{% set _show_comment_form = true %}{% include "_post_card.html" %}
{% else %}
<div class="empty-state">No posts yet. Be the first!</div>
{% endfor %}
@ -160,15 +104,15 @@
{% for author in top_authors %}
<div class="stat-row">
<span class="label">
<a href="/profile/{{ author['username'] }}">
<img src="{{ avatar_url('multiavatar', author['username'], 20) }}" class="avatar-img avatar-xs" alt="{{ author['username'] }}" loading="lazy">
{{ author['username'] }}</a>
{% set _user = author %}{% set _size = 20 %}{% set _size_class = "xs" %}{% include "_avatar_link.html" %}
<a href="/profile/{{ author['username'] }}" class="top-author-name">{{ author['username'] }}</a>
</span>
<span class="value">{{ author.get('stars', 0) }}</span>
</div>
{% else %}
<div class="no-authors-msg">No top authors yet</div>
{% endfor %}
<a href="/leaderboard" class="top-authors-link">View full leaderboard</a>
</div>
</div>
</aside>
@ -177,22 +121,9 @@
{% if user %}
<a href="#" class="feed-fab" data-modal="create-post-modal" title="Create New Post">+</a>
<div class="modal-overlay" id="create-post-modal">
<div class="card modal-card">
<div class="modal-header">
<h3>Create New Post</h3>
<button type="button" class="modal-close btn-ghost btn-icon modal-close-btn">&times;</button>
</div>
{% call modal('create-post-modal', 'Create New Post') %}
<form id="create-post-form" method="POST" action="/posts/create" enctype="multipart/form-data">
<div class="form-row">
{% for t in TOPICS %}
<label class="topic-label">
<input type="radio" name="topic" value="{{ t }}" {% if t == 'random' %}checked{% endif %} class="topic-radio">
{{ t|capitalize }}
</label>
{% endfor %}
</div>
{% set _topics = TOPICS %}{% set _selected = 'random' %}{% include "_topic_selector.html" %}
<div class="auth-field auth-field-gap">
<label for="post-content">What are you sharing?</label>
@ -226,8 +157,7 @@
<button type="submit" class="btn btn-primary">Post</button>
</div>
</form>
</div>
</div>
{% endcall %}
{% endif %}
{% endblock %}

View File

@ -1,4 +1,5 @@
{% extends "base.html" %}
{% from "_macros.html" import modal %}
{% block extra_head %}
<link rel="stylesheet" href="/static/css/gists.css">
<link rel="stylesheet" href="/static/css/post.css">
@ -45,10 +46,7 @@
<div class="gist-detail-actions">
<button type="button" class="gist-star-btn" data-share="/gists/{{ gist['slug'] or gist['uid'] }}">&#x1F517; Share</button>
{% if user %}
<form method="POST" action="/votes/gist/{{ gist['uid'] }}" style="display:inline;">
<input type="hidden" name="value" value="1">
<button type="submit" class="gist-star-btn">&#x2606; <span class="vote-count-value" data-vote-count="{{ gist['uid'] }}">{{ star_count }}</span></button>
</form>
{% set _type = "gist" %}{% set _uid = gist['uid'] %}{% set _my_vote = my_vote %}{% set _count = star_count %}{% set _btn_class = "gist-star-btn" %}{% include "_star_vote.html" %}
{% endif %}
{% if is_owner %}
<button class="gist-star-btn" data-modal="edit-gist-modal"><span class="icon">&#x270F;&#xFE0F;</span>Edit</button>
@ -60,12 +58,7 @@
</article>
{% if is_owner %}
<div class="modal-overlay" id="edit-gist-modal">
<div class="card modal-card modal-card-wide">
<div class="modal-header">
<h3>Edit Gist</h3>
<button type="button" class="modal-close btn-ghost btn-icon modal-close-btn">&times;</button>
</div>
{% call modal('edit-gist-modal', 'Edit Gist', wide=true) %}
<form method="POST" action="/gists/edit/{{ gist['slug'] or gist['uid'] }}">
<div class="auth-field auth-field-gap">
<label for="edit-gist-title">Title</label>
@ -93,8 +86,7 @@
<button type="submit" class="btn btn-primary"><span class="icon">&#x1F4BE;</span>Save Changes</button>
</div>
</form>
</div>
</div>
{% endcall %}
{% endif %}
{% with target_uid=gist['uid'], target_type="gist" %}

View File

@ -1,4 +1,5 @@
{% extends "base.html" %}
{% from "_macros.html" import modal %}
{% block extra_head %}
<link rel="stylesheet" href="/static/css/feed.css">
<link rel="stylesheet" href="/static/css/gists.css">
@ -55,10 +56,7 @@
{% include "_card_link.html" %}
<div class="gist-card-header">
<h3 class="gist-card-title">{{ item.gist['title'] }}</h3>
<form method="POST" action="/votes/gist/{{ item.gist['uid'] }}" class="inline-form" data-stop-propagation>
<input type="hidden" name="value" value="1">
<button type="submit" class="gist-card-star">&#x2606; <span class="vote-count-value" data-vote-count="{{ item.gist['uid'] }}">{{ item.gist.get('stars', 0) }}</span></button>
</form>
{% set _type = "gist" %}{% set _uid = item.gist['uid'] %}{% set _my_vote = item.my_vote %}{% set _count = item.gist.get('stars', 0) %}{% set _btn_class = "gist-card-star" %}{% set _stop = True %}{% include "_star_vote.html" %}
</div>
<div class="gist-card-meta">
@ -87,13 +85,7 @@
{% if user %}
<button class="feed-fab" id="create-gist-btn" title="Create Gist" data-modal="create-gist-modal">+</button>
<div class="modal-overlay" id="create-gist-modal">
<div class="card modal-card modal-card-wide">
<div class="modal-header">
<h3>Create Gist</h3>
<button type="button" class="modal-close btn-ghost btn-icon modal-close-btn">&times;</button>
</div>
{% call modal('create-gist-modal', 'Create Gist', wide=true) %}
<form method="POST" action="/gists/create">
<div class="auth-field auth-field-gap">
<label for="gist-title">Title</label>
@ -126,8 +118,7 @@
<button type="submit" class="btn btn-primary">Create Gist</button>
</div>
</form>
</div>
</div>
{% endcall %}
{% endif %}
{% endblock %}

View File

@ -0,0 +1,79 @@
{% extends "base.html" %}
{% block extra_head %}
<link rel="stylesheet" href="/static/css/feed.css">
{% endblock %}
{% block content %}
<div class="leaderboard-page">
<aside class="community-stats">
<h3>Community</h3>
<div class="stat-row">
<span class="label">Total Members</span>
<span class="value">{{ total_members }}</span>
</div>
<div class="stat-row">
<span class="label">Posts Today</span>
<span class="value">{{ posts_today }}</span>
</div>
<div class="stat-row">
<span class="label">Total Projects</span>
<span class="value">{{ total_projects }}</span>
</div>
<div class="stat-row">
<span class="label">Total Gists</span>
<span class="value">{{ total_gists }}</span>
</div>
<div class="top-authors-section">
<h3 class="top-authors-title">Top Authors</h3>
{% for author in top_authors %}
<div class="stat-row">
<span class="label">
{% set _user = author %}{% set _size = 20 %}{% set _size_class = "xs" %}{% include "_avatar_link.html" %}
<a href="/profile/{{ author['username'] }}" class="top-author-name">{{ author['username'] }}</a>
</span>
<span class="value">{{ author.get('stars', 0) }}</span>
</div>
{% else %}
<div class="no-authors-msg">No top authors yet</div>
{% endfor %}
</div>
</aside>
<div class="leaderboard-main">
<div class="leaderboard-header">
<h1>Leaderboard</h1>
{% if user_rank %}
<span class="leaderboard-you">Your rank: #{{ user_rank }}</span>
{% endif %}
</div>
<p class="leaderboard-intro">Ranked by total stars earned across posts, projects, and gists.</p>
<ol class="leaderboard-list">
{% for entry in entries %}
<li class="leaderboard-row{% if user and entry['uid'] == user['uid'] %} leaderboard-row-self{% endif %}">
<span class="leaderboard-rank">#{{ entry['rank'] }}</span>
{% set _user = entry %}{% set _size = 32 %}{% set _size_class = "sm" %}{% include "_avatar_link.html" %}
<a href="/profile/{{ entry['username'] }}" class="leaderboard-name">{{ entry['username'] }}</a>
<span class="leaderboard-level">Level {{ entry.get('level', 1) }}</span>
<span class="leaderboard-stars">{{ entry['stars'] }} <span class="leaderboard-star-icon">&#x2605;</span></span>
</li>
{% else %}
<li class="leaderboard-empty">No ranked contributors yet. Earn stars on your posts, projects, and gists to appear here.</li>
{% endfor %}
</ol>
</div>
<aside class="feed-right">
<div class="featured-news">
<h3>Featured</h3>
{% for article in featured_news %}
<a class="featured-news-item" href="/news/{{ article['slug'] }}">
<span class="featured-news-title">{{ article['title'] }}</span>
<span class="featured-news-meta">{{ article['source_name'] }}{% if article['time_ago'] %} &middot; {{ article['time_ago'] }}{% endif %}</span>
</a>
{% else %}
<div class="no-authors-msg">No featured articles yet</div>
{% endfor %}
</div>
</aside>
</div>
{% endblock %}

View File

@ -1,4 +1,5 @@
{% extends "base.html" %}
{% from "_macros.html" import modal %}
{% block extra_head %}
<link rel="stylesheet" href="/static/css/feed.css">
<link rel="stylesheet" href="/static/css/post.css">
@ -36,17 +37,7 @@
{% endif %}
<div class="post-detail-actions">
<div class="post-votes">
<form method="POST" action="/votes/post/{{ post['uid'] }}" class="inline-form">
<input type="hidden" name="value" value="1">
<button type="submit" class="post-action-btn vote-up">+</button>
</form>
<span class="post-vote-count" data-vote-count="{{ post['uid'] }}">{{ post.get('stars', 0) }}</span>
<form method="POST" action="/votes/post/{{ post['uid'] }}" class="inline-form">
<input type="hidden" name="value" value="-1">
<button type="submit" class="post-action-btn vote-down"></button>
</form>
</div>
{% set _uid = post['uid'] %}{% set _my_vote = my_vote %}{% set _count = post.get('stars', 0) %}{% include "_post_votes.html" %}
<button type="button" class="post-action-btn" data-share="/posts/{{ post['slug'] or post['uid'] }}">&#x1F517; Share</button>
{% if user and post['user_uid'] == user['uid'] %}
<button class="post-action-btn" data-modal="edit-post-modal"><span class="icon">&#x270F;&#xFE0F;</span>Edit</button>
@ -58,21 +49,9 @@
</article>
{% if user and post['user_uid'] == user['uid'] %}
<div class="modal-overlay" id="edit-post-modal">
<div class="card modal-card">
<div class="modal-header">
<h3>Edit Post</h3>
<button type="button" class="modal-close btn-ghost btn-icon modal-close-btn">&times;</button>
</div>
{% call modal('edit-post-modal', 'Edit Post') %}
<form id="edit-post-form" method="POST" action="/posts/edit/{{ post['slug'] or post['uid'] }}">
<div class="form-row">
{% for t in topics %}
<label class="topic-label">
<input type="radio" name="topic" value="{{ t }}" {% if t == post['topic'] %}checked{% endif %} class="topic-radio">
{{ t|capitalize }}
</label>
{% endfor %}
</div>
{% set _topics = topics %}{% set _selected = post['topic'] %}{% include "_topic_selector.html" %}
<div class="auth-field auth-field-gap">
<label for="edit-title">Title</label>
<input type="text" id="edit-title" name="title" maxlength="500" value="{{ post.get('title', '') }}">
@ -87,8 +66,7 @@
<button type="submit" class="btn btn-primary"><span class="icon">&#x1F4BE;</span>Save Changes</button>
</div>
</form>
</div>
</div>
{% endcall %}
{% endif %}
{% with target_uid=post['uid'], target_type="post" %}

View File

@ -4,9 +4,6 @@
<link rel="stylesheet" href="/static/css/profile.css">
<link rel="stylesheet" href="/static/css/projects.css">
<link rel="stylesheet" href="/static/css/gists.css">
<style>
.hidden { display: none !important; }
</style>
{% endblock %}
{% block content %}
<div class="profile-layout">
@ -33,6 +30,10 @@
<span class="profile-stat-value">{{ profile_user.get('stars', 0) }}</span>
<span class="profile-stat-label">Stars</span>
</div>
<div class="profile-stat">
<a href="/leaderboard" class="profile-stat-value">{% if rank %}#{{ rank }}{% else %}&mdash;{% endif %}</a>
<span class="profile-stat-label">Rank</span>
</div>
</div>
<div class="profile-level-bar">
@ -47,9 +48,11 @@
<div class="profile-badges">
{% for badge in badges %}
<span class="profile-badge">{{ badge['badge_name'] }}</span>
{% set meta = badge_info(badge['badge_name']) %}
<span class="profile-badge" title="{{ meta['description'] }}"><span class="profile-badge-icon">{{ meta['icon'] }}</span>{{ badge['badge_name'] }}</span>
{% else %}
<span class="profile-badge">Member</span>
{% set meta = badge_info('Member') %}
<span class="profile-badge" title="{{ meta['description'] }}"><span class="profile-badge-icon">{{ meta['icon'] }}</span>Member</span>
{% endfor %}
</div>
</div>
@ -155,46 +158,7 @@
<div class="profile-posts">
{% if current_tab == 'posts' %}
{% for item in posts %}
<article class="post-card fade-in">
<div class="post-header">
<a href="/profile/{{ profile_user['username'] }}">
<img src="{{ avatar_url('multiavatar', profile_user['username'], 32) }}" class="avatar-img avatar-sm" alt="{{ profile_user['username'] }}" loading="lazy">
</a>
<div class="post-author-wrap">
<a href="/profile/{{ profile_user['username'] }}" class="post-author-link">{{ profile_user['username'] }}</a>
{% if profile_user.get('role') %}
<span class="post-author-role">{{ profile_user['role'] }}</span>
{% endif %}
</div>
<span class="post-time">{{ item.time_ago }}</span>
</div>
<div class="post-topic">
<span class="badge badge-{{ item.post['topic'] }}">{{ item.post['topic'] }}</span>
</div>
{% if item.post.get('title') %}
<a href="/posts/{{ item.post['slug'] or item.post['uid'] }}" class="post-title-link">
<h3 class="post-title">{{ item.post['title'] }}</h3>
</a>
{% endif %}
<div class="post-content rendered-content" data-render onclick="if (!event.target.closest('a')) window.location.href='/posts/{{ item.post['slug'] or item.post['uid'] }}'">{{ item.post['content'][:300] }}{% if item.post['content']|length > 300 %}...{% endif %}</div>
<div class="post-actions">
<div class="post-votes">
<form method="POST" action="/votes/post/{{ item.post['uid'] }}" class="inline-form">
<input type="hidden" name="value" value="1">
<button type="submit" class="post-action-btn vote-up">+</button>
</form>
<span class="post-vote-count" data-vote-count="{{ item.post['uid'] }}">{{ item.post.get('stars', 0) }}</span>
<form method="POST" action="/votes/post/{{ item.post['uid'] }}" class="inline-form">
<input type="hidden" name="value" value="-1">
<button type="submit" class="post-action-btn vote-down"></button>
</form>
</div>
<a href="/posts/{{ item.post['slug'] or item.post['uid'] }}" class="post-action-btn">
&#x1F4AC; {{ item.comment_count }}
</a>
<a href="/posts/{{ item.post['slug'] or item.post['uid'] }}" class="post-action-btn share">&#x2197;&#xFE0E; Open</a>
</div>
</article>
{% set _author = profile_user %}{% set _time = item.time_ago %}{% set _show_share = false %}{% set _show_comment_form = false %}{% include "_post_card.html" %}
{% else %}
<div class="empty-state">No posts yet.</div>
{% endfor %}

View File

@ -2,85 +2,6 @@
{% block extra_head %}
<link rel="stylesheet" href="/static/css/projects.css">
<link rel="stylesheet" href="/static/css/post.css">
<style>
.project-detail-page {
max-width: 720px;
margin: 0 auto;
}
.project-detail {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1.5rem;
}
.project-detail-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 1rem;
}
.project-detail-title {
font-size: 1.5rem;
font-weight: 700;
}
.project-detail-meta {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-bottom: 1rem;
font-size: 0.8125rem;
color: var(--text-muted);
}
.project-detail-meta span {
display: flex;
align-items: center;
gap: 0.25rem;
}
.project-detail-author {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border);
}
.project-detail-author a {
font-weight: 600;
font-size: 0.875rem;
color: var(--text-primary);
}
.project-detail-desc {
font-size: 0.9375rem;
color: var(--text-secondary);
line-height: 1.7;
margin-bottom: 1.5rem;
}
.project-detail-actions {
display: flex;
align-items: center;
gap: 0.5rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
}
.project-star-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border-radius: var(--radius);
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-muted);
background: none;
border: none;
cursor: pointer;
transition: all 0.2s;
}
.project-star-btn:hover {
background: var(--bg-card-hover);
color: var(--warning);
}
</style>
{% endblock %}
{% block content %}
<div class="project-detail-page">
@ -134,10 +55,7 @@
<div class="project-detail-actions">
<button type="button" class="project-star-btn" data-share="/projects/{{ project['slug'] or project['uid'] }}">&#x1F517; Share</button>
{% if user %}
<form method="POST" action="/votes/project/{{ project['uid'] }}" style="display:inline;">
<input type="hidden" name="value" value="1">
<button type="submit" class="project-star-btn">&#x2606; <span class="vote-count-value" data-vote-count="{{ project['uid'] }}">{{ star_count }}</span></button>
</form>
{% set _type = "project" %}{% set _uid = project['uid'] %}{% set _my_vote = my_vote %}{% set _count = star_count %}{% set _btn_class = "project-star-btn" %}{% include "_star_vote.html" %}
{% endif %}
{% if is_owner %}
<form method="POST" action="/projects/delete/{{ project['slug'] or project['uid'] }}" style="display:inline;">

View File

@ -1,4 +1,5 @@
{% extends "base.html" %}
{% from "_macros.html" import modal %}
{% block extra_head %}
<link rel="stylesheet" href="/static/css/feed.css">
<link rel="stylesheet" href="/static/css/projects.css">
@ -59,10 +60,7 @@
{% include "_card_link.html" %}
<div class="project-card-header">
<h3 class="project-card-title">{{ project['title'] }}</h3>
<form method="POST" action="/votes/project/{{ project['uid'] }}" class="inline-form">
<input type="hidden" name="value" value="1">
<button type="submit" class="project-card-star">&#x2606; <span class="vote-count-value" data-vote-count="{{ project['uid'] }}">{{ project.get('stars', 0) }}</span></button>
</form>
{% set _type = "project" %}{% set _uid = project['uid'] %}{% set _my_vote = project.my_vote %}{% set _count = project.get('stars', 0) %}{% set _btn_class = "project-card-star" %}{% include "_star_vote.html" %}
</div>
<div class="project-card-meta">
@ -98,13 +96,7 @@
{% if user %}
<button class="feed-fab" id="create-project-btn" title="Add Project" data-modal="create-project-modal">+</button>
<div class="modal-overlay" id="create-project-modal">
<div class="card modal-card">
<div class="modal-header">
<h3>Create Project</h3>
<button type="button" class="modal-close btn-ghost btn-icon modal-close-btn">&times;</button>
</div>
{% call modal('create-project-modal', 'Create Project') %}
<form method="POST" action="/projects/create">
<div class="auth-field auth-field-gap">
<label for="title">Title</label>
@ -170,7 +162,6 @@
<button type="submit" class="btn btn-primary">Create Project</button>
</div>
</form>
</div>
</div>
{% endcall %}
{% endif %}
{% endblock %}

View File

@ -5,6 +5,7 @@ from devplacepy.constants import TOPICS
from devplacepy.database import get_table
from devplacepy.avatar import avatar_url
from devplacepy.utils import format_date as _format_date
from devplacepy.utils import badge_info
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
_unread_cache = TTLCache(ttl=60)
@ -30,6 +31,7 @@ templates.env.globals["get_unread_count"] = jinja_unread_count
templates.env.globals["get_user_projects"] = jinja_user_projects
templates.env.globals["avatar_url"] = avatar_url
templates.env.globals["format_date"] = _format_date
templates.env.globals["badge_info"] = badge_info
templates.env.globals["TOPICS"] = TOPICS
def jinja_max_upload_size_mb():
from devplacepy.database import get_int_setting

View File

@ -7,7 +7,7 @@ from datetime import datetime, timedelta, timezone
from passlib.hash import pbkdf2_sha256
from fastapi import Request, HTTPException, status
from devplacepy.cache import TTLCache
from devplacepy.database import get_table
from devplacepy.database import get_table, get_user_stars
from devplacepy.config import SESSION_MAX_AGE
logger = logging.getLogger(__name__)
@ -87,6 +87,10 @@ def require_admin(request: Request):
return user
def not_found(detail: str = "Not found") -> HTTPException:
return HTTPException(status_code=404, detail=detail)
def require_user_api(request: Request):
user = get_current_user(request)
if not user:
@ -207,6 +211,91 @@ def award_badge(user_uid: str, badge_name: str) -> bool:
return True
LEVEL_XP = 100
XP_POST = 10
XP_COMMENT = 2
XP_PROJECT = 15
XP_GIST = 5
XP_UPVOTE = 5
XP_FOLLOW = 5
LEVEL_BADGES = {5: "Level 5", 10: "Level 10"}
BADGE_CATALOG = {
"Member": {"icon": "", "description": "Joined DevPlace"},
"First Post": {"icon": "", "description": "Published a first post"},
"First Comment": {"icon": "", "description": "Wrote a first comment"},
"First Project": {"icon": "", "description": "Shared a first project"},
"First Gist": {"icon": "", "description": "Shared a first gist"},
"Prolific": {"icon": "", "description": "Published 10 posts"},
"Rising Star": {"icon": "", "description": "Earned 25 stars"},
"Star Author": {"icon": "", "description": "Earned 100 stars"},
"Popular": {"icon": "", "description": "Reached 10 followers"},
"Level 5": {"icon": "", "description": "Reached level 5"},
"Level 10": {"icon": "", "description": "Reached level 10"},
}
def badge_info(badge_name: str) -> dict:
return BADGE_CATALOG.get(badge_name, {"icon": "", "description": badge_name})
def level_for_xp(xp: int) -> int:
return 1 + max(0, xp) // LEVEL_XP
def notify_badge(user_uid: str, badge_name: str) -> None:
user = get_table("users").find_one(uid=user_uid)
if not user:
return
create_notification(user_uid, "badge", f"You earned the {badge_name} badge", user_uid, f"/profile/{user['username']}")
def award_xp(user_uid: str, amount: int) -> dict:
users = get_table("users")
user = users.find_one(uid=user_uid)
if not user or amount <= 0:
return {"xp": user.get("xp", 0) if user else 0, "level": user.get("level", 1) if user else 1, "leveled_up": False}
current_xp = user.get("xp", 0) or 0
current_level = user.get("level", 1) or 1
new_xp = max(0, current_xp + amount)
new_level = level_for_xp(new_xp)
users.update({"uid": user_uid, "xp": new_xp, "level": new_level}, ["uid"])
clear_user_cache(user_uid)
leveled_up = new_level > current_level
if leveled_up:
create_notification(user_uid, "level", f"You reached level {new_level}", user_uid, f"/profile/{user['username']}")
for level in range(current_level + 1, new_level + 1):
badge_name = LEVEL_BADGES.get(level)
if badge_name and award_badge(user_uid, badge_name):
notify_badge(user_uid, badge_name)
return {"xp": new_xp, "level": new_level, "leveled_up": leveled_up}
def check_milestone_badges(user_uid: str) -> list:
awarded = []
if get_table("posts").count(user_uid=user_uid) >= 10 and award_badge(user_uid, "Prolific"):
awarded.append("Prolific")
stars = get_user_stars(user_uid)
if stars >= 100 and award_badge(user_uid, "Star Author"):
awarded.append("Star Author")
if stars >= 25 and award_badge(user_uid, "Rising Star"):
awarded.append("Rising Star")
if get_table("follows").count(following_uid=user_uid) >= 10 and award_badge(user_uid, "Popular"):
awarded.append("Popular")
for badge_name in awarded:
notify_badge(user_uid, badge_name)
return awarded
def award_rewards(user_uid: str, amount: int, first_badge: str | None = None) -> None:
if first_badge:
award_badge(user_uid, first_badge)
award_xp(user_uid, amount)
check_milestone_badges(user_uid)
def create_mention_notifications(content: str, actor_uid: str, target_url: str) -> None:
usernames = extract_mentions(content)
if not usernames:

View File

@ -1,6 +1,24 @@
import re
from playwright.sync_api import expect
from tests.conftest import BASE_URL, assert_share_copies
def test_feed_vote_voted_state_persists(alice):
page, _ = alice
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
page.locator(".feed-fab").first.click()
page.fill("#post-content", "Feed vote persistence test content")
page.locator("#create-post-modal button.btn-primary:has-text('Post')").click()
page.wait_for_url(f"{BASE_URL}/posts/*", wait_until="domcontentloaded")
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
page.locator(".post-action-btn.vote-up").first.click()
expect(page.locator(".post-vote-count").first).to_have_text("1")
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
expect(page.locator(".post-action-btn.vote-up").first).to_have_class(re.compile(r"\bvoted\b"))
def test_feed_card_share_button(alice):
page, _ = alice
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")

View File

@ -1,4 +1,8 @@
import re
import time
from playwright.sync_api import expect
from tests.conftest import BASE_URL, assert_share_copies
@ -195,3 +199,13 @@ def test_gist_listing_shows_created_gist(alice, app_server):
_create_gist(page, title=title, source_code="show_in_list = True")
page.goto(f"{BASE_URL}/gists", wait_until="domcontentloaded")
assert page.is_visible(f"text={title}")
def test_gist_voted_state_persists(alice):
page, _ = alice
_create_gist(page, title="Voted State Gist")
star = "form[action*='/votes/gist/'] button"
page.locator(star).first.click()
page.wait_for_timeout(500)
page.reload(wait_until="domcontentloaded")
expect(page.locator(star).first).to_have_class(re.compile(r"\bvoted\b"))

62
tests/test_leaderboard.py Normal file
View File

@ -0,0 +1,62 @@
from tests.conftest import BASE_URL
def test_leaderboard_page_loads(alice):
page, _ = alice
page.goto(f"{BASE_URL}/leaderboard", wait_until="domcontentloaded")
assert page.locator(".leaderboard-header h1").is_visible()
assert page.is_visible("text=Ranked by total stars")
def test_leaderboard_nav_link(alice):
page, _ = alice
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
assert page.is_visible("a.topnav-link:has-text('Leaderboard')")
def test_leaderboard_no_pagination(alice):
page, _ = alice
page.goto(f"{BASE_URL}/leaderboard", wait_until="domcontentloaded")
assert page.locator(".pagination").count() == 0
def test_profile_rank_stat(alice):
page, user = alice
page.goto(f"{BASE_URL}/profile/{user['username']}", wait_until="domcontentloaded")
assert page.is_visible("text=Rank")
def test_leaderboard_ranks_after_upvote(app_server, browser, seeded_db):
from tests.conftest import login_user
ctx_a = browser.new_context(viewport={"width": 1400, "height": 900})
ctx_b = browser.new_context(viewport={"width": 1400, "height": 900})
pa = ctx_a.new_page()
pb = ctx_b.new_page()
pa.set_default_timeout(15000)
pb.set_default_timeout(15000)
login_user(pa, seeded_db["alice"])
login_user(pb, seeded_db["bob"])
pb.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
pb.locator(".feed-fab").first.wait_for(state="visible", timeout=10000)
pb.locator(".feed-fab").first.click()
pb.fill("#post-content", "Post for leaderboard ranking test")
pb.locator("#create-post-modal button.btn-primary:has-text('Post')").click()
pb.wait_for_url("**/posts/*", timeout=10000, wait_until="domcontentloaded")
post_url = pb.url
pa.goto(post_url, wait_until="domcontentloaded")
pa.wait_for_timeout(1000)
vote_btn = pa.locator("button.post-action-btn").filter(has_text="+").first
vote_btn.wait_for(state="visible", timeout=10000)
vote_btn.click()
pa.wait_for_timeout(1500)
pa.goto(f"{BASE_URL}/leaderboard", wait_until="domcontentloaded")
body = pa.locator("body").text_content()
assert "Internal Server Error" not in body, f"Got 500 on leaderboard: {body[:300]}"
assert pa.is_visible("a.leaderboard-name:has-text('bob_test')")
ctx_a.close()
ctx_b.close()

View File

@ -1,3 +1,5 @@
import re
from playwright.sync_api import expect
from tests.conftest import BASE_URL, assert_share_copies
@ -49,6 +51,39 @@ def test_post_vote_increment(alice):
expect(page.locator(".post-vote-count").first).to_have_text("1")
def test_post_upvote_voted_state_persists(alice):
page, _ = alice
create_post(page, "showcase", "Upvote persistence test")
page.locator(".post-action-btn.vote-up").first.click()
expect(page.locator(".post-vote-count").first).to_have_text("1")
page.reload(wait_until="domcontentloaded")
expect(page.locator(".post-action-btn.vote-up").first).to_have_class(re.compile(r"\bvoted\b"))
assert "voted" not in (page.locator(".post-action-btn.vote-down").first.get_attribute("class") or "")
def test_post_downvote_voted_state_persists(alice):
page, _ = alice
create_post(page, "showcase", "Downvote persistence test")
page.locator(".post-action-btn.vote-down").first.click()
expect(page.locator(".post-vote-count").first).to_have_text("-1")
page.reload(wait_until="domcontentloaded")
expect(page.locator(".post-action-btn.vote-down").first).to_have_class(re.compile(r"\bvoted\b"))
assert "voted" not in (page.locator(".post-action-btn.vote-up").first.get_attribute("class") or "")
def test_comment_voted_state_persists(alice):
page, _ = alice
create_post(page, "devlog", "Post for comment vote persistence")
textarea = page.locator(".comment-form textarea[name='content']")
textarea.fill("Comment whose vote should persist")
page.locator(".comment-form button:has-text('Post')").click()
page.wait_for_timeout(500)
page.locator(".comment-vote-btn").first.click()
page.wait_for_timeout(500)
page.reload(wait_until="domcontentloaded")
expect(page.locator(".comment-vote-btn").first).to_have_class(re.compile(r"\bvoted\b"))
def _profile_stars(page, username):
page.goto(f"{BASE_URL}/profile/{username}", wait_until="domcontentloaded")
value = page.locator(".profile-stat:has(.profile-stat-label:has-text('Stars')) .profile-stat-value").first

View File

@ -1,3 +1,5 @@
import re
from playwright.sync_api import expect
from tests.conftest import BASE_URL, assert_share_copies
@ -28,6 +30,16 @@ def test_project_vote(alice):
assert after == before + 1
def test_project_voted_state_persists(alice):
page, _ = alice
_create_project(page, "Voted State Project")
star = "form[action*='/votes/project/'] button"
page.locator(star).first.click()
page.wait_for_timeout(500)
page.reload(wait_until="domcontentloaded")
expect(page.locator(star).first).to_have_class(re.compile(r"\bvoted\b"))
def test_delete_own_project(alice):
page, _ = alice
_create_project(page, "Deletable Project XYZ")

View File

@ -1,4 +1,4 @@
from devplacepy.utils import hash_password, verify_password, generate_uid, slugify, time_ago
from devplacepy.utils import hash_password, verify_password, generate_uid, slugify, time_ago, level_for_xp, badge_info
from datetime import datetime, timedelta, timezone
@ -80,3 +80,27 @@ def test_time_ago_years():
dt = (datetime.now(timezone.utc) - timedelta(days=400)).isoformat()
result = time_ago(dt)
assert "/" in result
def test_level_for_xp_boundaries():
assert level_for_xp(0) == 1
assert level_for_xp(99) == 1
assert level_for_xp(100) == 2
assert level_for_xp(250) == 3
assert level_for_xp(450) == 5
def test_level_for_xp_negative():
assert level_for_xp(-50) == 1
def test_badge_info_known():
meta = badge_info("Star Author")
assert meta["icon"]
assert meta["description"] == "Earned 100 stars"
def test_badge_info_unknown_fallback():
meta = badge_info("Nonexistent Badge")
assert meta["icon"]
assert meta["description"] == "Nonexistent Badge"