This commit is contained in:
parent
3f900b4002
commit
c3a8347cc0
54
AGENTS.md
54
AGENTS.md
@ -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.
|
||||
|
||||
13
README.md
13
README.md
@ -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 |
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -12,7 +12,7 @@ from devplacepy.database import init_db, get_table, db, get_users_by_uids, get_c
|
||||
from devplacepy.templating import templates
|
||||
from devplacepy.utils import get_current_user, time_ago
|
||||
from devplacepy.seo import base_seo_context, site_url, website_schema
|
||||
from devplacepy.routers import auth, feed, posts, comments, projects, profile, messages, notifications, votes, avatar, follow, admin, seo, bugs, news, gists, services as services_router, uploads, push
|
||||
from devplacepy.routers import auth, feed, posts, comments, projects, profile, messages, notifications, votes, avatar, follow, admin, seo, bugs, news, gists, services as services_router, uploads, push, leaderboard
|
||||
from devplacepy.services.manager import service_manager
|
||||
from devplacepy.services.news import NewsService
|
||||
|
||||
@ -106,6 +106,7 @@ app.include_router(notifications.router, prefix="/notifications")
|
||||
app.include_router(votes.router, prefix="/votes")
|
||||
app.include_router(avatar.router, prefix="/avatar")
|
||||
app.include_router(follow.router, prefix="/follow")
|
||||
app.include_router(leaderboard.router, prefix="/leaderboard")
|
||||
app.include_router(admin.router, prefix="/admin")
|
||||
app.include_router(seo.router)
|
||||
app.include_router(push.router)
|
||||
|
||||
@ -32,7 +32,7 @@ async def bugs_page(request: Request):
|
||||
"bug": b,
|
||||
"author": users_map.get(b["user_uid"]),
|
||||
"time_ago": time_ago(b["created_at"]),
|
||||
"comments": load_comments("bug", b["uid"]),
|
||||
"comments": load_comments("bug", b["uid"], user),
|
||||
"attachments": attachments_map.get(b["uid"], []),
|
||||
})
|
||||
|
||||
|
||||
@ -5,7 +5,8 @@ from fastapi import APIRouter, Request, Form
|
||||
from fastapi.responses import RedirectResponse
|
||||
from devplacepy.database import get_table, resolve_object_url
|
||||
from devplacepy.attachments import link_attachments, delete_target_attachments
|
||||
from devplacepy.utils import generate_uid, require_user, create_mention_notifications, create_notification, award_badge
|
||||
from devplacepy.content import is_owner
|
||||
from devplacepy.utils import generate_uid, require_user, create_mention_notifications, create_notification, award_rewards, XP_COMMENT
|
||||
from devplacepy.models import CommentForm
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -39,7 +40,7 @@ async def create_comment(request: Request, data: Annotated[CommentForm, Form()])
|
||||
if data.attachment_uids:
|
||||
link_attachments(data.attachment_uids, "comment", comment_uid)
|
||||
|
||||
award_badge(user["uid"], "First Comment")
|
||||
award_rewards(user["uid"], XP_COMMENT, "First Comment")
|
||||
|
||||
comment_url = f"{redirect_url}#comment-{comment_uid}"
|
||||
|
||||
@ -66,9 +67,7 @@ async def delete_comment(request: Request, comment_uid: str):
|
||||
user = require_user(request)
|
||||
comments = get_table("comments")
|
||||
comment = comments.find_one(uid=comment_uid)
|
||||
if not comment:
|
||||
return RedirectResponse(url="/feed", status_code=302)
|
||||
if comment["user_uid"] != user["uid"]:
|
||||
if not is_owner(comment, user):
|
||||
return RedirectResponse(url="/feed", status_code=302)
|
||||
target_type = comment.get("target_type", "post")
|
||||
target_uid = comment.get("target_uid") or comment.get("post_uid", "")
|
||||
|
||||
@ -6,7 +6,7 @@ from devplacepy.attachments import get_attachments_batch
|
||||
from devplacepy.content import enrich_items
|
||||
from devplacepy.templating import templates
|
||||
from devplacepy.utils import get_current_user
|
||||
from devplacepy.seo import base_seo_context, site_url, website_schema
|
||||
from devplacepy.seo import list_page_seo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
@ -48,7 +48,7 @@ def get_feed_posts(user, tab: str = "all", topic: str = None, before: str = None
|
||||
authors = get_users_by_uids([p["user_uid"] for p in posts])
|
||||
counts = get_comment_counts_by_post_uids([p["uid"] for p in posts])
|
||||
|
||||
result = enrich_items(posts, "post", authors, {"comment_count": counts})
|
||||
result = enrich_items(posts, "post", authors, {"comment_count": counts}, user=user)
|
||||
return result, next_cursor
|
||||
|
||||
|
||||
@ -65,13 +65,11 @@ async def feed_page(request: Request, tab: str = "all", topic: str = None, befor
|
||||
for item in posts:
|
||||
item["attachments"] = attachments_map.get(item["post"]["uid"], [])
|
||||
|
||||
base = site_url(request)
|
||||
seo_ctx = base_seo_context(
|
||||
seo_ctx = list_page_seo(
|
||||
request,
|
||||
title="Feed",
|
||||
description="Discover the latest developer discussions, projects, and community activity on DevPlace.",
|
||||
breadcrumbs=[{"name": "Home", "url": "/feed"}, {"name": "Feed", "url": "/feed"}],
|
||||
schemas=[website_schema(base)],
|
||||
)
|
||||
return templates.TemplateResponse(request, "feed.html", {
|
||||
**seo_ctx,
|
||||
|
||||
@ -3,7 +3,7 @@ from datetime import datetime, timezone
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from devplacepy.database import get_table
|
||||
from devplacepy.utils import generate_uid, require_user, create_notification
|
||||
from devplacepy.utils import generate_uid, require_user, create_notification, award_rewards, XP_FOLLOW
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
@ -30,6 +30,7 @@ async def follow_user(request: Request, username: str):
|
||||
})
|
||||
|
||||
create_notification(target["uid"], "follow", f"{user['username']} started following you", user["uid"], f"/profile/{user['username']}")
|
||||
award_rewards(target["uid"], XP_FOLLOW)
|
||||
|
||||
logger.info(f"{user['username']} followed {username}")
|
||||
return RedirectResponse(url=f"/profile/{username}", status_code=302)
|
||||
|
||||
@ -1,14 +1,13 @@
|
||||
import logging
|
||||
from typing import Annotated
|
||||
from datetime import datetime, timezone
|
||||
from fastapi import APIRouter, Request, HTTPException, Form
|
||||
from fastapi import APIRouter, Request, Form
|
||||
from devplacepy.models import GistForm, GistEditForm
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from devplacepy.database import get_table, get_users_by_uids, get_gist_languages
|
||||
from devplacepy.content import load_detail, edit_content_item, delete_content_item, enrich_items
|
||||
from devplacepy.content import load_detail, edit_content_item, delete_content_item, enrich_items, create_content_item, detail_context
|
||||
from devplacepy.templating import templates
|
||||
from devplacepy.utils import generate_uid, get_current_user, require_user, make_combined_slug, create_mention_notifications
|
||||
from devplacepy.seo import base_seo_context, site_url, website_schema, software_source_code_schema
|
||||
from devplacepy.utils import get_current_user, require_user, not_found, XP_GIST
|
||||
from devplacepy.seo import base_seo_context, site_url, website_schema, software_source_code_schema, list_page_seo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
@ -24,7 +23,7 @@ LANGUAGES = [
|
||||
]
|
||||
|
||||
|
||||
def get_gists_list(user_uid=None, language=None):
|
||||
def get_gists_list(user_uid=None, language=None, viewer=None):
|
||||
gists_table = get_table("gists")
|
||||
filters = {}
|
||||
if user_uid:
|
||||
@ -38,16 +37,15 @@ def get_gists_list(user_uid=None, language=None):
|
||||
return []
|
||||
|
||||
users_map = get_users_by_uids([g["user_uid"] for g in all_gists])
|
||||
return enrich_items(all_gists, "gist", users_map)
|
||||
return enrich_items(all_gists, "gist", users_map, user=viewer)
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
async def gists_page(request: Request, language: str = None, user_uid: str = None):
|
||||
user = get_current_user(request)
|
||||
gists_data = get_gists_list(user_uid, language)
|
||||
gists_data = get_gists_list(user_uid, language, viewer=user)
|
||||
total_count = len(gists_data)
|
||||
base = site_url(request)
|
||||
seo_ctx = base_seo_context(
|
||||
seo_ctx = list_page_seo(
|
||||
request,
|
||||
title="Gists",
|
||||
description=f"Browse {total_count} code snippets on DevPlace.",
|
||||
@ -55,7 +53,6 @@ async def gists_page(request: Request, language: str = None, user_uid: str = Non
|
||||
{"name": "Home", "url": "/feed"},
|
||||
{"name": "Gists", "url": "/gists"},
|
||||
],
|
||||
schemas=[website_schema(base)],
|
||||
)
|
||||
return templates.TemplateResponse(request, "gists.html", {
|
||||
**seo_ctx,
|
||||
@ -74,7 +71,7 @@ async def gist_detail(request: Request, gist_slug: str):
|
||||
user = get_current_user(request)
|
||||
detail = load_detail("gists", "gist", gist_slug, user)
|
||||
if not detail:
|
||||
raise HTTPException(status_code=404, detail="Gist not found")
|
||||
raise not_found("Gist not found")
|
||||
gist = detail["item"]
|
||||
|
||||
base = site_url(request)
|
||||
@ -90,19 +87,9 @@ async def gist_detail(request: Request, gist_slug: str):
|
||||
],
|
||||
schemas=[website_schema(base), software_source_code_schema(gist, base)],
|
||||
)
|
||||
return templates.TemplateResponse(request, "gist_detail.html", {
|
||||
**seo_ctx,
|
||||
"request": request,
|
||||
"user": user,
|
||||
"gist": gist,
|
||||
"author": detail["author"],
|
||||
"is_owner": detail["is_owner"],
|
||||
"star_count": detail["star_count"],
|
||||
"time_ago": detail["time_ago"],
|
||||
"comments": detail["comments"],
|
||||
return templates.TemplateResponse(request, "gist_detail.html", detail_context(request, user, detail, "gist", seo_ctx, {
|
||||
"languages": LANGUAGES,
|
||||
"attachments": detail["attachments"],
|
||||
})
|
||||
}))
|
||||
|
||||
|
||||
@router.post("/create")
|
||||
@ -117,27 +104,12 @@ async def create_gist(request: Request, data: Annotated[GistForm, Form()]):
|
||||
if language not in valid_languages:
|
||||
language = "plaintext"
|
||||
|
||||
gists = get_table("gists")
|
||||
uid = generate_uid()
|
||||
gist_slug = make_combined_slug(title, uid)
|
||||
gists.insert({
|
||||
"uid": uid,
|
||||
"user_uid": user["uid"],
|
||||
uid, gist_slug = create_content_item("gists", "gist", user, {
|
||||
"title": title,
|
||||
"slug": gist_slug,
|
||||
"description": description or None,
|
||||
"source_code": source_code,
|
||||
"language": language,
|
||||
"stars": 0,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
})
|
||||
|
||||
from devplacepy.attachments import link_attachments
|
||||
link_attachments(data.attachment_uids, "gist", uid)
|
||||
|
||||
create_mention_notifications(description or "", user["uid"], f"/gists/{gist_slug}")
|
||||
|
||||
logger.info(f"Gist {uid} created by {user['username']}")
|
||||
}, title, XP_GIST, "First Gist", description or "", data.attachment_uids)
|
||||
return RedirectResponse(url=f"/gists/{gist_slug}", status_code=302)
|
||||
|
||||
|
||||
|
||||
44
devplacepy/routers/leaderboard.py
Normal file
44
devplacepy/routers/leaderboard.py
Normal 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,
|
||||
})
|
||||
@ -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}"
|
||||
|
||||
@ -1,15 +1,14 @@
|
||||
import logging
|
||||
from typing import Annotated
|
||||
from datetime import datetime, timezone
|
||||
from fastapi import APIRouter, Request, HTTPException, Form
|
||||
from fastapi import APIRouter, Request, Form
|
||||
from fastapi.responses import RedirectResponse, HTMLResponse
|
||||
from devplacepy.constants import TOPICS
|
||||
from devplacepy.database import get_table, load_comments, db, resolve_by_slug
|
||||
from devplacepy.database import db
|
||||
from devplacepy.templating import templates
|
||||
from devplacepy.utils import generate_uid, get_current_user, require_user, time_ago, make_combined_slug, create_mention_notifications, award_badge
|
||||
from devplacepy.content import edit_content_item, delete_content_item
|
||||
from devplacepy.utils import get_current_user, require_user, time_ago, not_found, XP_POST
|
||||
from devplacepy.content import load_detail, edit_content_item, delete_content_item, create_content_item, detail_context
|
||||
from devplacepy.seo import base_seo_context, site_url, website_schema, discussion_forum_posting, truncate
|
||||
from devplacepy.attachments import get_attachments, link_attachments, save_inline_image
|
||||
from devplacepy.attachments import save_inline_image
|
||||
from devplacepy.models import PostForm, PostEditForm
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -32,45 +31,26 @@ async def create_post(request: Request, data: Annotated[PostForm, Form()]):
|
||||
if image_filename:
|
||||
content += f"\n\n"
|
||||
|
||||
posts = get_table("posts")
|
||||
uid = generate_uid()
|
||||
slug_text = title if title else content[:50]
|
||||
post_slug = make_combined_slug(slug_text, uid)
|
||||
posts.insert({
|
||||
"uid": uid,
|
||||
"user_uid": user["uid"],
|
||||
uid, post_slug = create_content_item("posts", "post", user, {
|
||||
"title": title or None,
|
||||
"slug": post_slug,
|
||||
"content": content,
|
||||
"topic": topic,
|
||||
"project_uid": project_uid or None,
|
||||
"image": image_filename,
|
||||
"stars": 0,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
})
|
||||
|
||||
award_badge(user["uid"], "First Post")
|
||||
|
||||
if data.attachment_uids:
|
||||
link_attachments(data.attachment_uids, "post", uid)
|
||||
|
||||
create_mention_notifications(content, user["uid"], f"/posts/{post_slug}")
|
||||
logger.info(f"Post {uid} created by {user['username']}")
|
||||
}, slug_text, XP_POST, "First Post", content, data.attachment_uids)
|
||||
return RedirectResponse(url=f"/posts/{post_slug}", status_code=302)
|
||||
|
||||
|
||||
@router.get("/{post_slug}", response_class=HTMLResponse)
|
||||
async def view_post(request: Request, post_slug: str):
|
||||
user = get_current_user(request)
|
||||
posts = get_table("posts")
|
||||
post = resolve_by_slug(posts, post_slug)
|
||||
if not post:
|
||||
raise HTTPException(status_code=404, detail="Post not found")
|
||||
|
||||
users_table = get_table("users")
|
||||
author = users_table.find_one(uid=post["user_uid"])
|
||||
|
||||
top_level = load_comments("post", post["uid"])
|
||||
detail = load_detail("posts", "post", post_slug, user)
|
||||
if not detail:
|
||||
raise not_found("Post not found")
|
||||
post = detail["item"]
|
||||
author = detail["author"]
|
||||
top_level = detail["comments"]
|
||||
|
||||
def count_all(items):
|
||||
total = len(items)
|
||||
@ -78,7 +58,6 @@ async def view_post(request: Request, post_slug: str):
|
||||
total += count_all(item.get("children", []))
|
||||
return total
|
||||
comment_count = count_all(top_level)
|
||||
star_count = post.get("stars", 0)
|
||||
base = site_url(request)
|
||||
seo_ctx = base_seo_context(
|
||||
request,
|
||||
@ -92,7 +71,7 @@ async def view_post(request: Request, post_slug: str):
|
||||
og_type="article",
|
||||
schemas=[
|
||||
website_schema(base),
|
||||
discussion_forum_posting(post, author, comment_count, star_count, base),
|
||||
discussion_forum_posting(post, author, comment_count, detail["star_count"], base),
|
||||
],
|
||||
)
|
||||
|
||||
@ -109,21 +88,11 @@ async def view_post(request: Request, post_slug: str):
|
||||
"time_ago": time_ago(r["created_at"]),
|
||||
})
|
||||
|
||||
post_attachments = get_attachments("post", post["uid"])
|
||||
|
||||
return templates.TemplateResponse(request, "post.html", {
|
||||
**seo_ctx,
|
||||
"request": request,
|
||||
"user": user,
|
||||
"post": post,
|
||||
"author": author,
|
||||
"comments": top_level,
|
||||
"time_ago": time_ago(post["created_at"]),
|
||||
return templates.TemplateResponse(request, "post.html", detail_context(request, user, detail, "post", seo_ctx, {
|
||||
"comment_count": comment_count,
|
||||
"related_posts": related_posts,
|
||||
"topics": list(TOPICS),
|
||||
"attachments": post_attachments,
|
||||
})
|
||||
}))
|
||||
|
||||
|
||||
@router.post("/edit/{post_slug}")
|
||||
|
||||
@ -3,7 +3,8 @@ from typing import Annotated
|
||||
from fastapi import APIRouter, Request, Form
|
||||
from devplacepy.models import ProfileForm
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
|
||||
from devplacepy.database import get_table, db, get_user_stars
|
||||
from devplacepy.database import get_table, db, get_user_stars, get_user_rank, get_comment_counts_by_post_uids
|
||||
from devplacepy.content import enrich_items
|
||||
from devplacepy.templating import templates
|
||||
from devplacepy.utils import get_current_user, require_user, require_user_api, time_ago, clear_user_cache
|
||||
from devplacepy.seo import base_seo_context, site_url, website_schema, profile_page_schema
|
||||
@ -36,22 +37,15 @@ async def profile_page(request: Request, username: str, tab: str = "posts"):
|
||||
if not profile_user:
|
||||
return RedirectResponse(url="/feed", status_code=302)
|
||||
profile_user["stars"] = get_user_stars(profile_user["uid"])
|
||||
rank = get_user_rank(profile_user["uid"])
|
||||
|
||||
posts = []
|
||||
if tab == "posts":
|
||||
posts_table = get_table("posts")
|
||||
raw_posts = list(posts_table.find(user_uid=profile_user["uid"], order_by=["-created_at"]))
|
||||
if raw_posts:
|
||||
from devplacepy.database import get_comment_counts_by_post_uids
|
||||
counts = get_comment_counts_by_post_uids([p["uid"] for p in raw_posts])
|
||||
else:
|
||||
counts = {}
|
||||
for p in raw_posts:
|
||||
posts.append({
|
||||
"post": p,
|
||||
"time_ago": time_ago(p["created_at"]),
|
||||
"comment_count": counts.get(p["uid"], 0),
|
||||
})
|
||||
counts = get_comment_counts_by_post_uids([p["uid"] for p in raw_posts]) if raw_posts else {}
|
||||
authors = {profile_user["uid"]: profile_user}
|
||||
posts = enrich_items(raw_posts, "post", authors, {"comment_count": counts}, user=current_user)
|
||||
|
||||
badges = list(get_table("badges").find(user_uid=profile_user["uid"]))
|
||||
projects = list(get_table("projects").find(user_uid=profile_user["uid"]))
|
||||
@ -111,6 +105,7 @@ async def profile_page(request: Request, username: str, tab: str = "posts"):
|
||||
"posts_count": posts_count,
|
||||
"is_following": is_following,
|
||||
"activities": activities,
|
||||
"rank": rank,
|
||||
})
|
||||
|
||||
|
||||
|
||||
@ -1,22 +1,20 @@
|
||||
import logging
|
||||
from typing import Annotated
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import or_
|
||||
from fastapi import APIRouter, Request, HTTPException, Form
|
||||
from fastapi import APIRouter, Request, Form
|
||||
from devplacepy.models import ProjectForm
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from devplacepy.database import get_table, get_users_by_uids, get_site_stats
|
||||
from devplacepy.content import load_detail, delete_content_item
|
||||
from devplacepy.attachments import link_attachments
|
||||
from devplacepy.database import get_table, get_users_by_uids, get_site_stats, get_user_votes
|
||||
from devplacepy.content import load_detail, delete_content_item, create_content_item, detail_context
|
||||
from devplacepy.templating import templates
|
||||
from devplacepy.utils import generate_uid, get_current_user, require_user, make_combined_slug, create_mention_notifications
|
||||
from devplacepy.seo import base_seo_context, site_url, website_schema, software_application_schema
|
||||
from devplacepy.utils import get_current_user, require_user, not_found, XP_PROJECT
|
||||
from devplacepy.seo import base_seo_context, site_url, website_schema, software_application_schema, list_page_seo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def get_projects_list(tab: str = "recent", search: str = "", user_uid: str = None, project_type: str = None):
|
||||
def get_projects_list(tab: str = "recent", search: str = "", user_uid: str = None, project_type: str = None, viewer: dict = None):
|
||||
projects = get_table("projects")
|
||||
|
||||
filters = {}
|
||||
@ -37,9 +35,11 @@ def get_projects_list(tab: str = "recent", search: str = "", user_uid: str = Non
|
||||
|
||||
if all_projects:
|
||||
users_map = get_users_by_uids([p["user_uid"] for p in all_projects])
|
||||
my_votes = get_user_votes(viewer["uid"], [p["uid"] for p in all_projects]) if viewer else {}
|
||||
for p in all_projects:
|
||||
author = users_map.get(p["user_uid"])
|
||||
p["author_name"] = author["username"] if author else "Unknown"
|
||||
p["my_vote"] = my_votes.get(p["uid"], 0)
|
||||
|
||||
return all_projects
|
||||
|
||||
@ -53,11 +53,10 @@ async def projects_page(
|
||||
project_type: str = None,
|
||||
):
|
||||
user = get_current_user(request)
|
||||
projects = get_projects_list(tab, search, user_uid, project_type)
|
||||
projects = get_projects_list(tab, search, user_uid, project_type, viewer=user)
|
||||
total_members = get_site_stats()["total_members"]
|
||||
|
||||
base = site_url(request)
|
||||
seo_ctx = base_seo_context(
|
||||
seo_ctx = list_page_seo(
|
||||
request,
|
||||
title="Projects",
|
||||
description=f"Explore {len(projects)} developer projects on DevPlace. Games, software, mobile apps and more.",
|
||||
@ -65,7 +64,6 @@ async def projects_page(
|
||||
{"name": "Home", "url": "/feed"},
|
||||
{"name": "Projects", "url": "/projects"},
|
||||
],
|
||||
schemas=[website_schema(base)],
|
||||
)
|
||||
return templates.TemplateResponse(request, "projects.html", {
|
||||
**seo_ctx,
|
||||
@ -85,7 +83,7 @@ async def project_detail(request: Request, project_slug: str):
|
||||
user = get_current_user(request)
|
||||
detail = load_detail("projects", "project", project_slug, user)
|
||||
if not detail:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
raise not_found("Project not found")
|
||||
project = detail["item"]
|
||||
|
||||
base = site_url(request)
|
||||
@ -100,18 +98,9 @@ async def project_detail(request: Request, project_slug: str):
|
||||
],
|
||||
schemas=[website_schema(base), software_application_schema(project, base)],
|
||||
)
|
||||
return templates.TemplateResponse(request, "project_detail.html", {
|
||||
**seo_ctx,
|
||||
"request": request,
|
||||
"user": user,
|
||||
"project": project,
|
||||
"author": detail["author"],
|
||||
"is_owner": detail["is_owner"],
|
||||
"star_count": detail["star_count"],
|
||||
return templates.TemplateResponse(request, "project_detail.html", detail_context(request, user, detail, "project", seo_ctx, {
|
||||
"platforms": project.get("platforms", "").split(",") if project.get("platforms") else [],
|
||||
"comments": detail["comments"],
|
||||
"attachments": detail["attachments"],
|
||||
})
|
||||
}))
|
||||
|
||||
|
||||
@router.post("/delete/{project_slug}")
|
||||
@ -126,27 +115,13 @@ async def create_project(request: Request, data: Annotated[ProjectForm, Form()])
|
||||
title = data.title.strip()
|
||||
description = data.description.strip()
|
||||
|
||||
projects = get_table("projects")
|
||||
uid = generate_uid()
|
||||
project_slug = make_combined_slug(title, uid)
|
||||
projects.insert({
|
||||
"uid": uid,
|
||||
"user_uid": user["uid"],
|
||||
uid, project_slug = create_content_item("projects", "project", user, {
|
||||
"title": title,
|
||||
"slug": project_slug,
|
||||
"description": description,
|
||||
"release_date": data.release_date or None,
|
||||
"demo_date": data.demo_date or None,
|
||||
"project_type": data.project_type,
|
||||
"platforms": data.platforms.strip(),
|
||||
"status": data.status,
|
||||
"stars": 0,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
})
|
||||
|
||||
if data.attachment_uids:
|
||||
link_attachments(data.attachment_uids, "project", uid)
|
||||
|
||||
create_mention_notifications(description, user["uid"], f"/projects/{project_slug}")
|
||||
logger.info(f"Project {uid} created by {user['username']}")
|
||||
}, title, XP_PROJECT, "First Project", description, data.attachment_uids)
|
||||
return RedirectResponse(url=f"/projects/{project_slug}", status_code=302)
|
||||
|
||||
@ -4,7 +4,7 @@ from datetime import datetime, timezone
|
||||
from fastapi import APIRouter, Request, Form
|
||||
from fastapi.responses import RedirectResponse, JSONResponse
|
||||
from devplacepy.database import get_table, update_target_stars, get_target_owner_uid, resolve_object_url
|
||||
from devplacepy.utils import generate_uid, require_user, create_notification
|
||||
from devplacepy.utils import generate_uid, require_user, create_notification, award_rewards, XP_UPVOTE
|
||||
from devplacepy.models import VoteForm
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -50,6 +50,7 @@ async def vote(request: Request, target_type: str, target_uid: str, data: Annota
|
||||
if owner_uid and owner_uid != user["uid"]:
|
||||
target_url = resolve_object_url(target_type, target_uid)
|
||||
create_notification(owner_uid, "vote", f"{user['username']} ++'d your {target_type}", user["uid"], target_url)
|
||||
award_rewards(owner_uid, XP_UPVOTE)
|
||||
|
||||
if request.headers.get("x-requested-with") == "fetch":
|
||||
current = votes.find_one(user_uid=user["uid"], target_uid=target_uid, target_type=target_type)
|
||||
|
||||
@ -206,6 +206,17 @@ def base_seo_context(request, title="", description="", robots="index,follow", o
|
||||
}
|
||||
|
||||
|
||||
def list_page_seo(request, title="", description="", breadcrumbs=None):
|
||||
base = site_url(request)
|
||||
return base_seo_context(
|
||||
request,
|
||||
title=title,
|
||||
description=description,
|
||||
breadcrumbs=breadcrumbs,
|
||||
schemas=[website_schema(base)],
|
||||
)
|
||||
|
||||
|
||||
def make_sitemap(base_url):
|
||||
from devplacepy.database import get_table, db
|
||||
|
||||
@ -236,6 +247,7 @@ def make_sitemap(base_url):
|
||||
urlset.append(url_element(f"{base_url}/news", changefreq="hourly", priority="0.9"))
|
||||
urlset.append(url_element(f"{base_url}/projects", changefreq="daily", priority="0.8"))
|
||||
urlset.append(url_element(f"{base_url}/gists", changefreq="daily", priority="0.8"))
|
||||
urlset.append(url_element(f"{base_url}/leaderboard", changefreq="daily", priority="0.7"))
|
||||
|
||||
if "posts" in db.tables:
|
||||
posts = list(get_table("posts").find(order_by=["-created_at"], _limit=500))
|
||||
|
||||
@ -15,6 +15,7 @@ NEWS_API_URL_DEFAULT = "https://news.app.molodetz.nl/api"
|
||||
AI_URL_DEFAULT = "https://openai.app.molodetz.nl/v1/chat/completions"
|
||||
AI_MODEL_DEFAULT = "molodetz"
|
||||
GRADE_THRESHOLD_DEFAULT = 7
|
||||
GRADE_MAX_TOKENS = 2000
|
||||
|
||||
|
||||
def _get_ai_key() -> str:
|
||||
@ -216,7 +217,7 @@ class NewsService(BaseService):
|
||||
payload = {
|
||||
"model": ai_model,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"max_tokens": 10,
|
||||
"max_tokens": GRADE_MAX_TOKENS,
|
||||
"temperature": 0.0,
|
||||
}
|
||||
|
||||
|
||||
@ -265,10 +265,6 @@
|
||||
transform: translateX(18px);
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@ -81,6 +81,9 @@
|
||||
.inline-form {
|
||||
display: inline;
|
||||
}
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
.text-muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
@ -115,6 +118,14 @@
|
||||
color: var(--text-muted);
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
.top-authors-link {
|
||||
display: block;
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
text-align: center;
|
||||
}
|
||||
.no-comments-msg {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
@ -986,3 +997,22 @@ img {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.vote-star {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.vote-star::before {
|
||||
content: "\2606";
|
||||
}
|
||||
|
||||
.vote-star.voted::before {
|
||||
content: "\2605";
|
||||
}
|
||||
|
||||
.vote-star.voted {
|
||||
color: var(--warning);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
58
devplacepy/static/css/bugs.css
Normal file
58
devplacepy/static/css/bugs.css
Normal 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);
|
||||
}
|
||||
@ -17,7 +17,9 @@
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.feed-nav-btn {
|
||||
.feed-nav-btn,
|
||||
.projects-tab,
|
||||
.profile-tab {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.8125rem;
|
||||
@ -178,15 +180,24 @@
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.post-action-btn.voted,
|
||||
.post-action-btn.vote-up {
|
||||
.post-action-btn.vote-up:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.post-action-btn.vote-down {
|
||||
.post-action-btn.vote-down:hover {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.post-action-btn.vote-up.voted {
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.post-action-btn.vote-down.voted {
|
||||
color: var(--danger);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.post-votes {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@ -287,6 +298,14 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.top-author-name {
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stat-row .value {
|
||||
@ -302,8 +321,8 @@
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background: #e53935;
|
||||
color: #fff;
|
||||
background: var(--danger);
|
||||
color: var(--white);
|
||||
font-size: 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -355,6 +374,12 @@
|
||||
.feed-right {
|
||||
display: none;
|
||||
}
|
||||
.leaderboard-page {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.leaderboard-page > aside {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@ -428,3 +453,146 @@
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.leaderboard-page {
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr 280px;
|
||||
gap: 1.5rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.leaderboard-page > aside {
|
||||
position: sticky;
|
||||
top: calc(var(--nav-height) + 1rem);
|
||||
}
|
||||
|
||||
.leaderboard-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.featured-news {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.featured-news h3 {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.featured-news-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.featured-news-item:not(:last-child) {
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.featured-news-title {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.featured-news-meta {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.leaderboard-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.leaderboard-header h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.leaderboard-you {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
background: var(--accent-light);
|
||||
padding: 0.25rem 0.625rem;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.leaderboard-intro {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.leaderboard-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.leaderboard-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.leaderboard-row-self {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-light);
|
||||
}
|
||||
|
||||
.leaderboard-rank {
|
||||
font-weight: 700;
|
||||
font-size: 0.9375rem;
|
||||
color: var(--text-muted);
|
||||
min-width: 2.5rem;
|
||||
}
|
||||
|
||||
.leaderboard-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.leaderboard-level {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.leaderboard-stars {
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.leaderboard-star-icon {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.leaderboard-empty {
|
||||
list-style: none;
|
||||
padding: 2rem 1rem;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
@ -100,10 +100,24 @@
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.comment-vote-btn:hover {
|
||||
.comment-vote-btn.vote-up:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.comment-vote-btn.vote-down:hover {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.comment-vote-btn.vote-up.voted {
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.comment-vote-btn.vote-down.voted {
|
||||
color: var(--danger);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.comment-vote-count {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
@ -230,49 +244,6 @@ button.comment-form-submit:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.post-action-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
transition: all 0.2s;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.post-action-btn:hover {
|
||||
background: var(--bg-card-hover);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.post-action-btn.vote-up {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.post-action-btn.vote-down {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.post-votes {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.post-vote-count {
|
||||
font-weight: 600;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary);
|
||||
min-width: 1.25rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.comment-thread-line {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
|
||||
@ -56,6 +56,15 @@
|
||||
display: block;
|
||||
}
|
||||
|
||||
a.profile-stat-value {
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a.profile-stat-value:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.profile-stat-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
@ -109,6 +118,11 @@
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.profile-badge-icon {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.profile-info {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
@ -161,15 +175,6 @@
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.profile-tab {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.profile-tab:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@ -16,15 +16,6 @@
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.projects-tab {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.projects-tab:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
@ -202,3 +193,81 @@
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.project-detail-page {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.project-detail {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.5rem;
|
||||
}
|
||||
.project-detail-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.project-detail-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.project-detail-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.project-detail-meta span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.project-detail-author {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.project-detail-author a {
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.project-detail-desc {
|
||||
font-size: 0.9375rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.7;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.project-detail-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.project-star-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.project-star-btn:hover {
|
||||
background: var(--bg-card-hover);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
@ -8,49 +8,63 @@ export class DomUtils {
|
||||
this.initStopPropagation();
|
||||
}
|
||||
|
||||
static onDataAttr(attr, event, handler) {
|
||||
document.querySelectorAll(`[data-${attr}]`).forEach((el) => {
|
||||
el.addEventListener(event, (e) => handler(el, e));
|
||||
});
|
||||
}
|
||||
|
||||
static show(el) {
|
||||
el.style.display = "block";
|
||||
}
|
||||
|
||||
static hide(el) {
|
||||
el.style.display = "none";
|
||||
}
|
||||
|
||||
static toggle(el) {
|
||||
el.style.display = el.style.display === "none" ? "block" : "none";
|
||||
}
|
||||
|
||||
static isShown(el) {
|
||||
return el.style.display === "block";
|
||||
}
|
||||
|
||||
initClipboardCopy() {
|
||||
document.querySelectorAll("[data-copy]").forEach((btn) => {
|
||||
btn.addEventListener("click", async () => {
|
||||
const source = document.getElementById(btn.dataset.copy);
|
||||
if (!source) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(source.textContent);
|
||||
Toast.flash(btn, "Copied!", 2000);
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
});
|
||||
DomUtils.onDataAttr("copy", "click", async (btn) => {
|
||||
const source = document.getElementById(btn.dataset.copy);
|
||||
if (!source) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(source.textContent);
|
||||
Toast.flash(btn, "Copied!", 2000);
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
initShareButtons() {
|
||||
document.querySelectorAll("[data-share]").forEach((btn) => {
|
||||
btn.addEventListener("click", async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const url = new URL(btn.dataset.share || window.location.href, window.location.href).href;
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
Toast.flash(btn, "Copied!", 1000);
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
});
|
||||
DomUtils.onDataAttr("share", "click", async (btn, e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const url = new URL(btn.dataset.share || window.location.href, window.location.href).href;
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
Toast.flash(btn, "Copied!", 1000);
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
initTogglers() {
|
||||
document.querySelectorAll("[data-toggle]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const target = document.getElementById(btn.dataset.toggle);
|
||||
if (target) target.classList.toggle("hidden");
|
||||
});
|
||||
DomUtils.onDataAttr("toggle", "click", (btn) => {
|
||||
const target = document.getElementById(btn.dataset.toggle);
|
||||
if (target) target.classList.toggle("hidden");
|
||||
});
|
||||
}
|
||||
|
||||
initStopPropagation() {
|
||||
document.querySelectorAll("[data-stop-propagation]").forEach((el) => {
|
||||
el.addEventListener("click", (e) => e.stopPropagation());
|
||||
});
|
||||
DomUtils.onDataAttr("stop-propagation", "click", (el, e) => e.stopPropagation());
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { TextInput } from "./TextInput.js";
|
||||
import { DomUtils } from "./DomUtils.js";
|
||||
|
||||
export class EmojiPicker {
|
||||
constructor(textarea) {
|
||||
@ -41,15 +42,11 @@ export class EmojiPicker {
|
||||
}
|
||||
|
||||
toggle() {
|
||||
if (this.wrapper.style.display === "none") {
|
||||
this.wrapper.style.display = "block";
|
||||
} else {
|
||||
this.wrapper.style.display = "none";
|
||||
}
|
||||
DomUtils.toggle(this.wrapper);
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.wrapper.style.display = "none";
|
||||
DomUtils.hide(this.wrapper);
|
||||
}
|
||||
}
|
||||
window.EmojiPicker = EmojiPicker;
|
||||
|
||||
@ -4,6 +4,33 @@ export class Http {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
static async sendForm(url, params = {}) {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-Requested-With": "fetch",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams(params),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`request failed with status ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
static async postJson(url, body) {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`request failed with status ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
static postForm(action, data = {}) {
|
||||
const form = document.createElement("form");
|
||||
form.method = "POST";
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { Http } from "./Http.js";
|
||||
import { Avatar } from "./Avatar.js";
|
||||
import { TextInput } from "./TextInput.js";
|
||||
import { DomUtils } from "./DomUtils.js";
|
||||
|
||||
export class MentionInput {
|
||||
constructor(element) {
|
||||
@ -25,7 +26,7 @@ export class MentionInput {
|
||||
this.input.addEventListener("input", () => this.onInput());
|
||||
this.input.addEventListener("keydown", (e) => this.onKeydown(e));
|
||||
this.input.addEventListener("blur", () => {
|
||||
setTimeout(() => { this.dropdown.style.display = "none"; }, 200);
|
||||
setTimeout(() => DomUtils.hide(this.dropdown), 200);
|
||||
});
|
||||
}
|
||||
|
||||
@ -36,7 +37,7 @@ export class MentionInput {
|
||||
let match = text.match(/(?:^|\s|\x28)@([a-zA-Z0-9_-]*)$/);
|
||||
|
||||
if (!match) {
|
||||
this.dropdown.style.display = "none";
|
||||
DomUtils.hide(this.dropdown);
|
||||
this.lastMatch = null;
|
||||
return;
|
||||
}
|
||||
@ -45,7 +46,7 @@ export class MentionInput {
|
||||
this.lastMatch = { query, index: match.index + (text[match.index] === "@" ? 0 : 1) };
|
||||
|
||||
if (query.length < 1) {
|
||||
this.dropdown.style.display = "none";
|
||||
DomUtils.hide(this.dropdown);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -57,12 +58,12 @@ export class MentionInput {
|
||||
const data = await Http.getJson("/profile/search?q=" + encodeURIComponent(query));
|
||||
const results = data.results || [];
|
||||
if (results.length === 0) {
|
||||
this.dropdown.style.display = "none";
|
||||
DomUtils.hide(this.dropdown);
|
||||
return;
|
||||
}
|
||||
this.render(results);
|
||||
} catch (e) {
|
||||
this.dropdown.style.display = "none";
|
||||
DomUtils.hide(this.dropdown);
|
||||
}
|
||||
}
|
||||
|
||||
@ -81,12 +82,12 @@ export class MentionInput {
|
||||
});
|
||||
this.dropdown.appendChild(item);
|
||||
}
|
||||
this.dropdown.style.display = "block";
|
||||
DomUtils.show(this.dropdown);
|
||||
}
|
||||
|
||||
onKeydown(e) {
|
||||
const items = this.dropdown.querySelectorAll(".mention-dropdown-item");
|
||||
if (this.dropdown.style.display !== "block" || items.length === 0) {
|
||||
if (!DomUtils.isShown(this.dropdown) || items.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -104,7 +105,7 @@ export class MentionInput {
|
||||
this.insert(items[this.selectedIndex].dataset.username);
|
||||
}
|
||||
} else if (e.key === "Escape") {
|
||||
this.dropdown.style.display = "none";
|
||||
DomUtils.hide(this.dropdown);
|
||||
}
|
||||
}
|
||||
|
||||
@ -125,7 +126,7 @@ export class MentionInput {
|
||||
before = before.replace(/@+$/, "");
|
||||
after = after.replace(/^@+/, "");
|
||||
TextInput.applyValue(this.input, before + "@" + username + " " + after, before.length + username.length + 2);
|
||||
this.dropdown.style.display = "none";
|
||||
DomUtils.hide(this.dropdown);
|
||||
this.lastMatch = null;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { Http } from "./Http.js";
|
||||
import { Avatar } from "./Avatar.js";
|
||||
import { DomUtils } from "./DomUtils.js";
|
||||
|
||||
export class MessageSearch {
|
||||
constructor() {
|
||||
@ -24,7 +25,7 @@ export class MessageSearch {
|
||||
const q = searchInput.value.trim();
|
||||
if (q.length < 1) {
|
||||
dropdown.innerHTML = "";
|
||||
dropdown.style.display = "none";
|
||||
DomUtils.hide(dropdown);
|
||||
return;
|
||||
}
|
||||
debounceTimer = setTimeout(async () => {
|
||||
@ -32,7 +33,7 @@ export class MessageSearch {
|
||||
const data = await Http.getJson(`/messages/search?q=${encodeURIComponent(q)}`);
|
||||
const results = data.results || [];
|
||||
if (results.length === 0) {
|
||||
dropdown.style.display = "none";
|
||||
DomUtils.hide(dropdown);
|
||||
return;
|
||||
}
|
||||
dropdown.innerHTML = "";
|
||||
@ -43,7 +44,7 @@ export class MessageSearch {
|
||||
item.innerHTML = `${Avatar.imgHtml(r.username)}<span>${r.username}</span>`;
|
||||
dropdown.appendChild(item);
|
||||
}
|
||||
dropdown.style.display = "block";
|
||||
DomUtils.show(dropdown);
|
||||
} catch (e) {
|
||||
// silently fail - no suggestions
|
||||
}
|
||||
@ -61,7 +62,7 @@ export class MessageSearch {
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
if (!wrap.contains(e.target)) {
|
||||
dropdown.style.display = "none";
|
||||
DomUtils.hide(dropdown);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
import { Http } from "./Http.js";
|
||||
|
||||
export class PushManager {
|
||||
constructor() {
|
||||
this.supported = "serviceWorker" in navigator && "PushManager" in window && "Notification" in window;
|
||||
@ -46,8 +48,7 @@ export class PushManager {
|
||||
return;
|
||||
}
|
||||
|
||||
const keyResponse = await fetch("/push.json");
|
||||
const keyData = await keyResponse.json();
|
||||
const keyData = await Http.getJson("/push.json");
|
||||
const applicationServerKey = Uint8Array.from(atob(keyData.publicKey), (c) => c.charCodeAt(0));
|
||||
|
||||
const subscription = await registration.pushManager.subscribe({
|
||||
@ -55,15 +56,7 @@ export class PushManager {
|
||||
applicationServerKey: applicationServerKey,
|
||||
});
|
||||
|
||||
const response = await fetch("/push.json", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(subscription.toJSON()),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Bad status code from server.");
|
||||
}
|
||||
await Http.postJson("/push.json", subscription.toJSON());
|
||||
|
||||
this.refreshTriggerVisibility();
|
||||
} catch (error) {
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { Toast } from "./Toast.js";
|
||||
import { Http } from "./Http.js";
|
||||
|
||||
export class VoteManager {
|
||||
constructor() {
|
||||
@ -20,18 +21,7 @@ export class VoteManager {
|
||||
const action = form.getAttribute("action");
|
||||
const value = form.querySelector('input[name="value"]').value;
|
||||
try {
|
||||
const response = await fetch(action, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-Requested-With": "fetch",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({ value }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`vote failed with status ${response.status}`);
|
||||
}
|
||||
const result = await response.json();
|
||||
const result = await Http.sendForm(action, { value });
|
||||
this.render(action, result);
|
||||
} catch (error) {
|
||||
console.error("vote failed", error);
|
||||
|
||||
@ -6,12 +6,12 @@
|
||||
<div class="comment-votes">
|
||||
<form method="POST" action="/votes/comment/{{ item.comment['uid'] }}">
|
||||
<input type="hidden" name="value" value="1">
|
||||
<button type="submit" class="comment-vote-btn">+</button>
|
||||
<button type="submit" class="comment-vote-btn vote-up{% if item.my_vote == 1 %} voted{% endif %}">+</button>
|
||||
</form>
|
||||
<span class="comment-vote-count" data-vote-count="{{ item.comment['uid'] }}">{{ item.votes.up - item.votes.down }}</span>
|
||||
<form method="POST" action="/votes/comment/{{ item.comment['uid'] }}">
|
||||
<input type="hidden" name="value" value="-1">
|
||||
<button type="submit" class="comment-vote-btn">-</button>
|
||||
<button type="submit" class="comment-vote-btn vote-down{% if item.my_vote == -1 %} voted{% endif %}">-</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
||||
13
devplacepy/templates/_macros.html
Normal file
13
devplacepy/templates/_macros.html
Normal 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">×</button>
|
||||
</div>
|
||||
{{ caller() }}
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
42
devplacepy/templates/_post_card.html
Normal file
42
devplacepy/templates/_post_card.html
Normal 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">
|
||||
💬 {{ item.comment_count }}
|
||||
</a>
|
||||
<a href="{{ content_url(item.post, 'posts') }}" class="post-action-btn share">
|
||||
↗︎ Open
|
||||
</a>
|
||||
{% if _show_share %}
|
||||
<button type="button" class="post-action-btn" data-share="{{ content_url(item.post, 'posts') }}">🔗 Share</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if _show_comment_form and user %}
|
||||
<form class="feed-comment-form" method="POST" action="/comments/create">
|
||||
<input type="hidden" name="post_uid" value="{{ item.post['uid'] }}">
|
||||
<img src="{{ avatar_url('multiavatar', user['username'], 24) }}" class="avatar-img avatar-24" alt="" loading="lazy">
|
||||
<input type="text" name="content" placeholder="Your opinion goes here..." maxlength="1000" autocomplete="off">
|
||||
<button type="submit" class="feed-comment-submit">Post</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</article>
|
||||
10
devplacepy/templates/_post_header.html
Normal file
10
devplacepy/templates/_post_header.html
Normal file
@ -0,0 +1,10 @@
|
||||
<div class="post-header">
|
||||
{% set _user = _author %}{% set _size = 32 %}{% set _size_class = "sm" %}{% include "_avatar_link.html" %}
|
||||
<div class="post-author-wrap">
|
||||
<a href="/profile/{{ _author['username'] if _author else '#' }}" class="post-author-link">{{ _author['username'] if _author else 'Unknown' }}</a>
|
||||
{% if _author and _author.get('role') %}
|
||||
<span class="post-author-role">{{ _author['role'] }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<span class="post-time">{{ _time }}</span>
|
||||
</div>
|
||||
11
devplacepy/templates/_post_votes.html
Normal file
11
devplacepy/templates/_post_votes.html
Normal file
@ -0,0 +1,11 @@
|
||||
<div class="post-votes">
|
||||
<form method="POST" action="/votes/post/{{ _uid }}" class="inline-form">
|
||||
<input type="hidden" name="value" value="1">
|
||||
<button type="submit" class="post-action-btn vote-up{% if _my_vote == 1 %} voted{% endif %}">+</button>
|
||||
</form>
|
||||
<span class="post-vote-count" data-vote-count="{{ _uid }}">{{ _count }}</span>
|
||||
<form method="POST" action="/votes/post/{{ _uid }}" class="inline-form">
|
||||
<input type="hidden" name="value" value="-1">
|
||||
<button type="submit" class="post-action-btn vote-down{% if _my_vote == -1 %} voted{% endif %}">−</button>
|
||||
</form>
|
||||
</div>
|
||||
4
devplacepy/templates/_star_vote.html
Normal file
4
devplacepy/templates/_star_vote.html
Normal 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>
|
||||
8
devplacepy/templates/_topic_selector.html
Normal file
8
devplacepy/templates/_topic_selector.html
Normal 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>
|
||||
@ -54,6 +54,7 @@
|
||||
<a href="/news" class="topnav-link {% if 'news' in request.url.path %}active{% endif %}"><span class="icon">📰</span> News</a>
|
||||
<a href="/gists" class="topnav-link {% if 'gists' in request.url.path %}active{% endif %}"><span class="icon">📄</span> Gists</a>
|
||||
<a href="/projects" class="topnav-link {% if 'projects' in request.url.path %}active{% endif %}"><span class="icon">🚀</span> Projects</a>
|
||||
<a href="/leaderboard" class="topnav-link {% if 'leaderboard' in request.url.path %}active{% endif %}"><span class="icon">🏆</span> Leaderboard</a>
|
||||
{% if user %}
|
||||
<a href="/messages" class="topnav-link {% if 'messages' in request.url.path %}active{% endif %}"><span class="icon">✉️</span> Messages</a>
|
||||
{% if user.get('role') == 'Admin' %}
|
||||
@ -105,6 +106,7 @@
|
||||
<a href="/news" class="topnav-mobile-link {% if 'news' in request.url.path %}active{% endif %}"><span class="icon">📰</span> News</a>
|
||||
<a href="/gists" class="topnav-mobile-link {% if 'gists' in request.url.path %}active{% endif %}"><span class="icon">📄</span> Gists</a>
|
||||
<a href="/projects" class="topnav-mobile-link {% if 'projects' in request.url.path %}active{% endif %}"><span class="icon">🚀</span> Projects</a>
|
||||
<a href="/leaderboard" class="topnav-mobile-link {% if 'leaderboard' in request.url.path %}active{% endif %}"><span class="icon">🏆</span> Leaderboard</a>
|
||||
{% if user %}
|
||||
<a href="/messages" class="topnav-mobile-link {% if 'messages' in request.url.path %}active{% endif %}"><span class="icon">✉️</span> Messages</a>
|
||||
<a href="/notifications" class="topnav-mobile-link {% if 'notifications' in request.url.path %}active{% endif %}"><span class="icon">🔔</span> Notifications</a>
|
||||
|
||||
@ -1,67 +1,9 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import modal %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/feed.css">
|
||||
<link rel="stylesheet" href="/static/css/post.css">
|
||||
<style>
|
||||
.bugs-layout {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.bugs-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.bugs-header h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.bug-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.bug-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.bug-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.bug-status {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.bug-status.open {
|
||||
background: var(--accent-light);
|
||||
color: var(--accent);
|
||||
}
|
||||
.bug-status.closed {
|
||||
background: rgba(76, 175, 80, 0.1);
|
||||
color: var(--success);
|
||||
}
|
||||
.bug-desc {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.bug-meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="/static/css/bugs.css">
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="bugs-layout">
|
||||
@ -101,28 +43,22 @@
|
||||
</div>
|
||||
|
||||
{% if user %}
|
||||
<div class="modal-overlay" id="create-bug-modal">
|
||||
<div class="card" style="width: 100%; max-width: 560px; margin: 1rem;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||||
<h3 style="font-size: 1.125rem; font-weight: 700;">Report a Bug</h3>
|
||||
<button type="button" class="modal-close btn-ghost btn-icon" style="font-size: 1.25rem;">×</button>
|
||||
</div>
|
||||
{% call modal('create-bug-modal', 'Report a Bug') %}
|
||||
<form method="POST" action="/bugs/create">
|
||||
<div class="auth-field" style="margin-bottom: 0.75rem;">
|
||||
<div class="auth-field auth-field-gap">
|
||||
<label for="bug-title">Title</label>
|
||||
<input type="text" id="bug-title" name="title" required maxlength="200" placeholder="Brief description of the issue">
|
||||
</div>
|
||||
<div class="auth-field" style="margin-bottom: 1rem;">
|
||||
<div class="auth-field auth-field-gap">
|
||||
<label for="bug-description">Description</label>
|
||||
<textarea id="bug-description" name="description" required maxlength="5000" placeholder="Detailed steps to reproduce..." style="min-height: 120px;" data-mention></textarea>
|
||||
<textarea id="bug-description" name="description" required maxlength="5000" placeholder="Detailed steps to reproduce..." class="min-h-120" data-mention></textarea>
|
||||
</div>
|
||||
{% include "_attachment_form.html" %}
|
||||
<div style="display: flex; gap: 0.75rem; justify-content: flex-end;">
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="modal-close btn btn-secondary">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary"><span class="icon">📤</span> Submit Report</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import modal %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/feed.css">
|
||||
<link rel="stylesheet" href="/static/css/sidebar.css">
|
||||
@ -54,64 +55,7 @@
|
||||
|
||||
<div class="feed-posts">
|
||||
{% for item in posts %}
|
||||
<article class="post-card fade-in">
|
||||
<div class="post-header">
|
||||
{% set _user = item.author %}{% set _size = 32 %}{% set _size_class = "sm" %}{% include "_avatar_link.html" %}
|
||||
<div class="post-author-wrap">
|
||||
<a href="/profile/{{ item.author['username'] if item.author else '#' }}" class="post-author-link">{{ item.author['username'] if item.author else 'Unknown' }}</a>
|
||||
{% if item.author and item.author.get('role') %}
|
||||
<span class="post-author-role">{{ item.author['role'] }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<span class="post-time">{{ item.time_ago }}</span>
|
||||
</div>
|
||||
|
||||
<div class="post-topic">
|
||||
<span class="badge badge-{{ item.post['topic'] }}">{{ item.post['topic'] }}</span>
|
||||
</div>
|
||||
|
||||
{% if item.post.get('title') %}
|
||||
<a href="/posts/{{ item.post['slug'] or item.post['uid'] }}" class="post-title-link">
|
||||
<h3 class="post-title">{{ item.post['title'] }}</h3>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<div class="post-content rendered-content" data-render onclick="if (!event.target.closest('a')) window.location.href='/posts/{{ item.post['slug'] or item.post['uid'] }}'">{{ item.post['content'][:300] }}{% if item.post['content']|length > 300 %}...{% endif %}</div>
|
||||
|
||||
{% if item.attachments %}
|
||||
{% include "_attachment_display.html" %}
|
||||
{% endif %}
|
||||
|
||||
<div class="post-actions">
|
||||
<div class="post-votes">
|
||||
<form method="POST" action="/votes/post/{{ item.post['uid'] }}" class="inline-form">
|
||||
<input type="hidden" name="value" value="1">
|
||||
<button type="submit" class="post-action-btn vote-up">+</button>
|
||||
</form>
|
||||
<span class="post-vote-count" data-vote-count="{{ item.post['uid'] }}">{{ item.post.get('stars', 0) }}</span>
|
||||
<form method="POST" action="/votes/post/{{ item.post['uid'] }}" class="inline-form">
|
||||
<input type="hidden" name="value" value="-1">
|
||||
<button type="submit" class="post-action-btn vote-down">−</button>
|
||||
</form>
|
||||
</div>
|
||||
<a href="/posts/{{ item.post['slug'] or item.post['uid'] }}" class="post-action-btn">
|
||||
💬 {{ item.comment_count }}
|
||||
</a>
|
||||
<a href="/posts/{{ item.post['slug'] or item.post['uid'] }}" class="post-action-btn share">
|
||||
↗︎ Open
|
||||
</a>
|
||||
<button type="button" class="post-action-btn" data-share="/posts/{{ item.post['slug'] or item.post['uid'] }}">🔗 Share</button>
|
||||
</div>
|
||||
|
||||
{% if user %}
|
||||
<form class="feed-comment-form" method="POST" action="/comments/create">
|
||||
<input type="hidden" name="post_uid" value="{{ item.post['uid'] }}">
|
||||
<img src="{{ avatar_url('multiavatar', user['username'], 24) }}" class="avatar-img avatar-24" alt="" loading="lazy">
|
||||
<input type="text" name="content" placeholder="Your opinion goes here..." maxlength="1000" autocomplete="off">
|
||||
<button type="submit" class="feed-comment-submit">Post</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</article>
|
||||
{% set _author = item.author %}{% set _time = item.time_ago %}{% set _show_share = true %}{% set _show_comment_form = true %}{% include "_post_card.html" %}
|
||||
{% else %}
|
||||
<div class="empty-state">No posts yet. Be the first!</div>
|
||||
{% endfor %}
|
||||
@ -160,15 +104,15 @@
|
||||
{% for author in top_authors %}
|
||||
<div class="stat-row">
|
||||
<span class="label">
|
||||
<a href="/profile/{{ author['username'] }}">
|
||||
<img src="{{ avatar_url('multiavatar', author['username'], 20) }}" class="avatar-img avatar-xs" alt="{{ author['username'] }}" loading="lazy">
|
||||
{{ author['username'] }}</a>
|
||||
{% set _user = author %}{% set _size = 20 %}{% set _size_class = "xs" %}{% include "_avatar_link.html" %}
|
||||
<a href="/profile/{{ author['username'] }}" class="top-author-name">{{ author['username'] }}</a>
|
||||
</span>
|
||||
<span class="value">{{ author.get('stars', 0) }}</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="no-authors-msg">No top authors yet</div>
|
||||
{% endfor %}
|
||||
<a href="/leaderboard" class="top-authors-link">View full leaderboard</a>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
@ -177,22 +121,9 @@
|
||||
{% if user %}
|
||||
<a href="#" class="feed-fab" data-modal="create-post-modal" title="Create New Post">+</a>
|
||||
|
||||
<div class="modal-overlay" id="create-post-modal">
|
||||
<div class="card modal-card">
|
||||
<div class="modal-header">
|
||||
<h3>Create New Post</h3>
|
||||
<button type="button" class="modal-close btn-ghost btn-icon modal-close-btn">×</button>
|
||||
</div>
|
||||
|
||||
{% call modal('create-post-modal', 'Create New Post') %}
|
||||
<form id="create-post-form" method="POST" action="/posts/create" enctype="multipart/form-data">
|
||||
<div class="form-row">
|
||||
{% for t in TOPICS %}
|
||||
<label class="topic-label">
|
||||
<input type="radio" name="topic" value="{{ t }}" {% if t == 'random' %}checked{% endif %} class="topic-radio">
|
||||
{{ t|capitalize }}
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% set _topics = TOPICS %}{% set _selected = 'random' %}{% include "_topic_selector.html" %}
|
||||
|
||||
<div class="auth-field auth-field-gap">
|
||||
<label for="post-content">What are you sharing?</label>
|
||||
@ -226,8 +157,7 @@
|
||||
<button type="submit" class="btn btn-primary">Post</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import modal %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/gists.css">
|
||||
<link rel="stylesheet" href="/static/css/post.css">
|
||||
@ -45,10 +46,7 @@
|
||||
<div class="gist-detail-actions">
|
||||
<button type="button" class="gist-star-btn" data-share="/gists/{{ gist['slug'] or gist['uid'] }}">🔗 Share</button>
|
||||
{% if user %}
|
||||
<form method="POST" action="/votes/gist/{{ gist['uid'] }}" style="display:inline;">
|
||||
<input type="hidden" name="value" value="1">
|
||||
<button type="submit" class="gist-star-btn">☆ <span class="vote-count-value" data-vote-count="{{ gist['uid'] }}">{{ star_count }}</span></button>
|
||||
</form>
|
||||
{% set _type = "gist" %}{% set _uid = gist['uid'] %}{% set _my_vote = my_vote %}{% set _count = star_count %}{% set _btn_class = "gist-star-btn" %}{% include "_star_vote.html" %}
|
||||
{% endif %}
|
||||
{% if is_owner %}
|
||||
<button class="gist-star-btn" data-modal="edit-gist-modal"><span class="icon">✏️</span>Edit</button>
|
||||
@ -60,12 +58,7 @@
|
||||
</article>
|
||||
|
||||
{% if is_owner %}
|
||||
<div class="modal-overlay" id="edit-gist-modal">
|
||||
<div class="card modal-card modal-card-wide">
|
||||
<div class="modal-header">
|
||||
<h3>Edit Gist</h3>
|
||||
<button type="button" class="modal-close btn-ghost btn-icon modal-close-btn">×</button>
|
||||
</div>
|
||||
{% call modal('edit-gist-modal', 'Edit Gist', wide=true) %}
|
||||
<form method="POST" action="/gists/edit/{{ gist['slug'] or gist['uid'] }}">
|
||||
<div class="auth-field auth-field-gap">
|
||||
<label for="edit-gist-title">Title</label>
|
||||
@ -93,8 +86,7 @@
|
||||
<button type="submit" class="btn btn-primary"><span class="icon">💾</span>Save Changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% endif %}
|
||||
|
||||
{% with target_uid=gist['uid'], target_type="gist" %}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import modal %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/feed.css">
|
||||
<link rel="stylesheet" href="/static/css/gists.css">
|
||||
@ -55,10 +56,7 @@
|
||||
{% include "_card_link.html" %}
|
||||
<div class="gist-card-header">
|
||||
<h3 class="gist-card-title">{{ item.gist['title'] }}</h3>
|
||||
<form method="POST" action="/votes/gist/{{ item.gist['uid'] }}" class="inline-form" data-stop-propagation>
|
||||
<input type="hidden" name="value" value="1">
|
||||
<button type="submit" class="gist-card-star">☆ <span class="vote-count-value" data-vote-count="{{ item.gist['uid'] }}">{{ item.gist.get('stars', 0) }}</span></button>
|
||||
</form>
|
||||
{% set _type = "gist" %}{% set _uid = item.gist['uid'] %}{% set _my_vote = item.my_vote %}{% set _count = item.gist.get('stars', 0) %}{% set _btn_class = "gist-card-star" %}{% set _stop = True %}{% include "_star_vote.html" %}
|
||||
</div>
|
||||
|
||||
<div class="gist-card-meta">
|
||||
@ -87,13 +85,7 @@
|
||||
{% if user %}
|
||||
<button class="feed-fab" id="create-gist-btn" title="Create Gist" data-modal="create-gist-modal">+</button>
|
||||
|
||||
<div class="modal-overlay" id="create-gist-modal">
|
||||
<div class="card modal-card modal-card-wide">
|
||||
<div class="modal-header">
|
||||
<h3>Create Gist</h3>
|
||||
<button type="button" class="modal-close btn-ghost btn-icon modal-close-btn">×</button>
|
||||
</div>
|
||||
|
||||
{% call modal('create-gist-modal', 'Create Gist', wide=true) %}
|
||||
<form method="POST" action="/gists/create">
|
||||
<div class="auth-field auth-field-gap">
|
||||
<label for="gist-title">Title</label>
|
||||
@ -126,8 +118,7 @@
|
||||
<button type="submit" class="btn btn-primary">Create Gist</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
79
devplacepy/templates/leaderboard.html
Normal file
79
devplacepy/templates/leaderboard.html
Normal 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">★</span></span>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="leaderboard-empty">No ranked contributors yet. Earn stars on your posts, projects, and gists to appear here.</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<aside class="feed-right">
|
||||
<div class="featured-news">
|
||||
<h3>Featured</h3>
|
||||
{% for article in featured_news %}
|
||||
<a class="featured-news-item" href="/news/{{ article['slug'] }}">
|
||||
<span class="featured-news-title">{{ article['title'] }}</span>
|
||||
<span class="featured-news-meta">{{ article['source_name'] }}{% if article['time_ago'] %} · {{ article['time_ago'] }}{% endif %}</span>
|
||||
</a>
|
||||
{% else %}
|
||||
<div class="no-authors-msg">No featured articles yet</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@ -1,4 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import modal %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/feed.css">
|
||||
<link rel="stylesheet" href="/static/css/post.css">
|
||||
@ -36,17 +37,7 @@
|
||||
{% endif %}
|
||||
|
||||
<div class="post-detail-actions">
|
||||
<div class="post-votes">
|
||||
<form method="POST" action="/votes/post/{{ post['uid'] }}" class="inline-form">
|
||||
<input type="hidden" name="value" value="1">
|
||||
<button type="submit" class="post-action-btn vote-up">+</button>
|
||||
</form>
|
||||
<span class="post-vote-count" data-vote-count="{{ post['uid'] }}">{{ post.get('stars', 0) }}</span>
|
||||
<form method="POST" action="/votes/post/{{ post['uid'] }}" class="inline-form">
|
||||
<input type="hidden" name="value" value="-1">
|
||||
<button type="submit" class="post-action-btn vote-down">−</button>
|
||||
</form>
|
||||
</div>
|
||||
{% set _uid = post['uid'] %}{% set _my_vote = my_vote %}{% set _count = post.get('stars', 0) %}{% include "_post_votes.html" %}
|
||||
<button type="button" class="post-action-btn" data-share="/posts/{{ post['slug'] or post['uid'] }}">🔗 Share</button>
|
||||
{% if user and post['user_uid'] == user['uid'] %}
|
||||
<button class="post-action-btn" data-modal="edit-post-modal"><span class="icon">✏️</span>Edit</button>
|
||||
@ -58,21 +49,9 @@
|
||||
</article>
|
||||
|
||||
{% if user and post['user_uid'] == user['uid'] %}
|
||||
<div class="modal-overlay" id="edit-post-modal">
|
||||
<div class="card modal-card">
|
||||
<div class="modal-header">
|
||||
<h3>Edit Post</h3>
|
||||
<button type="button" class="modal-close btn-ghost btn-icon modal-close-btn">×</button>
|
||||
</div>
|
||||
{% call modal('edit-post-modal', 'Edit Post') %}
|
||||
<form id="edit-post-form" method="POST" action="/posts/edit/{{ post['slug'] or post['uid'] }}">
|
||||
<div class="form-row">
|
||||
{% for t in topics %}
|
||||
<label class="topic-label">
|
||||
<input type="radio" name="topic" value="{{ t }}" {% if t == post['topic'] %}checked{% endif %} class="topic-radio">
|
||||
{{ t|capitalize }}
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% set _topics = topics %}{% set _selected = post['topic'] %}{% include "_topic_selector.html" %}
|
||||
<div class="auth-field auth-field-gap">
|
||||
<label for="edit-title">Title</label>
|
||||
<input type="text" id="edit-title" name="title" maxlength="500" value="{{ post.get('title', '') }}">
|
||||
@ -87,8 +66,7 @@
|
||||
<button type="submit" class="btn btn-primary"><span class="icon">💾</span>Save Changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% endif %}
|
||||
|
||||
{% with target_uid=post['uid'], target_type="post" %}
|
||||
|
||||
@ -4,9 +4,6 @@
|
||||
<link rel="stylesheet" href="/static/css/profile.css">
|
||||
<link rel="stylesheet" href="/static/css/projects.css">
|
||||
<link rel="stylesheet" href="/static/css/gists.css">
|
||||
<style>
|
||||
.hidden { display: none !important; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="profile-layout">
|
||||
@ -33,6 +30,10 @@
|
||||
<span class="profile-stat-value">{{ profile_user.get('stars', 0) }}</span>
|
||||
<span class="profile-stat-label">Stars</span>
|
||||
</div>
|
||||
<div class="profile-stat">
|
||||
<a href="/leaderboard" class="profile-stat-value">{% if rank %}#{{ rank }}{% else %}—{% endif %}</a>
|
||||
<span class="profile-stat-label">Rank</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="profile-level-bar">
|
||||
@ -47,9 +48,11 @@
|
||||
|
||||
<div class="profile-badges">
|
||||
{% for badge in badges %}
|
||||
<span class="profile-badge">{{ badge['badge_name'] }}</span>
|
||||
{% set meta = badge_info(badge['badge_name']) %}
|
||||
<span class="profile-badge" title="{{ meta['description'] }}"><span class="profile-badge-icon">{{ meta['icon'] }}</span>{{ badge['badge_name'] }}</span>
|
||||
{% else %}
|
||||
<span class="profile-badge">Member</span>
|
||||
{% set meta = badge_info('Member') %}
|
||||
<span class="profile-badge" title="{{ meta['description'] }}"><span class="profile-badge-icon">{{ meta['icon'] }}</span>Member</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
@ -155,46 +158,7 @@
|
||||
<div class="profile-posts">
|
||||
{% if current_tab == 'posts' %}
|
||||
{% for item in posts %}
|
||||
<article class="post-card fade-in">
|
||||
<div class="post-header">
|
||||
<a href="/profile/{{ profile_user['username'] }}">
|
||||
<img src="{{ avatar_url('multiavatar', profile_user['username'], 32) }}" class="avatar-img avatar-sm" alt="{{ profile_user['username'] }}" loading="lazy">
|
||||
</a>
|
||||
<div class="post-author-wrap">
|
||||
<a href="/profile/{{ profile_user['username'] }}" class="post-author-link">{{ profile_user['username'] }}</a>
|
||||
{% if profile_user.get('role') %}
|
||||
<span class="post-author-role">{{ profile_user['role'] }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<span class="post-time">{{ item.time_ago }}</span>
|
||||
</div>
|
||||
<div class="post-topic">
|
||||
<span class="badge badge-{{ item.post['topic'] }}">{{ item.post['topic'] }}</span>
|
||||
</div>
|
||||
{% if item.post.get('title') %}
|
||||
<a href="/posts/{{ item.post['slug'] or item.post['uid'] }}" class="post-title-link">
|
||||
<h3 class="post-title">{{ item.post['title'] }}</h3>
|
||||
</a>
|
||||
{% endif %}
|
||||
<div class="post-content rendered-content" data-render onclick="if (!event.target.closest('a')) window.location.href='/posts/{{ item.post['slug'] or item.post['uid'] }}'">{{ item.post['content'][:300] }}{% if item.post['content']|length > 300 %}...{% endif %}</div>
|
||||
<div class="post-actions">
|
||||
<div class="post-votes">
|
||||
<form method="POST" action="/votes/post/{{ item.post['uid'] }}" class="inline-form">
|
||||
<input type="hidden" name="value" value="1">
|
||||
<button type="submit" class="post-action-btn vote-up">+</button>
|
||||
</form>
|
||||
<span class="post-vote-count" data-vote-count="{{ item.post['uid'] }}">{{ item.post.get('stars', 0) }}</span>
|
||||
<form method="POST" action="/votes/post/{{ item.post['uid'] }}" class="inline-form">
|
||||
<input type="hidden" name="value" value="-1">
|
||||
<button type="submit" class="post-action-btn vote-down">−</button>
|
||||
</form>
|
||||
</div>
|
||||
<a href="/posts/{{ item.post['slug'] or item.post['uid'] }}" class="post-action-btn">
|
||||
💬 {{ item.comment_count }}
|
||||
</a>
|
||||
<a href="/posts/{{ item.post['slug'] or item.post['uid'] }}" class="post-action-btn share">↗︎ Open</a>
|
||||
</div>
|
||||
</article>
|
||||
{% set _author = profile_user %}{% set _time = item.time_ago %}{% set _show_share = false %}{% set _show_comment_form = false %}{% include "_post_card.html" %}
|
||||
{% else %}
|
||||
<div class="empty-state">No posts yet.</div>
|
||||
{% endfor %}
|
||||
|
||||
@ -2,85 +2,6 @@
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/projects.css">
|
||||
<link rel="stylesheet" href="/static/css/post.css">
|
||||
<style>
|
||||
.project-detail-page {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.project-detail {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.5rem;
|
||||
}
|
||||
.project-detail-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.project-detail-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.project-detail-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.project-detail-meta span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.project-detail-author {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.project-detail-author a {
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.project-detail-desc {
|
||||
font-size: 0.9375rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.7;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.project-detail-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.project-star-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.project-star-btn:hover {
|
||||
background: var(--bg-card-hover);
|
||||
color: var(--warning);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="project-detail-page">
|
||||
@ -134,10 +55,7 @@
|
||||
<div class="project-detail-actions">
|
||||
<button type="button" class="project-star-btn" data-share="/projects/{{ project['slug'] or project['uid'] }}">🔗 Share</button>
|
||||
{% if user %}
|
||||
<form method="POST" action="/votes/project/{{ project['uid'] }}" style="display:inline;">
|
||||
<input type="hidden" name="value" value="1">
|
||||
<button type="submit" class="project-star-btn">☆ <span class="vote-count-value" data-vote-count="{{ project['uid'] }}">{{ star_count }}</span></button>
|
||||
</form>
|
||||
{% set _type = "project" %}{% set _uid = project['uid'] %}{% set _my_vote = my_vote %}{% set _count = star_count %}{% set _btn_class = "project-star-btn" %}{% include "_star_vote.html" %}
|
||||
{% endif %}
|
||||
{% if is_owner %}
|
||||
<form method="POST" action="/projects/delete/{{ project['slug'] or project['uid'] }}" style="display:inline;">
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import modal %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/feed.css">
|
||||
<link rel="stylesheet" href="/static/css/projects.css">
|
||||
@ -59,10 +60,7 @@
|
||||
{% include "_card_link.html" %}
|
||||
<div class="project-card-header">
|
||||
<h3 class="project-card-title">{{ project['title'] }}</h3>
|
||||
<form method="POST" action="/votes/project/{{ project['uid'] }}" class="inline-form">
|
||||
<input type="hidden" name="value" value="1">
|
||||
<button type="submit" class="project-card-star">☆ <span class="vote-count-value" data-vote-count="{{ project['uid'] }}">{{ project.get('stars', 0) }}</span></button>
|
||||
</form>
|
||||
{% set _type = "project" %}{% set _uid = project['uid'] %}{% set _my_vote = project.my_vote %}{% set _count = project.get('stars', 0) %}{% set _btn_class = "project-card-star" %}{% include "_star_vote.html" %}
|
||||
</div>
|
||||
|
||||
<div class="project-card-meta">
|
||||
@ -98,13 +96,7 @@
|
||||
{% if user %}
|
||||
<button class="feed-fab" id="create-project-btn" title="Add Project" data-modal="create-project-modal">+</button>
|
||||
|
||||
<div class="modal-overlay" id="create-project-modal">
|
||||
<div class="card modal-card">
|
||||
<div class="modal-header">
|
||||
<h3>Create Project</h3>
|
||||
<button type="button" class="modal-close btn-ghost btn-icon modal-close-btn">×</button>
|
||||
</div>
|
||||
|
||||
{% call modal('create-project-modal', 'Create Project') %}
|
||||
<form method="POST" action="/projects/create">
|
||||
<div class="auth-field auth-field-gap">
|
||||
<label for="title">Title</label>
|
||||
@ -170,7 +162,6 @@
|
||||
<button type="submit" class="btn btn-primary">Create Project</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@ -5,6 +5,7 @@ from devplacepy.constants import TOPICS
|
||||
from devplacepy.database import get_table
|
||||
from devplacepy.avatar import avatar_url
|
||||
from devplacepy.utils import format_date as _format_date
|
||||
from devplacepy.utils import badge_info
|
||||
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
||||
|
||||
_unread_cache = TTLCache(ttl=60)
|
||||
@ -30,6 +31,7 @@ templates.env.globals["get_unread_count"] = jinja_unread_count
|
||||
templates.env.globals["get_user_projects"] = jinja_user_projects
|
||||
templates.env.globals["avatar_url"] = avatar_url
|
||||
templates.env.globals["format_date"] = _format_date
|
||||
templates.env.globals["badge_info"] = badge_info
|
||||
templates.env.globals["TOPICS"] = TOPICS
|
||||
def jinja_max_upload_size_mb():
|
||||
from devplacepy.database import get_int_setting
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
62
tests/test_leaderboard.py
Normal 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()
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user