Update
Some checks failed
DevPlace CI / test (push) Failing after 9m52s

This commit is contained in:
retoor 2026-06-06 16:31:42 +02:00
parent 2eb1e6a004
commit 61c24f7d1e
79 changed files with 2431 additions and 185 deletions

View File

@ -40,6 +40,9 @@ make locust-headless # Locust in headless CLI mode (for CI)
| `/messages` | `routers/messages.py` | | `/messages` | `routers/messages.py` |
| `/notifications` | `routers/notifications.py` | | `/notifications` | `routers/notifications.py` |
| `/votes` | `routers/votes.py` | | `/votes` | `routers/votes.py` |
| `/reactions` | `routers/reactions.py` |
| `/bookmarks` | `routers/bookmarks.py` |
| `/polls` | `routers/polls.py` |
| `/avatar` | `routers/avatar.py` | | `/avatar` | `routers/avatar.py` |
| `/follow` | `routers/follow.py` | | `/follow` | `routers/follow.py` |
| `/leaderboard` | `routers/leaderboard.py` | | `/leaderboard` | `routers/leaderboard.py` |
@ -210,7 +213,7 @@ if "comments" not in db.tables:
- No comments/docstrings in source - code is self-documenting. - No comments/docstrings in source - code is self-documenting.
- Forbidden variable name patterns: `_new`, `_old`, `_temp`, `_v2`, `better_`, `my_`, `the_` (see CLAUDE.md for full list). - Forbidden variable name patterns: `_new`, `_old`, `_temp`, `_v2`, `better_`, `my_`, `the_` (see CLAUDE.md for full list).
- Form validation uses Pydantic models in `models.py` via `Annotated[Model, Form()]` params; invalid input is caught by the global `RequestValidationError` handler in `main.py` (auth pages re-render with messages at 400, other routes redirect). - Form validation uses Pydantic models in `models.py` via `Annotated[Model, Form()]` params; invalid input is caught by the global `RequestValidationError` handler in `main.py` (auth pages re-render with messages at 400, other routes redirect).
- Template globals: `get_unread_count(user_uid)`, `get_user_projects(user_uid)`, `avatar_url(style, seed, size)`, `format_date(dt_str, include_time=False)` (ISO → `DD/MM/YYYY` or `DD/MM/YYYY HH:MM`). - Template globals: `get_unread_count(user_uid)`, `get_unread_messages(user_uid)`, `get_user_projects(user_uid)`, `avatar_url(style, seed, size)`, `format_date(dt_str, include_time=False)` (ISO → `DD/MM/YYYY` or `DD/MM/YYYY HH:MM`).
- `DEVPLACE_DATABASE_URL` env var overrides the SQLite path (used by tests). - `DEVPLACE_DATABASE_URL` env var overrides the SQLite path (used by tests).
- All `RedirectResponse` must use `status_code=302` (integer, not `status` module). - All `RedirectResponse` must use `status_code=302` (integer, not `status` module).
- All `dataset` operations are synchronous and run in the async event loop - keep them fast. No external HTTP calls in request handlers. - All `dataset` operations are synchronous and run in the async event loop - keep them fast. No external HTTP calls in request handlers.
@ -291,6 +294,13 @@ page.locator("button:has-text('Delete')")
- **`bob` fixture logs in bob_test** in a separate Playwright context. - **`bob` fixture logs in bob_test** in a separate Playwright context.
- **Use `alice` for single-user tests.** It returns `(page, user_dict)`. - **Use `alice` for single-user tests.** It returns `(page, user_dict)`.
### Global `site_settings` tests
- **The uvicorn server is shared for the whole session.** A test that flips a global setting (maintenance mode, registration, rate limit, session length) changes behavior for every later test, so **restore the value in a `try/finally`** — see `tests/test_operational.py`.
- **Saving through the admin form is the reliable mutation path**, not a direct DB write: the form handler runs in the server process and calls `clear_settings_cache()`, so the change takes effect immediately (a direct DB write waits out the 60s server-side cache).
- **`conftest.py`'s `app_server` seeds `rate_limit_per_minute="1000000"`** so the suite is never throttled and the settings form round-trips a safe value instead of the template's `"60"` default.
- **Rate-limit unit tests patch `m.get_int_setting`**, not the `RATE_LIMIT` constant — the constant is now only the fallback default passed to `get_int_setting`.
### Failure handling ### Failure handling
- **Failure screenshots auto-save** to `/tmp/devplace_test_screenshots/`. - **Failure screenshots auto-save** to `/tmp/devplace_test_screenshots/`.
@ -324,7 +334,11 @@ page.locator("button:has-text('Delete')")
## Notification System ## Notification System
Notifications are created server-side in the route handlers and stored in the `notifications` table. The unread count is cached per-process in `_unread_cache`. Notifications are created server-side in the route handlers and stored in the `notifications` table. Unread counts are cached per-process in `_unread_cache` (notifications) and `_messages_cache` (messages), both 10s TTL in `templating.py`. Mutating routes call `clear_unread_cache(uid)` / `clear_messages_cache(uid)` to invalidate.
### Live counter badges
The top-nav bell and Messages link carry `data-counter="notifications"` / `data-counter="messages"` with a child `<span data-counter-badge hidden>`. `CounterManager.js` polls `GET /notifications/counts` (returns `{"notifications", "messages"}`, zeros for guests) every 30s and on tab focus, then sets each badge's text and toggles its `hidden` attribute. Server-rendered counts seed the initial state, so the badges are correct on first paint and self-heal without a reload. Absolute badges use `.nav-badge`; inline badges (next to text links) add `.nav-badge-inline`.
### Notification types and trigger points ### Notification types and trigger points
@ -496,10 +510,34 @@ Implements `BaseService`:
| Key | Default | Purpose | | Key | Default | Purpose |
|-----|---------|---------| |-----|---------|---------|
| `site_name` / `site_description` / `site_tagline` | DevPlace branding | General site metadata |
| `news_grade_threshold` | `"7"` | Minimum AI grade for auto-publish | | `news_grade_threshold` | `"7"` | Minimum AI grade for auto-publish |
| `news_api_url` | `"https://news.app.molodetz.nl/api"` | News source API | | `news_api_url` | `"https://news.app.molodetz.nl/api"` | News source API |
| `news_ai_url` | `"https://openai.app.molodetz.nl/v1/chat/completions"` | AI grading endpoint | | `news_ai_url` | `"https://openai.app.molodetz.nl/v1/chat/completions"` | AI grading endpoint |
| `news_ai_model` | `"molodetz"` | AI model identifier | | `news_ai_model` | `"molodetz"` | AI model identifier |
| `max_upload_size_mb` / `allowed_file_types` / `max_attachments_per_resource` | `"10"` / `""` / `"10"` | Upload limits |
| `rate_limit_per_minute` | `"60"` | Mutating requests per IP per window (`main.py` middleware) |
| `rate_limit_window_seconds` | `"60"` | Rolling window for the rate limit |
| `news_service_interval` | `"3600"` | Seconds between news fetch cycles (`NewsService.run_once` re-reads each cycle) |
| `session_max_age_days` | `"7"` | Standard session cookie + DB session lifetime |
| `session_remember_days` | `"30"` | Remember-me session lifetime |
| `registration_open` | `"1"` | When `"0"`, signup GET shows a closed notice and POST is rejected (`auth.py`) |
| `maintenance_mode` | `"0"` | When `"1"`, non-admins get a 503 (`main.py` maintenance middleware) |
| `maintenance_message` | scheduled-maintenance text | Body shown on the maintenance 503 page |
The seed block in `database.py` is guarded by `if "site_settings" in tables:` — on a brand-new DB the table does not exist yet (dataset creates tables lazily on first insert), so none of these rows are written until the table exists. Correct runtime behavior therefore relies on every consumer passing the production default to `get_setting`/`get_int_setting`, not on the seed.
### Operational settings — read sites and rules
| Setting(s) | Read at | Notes |
|-----------|---------|-------|
| `rate_limit_*` | `rate_limit_middleware` in `main.py` | `max(1, get_int_setting(...))` so `0` can't block all writes |
| `maintenance_mode` / `maintenance_message` | `maintenance_middleware` in `main.py` | Allows `/static`, `/avatar`, `/auth`, `/admin` and admins; everyone else gets `error.html` at 503 |
| `news_service_interval` | top of `NewsService.run_once` | `max(60, …)`; `BaseService._run_loop` reads `self.interval_seconds` for its sleep after each cycle, so a change applies next cycle |
| `session_max_age_days` / `session_remember_days` | `auth.py` signup + login | Multiplied by `SECONDS_PER_DAY`; passed to `create_session(uid, max_age)` so the cookie and the DB session row expire together |
| `registration_open` | `auth.py` `signup_page` (GET) and `signup` (POST) | POST returns before any DB write when closed |
**Booleans are `<select>`, never checkboxes.** The settings save handler (`admin.py`) skips empty form values so empty fields don't clobber existing rows. An unchecked checkbox submits nothing, so it could never be turned off — `registration_open` and `maintenance_mode` use `<option value="1">`/`<option value="0">` so a value is always submitted.
### Adding a new service ### Adding a new service
@ -692,3 +730,29 @@ All SEO features are implemented across the following locations:
### SEO tests ### SEO tests
- `tests/test_seo.py` - 13 tests covering: robots.txt, sitemap.xml, page titles, noindex, canonical URLs, OG tags, Twitter cards, structured data, security headers - `tests/test_seo.py` - 13 tests covering: robots.txt, sitemap.xml, page titles, noindex, canonical URLs, OG tags, Twitter cards, structured data, security headers
## Engagement: reactions, bookmarks, polls, contribution heatmap
### Emoji reactions
- Curated palette only: `REACTION_EMOJI` in `constants.py` (registered as a template global). `ReactionForm` rejects anything outside it; free-text emoji are not allowed.
- Endpoint `POST /reactions/{target_type}/{target_uid}` (`routers/reactions.py`) toggles one `(user, target, emoji)` row in the `reactions` table. Target types: `post`, `comment`, `gist`, `project`. AJAX (`x-requested-with: fetch`) returns `{counts, mine}`.
- Reactions are **non-ranking** — they never touch `stars` or XP and intentionally send **no notifications** (votes already notify; reactions would be notification spam).
- Batch reads via `get_reactions_by_targets(target_type, uids, user)` in `database.py` (used by feed, profile, comment loader, `load_detail`) — never per-row. The `_reaction_bar.html` partial takes `_type`, `_uid`, `_reactions` ({counts, mine}) and renders the full palette as toggle chips; `ReactionBar.js` uses document-level click delegation.
### Bookmarks
- `POST /bookmarks/{target_type}/{target_uid}` toggles a `bookmarks` row; `GET /bookmarks/saved` renders the personal list (`saved.html`). Target types: `post`, `gist`, `project`, `news`.
- `_bookmark_button.html` takes `_type`, `_uid`, `_bookmarked`; `BookmarkManager.js` swaps the label/`bookmarked` class from the JSON `{saved}`. Batch state via `get_user_bookmarks(user_uid, target_type, uids)`.
### Polls
- A poll rides on a post (one `polls` row keyed by `post_uid`, options in `poll_options`, one-per-user votes in `poll_votes`). Created in `posts.py:create_poll` when `PostForm.poll_question` plus ≥2 non-empty `poll_options` are submitted (capped at 6). The create-post modal in `feed.html` has the builder (`data-poll-toggle` / `data-poll-add-option`).
- `POST /polls/{poll_uid}/vote` toggles the voter's choice and returns the full poll dict: voting on a new option switches, voting the **same** option again **retracts** (one vote per user, like reactions). Results (bars + `%`) are **always shown** to everyone; the chosen option gets `.chosen` + a `✓`. Guests see read-only bars with a "Log in to vote" hint (options `disabled`). Batch reads via `get_polls_by_post_uids(post_uids, user)` / `get_poll_for_post(post_uid, user)`.
- Composer poll builder (`feed.html`, `PollManager.js`): poll inputs are `disabled` until "Add poll" is opened (so a never-opened or "Remove poll"-collapsed builder submits **nothing**); a capture-phase `submit` listener blocks an incomplete poll (question + ≥2 options) with an inline error instead of silently dropping it — capture phase + `stopImmediatePropagation()` is required so it pre-empts `FormManager`'s bubble-phase submit handlers.
### Cascade
- `delete_engagement(target_type, uids)` in `database.py` removes reactions, bookmarks and (for posts) poll rows. It is called from `content.py:delete_content_item` (for the item and its comments) and `comments.py:delete_comment`. Add it to any new delete path.
### Contribution heatmap + streaks
- Derived, **no new table**: `get_activity_calendar(user_uid)` aggregates `date(created_at)` across `posts`/`comments`/`gists`/`projects` (cached 120s); `get_activity_heatmap` returns 53 Monday-aligned weeks of `{date, count, level}` (level 04); `get_streaks` returns `{current, longest}`. Rendered server-side in `profile.html` (`.heatmap-grid`, styles in `profile.css`). The `On Fire` badge is awarded in `check_milestone_badges` when the current streak ≥ 7.
### CSS
- Reactions, bookmarks, polls and the saved page live in `static/css/engagement.css`, loaded globally in `base.html` (the controls appear across feed, detail pages and comments). Heatmap styles are in `profile.css`.

View File

@ -59,8 +59,11 @@ devplacepy/
| `/projects` | Project listing, creation | | `/projects` | Project listing, creation |
| `/profile` | Profile view, editing | | `/profile` | Profile view, editing |
| `/messages` | Direct messaging | | `/messages` | Direct messaging |
| `/notifications` | Notification list, mark read | | `/notifications` | Notification list, mark read, live unread counts (`/notifications/counts`) |
| `/votes` | Upvote/downvote on posts, comments, projects | | `/votes` | Upvote/downvote on posts, comments, projects |
| `/reactions` | Emoji reactions on posts, comments, gists, projects |
| `/bookmarks` | Save/unsave content; `/bookmarks/saved` personal list |
| `/polls` | Vote on post-attached polls |
| `/follow` | Follow/unfollow users | | `/follow` | Follow/unfollow users |
| `/leaderboard` | Contributor ranking by total stars earned | | `/leaderboard` | Contributor ranking by total stars earned |
| `/avatar` | Multiavatar proxy with in-memory cache | | `/avatar` | Multiavatar proxy with in-memory cache |
@ -76,10 +79,17 @@ 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. - **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. - **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. - **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), a 7-day activity streak (On Fire), 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. - **Leaderboard** (`/leaderboard`) ranks the top 50 members by total stars (single page, no pagination); a member's own rank is shown on their profile.
- **Contribution heatmap and streaks.** Each profile shows a 12-month activity heatmap and the current/longest daily streak, derived from post/comment/gist/project timestamps (no extra storage).
- **Reward notifications** fire when a member levels up or earns a badge. - **Reward notifications** fire when a member levels up or earns a badge.
## Engagement
- **Emoji reactions** — a fixed palette of reactions on posts, comments, gists, and projects, separate from voting and carrying no ranking weight.
- **Polls** — a post can carry a poll (question plus up to six options); results appear as live bars once the viewer votes, one vote per member.
- **Bookmarks** — save posts, gists, projects, and news to a personal list at `/bookmarks/saved`.
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()`). 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 ## Configuration
@ -90,6 +100,23 @@ XP awards are wired at the existing content-creation, vote, and follow hook poin
| `SECRET_KEY` | hardcoded fallback | Session signing key | | `SECRET_KEY` | hardcoded fallback | Session signing key |
| `DEVPLACE_VAPID_SUB` | `mailto:retoor@molodetz.nl` | Contact address in the VAPID JWT `sub` claim | | `DEVPLACE_VAPID_SUB` | `mailto:retoor@molodetz.nl` | Contact address in the VAPID JWT `sub` claim |
### Runtime settings
Operational behavior is tunable live from `/admin/settings` (stored in `site_settings`, no redeploy). The Operational group covers:
| Setting | Default | Effect |
|---------|---------|--------|
| `rate_limit_per_minute` | `60` | Mutating requests allowed per IP per window |
| `rate_limit_window_seconds` | `60` | Rolling window for the request limit |
| `news_service_interval` | `3600` | Seconds between news fetch cycles (min 60) |
| `session_max_age_days` | `7` | Standard session cookie lifetime |
| `session_remember_days` | `30` | Remember-me session cookie lifetime |
| `registration_open` | `1` | When `0`, new sign-ups are rejected |
| `maintenance_mode` | `0` | When `1`, non-admins see the maintenance page; admins retain access |
| `maintenance_message` | scheduled-maintenance text | Message shown during maintenance |
Numeric values are floored to safe minimums so an invalid entry cannot lock out writes or stall services. Consumers read via `get_setting`/`get_int_setting`, which fall back to these defaults when a row is absent.
## Background Services ## Background Services
`devplacepy/services/` provides a generic async service framework. Services start on server boot, run at configurable intervals, and are gracefully cancelled on shutdown. `devplacepy/services/` provides a generic async service framework. Services start on server boot, run at configurable intervals, and are gracefully cancelled on shutdown.
@ -202,7 +229,7 @@ All indexes are created via `CREATE INDEX IF NOT EXISTS` wrapped in try/except -
## Testing ## Testing
- **274 tests** across 23 files: Playwright integration + unit tests - **419 tests** across 34 files: Playwright integration + in-process unit tests
- Playwright (NOT pytest-playwright plugin - conflicts, uninstall it) - Playwright (NOT pytest-playwright plugin - conflicts, uninstall it)
- Server starts as subprocess on port 10501 with isolated temp database - Server starts as subprocess on port 10501 with isolated temp database
- Test users `alice_test` / `bob_test` seeded via HTTP at session start - Test users `alice_test` / `bob_test` seeded via HTTP at session start

View File

@ -9,7 +9,9 @@ STATIC_DIR = BASE_DIR / "devplacepy" / "static"
TEMPLATES_DIR = BASE_DIR / "devplacepy" / "templates" TEMPLATES_DIR = BASE_DIR / "devplacepy" / "templates"
DATABASE_URL = environ.get("DEVPLACE_DATABASE_URL", f"sqlite:///{BASE_DIR / 'devplace.db'}") DATABASE_URL = environ.get("DEVPLACE_DATABASE_URL", f"sqlite:///{BASE_DIR / 'devplace.db'}")
SECRET_KEY = environ.get("SECRET_KEY", "devplace-secret-key-change-in-production") SECRET_KEY = environ.get("SECRET_KEY", "devplace-secret-key-change-in-production")
SESSION_MAX_AGE = 86400 * 7 SECONDS_PER_DAY = 86400
SESSION_MAX_AGE = SECONDS_PER_DAY * 7
SESSION_MAX_AGE_REMEMBER = SECONDS_PER_DAY * 30
PORT = 10500 PORT = 10500
SITE_URL = environ.get("DEVPLACE_SITE_URL", "").rstrip("/") SITE_URL = environ.get("DEVPLACE_SITE_URL", "").rstrip("/")

View File

@ -1 +1,3 @@
TOPICS = ["devlog", "showcase", "question", "rant", "fun", "random", "signals"] TOPICS = ["devlog", "showcase", "question", "rant", "fun", "random", "signals"]
REACTION_EMOJI = ["\U0001F44D", "❤️", "\U0001F680", "\U0001F389", "\U0001F602", "\U0001F440", "\U0001F525", "\U0001F92F"]

View File

@ -9,9 +9,16 @@ from devplacepy.database import (
get_users_by_uids, get_users_by_uids,
get_vote_counts, get_vote_counts,
get_user_votes, get_user_votes,
get_reactions_by_targets,
get_user_bookmarks,
get_poll_for_post,
delete_engagement,
load_comments, load_comments,
db, db,
) )
BOOKMARKABLE_TYPES = {"post", "gist", "project", "news"}
REACTABLE_TYPES = {"post", "comment", "gist", "project"}
from devplacepy.attachments import delete_attachments_for, delete_inline_image, get_attachments, link_attachments from devplacepy.attachments import delete_attachments_for, delete_inline_image, get_attachments, link_attachments
from devplacepy.utils import time_ago, generate_uid, make_combined_slug, award_rewards, create_mention_notifications from devplacepy.utils import time_ago, generate_uid, make_combined_slug, award_rewards, create_mention_notifications
@ -71,6 +78,9 @@ def detail_context(request, user: dict | None, detail: dict, key: str, seo_ctx:
"time_ago": detail["time_ago"], "time_ago": detail["time_ago"],
"comments": detail["comments"], "comments": detail["comments"],
"attachments": detail["attachments"], "attachments": detail["attachments"],
"reactions": detail.get("reactions", {"counts": {}, "mine": []}),
"bookmarked": detail.get("bookmarked", False),
"poll": detail.get("poll"),
} }
if extra: if extra:
context.update(extra) context.update(extra)
@ -104,6 +114,9 @@ def delete_content_item(table_name: str, target_type: str, user: dict, slug: str
votes.delete(target_uid=item["uid"]) votes.delete(target_uid=item["uid"])
if comment_uids: if comment_uids:
votes.delete(votes.table.columns.target_uid.in_(comment_uids), target_type="comment") votes.delete(votes.table.columns.target_uid.in_(comment_uids), target_type="comment")
delete_engagement(target_type, [item["uid"]])
if comment_uids:
delete_engagement("comment", comment_uids)
if inline_image_field: if inline_image_field:
delete_inline_image(item.get(inline_image_field)) delete_inline_image(item.get(inline_image_field))
table.delete(id=item["id"]) table.delete(id=item["id"])
@ -117,6 +130,8 @@ def load_detail(table_name: str, target_type: str, slug: str, user: dict | None)
return None return None
author = get_users_by_uids([item["user_uid"]]).get(item["user_uid"]) author = get_users_by_uids([item["user_uid"]]).get(item["user_uid"])
ups, downs = get_vote_counts([item["uid"]]) ups, downs = get_vote_counts([item["uid"]])
reactions = get_reactions_by_targets(target_type, [item["uid"]], user).get(item["uid"], {"counts": {}, "mine": []}) if target_type in REACTABLE_TYPES else {"counts": {}, "mine": []}
bookmarked = bool(user) and target_type in BOOKMARKABLE_TYPES and item["uid"] in get_user_bookmarks(user["uid"], target_type, [item["uid"]])
return { return {
"item": item, "item": item,
"author": author, "author": author,
@ -126,6 +141,9 @@ def load_detail(table_name: str, target_type: str, slug: str, user: dict | None)
"comments": load_comments(target_type, item["uid"], user), "comments": load_comments(target_type, item["uid"], user),
"attachments": get_attachments(target_type, item["uid"]), "attachments": get_attachments(target_type, item["uid"]),
"time_ago": time_ago(item["created_at"]), "time_ago": time_ago(item["created_at"]),
"reactions": reactions,
"bookmarked": bookmarked,
"poll": get_poll_for_post(item["uid"], user) if target_type == "post" else None,
} }

View File

@ -1,7 +1,7 @@
import dataset import dataset
import logging import logging
from collections import defaultdict from collections import defaultdict
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from devplacepy.cache import TTLCache from devplacepy.cache import TTLCache
from devplacepy.config import DATABASE_URL from devplacepy.config import DATABASE_URL
@ -68,6 +68,14 @@ def init_db():
_index(db, "gists", "idx_gists_language", ["language"]) _index(db, "gists", "idx_gists_language", ["language"])
_index(db, "attachments", "idx_attachments_resource", ["resource_type", "resource_uid"]) _index(db, "attachments", "idx_attachments_resource", ["resource_type", "resource_uid"])
_index(db, "attachments", "idx_attachments_target", ["target_type", "target_uid"]) _index(db, "attachments", "idx_attachments_target", ["target_type", "target_uid"])
_index(db, "reactions", "idx_reactions_target", ["target_type", "target_uid"])
_index(db, "reactions", "idx_reactions_user_target", ["user_uid", "target_type", "target_uid"])
_index(db, "bookmarks", "idx_bookmarks_user", ["user_uid"])
_index(db, "bookmarks", "idx_bookmarks_target", ["target_type", "target_uid"])
_index(db, "polls", "idx_polls_post", ["post_uid"])
_index(db, "poll_options", "idx_poll_options_poll", ["poll_uid"])
_index(db, "poll_votes", "idx_poll_votes_poll", ["poll_uid"])
_index(db, "poll_votes", "idx_poll_votes_user", ["poll_uid", "user_uid"])
if "site_settings" in tables: if "site_settings" in tables:
defaults = {"site_name": "DevPlace", "site_description": "The Developer Social Network", "site_tagline": "Track industry shifts. Discover bold releases. Share what you are building in an open, uncensored environment."} defaults = {"site_name": "DevPlace", "site_description": "The Developer Social Network", "site_tagline": "Track industry shifts. Discover bold releases. Share what you are building in an open, uncensored environment."}
@ -140,6 +148,21 @@ def init_db():
if not existing: if not existing:
db["site_settings"].insert({"uid": f"default_{key}", "key": key, "value": value}) db["site_settings"].insert({"uid": f"default_{key}", "key": key, "value": value})
operational_defaults = {
"rate_limit_per_minute": "60",
"rate_limit_window_seconds": "60",
"news_service_interval": "3600",
"session_max_age_days": "7",
"session_remember_days": "30",
"registration_open": "1",
"maintenance_mode": "0",
"maintenance_message": "DevPlace is undergoing scheduled maintenance. Please check back shortly.",
}
for key, value in operational_defaults.items():
existing = db["site_settings"].find_one(key=key)
if not existing:
db["site_settings"].insert({"uid": f"default_{key}", "key": key, "value": value})
_backfill_gamification() _backfill_gamification()
logger.info("Database initialized") logger.info("Database initialized")
@ -256,12 +279,99 @@ def get_user_votes(user_uid, target_uids):
return {r["target_uid"]: r["value"] for r in rows} return {r["target_uid"]: r["value"] for r in rows}
def get_reactions_by_targets(target_type, target_uids, user=None):
empty = {"counts": {}, "mine": []}
if not target_uids or "reactions" not in db.tables:
return {}
placeholders, params = _in_clause(target_uids)
params["tt"] = target_type
rows = db.query(f"SELECT target_uid, emoji, COUNT(*) as c FROM reactions WHERE target_type=:tt AND target_uid IN ({placeholders}) GROUP BY target_uid, emoji", **params)
counts = defaultdict(dict)
for row in rows:
counts[row["target_uid"]][row["emoji"]] = row["c"]
mine = defaultdict(list)
if user:
my_placeholders, my_params = _in_clause(target_uids, prefix="m")
my_params["tt"] = target_type
my_params["u"] = user["uid"]
for row in db.query(f"SELECT target_uid, emoji FROM reactions WHERE user_uid=:u AND target_type=:tt AND target_uid IN ({my_placeholders})", **my_params):
mine[row["target_uid"]].append(row["emoji"])
result = {}
for uid in target_uids:
result[uid] = {"counts": dict(counts.get(uid, {})), "mine": list(mine.get(uid, []))}
return result
def get_user_bookmarks(user_uid, target_type, target_uids):
if not user_uid or not target_uids or "bookmarks" not in db.tables:
return set()
placeholders, params = _in_clause(target_uids)
params["u"] = user_uid
params["tt"] = target_type
rows = db.query(f"SELECT target_uid FROM bookmarks WHERE user_uid=:u AND target_type=:tt AND target_uid IN ({placeholders})", **params)
return {row["target_uid"] for row in rows}
def get_polls_by_post_uids(post_uids, user=None):
if not post_uids or "polls" not in db.tables:
return {}
placeholders, params = _in_clause(post_uids)
polls = list(db.query(f"SELECT * FROM polls WHERE post_uid IN ({placeholders})", **params))
if not polls:
return {}
poll_uids = [poll["uid"] for poll in polls]
option_placeholders, option_params = _in_clause(poll_uids, prefix="o")
options = list(db.query(f"SELECT * FROM poll_options WHERE poll_uid IN ({option_placeholders}) ORDER BY position", **option_params))
counts = defaultdict(dict)
totals = defaultdict(int)
if "poll_votes" in db.tables:
vote_placeholders, vote_params = _in_clause(poll_uids, prefix="v")
for row in db.query(f"SELECT poll_uid, option_uid, COUNT(*) as c FROM poll_votes WHERE poll_uid IN ({vote_placeholders}) GROUP BY poll_uid, option_uid", **vote_params):
counts[row["poll_uid"]][row["option_uid"]] = row["c"]
totals[row["poll_uid"]] += row["c"]
my_choice = {}
if user and "poll_votes" in db.tables:
my_placeholders, my_params = _in_clause(poll_uids, prefix="m")
my_params["u"] = user["uid"]
for row in db.query(f"SELECT poll_uid, option_uid FROM poll_votes WHERE user_uid=:u AND poll_uid IN ({my_placeholders})", **my_params):
my_choice[row["poll_uid"]] = row["option_uid"]
options_by_poll = defaultdict(list)
for option in options:
options_by_poll[option["poll_uid"]].append(option)
result = {}
for poll in polls:
poll_uid = poll["uid"]
total = totals.get(poll_uid, 0)
rendered = []
for option in options_by_poll.get(poll_uid, []):
count = counts.get(poll_uid, {}).get(option["uid"], 0)
rendered.append({
"uid": option["uid"],
"label": option["label"],
"count": count,
"pct": round(count * 100 / total) if total else 0,
})
result[poll["post_uid"]] = {
"uid": poll_uid,
"question": poll["question"],
"options": rendered,
"total": total,
"my_choice": my_choice.get(poll_uid),
}
return result
def get_poll_for_post(post_uid, user=None):
return get_polls_by_post_uids([post_uid], user).get(post_uid)
def _build_comment_items(raw, user=None): def _build_comment_items(raw, user=None):
uids = [c["user_uid"] for c in raw] uids = [c["user_uid"] for c in raw]
cids = [c["uid"] for c in raw] cids = [c["uid"] for c in raw]
users = get_users_by_uids(uids) users = get_users_by_uids(uids)
ups, downs = get_vote_counts(cids) ups, downs = get_vote_counts(cids)
my_votes = get_user_votes(user["uid"], cids) if user else {} my_votes = get_user_votes(user["uid"], cids) if user else {}
reactions = get_reactions_by_targets("comment", cids, user)
from devplacepy.utils import time_ago from devplacepy.utils import time_ago
from devplacepy.attachments import get_attachments_batch as _gab from devplacepy.attachments import get_attachments_batch as _gab
atts_map = _gab("comment", cids) if "attachments" in db.tables else {} atts_map = _gab("comment", cids) if "attachments" in db.tables else {}
@ -275,6 +385,7 @@ def _build_comment_items(raw, user=None):
"my_vote": my_votes.get(c["uid"], 0), "my_vote": my_votes.get(c["uid"], 0),
"children": [], "children": [],
"attachments": atts_map.get(c["uid"], []), "attachments": atts_map.get(c["uid"], []),
"reactions": reactions.get(c["uid"], {"counts": {}, "mine": []}),
} }
return items return items
@ -434,6 +545,89 @@ def get_site_stats() -> dict:
return stats return stats
_activity_cache = TTLCache(ttl=120)
_ACTIVITY_TABLES = ("posts", "comments", "gists", "projects")
def get_activity_calendar(user_uid: str) -> dict:
cached = _activity_cache.get(user_uid)
if cached is not None:
return cached
sources = [table for table in _ACTIVITY_TABLES if table in db.tables]
calendar: dict[str, int] = {}
if sources:
cutoff = (datetime.now(timezone.utc) - timedelta(days=364)).date().isoformat()
union = " UNION ALL ".join(f"SELECT created_at FROM {table} WHERE user_uid = :u" for table in sources)
rows = db.query(
f"SELECT date(created_at) AS day, COUNT(*) AS c FROM ({union}) WHERE date(created_at) >= :cutoff GROUP BY day",
u=user_uid, cutoff=cutoff,
)
for row in rows:
if row["day"]:
calendar[row["day"]] = row["c"]
_activity_cache.set(user_uid, calendar)
return calendar
def _activity_level(count: int) -> int:
if count <= 0:
return 0
if count == 1:
return 1
if count <= 3:
return 2
if count <= 6:
return 3
return 4
def get_activity_heatmap(user_uid: str) -> list:
calendar = get_activity_calendar(user_uid)
today = datetime.now(timezone.utc).date()
start = today - timedelta(days=363)
start = start - timedelta(days=start.weekday())
weeks = []
week = []
day = start
while day <= today:
iso = day.isoformat()
count = calendar.get(iso, 0)
week.append({"date": iso, "count": count, "level": _activity_level(count)})
if len(week) == 7:
weeks.append(week)
week = []
day = day + timedelta(days=1)
if week:
weeks.append(week)
return weeks
def get_streaks(user_uid: str) -> dict:
calendar = get_activity_calendar(user_uid)
if not calendar:
return {"current": 0, "longest": 0}
dates = sorted(datetime.fromisoformat(day).date() for day in calendar)
date_set = set(dates)
longest = 1
run = 1
for index in range(1, len(dates)):
if (dates[index] - dates[index - 1]).days == 1:
run += 1
else:
run = 1
longest = max(longest, run)
today = datetime.now(timezone.utc).date()
cursor = today
if today not in date_set and (today - timedelta(days=1)) in date_set:
cursor = today - timedelta(days=1)
current = 0
while cursor in date_set:
current += 1
cursor = cursor - timedelta(days=1)
return {"current": current, "longest": longest}
_gist_languages_cache = TTLCache(ttl=60) _gist_languages_cache = TTLCache(ttl=60)
@ -563,6 +757,28 @@ def update_target_stars(target_type: str, target_uid: str, net_stars: int) -> No
_authors_cache.clear() _authors_cache.clear()
def delete_engagement(target_type: str, target_uids: list) -> None:
uids = [uid for uid in (target_uids or []) if uid]
if not uids:
return
if "reactions" in db.tables:
placeholders, params = _in_clause(uids)
params["tt"] = target_type
db.query(f"DELETE FROM reactions WHERE target_type=:tt AND target_uid IN ({placeholders})", **params)
if "bookmarks" in db.tables:
placeholders, params = _in_clause(uids)
params["tt"] = target_type
db.query(f"DELETE FROM bookmarks WHERE target_type=:tt AND target_uid IN ({placeholders})", **params)
if target_type == "post" and "polls" in db.tables:
for uid in uids:
for poll in db["polls"].find(post_uid=uid):
if "poll_votes" in db.tables:
db["poll_votes"].delete(poll_uid=poll["uid"])
if "poll_options" in db.tables:
db["poll_options"].delete(poll_uid=poll["uid"])
db["polls"].delete(post_uid=uid)
def get_target_owner_uid(target_type: str, target_uid: str) -> str | None: def get_target_owner_uid(target_type: str, target_uid: str) -> str | None:
table_name = VOTABLE_TARGETS.get(target_type) table_name = VOTABLE_TARGETS.get(target_type)
if not table_name: if not table_name:

View File

@ -9,11 +9,11 @@ from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.exceptions import RequestValidationError from fastapi.exceptions import RequestValidationError
from devplacepy.config import STATIC_DIR, PORT, SERVICE_LOCK_FILE from devplacepy.config import STATIC_DIR, PORT, SERVICE_LOCK_FILE
from devplacepy.database import init_db, get_table, db, refresh_snapshot, get_users_by_uids, get_comment_counts_by_post_uids, get_vote_counts, get_news_images_by_uids from devplacepy.database import init_db, get_table, db, refresh_snapshot, get_users_by_uids, get_comment_counts_by_post_uids, get_vote_counts, get_news_images_by_uids, get_setting, get_int_setting
from devplacepy.templating import templates from devplacepy.templating import templates
from devplacepy.utils import get_current_user, time_ago from devplacepy.utils import get_current_user, time_ago
from devplacepy.seo import base_seo_context, site_url, website_schema 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, leaderboard 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, reactions, bookmarks, polls
from devplacepy.services.manager import service_manager from devplacepy.services.manager import service_manager
from devplacepy.services.news import NewsService from devplacepy.services.news import NewsService
@ -119,6 +119,9 @@ app.include_router(profile.router, prefix="/profile")
app.include_router(messages.router, prefix="/messages") app.include_router(messages.router, prefix="/messages")
app.include_router(notifications.router, prefix="/notifications") app.include_router(notifications.router, prefix="/notifications")
app.include_router(votes.router, prefix="/votes") app.include_router(votes.router, prefix="/votes")
app.include_router(reactions.router, prefix="/reactions")
app.include_router(bookmarks.router, prefix="/bookmarks")
app.include_router(polls.router, prefix="/polls")
app.include_router(avatar.router, prefix="/avatar") app.include_router(avatar.router, prefix="/avatar")
app.include_router(follow.router, prefix="/follow") app.include_router(follow.router, prefix="/follow")
app.include_router(leaderboard.router, prefix="/leaderboard") app.include_router(leaderboard.router, prefix="/leaderboard")
@ -150,16 +153,35 @@ async def add_security_headers(request: Request, call_next):
@app.middleware("http") @app.middleware("http")
async def rate_limit_middleware(request: Request, call_next): async def rate_limit_middleware(request: Request, call_next):
if request.method in ("POST", "PUT", "DELETE", "PATCH"): if request.method in ("POST", "PUT", "DELETE", "PATCH"):
limit = max(1, get_int_setting("rate_limit_per_minute", RATE_LIMIT))
window = max(1, get_int_setting("rate_limit_window_seconds", RATE_WINDOW))
ip = request.headers.get("X-Real-IP") or (request.client.host if request.client else "unknown") ip = request.headers.get("X-Real-IP") or (request.client.host if request.client else "unknown")
now = time.time() now = time.time()
window_start = now - RATE_WINDOW window_start = now - window
_rate_limit_store[ip] = [t for t in _rate_limit_store[ip] if t > window_start] _rate_limit_store[ip] = [t for t in _rate_limit_store[ip] if t > window_start]
if len(_rate_limit_store[ip]) >= RATE_LIMIT: if len(_rate_limit_store[ip]) >= limit:
return HTMLResponse("Rate limit exceeded. Try again later.", status_code=429) return HTMLResponse("Rate limit exceeded. Try again later.", status_code=429)
_rate_limit_store[ip].append(now) _rate_limit_store[ip].append(now)
return await call_next(request) return await call_next(request)
_MAINTENANCE_ALLOWED_PREFIXES = ("/static", "/avatar", "/auth", "/admin")
@app.middleware("http")
async def maintenance_middleware(request: Request, call_next):
if get_setting("maintenance_mode", "0") != "1":
return await call_next(request)
if request.url.path.startswith(_MAINTENANCE_ALLOWED_PREFIXES):
return await call_next(request)
user = get_current_user(request)
if user and user.get("role") == "Admin":
return await call_next(request)
message = get_setting("maintenance_message", "DevPlace is undergoing scheduled maintenance. Please check back shortly.")
seo_ctx = base_seo_context(request, title="Maintenance - DevPlace", description=message, robots="noindex")
return templates.TemplateResponse(request, "error.html", {**seo_ctx, "request": request, "error_code": 503, "error_message": message}, status_code=503)
@app.on_event("startup") @app.on_event("startup")
async def startup(): async def startup():
init_db() init_db()

View File

@ -1,6 +1,6 @@
from typing import Literal from typing import Literal
from pydantic import BaseModel, Field, field_validator, model_validator from pydantic import BaseModel, Field, field_validator, model_validator
from devplacepy.constants import TOPICS from devplacepy.constants import TOPICS, REACTION_EMOJI
class SignupForm(BaseModel): class SignupForm(BaseModel):
@ -65,6 +65,8 @@ class PostForm(BaseModel):
topic: str = "random" topic: str = "random"
project_uid: str = "" project_uid: str = ""
attachment_uids: list[str] = [] attachment_uids: list[str] = []
poll_question: str = Field(default="", max_length=200)
poll_options: list[str] = []
@field_validator("topic") @field_validator("topic")
@classmethod @classmethod
@ -154,6 +156,21 @@ class VoteForm(BaseModel):
return value return value
class ReactionForm(BaseModel):
emoji: str = Field(min_length=1, max_length=16)
@field_validator("emoji")
@classmethod
def valid_emoji(cls, value):
if value not in REACTION_EMOJI:
raise ValueError("Invalid reaction")
return value
class PollVoteForm(BaseModel):
option_uid: str = Field(min_length=1)
class AdminRoleForm(BaseModel): class AdminRoleForm(BaseModel):
role: Literal["member", "admin"] role: Literal["member", "admin"]
@ -174,3 +191,11 @@ class AdminSettingsForm(BaseModel):
max_upload_size_mb: str = Field(default="", max_length=10) max_upload_size_mb: str = Field(default="", max_length=10)
allowed_file_types: str = Field(default="", max_length=1000) allowed_file_types: str = Field(default="", max_length=1000)
max_attachments_per_resource: str = Field(default="", max_length=10) max_attachments_per_resource: str = Field(default="", max_length=10)
rate_limit_per_minute: str = Field(default="", max_length=10)
rate_limit_window_seconds: str = Field(default="", max_length=10)
news_service_interval: str = Field(default="", max_length=10)
session_max_age_days: str = Field(default="", max_length=10)
session_remember_days: str = Field(default="", max_length=10)
registration_open: str = Field(default="", max_length=1)
maintenance_mode: str = Field(default="", max_length=1)
maintenance_message: str = Field(default="", max_length=300)

View File

@ -20,6 +20,7 @@ from cryptography.hazmat.primitives.hashes import SHA256
from cryptography.hazmat.primitives.kdf.hkdf import HKDF from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from devplacepy.config import ( from devplacepy.config import (
SECONDS_PER_DAY,
VAPID_PRIVATE_KEY_FILE, VAPID_PRIVATE_KEY_FILE,
VAPID_PRIVATE_KEY_PKCS8_FILE, VAPID_PRIVATE_KEY_PKCS8_FILE,
VAPID_PUBLIC_KEY_FILE, VAPID_PUBLIC_KEY_FILE,
@ -31,7 +32,7 @@ from devplacepy.utils import generate_uid
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
JWT_LIFETIME_SECONDS = 60 * 60 JWT_LIFETIME_SECONDS = 60 * 60
PUSH_TTL_SECONDS = "86400" PUSH_TTL_SECONDS = str(SECONDS_PER_DAY)
DEAD_SUBSCRIPTION_STATUSES = (404, 410) DEAD_SUBSCRIPTION_STATUSES = (404, 410)
ACCEPTED_STATUSES = (200, 201) ACCEPTED_STATUSES = (200, 201)

View File

@ -5,11 +5,12 @@ from typing import Annotated
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Request, Form from fastapi import APIRouter, Request, Form
from fastapi.responses import RedirectResponse, HTMLResponse from fastapi.responses import RedirectResponse, HTMLResponse
from devplacepy.database import get_table from devplacepy.database import get_table, get_setting, get_int_setting
from devplacepy.templating import templates from devplacepy.templating import templates
from devplacepy.utils import hash_password, verify_password, create_session, generate_uid, get_current_user, award_badge, clear_session_cache, safe_next from devplacepy.utils import hash_password, verify_password, create_session, generate_uid, get_current_user, award_badge, clear_session_cache, safe_next
from devplacepy.seo import base_seo_context from devplacepy.seo import base_seo_context
from devplacepy.models import SignupForm, LoginForm, ForgotPasswordForm, ResetPasswordForm from devplacepy.models import SignupForm, LoginForm, ForgotPasswordForm, ResetPasswordForm
from devplacepy.config import SECONDS_PER_DAY
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@ -26,7 +27,8 @@ async def signup_page(request: Request):
description="Create your DevPlace account and start connecting with developers.", description="Create your DevPlace account and start connecting with developers.",
robots="noindex,nofollow", robots="noindex,nofollow",
) )
return templates.TemplateResponse(request, "signup.html", {**seo_ctx, "request": request}) registration_closed = get_setting("registration_open", "1") != "1"
return templates.TemplateResponse(request, "signup.html", {**seo_ctx, "request": request, "registration_closed": registration_closed})
@router.get("/login", response_class=HTMLResponse) @router.get("/login", response_class=HTMLResponse)
@ -49,6 +51,13 @@ async def signup(request: Request, data: Annotated[SignupForm, Form()]):
email = data.email.strip().lower() email = data.email.strip().lower()
password = data.password password = data.password
if get_setting("registration_open", "1") != "1":
seo_ctx = base_seo_context(request, title="Join DevPlace", robots="noindex,nofollow")
return templates.TemplateResponse(
request, "signup.html",
{**seo_ctx, "request": request, "registration_closed": True},
)
errors = [] errors = []
users = get_table("users") users = get_table("users")
if users.find_one(username=username): if users.find_one(username=username):
@ -84,9 +93,10 @@ async def signup(request: Request, data: Annotated[SignupForm, Form()]):
award_badge(uid, "Member") award_badge(uid, "Member")
token = create_session(uid) max_age = max(1, get_int_setting("session_max_age_days", 7)) * SECONDS_PER_DAY
token = create_session(uid, max_age)
response = RedirectResponse(url="/feed", status_code=302) response = RedirectResponse(url="/feed", status_code=302)
response.set_cookie(key="session", value=token, max_age=86400 * 7, httponly=True, samesite="lax") response.set_cookie(key="session", value=token, max_age=max_age, httponly=True, samesite="lax")
logger.info(f"User {username} signed up") logger.info(f"User {username} signed up")
return response return response
@ -111,8 +121,9 @@ async def login(request: Request, data: Annotated[LoginForm, Form()]):
{**seo_ctx, "request": request, "errors": errors, "email": email, "next_url": safe_next(data.next, "")}, {**seo_ctx, "request": request, "errors": errors, "email": email, "next_url": safe_next(data.next, "")},
) )
token = create_session(user["uid"]) days = get_int_setting("session_remember_days", 30) if remember_me else get_int_setting("session_max_age_days", 7)
max_age = 86400 * 30 if remember_me else 86400 * 7 max_age = max(1, days) * SECONDS_PER_DAY
token = create_session(user["uid"], max_age)
response = RedirectResponse(url=safe_next(data.next), status_code=302) response = RedirectResponse(url=safe_next(data.next), status_code=302)
response.set_cookie(key="session", value=token, max_age=max_age, httponly=True, samesite="lax") response.set_cookie(key="session", value=token, max_age=max_age, httponly=True, samesite="lax")
logger.info(f"User {user['username']} logged in") logger.info(f"User {user['username']} logged in")

View File

@ -4,12 +4,13 @@ from fastapi import APIRouter, Request
from fastapi.responses import Response from fastapi.responses import Response
from devplacepy.avatar import generate_avatar_svg from devplacepy.avatar import generate_avatar_svg
from devplacepy.cache import TTLCache from devplacepy.cache import TTLCache
from devplacepy.config import SECONDS_PER_DAY
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
_cache = TTLCache(ttl=86400, max_size=4096) _cache = TTLCache(ttl=SECONDS_PER_DAY, max_size=4096)
_CACHE_CONTROL = "public, max-age=86400, immutable" _CACHE_CONTROL = f"public, max-age={SECONDS_PER_DAY}, immutable"
@router.get("/{style}/{seed}") @router.get("/{style}/{seed}")

View File

@ -0,0 +1,87 @@
# retoor <retoor@molodetz.nl>
import logging
from datetime import datetime, timezone
from fastapi import APIRouter, Request
from fastapi.responses import RedirectResponse, JSONResponse, HTMLResponse
from devplacepy.database import get_table, db, paginate, resolve_object_url
from devplacepy.templating import templates
from devplacepy.utils import generate_uid, require_user, time_ago
from devplacepy.seo import base_seo_context
logger = logging.getLogger(__name__)
router = APIRouter()
BOOKMARKABLE: set[str] = {"post", "gist", "project", "news"}
TABLE_BY_TYPE: dict[str, str] = {"post": "posts", "gist": "gists", "project": "projects", "news": "news"}
LABEL_BY_TYPE: dict[str, str] = {"post": "Post", "gist": "Gist", "project": "Project", "news": "Article"}
@router.get("/saved", response_class=HTMLResponse)
async def saved_page(request: Request, before: str = None):
user = require_user(request)
bookmarks = get_table("bookmarks")
rows, next_cursor = paginate(bookmarks, before=before, user_uid=user["uid"])
uids_by_type: dict[str, list] = {}
for row in rows:
uids_by_type.setdefault(row["target_type"], []).append(row["target_uid"])
resolved: dict[tuple, dict] = {}
for target_type, uids in uids_by_type.items():
table_name = TABLE_BY_TYPE.get(target_type)
if not table_name or table_name not in db.tables:
continue
table = get_table(table_name)
for obj in table.find(table.table.columns.uid.in_(uids)):
resolved[(target_type, obj["uid"])] = obj
items = []
for row in rows:
obj = resolved.get((row["target_type"], row["target_uid"]))
if not obj:
continue
title = obj.get("title") or (obj.get("content", "") or "")[:80] or "Untitled"
items.append({
"target_type": row["target_type"],
"type_label": LABEL_BY_TYPE.get(row["target_type"], row["target_type"].title()),
"title": title,
"url": resolve_object_url(row["target_type"], row["target_uid"]),
"time_ago": time_ago(row["created_at"]),
})
seo_ctx = base_seo_context(request, title="Saved", description="Your saved content on DevPlace.", robots="noindex,nofollow")
return templates.TemplateResponse(request, "saved.html", {
**seo_ctx,
"request": request,
"user": user,
"items": items,
"next_cursor": next_cursor,
})
@router.post("/{target_type}/{target_uid}")
async def toggle_bookmark(request: Request, target_type: str, target_uid: str):
user = require_user(request)
if target_type not in BOOKMARKABLE:
return JSONResponse({"error": "Invalid target"}, status_code=400)
bookmarks = get_table("bookmarks")
existing = bookmarks.find_one(user_uid=user["uid"], target_uid=target_uid, target_type=target_type)
if existing:
bookmarks.delete(id=existing["id"])
saved = False
else:
bookmarks.insert({
"uid": generate_uid(),
"user_uid": user["uid"],
"target_uid": target_uid,
"target_type": target_type,
"created_at": datetime.now(timezone.utc).isoformat(),
})
saved = True
if request.headers.get("x-requested-with") == "fetch":
return JSONResponse({"saved": saved})
return RedirectResponse(url=request.headers.get("Referer", "/feed"), status_code=302)

View File

@ -3,7 +3,7 @@ from typing import Annotated
from datetime import datetime, timezone from datetime import datetime, timezone
from fastapi import APIRouter, Request, Form from fastapi import APIRouter, Request, Form
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from devplacepy.database import get_table, resolve_object_url from devplacepy.database import get_table, resolve_object_url, delete_engagement
from devplacepy.attachments import link_attachments, delete_target_attachments from devplacepy.attachments import link_attachments, delete_target_attachments
from devplacepy.content import is_owner 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.utils import generate_uid, require_user, create_mention_notifications, create_notification, award_rewards, XP_COMMENT
@ -73,6 +73,7 @@ async def delete_comment(request: Request, comment_uid: str):
target_uid = comment.get("target_uid") or comment.get("post_uid", "") target_uid = comment.get("target_uid") or comment.get("post_uid", "")
delete_target_attachments("comment", comment_uid) delete_target_attachments("comment", comment_uid)
get_table("votes").delete(target_uid=comment_uid, target_type="comment") get_table("votes").delete(target_uid=comment_uid, target_type="comment")
delete_engagement("comment", [comment_uid])
comments.delete(id=comment["id"]) comments.delete(id=comment["id"])
logger.info(f"Comment {comment_uid} deleted by {user['username']}") logger.info(f"Comment {comment_uid} deleted by {user['username']}")
redirect_url = resolve_object_url(target_type, target_uid) redirect_url = resolve_object_url(target_type, target_uid)

View File

@ -1,7 +1,7 @@
import logging import logging
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from devplacepy.database import get_table, get_daily_topic, get_users_by_uids, get_comment_counts_by_post_uids, get_recent_comments_by_post_uids, get_site_stats, get_top_authors, paginate from devplacepy.database import get_table, get_daily_topic, get_users_by_uids, get_comment_counts_by_post_uids, get_recent_comments_by_post_uids, get_site_stats, get_top_authors, get_reactions_by_targets, get_user_bookmarks, get_polls_by_post_uids, paginate
from devplacepy.attachments import get_attachments_batch from devplacepy.attachments import get_attachments_batch
from devplacepy.content import enrich_items from devplacepy.content import enrich_items
from devplacepy.templating import templates from devplacepy.templating import templates
@ -49,10 +49,16 @@ async def feed_page(request: Request, tab: str = "all", topic: str = None, befor
post_uids_list = [item["post"]["uid"] for item in posts] post_uids_list = [item["post"]["uid"] for item in posts]
attachments_map = get_attachments_batch("post", post_uids_list) attachments_map = get_attachments_batch("post", post_uids_list)
recent_comments = get_recent_comments_by_post_uids(post_uids_list, 3, user) recent_comments = get_recent_comments_by_post_uids(post_uids_list, 3, user)
reactions_map = get_reactions_by_targets("post", post_uids_list, user)
bookmark_set = get_user_bookmarks(user["uid"], "post", post_uids_list) if user else set()
polls_map = get_polls_by_post_uids(post_uids_list, user)
for item in posts: for item in posts:
uid = item["post"]["uid"] uid = item["post"]["uid"]
item["attachments"] = attachments_map.get(uid, []) item["attachments"] = attachments_map.get(uid, [])
item["recent_comments"] = recent_comments.get(uid, []) item["recent_comments"] = recent_comments.get(uid, [])
item["reactions"] = reactions_map.get(uid, {"counts": {}, "mine": []})
item["bookmarked"] = uid in bookmark_set
item["poll"] = polls_map.get(uid)
seo_ctx = list_page_seo( seo_ctx = list_page_seo(
request, request,

View File

@ -6,7 +6,7 @@ from devplacepy.models import MessageForm
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
from devplacepy.database import get_table, db from devplacepy.database import get_table, db
from devplacepy.attachments import get_attachments_batch from devplacepy.attachments import get_attachments_batch
from devplacepy.templating import templates, clear_unread_cache from devplacepy.templating import templates, clear_unread_cache, clear_messages_cache
from devplacepy.utils import generate_uid, require_user, time_ago, create_mention_notifications from devplacepy.utils import generate_uid, require_user, time_ago, create_mention_notifications
from devplacepy.seo import base_seo_context from devplacepy.seo import base_seo_context
@ -71,6 +71,7 @@ def get_conversation_messages(user_uid: str, other_uid: str):
if "messages" in db.tables: if "messages" in db.tables:
with db: with db:
db.query("UPDATE messages SET read = 1 WHERE receiver_uid = :me AND sender_uid = :other AND read = 0", me=user_uid, other=other_uid) db.query("UPDATE messages SET read = 1 WHERE receiver_uid = :me AND sender_uid = :other AND read = 0", me=user_uid, other=other_uid)
clear_messages_cache(user_uid)
from devplacepy.database import get_users_by_uids from devplacepy.database import get_users_by_uids
user_ids = list({m["sender_uid"] for m in msgs} | {other_uid}) user_ids = list({m["sender_uid"] for m in msgs} | {other_uid})
@ -182,6 +183,7 @@ async def send_message(request: Request, data: Annotated[MessageForm, Form()]):
"created_at": datetime.now(timezone.utc).isoformat(), "created_at": datetime.now(timezone.utc).isoformat(),
}) })
clear_unread_cache(receiver_uid) clear_unread_cache(receiver_uid)
clear_messages_cache(receiver_uid)
create_mention_notifications(content, user["uid"], f"/messages?with_uid={receiver_uid}") create_mention_notifications(content, user["uid"], f"/messages?with_uid={receiver_uid}")

View File

@ -2,7 +2,7 @@ import logging
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from devplacepy.database import get_table, db, load_comments, resolve_by_slug, get_news_images_by_uids, paginate from devplacepy.database import get_table, db, load_comments, resolve_by_slug, get_news_images_by_uids, get_user_bookmarks, paginate
from devplacepy.templating import templates from devplacepy.templating import templates
from devplacepy.utils import get_current_user, time_ago, not_found from devplacepy.utils import get_current_user, time_ago, not_found
from devplacepy.content import canonical_redirect from devplacepy.content import canonical_redirect
@ -77,6 +77,7 @@ async def news_detail_page(request: Request, news_slug: str):
canonical_slug = article.get("slug", "") or article["uid"] canonical_slug = article.get("slug", "") or article["uid"]
comments = load_comments("news", article["uid"], user) comments = load_comments("news", article["uid"], user)
bookmarked = bool(user) and article["uid"] in get_user_bookmarks(user["uid"], "news", [article["uid"]])
base = site_url(request) base = site_url(request)
page_url = f"{base}/news/{canonical_slug}" page_url = f"{base}/news/{canonical_slug}"
@ -100,4 +101,5 @@ async def news_detail_page(request: Request, news_slug: str):
"grade": article.get("grade", 0), "grade": article.get("grade", 0),
"time_ago": time_ago(article["synced_at"]), "time_ago": time_ago(article["synced_at"]),
"comments": comments, "comments": comments,
"bookmarked": bookmarked,
}) })

View File

@ -1,10 +1,15 @@
import logging import logging
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
from devplacepy.database import get_table, db from devplacepy.database import get_table, db
from devplacepy.templating import templates, clear_unread_cache from devplacepy.templating import (
from devplacepy.utils import require_user, time_ago templates,
clear_unread_cache,
jinja_unread_count,
jinja_unread_messages,
)
from devplacepy.utils import require_user, get_current_user, time_ago
from devplacepy.seo import base_seo_context from devplacepy.seo import base_seo_context
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -98,6 +103,17 @@ async def notifications_page(request: Request, before: str = None):
}) })
@router.get("/counts")
async def unread_counts(request: Request):
user = get_current_user(request)
if not user:
return JSONResponse({"notifications": 0, "messages": 0})
return JSONResponse({
"notifications": jinja_unread_count(user["uid"]),
"messages": jinja_unread_messages(user["uid"]),
})
@router.get("/open/{notification_uid}") @router.get("/open/{notification_uid}")
async def open_notification(request: Request, notification_uid: str): async def open_notification(request: Request, notification_uid: str):
user = require_user(request) user = require_user(request)

View File

@ -0,0 +1,44 @@
# retoor <retoor@molodetz.nl>
import logging
from typing import Annotated
from datetime import datetime, timezone
from fastapi import APIRouter, Request, Form
from fastapi.responses import RedirectResponse, JSONResponse
from devplacepy.database import get_table, get_poll_for_post
from devplacepy.utils import generate_uid, require_user
from devplacepy.models import PollVoteForm
logger = logging.getLogger(__name__)
router = APIRouter()
@router.post("/{poll_uid}/vote")
async def vote_poll(request: Request, poll_uid: str, data: Annotated[PollVoteForm, Form()]):
user = require_user(request)
poll = get_table("polls").find_one(uid=poll_uid)
if not poll:
return JSONResponse({"error": "Poll not found"}, status_code=404)
option = get_table("poll_options").find_one(uid=data.option_uid, poll_uid=poll_uid)
if not option:
return JSONResponse({"error": "Invalid option"}, status_code=400)
poll_votes = get_table("poll_votes")
existing = poll_votes.find_one(poll_uid=poll_uid, user_uid=user["uid"])
if existing:
if existing["option_uid"] == data.option_uid:
poll_votes.delete(id=existing["id"])
else:
poll_votes.update({"id": existing["id"], "option_uid": data.option_uid}, ["id"])
else:
poll_votes.insert({
"uid": generate_uid(),
"poll_uid": poll_uid,
"option_uid": data.option_uid,
"user_uid": user["uid"],
"created_at": datetime.now(timezone.utc).isoformat(),
})
result = get_poll_for_post(poll["post_uid"], user)
if request.headers.get("x-requested-with") == "fetch":
return JSONResponse(result)
return RedirectResponse(url=request.headers.get("Referer", "/feed"), status_code=302)

View File

@ -1,11 +1,12 @@
import logging import logging
from typing import Annotated from typing import Annotated
from datetime import datetime, timezone
from fastapi import APIRouter, Request, Form from fastapi import APIRouter, Request, Form
from fastapi.responses import RedirectResponse, HTMLResponse from fastapi.responses import RedirectResponse, HTMLResponse
from devplacepy.constants import TOPICS from devplacepy.constants import TOPICS
from devplacepy.database import db from devplacepy.database import db, get_table
from devplacepy.templating import templates from devplacepy.templating import templates
from devplacepy.utils import get_current_user, require_user, time_ago, not_found, XP_POST from devplacepy.utils import get_current_user, require_user, time_ago, not_found, generate_uid, XP_POST
from devplacepy.content import load_detail, edit_content_item, delete_content_item, create_content_item, detail_context, canonical_redirect, first_image_url from devplacepy.content import load_detail, edit_content_item, delete_content_item, create_content_item, detail_context, canonical_redirect, first_image_url
from devplacepy.seo import base_seo_context, site_url, website_schema, discussion_forum_posting, truncate from devplacepy.seo import base_seo_context, site_url, website_schema, discussion_forum_posting, truncate
from devplacepy.attachments import save_inline_image from devplacepy.attachments import save_inline_image
@ -39,9 +40,32 @@ async def create_post(request: Request, data: Annotated[PostForm, Form()]):
"project_uid": project_uid or None, "project_uid": project_uid or None,
"image": image_filename, "image": image_filename,
}, slug_text, XP_POST, "First Post", content, data.attachment_uids) }, slug_text, XP_POST, "First Post", content, data.attachment_uids)
create_poll(uid, user, data.poll_question, data.poll_options)
return RedirectResponse(url=f"/posts/{post_slug}", status_code=302) return RedirectResponse(url=f"/posts/{post_slug}", status_code=302)
def create_poll(post_uid: str, user: dict, question: str, options: list[str]) -> None:
question = question.strip()
labels = [option.strip() for option in options if option.strip()][:6]
if not question or len(labels) < 2:
return
poll_uid = generate_uid()
get_table("polls").insert({
"uid": poll_uid,
"post_uid": post_uid,
"user_uid": user["uid"],
"question": question,
"created_at": datetime.now(timezone.utc).isoformat(),
})
get_table("poll_options").insert_many([{
"uid": generate_uid(),
"poll_uid": poll_uid,
"label": label,
"position": index,
} for index, label in enumerate(labels)])
@router.get("/{post_slug}", response_class=HTMLResponse) @router.get("/{post_slug}", response_class=HTMLResponse)
async def view_post(request: Request, post_slug: str): async def view_post(request: Request, post_slug: str):
user = get_current_user(request) user = get_current_user(request)

View File

@ -3,7 +3,7 @@ from typing import Annotated
from fastapi import APIRouter, Request, Form from fastapi import APIRouter, Request, Form
from devplacepy.models import ProfileForm from devplacepy.models import ProfileForm
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
from devplacepy.database import get_table, db, get_user_stars, get_user_rank, get_comment_counts_by_post_uids from devplacepy.database import get_table, db, get_user_stars, get_user_rank, get_comment_counts_by_post_uids, get_reactions_by_targets, get_user_bookmarks, get_polls_by_post_uids, get_activity_heatmap, get_streaks
from devplacepy.content import enrich_items from devplacepy.content import enrich_items
from devplacepy.templating import templates from devplacepy.templating import templates
from devplacepy.utils import get_current_user, require_user, require_user_api, time_ago, clear_user_cache, not_found from devplacepy.utils import get_current_user, require_user, require_user_api, time_ago, clear_user_cache, not_found
@ -44,9 +44,18 @@ async def profile_page(request: Request, username: str, tab: str = "posts"):
if tab == "posts": if tab == "posts":
posts_table = get_table("posts") posts_table = get_table("posts")
raw_posts = list(posts_table.find(user_uid=profile_user["uid"], order_by=["-created_at"])) raw_posts = list(posts_table.find(user_uid=profile_user["uid"], order_by=["-created_at"]))
counts = get_comment_counts_by_post_uids([p["uid"] for p in raw_posts]) if raw_posts else {} post_uids = [p["uid"] for p in raw_posts]
counts = get_comment_counts_by_post_uids(post_uids) if raw_posts else {}
authors = {profile_user["uid"]: profile_user} authors = {profile_user["uid"]: profile_user}
posts = enrich_items(raw_posts, "post", authors, {"comment_count": counts}, user=current_user) posts = enrich_items(raw_posts, "post", authors, {"comment_count": counts}, user=current_user)
reactions_map = get_reactions_by_targets("post", post_uids, current_user)
bookmark_set = get_user_bookmarks(current_user["uid"], "post", post_uids) if current_user else set()
polls_map = get_polls_by_post_uids(post_uids, current_user)
for item in posts:
uid = item["post"]["uid"]
item["reactions"] = reactions_map.get(uid, {"counts": {}, "mine": []})
item["bookmarked"] = uid in bookmark_set
item["poll"] = polls_map.get(uid)
badges = list(get_table("badges").find(user_uid=profile_user["uid"])) badges = list(get_table("badges").find(user_uid=profile_user["uid"]))
projects = list(get_table("projects").find(user_uid=profile_user["uid"])) projects = list(get_table("projects").find(user_uid=profile_user["uid"]))
@ -68,6 +77,9 @@ async def profile_page(request: Request, username: str, tab: str = "posts"):
activities.append({"type": "comment", "content": c["content"][:80], "time_ago": time_ago(c["created_at"]), "created_at": c["created_at"], "uid": target_uid, "target_type": target_type}) activities.append({"type": "comment", "content": c["content"][:80], "time_ago": time_ago(c["created_at"]), "created_at": c["created_at"], "uid": target_uid, "target_type": target_type})
activities.sort(key=lambda a: a["created_at"], reverse=True) activities.sort(key=lambda a: a["created_at"], reverse=True)
heatmap = get_activity_heatmap(profile_user["uid"])
streak = get_streaks(profile_user["uid"])
is_following = False is_following = False
if current_user: if current_user:
follows = get_table("follows") follows = get_table("follows")
@ -108,6 +120,8 @@ async def profile_page(request: Request, username: str, tab: str = "posts"):
"is_following": is_following, "is_following": is_following,
"activities": activities, "activities": activities,
"rank": rank, "rank": rank,
"heatmap": heatmap,
"streak": streak,
}) })

View File

@ -0,0 +1,46 @@
# retoor <retoor@molodetz.nl>
import logging
from typing import Annotated
from datetime import datetime, timezone
from fastapi import APIRouter, Request, Form
from fastapi.responses import RedirectResponse, JSONResponse
from devplacepy.database import get_table, db
from devplacepy.utils import generate_uid, require_user
from devplacepy.models import ReactionForm
logger = logging.getLogger(__name__)
router = APIRouter()
REACTABLE: set[str] = {"post", "comment", "gist", "project"}
@router.post("/{target_type}/{target_uid}")
async def react(request: Request, target_type: str, target_uid: str, data: Annotated[ReactionForm, Form()]):
user = require_user(request)
if target_type not in REACTABLE:
return JSONResponse({"error": "Invalid target"}, status_code=400)
emoji = data.emoji
reactions = get_table("reactions")
existing = reactions.find_one(user_uid=user["uid"], target_uid=target_uid, target_type=target_type, emoji=emoji)
if existing:
reactions.delete(id=existing["id"])
else:
reactions.insert({
"uid": generate_uid(),
"user_uid": user["uid"],
"target_uid": target_uid,
"target_type": target_type,
"emoji": emoji,
"created_at": datetime.now(timezone.utc).isoformat(),
})
counts = {row["emoji"]: row["c"] for row in db.query(
"SELECT emoji, COUNT(*) as c FROM reactions WHERE target_type=:tt AND target_uid=:tu GROUP BY emoji",
tt=target_type, tu=target_uid,
)}
mine = [row["emoji"] for row in reactions.find(user_uid=user["uid"], target_uid=target_uid, target_type=target_type)]
if request.headers.get("x-requested-with") == "fetch":
return JSONResponse({"counts": counts, "mine": mine})
return RedirectResponse(url=request.headers.get("Referer", "/feed"), status_code=302)

View File

@ -5,7 +5,7 @@ from datetime import datetime, timezone
import httpx import httpx
from devplacepy.database import get_table, get_setting from devplacepy.database import get_table, get_setting, get_int_setting
from devplacepy.services.base import BaseService from devplacepy.services.base import BaseService
from devplacepy.utils import generate_uid, make_combined_slug, strip_html from devplacepy.utils import generate_uid, make_combined_slug, strip_html
@ -68,6 +68,7 @@ class NewsService(BaseService):
super().__init__(name="news", interval_seconds=3600) super().__init__(name="news", interval_seconds=3600)
async def run_once(self) -> None: async def run_once(self) -> None:
self.interval_seconds = max(60, get_int_setting("news_service_interval", 3600))
api_url = get_setting("news_api_url", NEWS_API_URL_DEFAULT) api_url = get_setting("news_api_url", NEWS_API_URL_DEFAULT)
ai_url = get_setting("news_ai_url", AI_URL_DEFAULT) ai_url = get_setting("news_ai_url", AI_URL_DEFAULT)
ai_model = get_setting("news_ai_model", AI_MODEL_DEFAULT) ai_model = get_setting("news_ai_model", AI_MODEL_DEFAULT)

View File

@ -116,7 +116,6 @@
color: var(--text-secondary); color: var(--text-secondary);
border: none; border: none;
cursor: pointer; cursor: pointer;
transition: all 0.2s;
} }
.admin-btn:hover { .admin-btn:hover {
@ -239,7 +238,6 @@
background: var(--bg-card-hover); background: var(--bg-card-hover);
border: 1px solid var(--border); border: 1px solid var(--border);
cursor: pointer; cursor: pointer;
transition: background 0.2s, border-color 0.2s;
padding: 0; padding: 0;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@ -258,7 +256,6 @@
height: 16px; height: 16px;
border-radius: 50%; border-radius: 50%;
background: var(--text-primary); background: var(--text-primary);
transition: transform 0.2s;
} }
.admin-toggle-switch.active .admin-toggle-knob { .admin-toggle-switch.active .admin-toggle-knob {
@ -297,7 +294,6 @@
color: var(--text-secondary); color: var(--text-secondary);
border: 1px solid var(--border); border: 1px solid var(--border);
cursor: pointer; cursor: pointer;
transition: all 0.2s;
text-decoration: none; text-decoration: none;
user-select: none; user-select: none;
} }
@ -338,7 +334,6 @@
font-weight: 600; font-weight: 600;
color: var(--text-secondary); color: var(--text-secondary);
text-decoration: none; text-decoration: none;
transition: all 0.15s;
} }
.pagination-page:hover { .pagination-page:hover {

View File

@ -7,7 +7,6 @@
font-size: 1.125rem; font-size: 1.125rem;
color: var(--text-muted); color: var(--text-muted);
cursor: pointer; cursor: pointer;
transition: all 0.2s;
background: none; background: none;
border: 1px solid transparent; border: 1px solid transparent;
line-height: 1; line-height: 1;
@ -99,7 +98,6 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
opacity: 0; opacity: 0;
transition: opacity 0.2s;
} }
.attachment-preview:hover .attachment-remove { .attachment-preview:hover .attachment-remove {
@ -118,7 +116,6 @@
.attachment-progress-bar { .attachment-progress-bar {
height: 100%; height: 100%;
background: var(--accent); background: var(--accent);
transition: width 0.3s;
} }
.attachment-gallery { .attachment-gallery {
@ -136,7 +133,6 @@
border: 1px solid var(--border); border: 1px solid var(--border);
overflow: hidden; overflow: hidden;
cursor: pointer; cursor: pointer;
transition: transform 0.2s;
background: var(--bg-card-hover); background: var(--bg-card-hover);
} }
@ -261,9 +257,9 @@
@media (hover: none) and (pointer: coarse) { @media (hover: none) and (pointer: coarse) {
.attachment-preview .attachment-remove { .attachment-preview .attachment-remove {
width: 28px; width: 32px;
height: 28px; height: 32px;
font-size: 0.875rem; font-size: 1rem;
opacity: 1; opacity: 1;
} }
} }

View File

@ -99,7 +99,6 @@
background: var(--accent); background: var(--accent);
color: #fff; color: #fff;
border-radius: var(--radius); border-radius: var(--radius);
transition: all 0.2s;
} }
.auth-submit:hover { .auth-submit:hover {

View File

@ -72,7 +72,6 @@
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
background: var(--bg-card-hover); background: var(--bg-card-hover);
transition: all 0.2s;
} }
.topic-radio { .topic-radio {
width: auto; width: auto;
@ -151,7 +150,6 @@
padding: 0.75rem; padding: 0.75rem;
width: 100%; width: 100%;
border-radius: var(--radius); border-radius: var(--radius);
transition: background 0.2s;
} }
.related-link:hover { .related-link:hover {
background: var(--bg-card); background: var(--bg-card);
@ -345,7 +343,6 @@ body {
a { a {
color: var(--accent); color: var(--accent);
text-decoration: none; text-decoration: none;
transition: color 0.2s;
} }
a:hover { a:hover {
@ -369,7 +366,6 @@ input, textarea, select {
border-radius: var(--radius); border-radius: var(--radius);
padding: 0.625rem 0.875rem; padding: 0.625rem 0.875rem;
outline: none; outline: none;
transition: border-color 0.2s;
width: 100%; width: 100%;
} }
@ -413,7 +409,6 @@ img {
border-radius: var(--radius); border-radius: var(--radius);
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 600; font-weight: 600;
transition: all 0.2s;
white-space: nowrap; white-space: nowrap;
} }
@ -537,12 +532,11 @@ img {
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 500; font-weight: 500;
color: var(--text-secondary); color: var(--text-secondary);
transition: all 0.2s;
} }
.topnav-link:hover { color: var(--text-primary); background: var(--bg-card); } .topnav-link:hover { color: var(--text-primary); background: var(--bg-card); }
.topnav-link.active { color: var(--accent); background: var(--accent-light); } .topnav-link.active { color: var(--accent); background: var(--accent-light); }
.topnav-right { margin-left: auto; display: flex; align-items: center; gap: 1rem; flex-shrink: 0; } .topnav-right { margin-left: auto; display: flex; align-items: center; gap: 1rem; flex-shrink: 0; }
.topnav-icon { position: relative; padding: 0.375rem; color: var(--text-secondary); font-size: 1.25rem; transition: color 0.2s; background: none; border: none; cursor: pointer; font-family: inherit; line-height: 1; } .topnav-icon { position: relative; padding: 0.375rem; color: var(--text-secondary); font-size: 1.25rem; background: none; border: none; cursor: pointer; font-family: inherit; line-height: 1; }
.topnav-icon:hover { color: var(--text-primary); } .topnav-icon:hover { color: var(--text-primary); }
.nav-badge { .nav-badge {
position: absolute; top: 0; right: 0; min-width: 16px; height: 16px; position: absolute; top: 0; right: 0; min-width: 16px; height: 16px;
@ -550,7 +544,12 @@ img {
font-size: 0.6875rem; font-weight: 700; font-size: 0.6875rem; font-weight: 700;
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
} }
.topnav-user { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem; border-radius: var(--radius); transition: background 0.2s; } .nav-badge-inline {
position: static; margin-left: 0.375rem;
display: inline-flex; vertical-align: middle;
}
.nav-badge[hidden] { display: none; }
.topnav-user { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem; border-radius: var(--radius); }
.topnav-user:hover { background: var(--bg-card); } .topnav-user:hover { background: var(--bg-card); }
.topnav-user-info { display: flex; flex-direction: column; line-height: 1.2; } .topnav-user-info { display: flex; flex-direction: column; line-height: 1.2; }
.topnav-user-name { font-size: 0.8125rem; font-weight: 600; color: var(--text-primary); } .topnav-user-name { font-size: 0.8125rem; font-weight: 600; color: var(--text-primary); }
@ -608,7 +607,6 @@ img {
z-index: 999; z-index: 999;
max-height: 0; max-height: 0;
overflow: hidden; overflow: hidden;
transition: max-height 0.3s ease;
} }
.topnav-mobile-panel.open { .topnav-mobile-panel.open {
@ -632,7 +630,6 @@ img {
font-size: 0.9375rem; font-size: 0.9375rem;
font-weight: 500; font-weight: 500;
color: var(--text-secondary); color: var(--text-secondary);
transition: all 0.15s;
} }
.topnav-mobile-link:hover { .topnav-mobile-link:hover {
@ -691,7 +688,7 @@ img {
.user-avatar-link { display: inline-flex; flex-shrink: 0; line-height: 0; } .user-avatar-link { display: inline-flex; flex-shrink: 0; line-height: 0; }
.user-avatar-link:hover { opacity: 0.85; } .user-avatar-link:hover { opacity: 0.85; }
.user-link { display: inline-flex; align-items: center; gap: 0.5rem; color: var(--text-primary); transition: color 0.2s; } .user-link { display: inline-flex; align-items: center; gap: 0.5rem; color: var(--text-primary); }
.user-link:hover { color: var(--accent); text-decoration: none; } .user-link:hover { color: var(--accent); text-decoration: none; }
.user-link-name { font-weight: 600; font-size: 0.875rem; } .user-link-name { font-weight: 600; font-size: 0.875rem; }
.user-link-role { font-size: 0.75rem; color: var(--text-muted); font-weight: 400; } .user-link-role { font-size: 0.75rem; color: var(--text-muted); font-weight: 400; }
@ -748,7 +745,6 @@ img {
border: none; border: none;
border-radius: var(--radius); border-radius: var(--radius);
cursor: pointer; cursor: pointer;
transition: color 0.2s;
} }
.comment-form-actions button:hover { .comment-form-actions button:hover {
@ -803,15 +799,6 @@ button.comment-form-submit:hover {
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
} }
.fade-in {
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.icon { .icon {
font-size: 1rem; font-size: 1rem;
width: 20px; width: 20px;
@ -843,7 +830,6 @@ button.comment-form-submit:hover {
padding: 0.5rem; padding: 0.5rem;
color: var(--text-muted); color: var(--text-muted);
font-size: 1.125rem; font-size: 1.125rem;
transition: color 0.2s;
background: none; background: none;
border: none; border: none;
cursor: pointer; cursor: pointer;
@ -880,7 +866,7 @@ button.comment-form-submit:hover {
@media (max-width: 360px) { @media (max-width: 360px) {
.page { .page {
padding: 0.25rem; padding: 0.5rem;
} }
.card { .card {

View File

@ -0,0 +1,318 @@
.reaction-bar {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-sm);
}
.reaction-list {
display: flex;
flex-wrap: wrap;
gap: var(--space-sm);
}
.reaction-chip {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
padding: 0.15rem 0.5rem;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--bg-secondary);
color: var(--text-secondary);
font-size: 0.85rem;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.reaction-chip:hover {
border-color: var(--border-light);
background: var(--bg-card-hover);
}
.reaction-chip.reacted {
border-color: var(--accent);
background: var(--accent-light);
color: var(--text-primary);
}
.reaction-chip[disabled] {
cursor: default;
}
.reaction-chip[hidden] {
display: none;
}
.reaction-count {
font-variant-numeric: tabular-nums;
min-width: 0.75rem;
text-align: center;
}
.reaction-add {
position: relative;
}
.reaction-add-btn {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
padding: 0.15rem 0.6rem;
border: 1px dashed var(--border-light);
border-radius: 999px;
background: transparent;
color: var(--text-muted);
font-size: 0.85rem;
line-height: 1.4;
cursor: pointer;
transition: border-color 0.15s, color 0.15s;
}
.reaction-add-btn:hover {
color: var(--accent);
border-color: var(--accent);
}
.reaction-add-icon {
font-size: 1rem;
line-height: 1;
}
.reaction-add-label {
font-weight: 500;
}
.reaction-palette {
position: absolute;
bottom: calc(100% + var(--space-xs));
left: 0;
z-index: 20;
display: flex;
gap: var(--space-xs);
padding: var(--space-sm);
background: var(--bg-modal);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.reaction-palette[hidden] {
display: none;
}
.reaction-palette-btn {
background: transparent;
border: none;
font-size: 1.15rem;
line-height: 1;
cursor: pointer;
padding: 0.15rem;
border-radius: var(--radius);
}
.reaction-palette-btn:hover {
background: var(--bg-card-hover);
}
.bookmark-btn.bookmarked {
color: var(--accent);
}
.poll {
margin: var(--space-md) 0;
padding: var(--space-md);
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-secondary);
}
.poll-question {
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--space-md);
}
.poll-options {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.poll-option {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-md);
width: 100%;
padding: 0.55rem 0.75rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-card);
color: var(--text-primary);
font-size: 0.9rem;
text-align: left;
cursor: pointer;
overflow: hidden;
}
.poll-option[disabled] {
cursor: default;
}
.poll-option:not([disabled]):hover {
border-color: var(--border-light);
}
.poll-option.chosen {
border-color: var(--accent);
}
.poll-option.chosen .poll-option-label {
font-weight: 600;
}
.poll-option-bar {
position: absolute;
inset: 0;
width: 0;
background: rgba(255, 107, 53, 0.18);
transition: width 0.3s ease;
z-index: 0;
}
.poll-option.chosen .poll-option-bar {
background: rgba(255, 107, 53, 0.32);
}
.poll-option-label {
position: relative;
z-index: 1;
}
.poll-option-meta {
position: relative;
z-index: 1;
display: inline-flex;
align-items: center;
gap: var(--space-xs);
white-space: nowrap;
}
.poll-option-check {
color: var(--accent);
font-weight: 700;
}
.poll-option-pct {
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
}
.poll-total {
margin-top: var(--space-sm);
font-size: 0.8rem;
color: var(--text-muted);
}
.poll-builder {
display: flex;
flex-direction: column;
gap: var(--space-sm);
padding: var(--space-md);
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: var(--space-lg);
}
.poll-builder[hidden] {
display: none;
}
.poll-builder-options {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.poll-builder-row {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.poll-builder-row input {
flex: 1;
}
.poll-builder-remove {
flex-shrink: 0;
width: 1.75rem;
height: 1.75rem;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--border);
border-radius: var(--radius);
background: transparent;
color: var(--text-muted);
font-size: 1.1rem;
line-height: 1;
cursor: pointer;
}
.poll-builder-remove:hover {
color: var(--danger);
border-color: var(--danger);
}
.poll-builder-error {
margin: 0;
color: var(--danger);
font-size: 0.8rem;
}
.saved-page {
max-width: 760px;
margin: 0 auto;
padding: var(--space-lg);
}
.saved-title {
margin-bottom: var(--space-lg);
}
.saved-list {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.saved-item {
display: flex;
align-items: center;
gap: var(--space-md);
padding: var(--space-md);
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-card);
color: var(--text-primary);
text-decoration: none;
}
.saved-item:hover {
background: var(--bg-card-hover);
}
.saved-item-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.saved-item-time {
color: var(--text-muted);
font-size: 0.8rem;
}

View File

@ -25,7 +25,6 @@
font-size: 0.8125rem; font-size: 0.8125rem;
font-weight: 600; font-weight: 600;
color: var(--text-secondary); color: var(--text-secondary);
transition: all 0.2s;
} }
.feed-nav-btn:hover { .feed-nav-btn:hover {
@ -49,7 +48,6 @@
padding: 0.375rem 0.5rem; padding: 0.375rem 0.5rem;
border-radius: var(--radius); border-radius: var(--radius);
font-size: 0.875rem; font-size: 0.875rem;
transition: all 0.2s;
} }
.feed-nav-actions button:hover { .feed-nav-actions button:hover {
@ -68,7 +66,6 @@
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
padding: 1rem; padding: 1rem;
transition: all 0.2s;
} }
.post-card:hover { .post-card:hover {
@ -130,7 +127,6 @@
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
color: var(--text-primary); color: var(--text-primary);
line-height: 1.3; line-height: 1.3;
transition: color 0.15s ease;
} }
.post-content { .post-content {
@ -144,7 +140,6 @@
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
cursor: pointer; cursor: pointer;
transition: color 0.15s ease;
} }
.post-content:hover { .post-content:hover {
@ -154,6 +149,7 @@
.post-actions { .post-actions {
display: flex; display: flex;
align-items: center; align-items: center;
flex-wrap: wrap;
gap: 0.5rem; gap: 0.5rem;
padding-top: 1rem; padding-top: 1rem;
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
@ -169,7 +165,6 @@
font-size: 0.8125rem; font-size: 0.8125rem;
font-weight: 500; font-weight: 500;
color: var(--text-muted); color: var(--text-muted);
transition: all 0.2s;
background: none; background: none;
border: none; border: none;
cursor: pointer; cursor: pointer;
@ -328,7 +323,6 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
box-shadow: 0 4px 16px rgba(229, 57, 53, 0.4); box-shadow: 0 4px 16px rgba(229, 57, 53, 0.4);
transition: all 0.2s;
z-index: 100; z-index: 100;
border: none; border: none;
cursor: pointer; cursor: pointer;

View File

@ -33,7 +33,6 @@
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
padding: 1.25rem; padding: 1.25rem;
transition: all 0.2s;
cursor: pointer; cursor: pointer;
} }
@ -97,7 +96,6 @@
gap: 0.25rem; gap: 0.25rem;
font-size: 0.75rem; font-size: 0.75rem;
color: var(--text-muted); color: var(--text-muted);
transition: color 0.2s;
} }
.gist-card-star:hover { .gist-card-star:hover {
@ -206,7 +204,6 @@
font-size: 0.75rem; font-size: 0.75rem;
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
border-radius: var(--radius); border-radius: var(--radius);
transition: all 0.2s;
} }
.gist-code-header .gist-copy-btn:hover { .gist-code-header .gist-copy-btn:hover {
@ -249,7 +246,6 @@
background: none; background: none;
border: none; border: none;
cursor: pointer; cursor: pointer;
transition: all 0.2s;
} }
.gist-star-btn:hover { .gist-star-btn:hover {

View File

@ -90,7 +90,6 @@
background: var(--accent); background: var(--accent);
color: #fff; color: #fff;
font-weight: 700; font-weight: 700;
transition: all 0.2s;
} }
.landing-cta:hover { .landing-cta:hover {
@ -164,7 +163,6 @@
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 600; font-weight: 600;
color: var(--accent); color: var(--accent);
transition: color 0.2s;
} }
.landing-section-link:hover { .landing-section-link:hover {
@ -185,7 +183,6 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.625rem; gap: 0.625rem;
transition: transform 0.2s, box-shadow 0.2s;
} }
.landing-post-card:hover { .landing-post-card:hover {
@ -226,7 +223,6 @@
.landing-post-title a { .landing-post-title a {
color: var(--text-primary); color: var(--text-primary);
transition: color 0.2s;
} }
.landing-post-title a:hover { .landing-post-title a:hover {
@ -264,7 +260,6 @@
font-weight: 600; font-weight: 600;
color: var(--accent); color: var(--accent);
margin-left: auto; margin-left: auto;
transition: color 0.2s;
} }
.landing-post-open:hover { .landing-post-open:hover {

View File

@ -32,7 +32,6 @@
cursor: pointer; cursor: pointer;
text-align: left; text-align: left;
font-family: inherit; font-family: inherit;
transition: background 0.1s;
} }
.mention-dropdown-item:hover, .mention-dropdown-item:hover,

View File

@ -1,6 +1,6 @@
.messages-layout { .messages-layout {
display: grid; display: grid;
grid-template-columns: 320px 1fr; grid-template-columns: minmax(0, 320px) 1fr;
gap: 0; gap: 0;
height: calc(100vh - var(--nav-height) - 2rem); height: calc(100vh - var(--nav-height) - 2rem);
background: var(--bg-card); background: var(--bg-card);
@ -13,6 +13,7 @@
border-right: 1px solid var(--border); border-right: 1px solid var(--border);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-width: 0;
} }
.messages-list-header { .messages-list-header {
@ -47,7 +48,6 @@
padding: 0.625rem 1rem; padding: 0.625rem 1rem;
font-size: 0.875rem; font-size: 0.875rem;
color: var(--text-primary); color: var(--text-primary);
transition: background 0.15s;
text-decoration: none; text-decoration: none;
} }
@ -65,7 +65,6 @@
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
transition: background 0.2s;
cursor: pointer; cursor: pointer;
} }
@ -207,7 +206,6 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 1rem; font-size: 1rem;
transition: background 0.15s;
} }
.messages-send-btn:hover { .messages-send-btn:hover {

View File

@ -31,7 +31,6 @@
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-xl); border-radius: var(--radius-xl);
overflow: hidden; overflow: hidden;
transition: transform 0.2s, box-shadow 0.2s;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
@ -112,7 +111,6 @@
.news-card-title a { .news-card-title a {
color: var(--text-primary); color: var(--text-primary);
transition: color 0.2s;
} }
.news-card-title a:hover { .news-card-title a:hover {
@ -136,7 +134,6 @@
font-weight: 600; font-weight: 600;
color: var(--accent); color: var(--accent);
align-self: flex-start; align-self: flex-start;
transition: color 0.2s;
} }
.news-read-link:hover { .news-read-link:hover {
@ -247,7 +244,6 @@
background: var(--accent); background: var(--accent);
color: #fff; color: #fff;
border-radius: var(--radius); border-radius: var(--radius);
transition: all 0.2s;
} }
.news-detail-read-link:hover { .news-detail-read-link:hover {

View File

@ -1,13 +1,11 @@
.notifications-page { .notifications-page {
max-width: 640px; max-width: 640px;
margin: 0 auto; margin: 0 auto;
padding-top: var(--nav-height);
} }
.notifications-header { .notifications-header {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.5rem; gap: 0.5rem;
margin-bottom: 1rem; margin-bottom: 1rem;
@ -18,6 +16,13 @@
font-weight: 700; font-weight: 700;
} }
.notifications-actions {
margin-left: auto;
display: flex;
align-items: center;
gap: 0.5rem;
}
.notifications-list { .notifications-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -32,7 +37,6 @@
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
padding: 1.25rem; padding: 1.25rem;
transition: all 0.2s;
cursor: pointer; cursor: pointer;
} }
@ -70,7 +74,6 @@
padding: 0.25rem; padding: 0.25rem;
color: var(--text-muted); color: var(--text-muted);
font-size: 0.875rem; font-size: 0.875rem;
transition: color 0.2s;
} }
.notification-dismiss:hover { .notification-dismiss:hover {

View File

@ -54,6 +54,7 @@
.post-detail-actions { .post-detail-actions {
display: flex; display: flex;
align-items: center; align-items: center;
flex-wrap: wrap;
gap: 1rem; gap: 1rem;
padding-top: 1rem; padding-top: 1rem;
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
@ -98,7 +99,6 @@
padding: 0.25rem; padding: 0.25rem;
color: var(--text-muted); color: var(--text-muted);
font-size: 0.8125rem; font-size: 0.8125rem;
transition: color 0.2s;
} }
.comment-vote-btn.vote-up:hover { .comment-vote-btn.vote-up:hover {
@ -130,22 +130,6 @@
min-width: 0; min-width: 0;
} }
.comment-highlight {
animation: comment-highlight-fade 2s ease-out;
border-radius: var(--radius);
}
@keyframes comment-highlight-fade {
from {
background: var(--accent-light);
box-shadow: 0 0 0 4px var(--accent-light);
}
to {
background: transparent;
box-shadow: 0 0 0 4px transparent;
}
}
.comment-header { .comment-header {
display: flex; display: flex;
align-items: center; align-items: center;
@ -172,6 +156,8 @@
.comment-actions { .comment-actions {
display: flex; display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem; gap: 0.5rem;
} }
@ -180,7 +166,6 @@
color: var(--text-muted); color: var(--text-muted);
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
border-radius: var(--radius); border-radius: var(--radius);
transition: all 0.2s;
} }
.comment-action-btn:hover { .comment-action-btn:hover {

View File

@ -95,7 +95,6 @@ a.profile-stat-value:hover {
height: 100%; height: 100%;
background: var(--accent); background: var(--accent);
border-radius: 3px; border-radius: 3px;
transition: width 0.3s;
} }
.profile-badges { .profile-badges {
@ -236,3 +235,66 @@ a.profile-stat-value:hover {
gap: 0.5rem; gap: 0.5rem;
} }
} }
.profile-heatmap-card {
padding: var(--space-lg);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--bg-card);
margin-bottom: var(--space-lg);
}
.heatmap-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--space-sm);
margin-bottom: var(--space-md);
}
.heatmap-title {
font-size: 0.95rem;
margin: 0;
}
.streak-info {
font-size: 0.8rem;
color: var(--text-secondary);
}
.heatmap-grid {
display: flex;
gap: 3px;
overflow-x: auto;
padding-bottom: var(--space-xs);
}
.heatmap-week {
display: flex;
flex-direction: column;
gap: 3px;
}
.heatmap-cell {
width: 11px;
height: 11px;
border-radius: 2px;
background: var(--border);
}
.heatmap-cell.level-1 {
background: rgba(255, 107, 53, 0.35);
}
.heatmap-cell.level-2 {
background: rgba(255, 107, 53, 0.55);
}
.heatmap-cell.level-3 {
background: rgba(255, 107, 53, 0.75);
}
.heatmap-cell.level-4 {
background: var(--accent);
}

View File

@ -58,7 +58,6 @@
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
padding: 1.25rem; padding: 1.25rem;
transition: border-color 0.2s;
cursor: pointer; cursor: pointer;
} }
@ -265,7 +264,6 @@
background: none; background: none;
border: none; border: none;
cursor: pointer; cursor: pointer;
transition: all 0.2s;
} }
.project-star-btn:hover { .project-star-btn:hover {
background: var(--bg-card-hover); background: var(--bg-card-hover);

View File

@ -38,7 +38,6 @@
border-radius: var(--radius); border-radius: var(--radius);
font-size: 0.875rem; font-size: 0.875rem;
color: var(--text-secondary); color: var(--text-secondary);
transition: all 0.2s;
} }
.sidebar-link:hover { .sidebar-link:hover {

View File

@ -10,6 +10,10 @@ import { ContentEnhancer } from "./ContentEnhancer.js";
import { DomUtils } from "./DomUtils.js"; import { DomUtils } from "./DomUtils.js";
import { PushManager } from "./PushManager.js"; import { PushManager } from "./PushManager.js";
import { PwaInstaller } from "./PwaInstaller.js"; import { PwaInstaller } from "./PwaInstaller.js";
import { CounterManager } from "./CounterManager.js";
import { ReactionBar } from "./ReactionBar.js";
import { BookmarkManager } from "./BookmarkManager.js";
import { PollManager } from "./PollManager.js";
class Application { class Application {
constructor() { constructor() {
@ -25,6 +29,10 @@ class Application {
this.dom = new DomUtils(); this.dom = new DomUtils();
this.push = new PushManager(); this.push = new PushManager();
this.pwa = new PwaInstaller(); this.pwa = new PwaInstaller();
this.counters = new CounterManager();
this.reactions = new ReactionBar();
this.bookmarks = new BookmarkManager();
this.polls = new PollManager();
} }
} }

View File

@ -122,11 +122,15 @@ export class AttachmentUploader {
preview.dataset.uid = result.uid; preview.dataset.uid = result.uid;
if (result.thumbnail_url) { if (result.thumbnail_url) {
const img = preview.querySelector("img"); const img = preview.querySelector("img");
if (img) img.src = result.thumbnail_url; if (img) {
this.revokePreview(preview);
img.src = result.thumbnail_url;
}
} }
} }
} catch (err) { } catch (err) {
this.showError(err.message); this.showError(err.message);
this.revokePreview(preview);
preview.remove(); preview.remove();
} finally { } finally {
this.button.classList.remove("uploading"); this.button.classList.remove("uploading");
@ -158,6 +162,7 @@ export class AttachmentUploader {
this.attachments.splice(idx, 1); this.attachments.splice(idx, 1);
this.updateUids(); this.updateUids();
} }
this.revokePreview(div);
div.remove(); div.remove();
}); });
div.appendChild(remove); div.appendChild(remove);
@ -168,6 +173,13 @@ export class AttachmentUploader {
return div; return div;
} }
revokePreview(preview) {
const img = preview.querySelector("img");
if (img && img.src.startsWith("blob:")) {
URL.revokeObjectURL(img.src);
}
}
updateUids() { updateUids() {
const uids = this.attachments.map((a) => a.uid); const uids = this.attachments.map((a) => a.uid);
this.uploadedUidsInput.value = uids.join(","); this.uploadedUidsInput.value = uids.join(",");

View File

@ -0,0 +1,27 @@
import { Http } from "./Http.js";
export class BookmarkManager {
constructor() {
document.addEventListener("click", (event) => this.onClick(event));
}
async onClick(event) {
const button = event.target.closest(".bookmark-btn");
if (!button) {
return;
}
event.preventDefault();
const type = button.dataset.bookmarkType;
const uid = button.dataset.bookmarkUid;
try {
const result = await Http.sendForm(`/bookmarks/${type}/${uid}`, {});
button.classList.toggle("bookmarked", result.saved);
const label = button.querySelector(".bookmark-label");
if (label) {
label.textContent = result.saved ? "Saved" : "Save";
}
} catch (error) {
console.error("bookmark failed", error);
}
}
}

View File

@ -0,0 +1,44 @@
import { Http } from "./Http.js";
export class CounterManager {
constructor() {
if (!document.querySelector("[data-counter]")) {
return;
}
this.interval = 30000;
this.poll();
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") {
this.poll();
}
});
setInterval(() => {
if (document.visibilityState === "visible") {
this.poll();
}
}, this.interval);
}
async poll() {
let counts;
try {
counts = await Http.getJson("/notifications/counts");
} catch (error) {
console.error("counter poll failed", error);
return;
}
this.apply("notifications", counts.notifications);
this.apply("messages", counts.messages);
}
apply(key, count) {
document.querySelectorAll(`[data-counter="${key}"]`).forEach((target) => {
const badge = target.querySelector("[data-counter-badge]");
if (!badge) {
return;
}
badge.textContent = count;
badge.hidden = count === 0;
});
}
}

View File

@ -6,6 +6,8 @@ export class DomUtils {
this.initShareButtons(); this.initShareButtons();
this.initTogglers(); this.initTogglers();
this.initStopPropagation(); this.initStopPropagation();
this.initReload();
this.initCardNav();
} }
static onDataAttr(attr, event, handler) { static onDataAttr(attr, event, handler) {
@ -67,4 +69,17 @@ export class DomUtils {
initStopPropagation() { initStopPropagation() {
DomUtils.onDataAttr("stop-propagation", "click", (el, e) => e.stopPropagation()); DomUtils.onDataAttr("stop-propagation", "click", (el, e) => e.stopPropagation());
} }
initReload() {
DomUtils.onDataAttr("reload", "click", () => window.location.reload());
}
initCardNav() {
DomUtils.onDataAttr("card-href", "click", (el, e) => {
if (e.target.closest("a, button")) {
return;
}
window.location.href = el.dataset.cardHref;
});
}
} }

View File

@ -6,6 +6,9 @@ export class Http {
static async getJson(url) { static async getJson(url) {
const response = await fetch(url); const response = await fetch(url);
if (!response.ok) {
throw new Error(`request failed with status ${response.status}`);
}
return response.json(); return response.json();
} }

View File

@ -14,7 +14,7 @@ export class ModalManager {
} }
const type = input.type === "password" ? "text" : "password"; const type = input.type === "password" ? "text" : "password";
input.type = type; input.type = type;
btn.textContent = type === "password" ? "\u{1F441}" : "\u{1F441}"; btn.textContent = type === "password" ? "\u{1F441}" : "\u{1F648}";
}); });
}); });
} }

View File

@ -13,11 +13,7 @@ export class NotificationManager {
if (!target) { if (!target) {
return; return;
} }
requestAnimationFrame(() => { target.scrollIntoView({ block: "center" });
target.scrollIntoView({ behavior: "smooth", block: "center" });
target.classList.add("comment-highlight");
setTimeout(() => target.classList.remove("comment-highlight"), 2000);
});
}); });
} }
} }

View File

@ -0,0 +1,182 @@
import { Http } from "./Http.js";
export class PollManager {
constructor() {
document.addEventListener("click", (event) => this.onClick(event));
document.addEventListener("submit", (event) => this.onSubmit(event), true);
}
onClick(event) {
const option = event.target.closest(".poll-option");
if (option && !option.disabled) {
event.preventDefault();
this.vote(option);
return;
}
const toggle = event.target.closest("[data-poll-toggle]");
if (toggle) {
event.preventDefault();
this.toggleBuilder(toggle);
return;
}
const addOption = event.target.closest("[data-poll-add-option]");
if (addOption) {
event.preventDefault();
this.addOption(addOption);
return;
}
const removeOption = event.target.closest("[data-poll-remove-option]");
if (removeOption) {
event.preventDefault();
this.removeOption(removeOption);
}
}
async vote(option) {
const poll = option.closest(".poll");
if (!poll) {
return;
}
const pollUid = poll.dataset.pollUid;
const optionUid = option.dataset.optionUid;
try {
const result = await Http.sendForm(`/polls/${pollUid}/vote`, { option_uid: optionUid });
this.render(poll, result);
} catch (error) {
console.error("poll vote failed", error);
}
}
render(poll, result) {
const byUid = {};
(result.options || []).forEach((option) => {
byUid[option.uid] = option;
});
poll.querySelectorAll(".poll-option").forEach((button) => {
const data = byUid[button.dataset.optionUid];
if (!data) {
return;
}
const chosen = result.my_choice === data.uid;
button.classList.toggle("chosen", chosen);
button.setAttribute("aria-pressed", chosen ? "true" : "false");
const bar = button.querySelector(".poll-option-bar");
if (bar) {
bar.style.width = `${data.pct}%`;
}
const pct = button.querySelector(".poll-option-pct");
if (pct) {
pct.textContent = `${data.pct}%`;
}
const check = button.querySelector(".poll-option-check");
if (check) {
check.hidden = !chosen;
}
});
const total = poll.querySelector(".poll-total");
if (total) {
total.textContent = `${result.total} vote${result.total === 1 ? "" : "s"}`;
}
}
toggleBuilder(toggle) {
const form = toggle.closest("form");
const builder = form ? form.querySelector("[data-poll-builder]") : null;
if (!builder) {
return;
}
const activating = builder.hidden;
builder.hidden = !activating;
this.setPollEnabled(builder, activating);
const label = toggle.querySelector("[data-poll-toggle-label]");
if (label) {
label.textContent = activating ? "Remove poll" : "Add poll";
}
}
setPollEnabled(builder, enabled) {
builder.querySelectorAll("input[name='poll_question'], input[name='poll_options']").forEach((input) => {
input.disabled = !enabled;
if (!enabled) {
input.value = "";
}
});
const error = builder.querySelector("[data-poll-error]");
if (error) {
error.hidden = true;
error.textContent = "";
}
}
addOption(button) {
const form = button.closest("form");
const container = form ? form.querySelector("[data-poll-options]") : null;
if (!container) {
return;
}
const rows = container.querySelectorAll(".poll-builder-row");
if (rows.length >= 6) {
return;
}
const row = document.createElement("div");
row.className = "poll-builder-row";
const input = document.createElement("input");
input.type = "text";
input.name = "poll_options";
input.maxLength = 100;
input.placeholder = `Option ${rows.length + 1}`;
const remove = document.createElement("button");
remove.type = "button";
remove.className = "poll-builder-remove";
remove.dataset.pollRemoveOption = "";
remove.setAttribute("aria-label", "Remove option");
remove.textContent = "×";
row.appendChild(input);
row.appendChild(remove);
container.appendChild(row);
}
removeOption(button) {
const container = button.closest("[data-poll-options]");
if (!container) {
return;
}
if (container.querySelectorAll(".poll-builder-row").length <= 2) {
return;
}
const row = button.closest(".poll-builder-row");
if (row) {
row.remove();
}
}
onSubmit(event) {
const form = event.target;
if (!form || typeof form.querySelector !== "function") {
return;
}
const builder = form.querySelector("[data-poll-builder]");
if (!builder || builder.hidden) {
return;
}
const question = builder.querySelector("input[name='poll_question']");
const options = [...builder.querySelectorAll("input[name='poll_options']")]
.map((input) => input.value.trim())
.filter(Boolean);
let problem = "";
if (!question || !question.value.trim()) {
problem = "Add a poll question, or remove the poll.";
} else if (options.length < 2) {
problem = "Add at least two poll options, or remove the poll.";
}
if (problem) {
event.preventDefault();
event.stopImmediatePropagation();
const error = builder.querySelector("[data-poll-error]");
if (error) {
error.textContent = problem;
error.hidden = false;
}
}
}
}

View File

@ -0,0 +1,65 @@
import { Http } from "./Http.js";
export class ReactionBar {
constructor() {
document.addEventListener("click", (event) => this.onClick(event));
}
async onClick(event) {
const addButton = event.target.closest(".reaction-add-btn");
if (addButton) {
event.preventDefault();
this.togglePalette(addButton);
return;
}
const trigger = event.target.closest("[data-reaction-emoji]");
if (trigger && !trigger.disabled) {
event.preventDefault();
await this.react(trigger);
return;
}
this.closeAllPalettes();
}
togglePalette(addButton) {
const palette = addButton.parentElement.querySelector(".reaction-palette");
const isOpen = palette.hidden === false;
this.closeAllPalettes();
palette.hidden = isOpen;
}
closeAllPalettes() {
document.querySelectorAll(".reaction-palette").forEach((palette) => {
palette.hidden = true;
});
}
async react(trigger) {
const bar = trigger.closest(".reaction-bar");
if (!bar) {
return;
}
const type = bar.dataset.reactionType;
const uid = bar.dataset.reactionUid;
const emoji = trigger.dataset.reactionEmoji;
try {
const result = await Http.sendForm(`/reactions/${type}/${uid}`, { emoji });
this.render(bar, result);
} catch (error) {
console.error("reaction failed", error);
}
this.closeAllPalettes();
}
render(bar, result) {
const counts = result.counts || {};
const mine = new Set(result.mine || []);
bar.querySelectorAll(".reaction-list .reaction-chip").forEach((chip) => {
const emoji = chip.dataset.reactionEmoji;
const count = counts[emoji] || 0;
chip.querySelector(".reaction-count").textContent = count;
chip.hidden = count === 0 && !mine.has(emoji);
chip.classList.toggle("reacted", mine.has(emoji));
});
}
}

View File

@ -0,0 +1,5 @@
{% if user %}
<button type="button" class="post-action-btn bookmark-btn{% if _bookmarked %} bookmarked{% endif %}" data-bookmark-type="{{ _type }}" data-bookmark-uid="{{ _uid }}" title="Save" aria-label="Save">
<span class="bookmark-icon">&#x1F516;</span> <span class="bookmark-label">{% if _bookmarked %}Saved{% else %}Save{% endif %}</span>
</button>
{% endif %}

View File

@ -3,12 +3,12 @@
<div class="comment-votes"> <div class="comment-votes">
<form method="POST" action="/votes/comment/{{ item.comment['uid'] }}"> <form method="POST" action="/votes/comment/{{ item.comment['uid'] }}">
<input type="hidden" name="value" value="1"> <input type="hidden" name="value" value="1">
<button type="submit" class="comment-vote-btn vote-up{% if item.my_vote == 1 %} voted{% endif %}">+</button> <button type="submit" class="comment-vote-btn vote-up{% if item.my_vote == 1 %} voted{% endif %}" aria-label="Upvote" title="Upvote">+</button>
</form> </form>
<span class="comment-vote-count" data-vote-count="{{ item.comment['uid'] }}">{{ item.votes.up - item.votes.down }}</span> <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'] }}"> <form method="POST" action="/votes/comment/{{ item.comment['uid'] }}">
<input type="hidden" name="value" value="-1"> <input type="hidden" name="value" value="-1">
<button type="submit" class="comment-vote-btn vote-down{% if item.my_vote == -1 %} voted{% endif %}">-</button> <button type="submit" class="comment-vote-btn vote-down{% if item.my_vote == -1 %} voted{% endif %}" aria-label="Downvote" title="Downvote">-</button>
</form> </form>
</div> </div>
@ -26,12 +26,13 @@
{% include "_attachment_display.html" %} {% include "_attachment_display.html" %}
{% endif %} {% endif %}
<div class="comment-actions"> <div class="comment-actions">
<button class="comment-action-btn" data-action="reply"><span class="icon">&#x1F4AC;</span> Reply</button> <button type="button" class="comment-action-btn" data-action="reply"><span class="icon">💬</span> Reply</button>
{% if user and item.comment['user_uid'] == user['uid'] %} {% if user and item.comment['user_uid'] == user['uid'] %}
<form method="POST" action="/comments/delete/{{ item.comment['uid'] }}" class="inline-form"> <form method="POST" action="/comments/delete/{{ item.comment['uid'] }}" class="inline-form">
<button type="submit" class="comment-action-btn"><span class="icon">&#x1F5D1;&#xFE0F;</span> Delete</button> <button type="submit" class="comment-action-btn" data-confirm="Delete this comment?"><span class="icon">🗑️</span> Delete</button>
</form> </form>
{% endif %} {% endif %}
{% set _type = "comment" %}{% set _uid = item.comment['uid'] %}{% set _reactions = item.reactions %}{% include "_reaction_bar.html" %}
</div> </div>
{% if item.children %} {% if item.children %}

View File

@ -0,0 +1,18 @@
{% if _poll %}
<div class="poll" data-poll-uid="{{ _poll.uid }}">
<div class="poll-question">{{ _poll.question }}</div>
<div class="poll-options" role="group" aria-label="Poll options">
{% for opt in _poll.options %}
<button type="button" class="poll-option{% if _poll.my_choice == opt.uid %} chosen{% endif %}" data-option-uid="{{ opt.uid }}" aria-pressed="{% if _poll.my_choice == opt.uid %}true{% else %}false{% endif %}"{% if not user %} disabled{% endif %}>
<span class="poll-option-bar" style="width: {{ opt.pct }}%;"></span>
<span class="poll-option-label">{{ opt.label }}</span>
<span class="poll-option-meta">
<span class="poll-option-check"{% if _poll.my_choice != opt.uid %} hidden{% endif %}>&#x2713;</span>
<span class="poll-option-pct">{{ opt.pct }}%</span>
</span>
</button>
{% endfor %}
</div>
<div class="poll-total">{{ _poll.total }} vote{% if _poll.total != 1 %}s{% endif %}{% if not user %} &middot; Log in to vote{% endif %}</div>
</div>
{% endif %}

View File

@ -1,5 +1,5 @@
{% from "_macros.html" import content_url %} {% from "_macros.html" import content_url %}
<article class="post-card fade-in"> <article class="post-card">
{% include "_post_header.html" %} {% include "_post_header.html" %}
<div class="post-topic"> <div class="post-topic">
@ -12,23 +12,29 @@
</a> </a>
{% endif %} {% 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> <div class="post-content rendered-content" data-render data-card-href="{{ content_url(item.post, 'posts') }}">{{ item.post['content'][:300] }}{% if item.post['content']|length > 300 %}...{% endif %}</div>
{% if item.attachments %} {% if item.attachments %}
{% include "_attachment_display.html" %} {% include "_attachment_display.html" %}
{% endif %} {% endif %}
{% if item.poll %}
{% set _poll = item.poll %}{% include "_poll.html" %}
{% endif %}
<div class="post-actions"> <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" %} {% 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"> <a href="{{ content_url(item.post, 'posts') }}" class="post-action-btn">
&#x1F4AC; {{ item.comment_count }} &#x1F4AC; {{ item.comment_count }}
</a> </a>
{% set _type = "post" %}{% set _uid = item.post['uid'] %}{% set _reactions = item.reactions %}{% include "_reaction_bar.html" %}
<a href="{{ content_url(item.post, 'posts') }}" class="post-action-btn share"> <a href="{{ content_url(item.post, 'posts') }}" class="post-action-btn share">
&#x2197;&#xFE0E; Open &#x2197;&#xFE0E; Open
</a> </a>
{% if _show_share %} {% if _show_share %}
<button type="button" class="post-action-btn" data-share="{{ content_url(item.post, 'posts') }}">&#x1F517; Share</button> <button type="button" class="post-action-btn" data-share="{{ content_url(item.post, 'posts') }}">&#x1F517; Share</button>
{% endif %} {% endif %}
{% set _type = "post" %}{% set _uid = item.post['uid'] %}{% set _bookmarked = item.bookmarked %}{% include "_bookmark_button.html" %}
</div> </div>
{% if item.recent_comments %} {% if item.recent_comments %}

View File

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

View File

@ -0,0 +1,24 @@
{% set _counts = (_reactions or {}).get('counts', {}) %}
{% set _mine = (_reactions or {}).get('mine', []) %}
{% if user or _counts %}
<div class="reaction-bar" data-reaction-type="{{ _type }}" data-reaction-uid="{{ _uid }}">
<div class="reaction-list">
{% for emoji in REACTION_EMOJI %}
<button type="button" class="reaction-chip{% if emoji in _mine %} reacted{% endif %}" data-reaction-emoji="{{ emoji }}"{% if not _counts.get(emoji) and emoji not in _mine %} hidden{% endif %}{% if not user %} disabled{% endif %}>
<span class="reaction-emoji">{{ emoji }}</span>
<span class="reaction-count">{{ _counts.get(emoji, 0) }}</span>
</button>
{% endfor %}
</div>
{% if user %}
<div class="reaction-add">
<button type="button" class="reaction-add-btn" aria-label="Add reaction" title="Add reaction"><span class="reaction-add-icon">&#x1F642;</span><span class="reaction-add-label">React</span></button>
<div class="reaction-palette" hidden>
{% for emoji in REACTION_EMOJI %}
<button type="button" class="reaction-palette-btn" data-reaction-emoji="{{ emoji }}">{{ emoji }}</button>
{% endfor %}
</div>
</div>
{% endif %}
</div>
{% endif %}

View File

@ -28,7 +28,7 @@
<div class="admin-field"> <div class="admin-field">
<label for="news_grade_threshold">Grade Threshold (1-10)</label> <label for="news_grade_threshold">Grade Threshold (1-10)</label>
<input type="number" id="news_grade_threshold" name="news_grade_threshold" value="{{ settings.get('news_grade_threshold', '7') }}" min="1" max="10" style="width: 100px;"> <input type="number" id="news_grade_threshold" name="news_grade_threshold" value="{{ settings.get('news_grade_threshold', '7') }}" min="1" max="10" style="max-width: 100px;">
<small class="hint-text">Articles with grade >= threshold are auto-published. Below this they go to draft.</small> <small class="hint-text">Articles with grade >= threshold are auto-published. Below this they go to draft.</small>
</div> </div>
@ -59,7 +59,7 @@
<div class="admin-field"> <div class="admin-field">
<label for="max_upload_size_mb">Max Upload Size (MB)</label> <label for="max_upload_size_mb">Max Upload Size (MB)</label>
<input type="number" id="max_upload_size_mb" name="max_upload_size_mb" value="{{ settings.get('max_upload_size_mb', '10') }}" min="1" max="500" style="width: 120px;"> <input type="number" id="max_upload_size_mb" name="max_upload_size_mb" value="{{ settings.get('max_upload_size_mb', '10') }}" min="1" max="500" style="max-width: 120px;">
<small class="hint-text">Maximum file size per upload in megabytes.</small> <small class="hint-text">Maximum file size per upload in megabytes.</small>
</div> </div>
@ -71,10 +71,68 @@
<div class="admin-field"> <div class="admin-field">
<label for="max_attachments_per_resource">Max Attachments Per Resource</label> <label for="max_attachments_per_resource">Max Attachments Per Resource</label>
<input type="number" id="max_attachments_per_resource" name="max_attachments_per_resource" value="{{ settings.get('max_attachments_per_resource', '10') }}" min="1" max="50" style="width: 120px;"> <input type="number" id="max_attachments_per_resource" name="max_attachments_per_resource" value="{{ settings.get('max_attachments_per_resource', '10') }}" min="1" max="50" style="max-width: 120px;">
<small class="hint-text">Maximum number of files that can be attached to a single post, comment, etc.</small> <small class="hint-text">Maximum number of files that can be attached to a single post, comment, etc.</small>
</div> </div>
<hr style="border: none; border-top: 1px solid var(--border); margin: 1.5rem 0;">
<h3 style="font-size: 0.875rem; font-weight: 700; margin-bottom: 0.75rem; color: var(--text-secondary);">Operational</h3>
<div class="admin-field">
<label for="registration_open">Registration</label>
<select id="registration_open" name="registration_open" style="max-width: 160px;">
<option value="1" {% if settings.get('registration_open', '1') == '1' %}selected{% endif %}>Open</option>
<option value="0" {% if settings.get('registration_open', '1') != '1' %}selected{% endif %}>Closed</option>
</select>
<small class="hint-text">When closed, new account sign-ups are rejected.</small>
</div>
<div class="admin-field">
<label for="maintenance_mode">Maintenance Mode</label>
<select id="maintenance_mode" name="maintenance_mode" style="max-width: 160px;">
<option value="0" {% if settings.get('maintenance_mode', '0') != '1' %}selected{% endif %}>Disabled</option>
<option value="1" {% if settings.get('maintenance_mode', '0') == '1' %}selected{% endif %}>Enabled</option>
</select>
<small class="hint-text">When enabled, non-admin visitors see the maintenance page. Admins retain full access.</small>
</div>
<div class="admin-field">
<label for="maintenance_message">Maintenance Message</label>
<input type="text" id="maintenance_message" name="maintenance_message" value="{{ settings.get('maintenance_message', 'DevPlace is undergoing scheduled maintenance. Please check back shortly.') }}" maxlength="300">
<small class="hint-text">Shown to visitors while maintenance mode is enabled.</small>
</div>
<div class="admin-field">
<label for="rate_limit_per_minute">Rate Limit (requests)</label>
<input type="number" id="rate_limit_per_minute" name="rate_limit_per_minute" value="{{ settings.get('rate_limit_per_minute', '60') }}" min="1" style="max-width: 120px;">
<small class="hint-text">Maximum mutating requests allowed per IP within the window below.</small>
</div>
<div class="admin-field">
<label for="rate_limit_window_seconds">Rate Limit Window (seconds)</label>
<input type="number" id="rate_limit_window_seconds" name="rate_limit_window_seconds" value="{{ settings.get('rate_limit_window_seconds', '60') }}" min="1" style="max-width: 120px;">
<small class="hint-text">Rolling window over which the request limit is counted.</small>
</div>
<div class="admin-field">
<label for="news_service_interval">News Service Interval (seconds)</label>
<input type="number" id="news_service_interval" name="news_service_interval" value="{{ settings.get('news_service_interval', '3600') }}" min="60" style="max-width: 120px;">
<small class="hint-text">Delay between news fetch cycles. Minimum 60 seconds.</small>
</div>
<div class="admin-field">
<label for="session_max_age_days">Session Length (days)</label>
<input type="number" id="session_max_age_days" name="session_max_age_days" value="{{ settings.get('session_max_age_days', '7') }}" min="1" style="max-width: 120px;">
<small class="hint-text">Cookie lifetime for a standard sign-in.</small>
</div>
<div class="admin-field">
<label for="session_remember_days">Remember-Me Length (days)</label>
<input type="number" id="session_remember_days" name="session_remember_days" value="{{ settings.get('session_remember_days', '30') }}" min="1" style="max-width: 120px;">
<small class="hint-text">Cookie lifetime when "remember me" is checked at login.</small>
</div>
<button type="submit" class="btn btn-primary"><span class="icon">&#x1F4BE;</span>Save Settings</button> <button type="submit" class="btn btn-primary"><span class="icon">&#x1F4BE;</span>Save Settings</button>
</form> </form>
{% endblock %} {% endblock %}

View File

@ -38,6 +38,7 @@
<link rel="stylesheet" href="/static/css/markdown.css"> <link rel="stylesheet" href="/static/css/markdown.css">
<link rel="stylesheet" href="/static/css/mention.css"> <link rel="stylesheet" href="/static/css/mention.css">
<link rel="stylesheet" href="/static/css/attachments.css"> <link rel="stylesheet" href="/static/css/attachments.css">
<link rel="stylesheet" href="/static/css/engagement.css">
{% block extra_head %}{% endblock %} {% block extra_head %}{% endblock %}
{% if page_schema %} {% if page_schema %}
@ -58,7 +59,7 @@
<a href="/projects" class="topnav-link {% if 'projects' in request.url.path %}active{% endif %}"><span class="icon">🚀</span> Projects</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> <a href="/leaderboard" class="topnav-link {% if 'leaderboard' in request.url.path %}active{% endif %}"><span class="icon">🏆</span> Leaderboard</a>
{% if user %} {% if user %}
<a href="/messages" class="topnav-link {% if 'messages' in request.url.path %}active{% endif %}"><span class="icon">✉️</span> Messages</a> <a href="/messages" class="topnav-link {% if 'messages' in request.url.path %}active{% endif %}" data-counter="messages"><span class="icon">✉️</span> Messages{% set msg_unread = get_unread_messages(user["uid"]) %}<span class="nav-badge nav-badge-inline" data-counter-badge {% if msg_unread == 0 %}hidden{% endif %}>{{ msg_unread }}</span></a>
{% if user.get('role') == 'Admin' %} {% if user.get('role') == 'Admin' %}
<a href="/admin" class="topnav-link {% if 'admin' in request.url.path %}active{% endif %}"><span class="icon">⚙️</span> Admin</a> <a href="/admin" class="topnav-link {% if 'admin' in request.url.path %}active{% endif %}"><span class="icon">⚙️</span> Admin</a>
{% endif %} {% endif %}
@ -67,17 +68,15 @@
<div class="topnav-right"> <div class="topnav-right">
{% if user %} {% if user %}
<button type="button" class="topnav-icon" data-pwa-install hidden title="Install DevPlace app" aria-label="Install DevPlace app"> <button type="button" class="topnav-icon" data-pwa-install hidden title="Install DevPlace app" aria-label="Install DevPlace app">
<span class="nav-bell">&#x2B07;&#xFE0F;</span> <span class="nav-bell">⬇️</span>
</button> </button>
<button type="button" class="topnav-icon" data-push-enable hidden title="Enable push notifications" aria-label="Enable push notifications"> <button type="button" class="topnav-icon" data-push-enable hidden title="Enable push notifications" aria-label="Enable push notifications">
<span class="nav-bell">&#x1F515;</span> <span class="nav-bell">🔕</span>
</button> </button>
<a href="/notifications" class="topnav-icon"> <a href="/notifications" class="topnav-icon" data-counter="notifications" title="Notifications" aria-label="Notifications">
<span class="nav-bell">&#x1F514;</span> <span class="nav-bell">🔔</span>
{% set unread_count = get_unread_count(user["uid"]) %} {% set unread_count = get_unread_count(user["uid"]) %}
{% if unread_count > 0 %} <span class="nav-badge" data-counter-badge {% if unread_count == 0 %}hidden{% endif %}>{{ unread_count }}</span>
<span class="nav-badge">{{ unread_count }}</span>
{% endif %}
</a> </a>
<div class="topnav-user-dropdown"> <div class="topnav-user-dropdown">
<a href="/profile/{{ user['username'] }}" class="topnav-user"> <a href="/profile/{{ user['username'] }}" class="topnav-user">
@ -88,6 +87,7 @@
</div> </div>
</a> </a>
<div class="dropdown-menu"> <div class="dropdown-menu">
<a href="/bookmarks/saved" class="dropdown-item"><span class="icon">🔖</span> Saved</a>
<a href="/auth/logout" class="dropdown-item"><span class="icon">🚪</span> Logout</a> <a href="/auth/logout" class="dropdown-item"><span class="icon">🚪</span> Logout</a>
</div> </div>
</div> </div>
@ -95,7 +95,7 @@
<a href="/auth/login" class="topnav-link"><span class="icon">🔑</span> Login</a> <a href="/auth/login" class="topnav-link"><span class="icon">🔑</span> Login</a>
<a href="/auth/signup" class="btn btn-primary btn-sm"><span class="icon"></span>Sign Up</a> <a href="/auth/signup" class="btn btn-primary btn-sm"><span class="icon"></span>Sign Up</a>
{% endif %} {% endif %}
<button class="topnav-hamburger" id="hamburger-btn" aria-label="Toggle menu">&#x2630;</button> <button type="button" class="topnav-hamburger" id="hamburger-btn" aria-label="Toggle menu"></button>
</div> </div>
</div> </div>
</nav> </nav>
@ -110,15 +110,15 @@
<a href="/projects" class="topnav-mobile-link {% if 'projects' in request.url.path %}active{% endif %}"><span class="icon">🚀</span> Projects</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> <a href="/leaderboard" class="topnav-mobile-link {% if 'leaderboard' in request.url.path %}active{% endif %}"><span class="icon">🏆</span> Leaderboard</a>
{% if user %} {% 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="/messages" class="topnav-mobile-link {% if 'messages' in request.url.path %}active{% endif %}" data-counter="messages"><span class="icon">✉️</span> Messages<span class="nav-badge nav-badge-inline" data-counter-badge {% if get_unread_messages(user["uid"]) == 0 %}hidden{% endif %}>{{ get_unread_messages(user["uid"]) }}</span></a>
<a href="/notifications" class="topnav-mobile-link {% if 'notifications' in request.url.path %}active{% endif %}"><span class="icon">&#x1F514;</span> Notifications</a> <a href="/notifications" class="topnav-mobile-link {% if 'notifications' in request.url.path %}active{% endif %}" data-counter="notifications"><span class="icon">🔔</span> Notifications<span class="nav-badge nav-badge-inline" data-counter-badge {% if get_unread_count(user["uid"]) == 0 %}hidden{% endif %}>{{ get_unread_count(user["uid"]) }}</span></a>
{% if user.get('role') == 'Admin' %} {% if user.get('role') == 'Admin' %}
<div class="topnav-mobile-divider"></div> <div class="topnav-mobile-divider"></div>
<div class="topnav-mobile-section-label">Admin</div> <div class="topnav-mobile-section-label">Admin</div>
<a href="/admin/users" class="topnav-mobile-link {% if 'admin/users' in request.url.path %}active{% endif %}"><span class="icon">&#x1F465;</span> Users</a> <a href="/admin/users" class="topnav-mobile-link {% if 'admin/users' in request.url.path %}active{% endif %}"><span class="icon">👥</span> Users</a>
<a href="/admin/news" class="topnav-mobile-link {% if 'admin/news' in request.url.path %}active{% endif %}"><span class="icon">&#x1F4F0;</span> News</a> <a href="/admin/news" class="topnav-mobile-link {% if 'admin/news' in request.url.path %}active{% endif %}"><span class="icon">📰</span> News</a>
<a href="/admin/services" class="topnav-mobile-link {% if 'admin/services' in request.url.path %}active{% endif %}"><span class="icon">&#x2699;&#xFE0F;</span> Services</a> <a href="/admin/services" class="topnav-mobile-link {% if 'admin/services' in request.url.path %}active{% endif %}"><span class="icon">⚙️</span> Services</a>
<a href="/admin/settings" class="topnav-mobile-link {% if 'admin/settings' in request.url.path %}active{% endif %}"><span class="icon">&#x1F527;</span> Settings</a> <a href="/admin/settings" class="topnav-mobile-link {% if 'admin/settings' in request.url.path %}active{% endif %}"><span class="icon">🔧</span> Settings</a>
{% endif %} {% endif %}
<div class="topnav-mobile-divider"></div> <div class="topnav-mobile-divider"></div>
<div class="topnav-mobile-user"> <div class="topnav-mobile-user">
@ -128,12 +128,13 @@
<span class="topnav-mobile-user-role">{{ user.get('role', 'Member') }}</span> <span class="topnav-mobile-user-role">{{ user.get('role', 'Member') }}</span>
</div> </div>
</div> </div>
<a href="/profile/{{ user['username'] }}" class="topnav-mobile-link"><span class="icon">&#x1F464;</span> Profile</a> <a href="/profile/{{ user['username'] }}" class="topnav-mobile-link"><span class="icon">👤</span> Profile</a>
<a href="/auth/logout" class="topnav-mobile-link"><span class="icon">&#x1F6AA;</span> Logout</a> <a href="/bookmarks/saved" class="topnav-mobile-link {% if 'bookmarks' in request.url.path %}active{% endif %}"><span class="icon">🔖</span> Saved</a>
<a href="/auth/logout" class="topnav-mobile-link"><span class="icon">🚪</span> Logout</a>
{% else %} {% else %}
<div class="topnav-mobile-divider"></div> <div class="topnav-mobile-divider"></div>
<a href="/auth/login" class="topnav-mobile-link"><span class="icon">&#x1F511;</span> Login</a> <a href="/auth/login" class="topnav-mobile-link"><span class="icon">🔑</span> Login</a>
<a href="/auth/signup" class="topnav-mobile-link"><span class="icon">&#x2728;</span> Sign Up</a> <a href="/auth/signup" class="topnav-mobile-link"><span class="icon"></span> Sign Up</a>
{% endif %} {% endif %}
</nav> </nav>
</div> </div>

View File

@ -10,13 +10,13 @@
<div class="bugs-header"> <div class="bugs-header">
<h1>Bug Reports</h1> <h1>Bug Reports</h1>
{% if user %} {% if user %}
<button class="btn btn-primary btn-sm" data-modal="create-bug-modal"><span class="icon">&#x1F41B;</span> Report Bug</button> <button type="button" class="btn btn-primary btn-sm" data-modal="create-bug-modal"><span class="icon">&#x1F41B;</span> Report Bug</button>
{% endif %} {% endif %}
</div> </div>
<div class="bugs-list"> <div class="bugs-list">
{% for item in bugs %} {% for item in bugs %}
<div class="bug-card fade-in"> <div class="bug-card">
<div class="bug-card-header"> <div class="bug-card-header">
{% set _user = item.author %}{% set _size = 24 %}{% set _size_class = "sm" %}{% include "_avatar_link.html" %} {% set _user = item.author %}{% set _size = 24 %}{% set _size_class = "sm" %}{% include "_avatar_link.html" %}
<span class="bug-title">{{ item.bug['title'] }}</span> <span class="bug-title">{{ item.bug['title'] }}</span>

View File

@ -49,8 +49,8 @@
<a href="/feed?tab=recent" class="feed-nav-btn {% if current_tab == 'recent' %}active{% endif %}"><span class="icon">&#x1F550;</span> Recent</a> <a href="/feed?tab=recent" class="feed-nav-btn {% if current_tab == 'recent' %}active{% endif %}"><span class="icon">&#x1F550;</span> Recent</a>
<a href="/feed?tab=following" class="feed-nav-btn {% if current_tab == 'following' %}active{% endif %}"><span class="icon">&#x1F465;</span> Following</a> <a href="/feed?tab=following" class="feed-nav-btn {% if current_tab == 'following' %}active{% endif %}"><span class="icon">&#x1F465;</span> Following</a>
<div class="feed-nav-actions"> <div class="feed-nav-actions">
<button class="btn-ghost btn-icon" title="Refresh" onclick="window.location.reload()">&#x21BB;</button> <button type="button" class="btn-ghost btn-icon" title="Refresh" aria-label="Refresh" data-reload></button>
<button class="btn-ghost btn-icon" title="Notifications" onclick="window.location.href='/notifications'">&#x1F514;</button> <a href="/notifications" class="btn-ghost btn-icon" title="Notifications" aria-label="Notifications">🔔</a>
</div> </div>
</div> </div>
@ -149,6 +149,19 @@
<input type="file" id="post-image" name="image" accept="image/*"> <input type="file" id="post-image" name="image" accept="image/*">
</div> </div>
<div class="auth-field auth-field-gap">
<button type="button" class="btn btn-secondary btn-sm" data-poll-toggle><span class="icon">&#x1F4CA;</span> <span data-poll-toggle-label>Add poll</span></button>
</div>
<div class="poll-builder" data-poll-builder hidden>
<input type="text" name="poll_question" maxlength="200" placeholder="Poll question" class="poll-builder-question" disabled>
<div class="poll-builder-options" data-poll-options>
<div class="poll-builder-row"><input type="text" name="poll_options" maxlength="100" placeholder="Option 1" disabled></div>
<div class="poll-builder-row"><input type="text" name="poll_options" maxlength="100" placeholder="Option 2" disabled></div>
</div>
<button type="button" class="btn-ghost btn-sm" data-poll-add-option>+ Add option</button>
<p class="poll-builder-error" data-poll-error hidden></p>
</div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="modal-close btn btn-secondary">Cancel</button> <button type="button" class="modal-close btn btn-secondary">Cancel</button>
<button type="submit" class="btn btn-primary">Post</button> <button type="submit" class="btn btn-primary">Post</button>

View File

@ -48,12 +48,14 @@
{% if user %} {% if user %}
{% 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" %} {% 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 %} {% endif %}
{% set _type = "gist" %}{% set _uid = gist['uid'] %}{% set _bookmarked = bookmarked %}{% include "_bookmark_button.html" %}
{% if is_owner %} {% if is_owner %}
<button class="gist-star-btn" data-modal="edit-gist-modal"><span class="icon">&#x270F;&#xFE0F;</span>Edit</button> <button type="button" class="gist-star-btn" data-modal="edit-gist-modal"><span class="icon">&#x270F;&#xFE0F;</span>Edit</button>
<form method="POST" action="/gists/delete/{{ gist['slug'] or gist['uid'] }}" style="display:inline;"> <form method="POST" action="/gists/delete/{{ gist['slug'] or gist['uid'] }}" style="display:inline;">
<button type="submit" class="gist-star-btn" data-confirm="Delete this gist?"><span class="icon">&#x1F5D1;&#xFE0F;</span>Delete</button> <button type="submit" class="gist-star-btn" data-confirm="Delete this gist?"><span class="icon">&#x1F5D1;&#xFE0F;</span>Delete</button>
</form> </form>
{% endif %} {% endif %}
{% set _type = "gist" %}{% set _uid = gist['uid'] %}{% set _reactions = reactions %}{% include "_reaction_bar.html" %}
</div> </div>
</article> </article>

View File

@ -50,7 +50,7 @@
<div class="gists-grid"> <div class="gists-grid">
{% for item in gists %} {% for item in gists %}
<div class="gist-card fade-in card-link-host"> <div class="gist-card card-link-host">
{% set _href = "/gists/" ~ (item.gist['slug'] or item.gist['uid']) %} {% set _href = "/gists/" ~ (item.gist['slug'] or item.gist['uid']) %}
{% set _label = item.gist['title'] %} {% set _label = item.gist['title'] %}
{% include "_card_link.html" %} {% include "_card_link.html" %}
@ -85,7 +85,7 @@
</div> </div>
{% if user %} {% if user %}
<button class="feed-fab" id="create-gist-btn" title="Create Gist" data-modal="create-gist-modal">+</button> <button type="button" class="feed-fab" id="create-gist-btn" title="Create Gist" data-modal="create-gist-modal">+</button>
{% call modal('create-gist-modal', 'Create Gist', wide=true) %} {% call modal('create-gist-modal', 'Create Gist', wide=true) %}
<form method="POST" action="/gists/create"> <form method="POST" action="/gists/create">

View File

@ -30,7 +30,7 @@
<div class="messages-main"> <div class="messages-main">
{% if other_user %} {% if other_user %}
<div class="messages-main-header"> <div class="messages-main-header">
<button class="messages-back-btn" id="messages-back-btn" aria-label="Back to conversations">&#x2190;</button> <button type="button" class="messages-back-btn" id="messages-back-btn" aria-label="Back to conversations">&#x2190;</button>
<a href="/profile/{{ other_user['username'] }}"> <a href="/profile/{{ other_user['username'] }}">
<img src="{{ avatar_url('multiavatar', other_user['username'], 32) }}" class="avatar-img avatar-sm" alt="{{ other_user['username'] }}" loading="lazy"> <img src="{{ avatar_url('multiavatar', other_user['username'], 32) }}" class="avatar-img avatar-sm" alt="{{ other_user['username'] }}" loading="lazy">
</a> </a>

View File

@ -40,6 +40,7 @@
Read on {{ article['source_name'] }} &#x2197; Read on {{ article['source_name'] }} &#x2197;
</a> </a>
<button type="button" class="news-detail-back" data-share="/news/{{ article['slug'] or article['uid'] }}">&#x1F517; Share</button> <button type="button" class="news-detail-back" data-share="/news/{{ article['slug'] or article['uid'] }}">&#x1F517; Share</button>
{% set _type = "news" %}{% set _uid = article['uid'] %}{% set _bookmarked = bookmarked %}{% include "_bookmark_button.html" %}
<a href="/news" class="news-detail-back">&larr; Back to News</a> <a href="/news" class="news-detail-back">&larr; Back to News</a>
</div> </div>
</div> </div>

View File

@ -6,10 +6,12 @@
<div class="notifications-page"> <div class="notifications-page">
<div class="notifications-header"> <div class="notifications-header">
<h2>Notifications</h2> <h2>Notifications</h2>
<button type="button" class="btn btn-ghost btn-sm" data-push-enable hidden><span class="icon">&#x1F514;</span>Enable push</button> <div class="notifications-actions">
<form method="POST" action="/notifications/mark-all-read" style="display:inline;"> <button type="button" class="btn btn-ghost btn-sm" data-push-enable hidden><span class="icon">🔔</span>Enable push</button>
<button type="submit" class="btn btn-ghost btn-sm"><span class="icon">&#x2705;</span>Clear</button> <form method="POST" action="/notifications/mark-all-read">
</form> <button type="submit" class="btn btn-ghost btn-sm"><span class="icon"></span>Clear</button>
</form>
</div>
</div> </div>
<div class="notifications-list"> <div class="notifications-list">

View File

@ -36,11 +36,17 @@
{% include "_attachment_display.html" %} {% include "_attachment_display.html" %}
{% endif %} {% endif %}
{% if poll %}
{% set _poll = poll %}{% include "_poll.html" %}
{% endif %}
<div class="post-detail-actions"> <div class="post-detail-actions">
{% set _uid = post['uid'] %}{% set _my_vote = my_vote %}{% set _count = post.get('stars', 0) %}{% include "_post_votes.html" %} {% set _uid = post['uid'] %}{% set _my_vote = my_vote %}{% set _count = post.get('stars', 0) %}{% include "_post_votes.html" %}
{% set _type = "post" %}{% set _uid = post['uid'] %}{% set _reactions = reactions %}{% include "_reaction_bar.html" %}
<button type="button" class="post-action-btn" data-share="/posts/{{ post['slug'] or post['uid'] }}">&#x1F517; Share</button> <button type="button" class="post-action-btn" data-share="/posts/{{ post['slug'] or post['uid'] }}">&#x1F517; Share</button>
{% set _type = "post" %}{% set _uid = post['uid'] %}{% set _bookmarked = bookmarked %}{% include "_bookmark_button.html" %}
{% if user and post['user_uid'] == user['uid'] %} {% if user and post['user_uid'] == user['uid'] %}
<button class="post-action-btn" data-modal="edit-post-modal"><span class="icon">&#x270F;&#xFE0F;</span>Edit</button> <button type="button" class="post-action-btn" data-modal="edit-post-modal"><span class="icon">&#x270F;&#xFE0F;</span>Edit</button>
<form method="POST" action="/posts/delete/{{ post['slug'] or post['uid'] }}" class="inline-form"> <form method="POST" action="/posts/delete/{{ post['slug'] or post['uid'] }}" class="inline-form">
<button type="submit" class="post-action-btn" data-confirm="Delete this post?"><span class="icon">&#x1F5D1;&#xFE0F;</span>Delete</button> <button type="submit" class="post-action-btn" data-confirm="Delete this post?"><span class="icon">&#x1F5D1;&#xFE0F;</span>Delete</button>
</form> </form>

View File

@ -147,6 +147,22 @@
<div class="profile-content"> <div class="profile-content">
<a href="/feed" class="back-link">&larr; Back</a> <a href="/feed" class="back-link">&larr; Back</a>
<div class="profile-heatmap-card">
<div class="heatmap-header">
<h3 class="heatmap-title">Contributions</h3>
<span class="streak-info"><span class="icon">&#x1F525;</span> {{ streak.current }} day streak &middot; Longest {{ streak.longest }}</span>
</div>
<div class="heatmap-grid">
{% for week in heatmap %}
<div class="heatmap-week">
{% for day in week %}
<span class="heatmap-cell level-{{ day.level }}" title="{{ day.count }} on {{ day.date }}"></span>
{% endfor %}
</div>
{% endfor %}
</div>
</div>
<div class="profile-tabs"> <div class="profile-tabs">
<a href="/profile/{{ profile_user['username'] }}?tab=posts" class="profile-tab {% if current_tab == 'posts' %}active{% endif %}"><span class="icon">&#x1F4DD;</span> Posts</a> <a href="/profile/{{ profile_user['username'] }}?tab=posts" class="profile-tab {% if current_tab == 'posts' %}active{% endif %}"><span class="icon">&#x1F4DD;</span> Posts</a>
<a href="/profile/{{ profile_user['username'] }}?tab=projects" class="profile-tab {% if current_tab == 'projects' %}active{% endif %}"><span class="icon">&#x1F680;</span> Projects</a> <a href="/profile/{{ profile_user['username'] }}?tab=projects" class="profile-tab {% if current_tab == 'projects' %}active{% endif %}"><span class="icon">&#x1F680;</span> Projects</a>
@ -164,7 +180,7 @@
{% endfor %} {% endfor %}
{% elif current_tab == 'projects' %} {% elif current_tab == 'projects' %}
{% for p in projects %} {% for p in projects %}
<a href="/projects/{{ p['slug'] or p['uid'] }}" class="project-card fade-in"> <a href="/projects/{{ p['slug'] or p['uid'] }}" class="project-card">
<div class="project-card-header"> <div class="project-card-header">
<h3 class="project-card-title">{{ p['title'] }}</h3> <h3 class="project-card-title">{{ p['title'] }}</h3>
</div> </div>
@ -189,7 +205,7 @@
{% elif current_tab == 'gists' %} {% elif current_tab == 'gists' %}
{% for entry in gists %} {% for entry in gists %}
{% set g = entry.gist %} {% set g = entry.gist %}
<a href="/gists/{{ g['slug'] or g['uid'] }}" class="gist-card fade-in"> <a href="/gists/{{ g['slug'] or g['uid'] }}" class="gist-card">
<div class="gist-card-header"> <div class="gist-card-header">
<h3 class="gist-card-title">{{ g['title'] }}</h3> <h3 class="gist-card-title">{{ g['title'] }}</h3>
<span class="gist-card-star">&#x2606; {{ g.get('stars', 0) }}</span> <span class="gist-card-star">&#x2606; {{ g.get('stars', 0) }}</span>
@ -209,7 +225,7 @@
{% endfor %} {% endfor %}
{% else %} {% else %}
{% for act in activities %} {% for act in activities %}
<div class="post-card fade-in activity-card"> <div class="post-card activity-card">
<div class="activity-row"> <div class="activity-row">
<span class="activity-icon"> <span class="activity-icon">
{% if act['type'] == 'post' %}&#x1F4DD;{% else %}&#x1F4AC;{% endif %} {% if act['type'] == 'post' %}&#x1F4DD;{% else %}&#x1F4AC;{% endif %}

View File

@ -57,11 +57,13 @@
{% if user %} {% if user %}
{% 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" %} {% 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 %} {% endif %}
{% set _type = "project" %}{% set _uid = project['uid'] %}{% set _bookmarked = bookmarked %}{% include "_bookmark_button.html" %}
{% if is_owner %} {% if is_owner %}
<form method="POST" action="/projects/delete/{{ project['slug'] or project['uid'] }}" style="display:inline;"> <form method="POST" action="/projects/delete/{{ project['slug'] or project['uid'] }}" style="display:inline;">
<button type="submit" class="project-star-btn" data-confirm="Delete this project?"><span class="icon">&#x1F5D1;&#xFE0F;</span> Delete</button> <button type="submit" class="project-star-btn" data-confirm="Delete this project?"><span class="icon">&#x1F5D1;&#xFE0F;</span> Delete</button>
</form> </form>
{% endif %} {% endif %}
{% set _type = "project" %}{% set _uid = project['uid'] %}{% set _reactions = reactions %}{% include "_reaction_bar.html" %}
</div> </div>
</article> </article>

View File

@ -54,7 +54,7 @@
<div class="projects-grid"> <div class="projects-grid">
{% for project in projects %} {% for project in projects %}
<div class="project-card fade-in card-link-host"> <div class="project-card card-link-host">
{% set _href = "/projects/" ~ (project['slug'] or project['uid']) %} {% set _href = "/projects/" ~ (project['slug'] or project['uid']) %}
{% set _label = project['title'] %} {% set _label = project['title'] %}
{% include "_card_link.html" %} {% include "_card_link.html" %}
@ -96,7 +96,7 @@
</div> </div>
{% if user %} {% if user %}
<button class="feed-fab" id="create-project-btn" title="Add Project" data-modal="create-project-modal">+</button> <button type="button" class="feed-fab" id="create-project-btn" title="Add Project" data-modal="create-project-modal">+</button>
{% call modal('create-project-modal', 'Create Project') %} {% call modal('create-project-modal', 'Create Project') %}
<form method="POST" action="/projects/create"> <form method="POST" action="/projects/create">

View File

@ -0,0 +1,27 @@
{% extends "base.html" %}
{% block extra_head %}
<link rel="stylesheet" href="/static/css/feed.css">
<link rel="stylesheet" href="/static/css/post.css">
{% endblock %}
{% block content %}
<div class="saved-page">
<h1 class="saved-title">Saved</h1>
<div class="saved-list">
{% for item in items %}
<a href="{{ item.url }}" class="saved-item">
<span class="saved-type badge">{{ item.type_label }}</span>
<span class="saved-item-title">{{ item.title }}</span>
<span class="saved-item-time">{{ item.time_ago }}</span>
</a>
{% else %}
<div class="empty-state">Nothing saved yet. Use the Save button on posts, gists, projects and news.</div>
{% endfor %}
</div>
{% if next_cursor %}
<div class="load-more-wrap">
<a href="/bookmarks/saved?before={{ next_cursor }}" class="btn btn-secondary">Load more</a>
</div>
{% endif %}
</div>
{% endblock %}

View File

@ -19,6 +19,19 @@
</div> </div>
{% endif %} {% endif %}
{% if registration_closed %}
<div class="auth-error">
<ul>
<li>Registration is currently closed.</li>
</ul>
</div>
<div class="auth-footer">
Already have an account? <a href="/auth/login">Sign in instead</a>
</div>
</div>
</div>
{% else %}
<form class="auth-form" method="POST" action="/auth/signup"> <form class="auth-form" method="POST" action="/auth/signup">
<div class="auth-field"> <div class="auth-field">
<label for="username">Username</label> <label for="username">Username</label>
@ -55,4 +68,5 @@
</div> </div>
</div> </div>
</div> </div>
{% endif %}
{% endblock %} {% endblock %}

View File

@ -1,19 +1,24 @@
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from devplacepy.cache import TTLCache from devplacepy.cache import TTLCache
from devplacepy.config import TEMPLATES_DIR from devplacepy.config import TEMPLATES_DIR
from devplacepy.constants import TOPICS from devplacepy.constants import TOPICS, REACTION_EMOJI
from devplacepy.database import get_table from devplacepy.database import get_table
from devplacepy.avatar import avatar_url from devplacepy.avatar import avatar_url
from devplacepy.utils import format_date as _format_date from devplacepy.utils import format_date as _format_date
from devplacepy.utils import badge_info from devplacepy.utils import badge_info
templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
_unread_cache = TTLCache(ttl=60) _unread_cache = TTLCache(ttl=10)
_messages_cache = TTLCache(ttl=10)
def clear_unread_cache(user_uid: str) -> None: def clear_unread_cache(user_uid: str) -> None:
_unread_cache.pop(user_uid) _unread_cache.pop(user_uid)
def clear_messages_cache(user_uid: str) -> None:
_messages_cache.pop(user_uid)
def jinja_unread_count(user_uid: str) -> int: def jinja_unread_count(user_uid: str) -> int:
cached = _unread_cache.get(user_uid) cached = _unread_cache.get(user_uid)
if cached is not None: if cached is not None:
@ -23,16 +28,28 @@ def jinja_unread_count(user_uid: str) -> int:
_unread_cache.set(user_uid, count) _unread_cache.set(user_uid, count)
return count return count
def jinja_unread_messages(user_uid: str) -> int:
cached = _messages_cache.get(user_uid)
if cached is not None:
return cached
messages = get_table("messages")
count = messages.count(receiver_uid=user_uid, read=False)
_messages_cache.set(user_uid, count)
return count
def jinja_user_projects(user_uid: str) -> list: def jinja_user_projects(user_uid: str) -> list:
projects = get_table("projects") projects = get_table("projects")
return list(projects.find(user_uid=user_uid)) return list(projects.find(user_uid=user_uid))
templates.env.globals["get_unread_count"] = jinja_unread_count templates.env.globals["get_unread_count"] = jinja_unread_count
templates.env.globals["get_unread_messages"] = jinja_unread_messages
templates.env.globals["get_user_projects"] = jinja_user_projects templates.env.globals["get_user_projects"] = jinja_user_projects
templates.env.globals["avatar_url"] = avatar_url templates.env.globals["avatar_url"] = avatar_url
templates.env.globals["format_date"] = _format_date templates.env.globals["format_date"] = _format_date
templates.env.globals["badge_info"] = badge_info templates.env.globals["badge_info"] = badge_info
templates.env.globals["TOPICS"] = TOPICS templates.env.globals["TOPICS"] = TOPICS
templates.env.globals["REACTION_EMOJI"] = REACTION_EMOJI
def jinja_max_upload_size_mb(): def jinja_max_upload_size_mb():
from devplacepy.database import get_int_setting from devplacepy.database import get_int_setting
return get_int_setting("max_upload_size_mb", 10) return get_int_setting("max_upload_size_mb", 10)

View File

@ -21,9 +21,9 @@ def verify_password(password: str, hashed: str) -> bool:
return pbkdf2_sha256.verify(password, hashed) return pbkdf2_sha256.verify(password, hashed)
def create_session(user_uid: str) -> str: def create_session(user_uid: str, max_age_seconds: int = SESSION_MAX_AGE) -> str:
token = secrets.token_hex(32) token = secrets.token_hex(32)
expires_at = datetime.now(timezone.utc) + timedelta(seconds=SESSION_MAX_AGE) expires_at = datetime.now(timezone.utc) + timedelta(seconds=max_age_seconds)
sessions = get_table("sessions") sessions = get_table("sessions")
sessions.insert({ sessions.insert({
"session_token": token, "session_token": token,
@ -242,6 +242,7 @@ BADGE_CATALOG = {
"Rising Star": {"icon": "", "description": "Earned 25 stars"}, "Rising Star": {"icon": "", "description": "Earned 25 stars"},
"Star Author": {"icon": "", "description": "Earned 100 stars"}, "Star Author": {"icon": "", "description": "Earned 100 stars"},
"Popular": {"icon": "", "description": "Reached 10 followers"}, "Popular": {"icon": "", "description": "Reached 10 followers"},
"On Fire": {"icon": "🔥", "description": "Maintained a 7-day activity streak"},
"Level 5": {"icon": "", "description": "Reached level 5"}, "Level 5": {"icon": "", "description": "Reached level 5"},
"Level 10": {"icon": "", "description": "Reached level 10"}, "Level 10": {"icon": "", "description": "Reached level 10"},
} }
@ -296,6 +297,10 @@ def check_milestone_badges(user_uid: str) -> list:
awarded.append("Rising Star") awarded.append("Rising Star")
if "Popular" not in held and get_table("follows").count(following_uid=user_uid) >= 10 and award_badge(user_uid, "Popular"): if "Popular" not in held and get_table("follows").count(following_uid=user_uid) >= 10 and award_badge(user_uid, "Popular"):
awarded.append("Popular") awarded.append("Popular")
if "On Fire" not in held:
from devplacepy.database import get_streaks
if get_streaks(user_uid)["current"] >= 7 and award_badge(user_uid, "On Fire"):
awarded.append("On Fire")
for badge_name in awarded: for badge_name in awarded:
notify_badge(user_uid, badge_name) notify_badge(user_uid, badge_name)
return awarded return awarded

View File

@ -133,6 +133,13 @@ def app_server(test_db_path):
f"Server did not start after 30s. " f"Server did not start after 30s. "
f"Log:\n{server_log()}" f"Log:\n{server_log()}"
) )
from devplacepy.database import get_table as _get_table
ops_settings = _get_table("site_settings")
for key, value in (("rate_limit_per_minute", "1000000"), ("rate_limit_window_seconds", "60")):
if not ops_settings.find_one(key=key):
ops_settings.insert({"uid": f"test_{key}", "key": key, "value": value})
yield proc yield proc
try: try:
proc.terminate() proc.terminate()

95
tests/test_bookmarks.py Normal file
View File

@ -0,0 +1,95 @@
import time
from datetime import datetime, timezone
import requests
from tests.conftest import BASE_URL
from devplacepy.database import get_table
from devplacepy.utils import generate_uid
_counter = [0]
AJAX = {"X-Requested-With": "fetch"}
def _session():
_counter[0] += 1
name = f"bkm{int(time.time() * 1000)}{_counter[0]}"
s = requests.Session()
s.post(f"{BASE_URL}/auth/signup", data={
"username": name, "email": f"{name}@t.dev",
"password": "secret123", "confirm_password": "secret123",
}, allow_redirects=True)
return s, name
def _uid(username):
return get_table("users").find_one(username=username)["uid"]
def _make_post(owner_uid, title):
uid = generate_uid()
get_table("posts").insert({
"uid": uid, "user_uid": owner_uid, "slug": f"{uid[:8]}-bookmark-post",
"title": title, "content": "bookmark target content", "topic": "random",
"project_uid": None, "image": None, "stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
})
return uid
def _make_gist(owner_uid, title):
uid = generate_uid()
get_table("gists").insert({
"uid": uid, "user_uid": owner_uid, "slug": f"{uid[:8]}-bookmark-gist",
"title": title, "description": None, "source_code": "print('x')",
"language": "python", "stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
})
return uid
def test_bookmark_toggle_on(app_server):
s, name = _session()
post_uid = _make_post(_uid(name), "Bookmark toggle on")
r = s.post(f"{BASE_URL}/bookmarks/post/{post_uid}", headers=AJAX)
assert r.json() == {"saved": True}
assert get_table("bookmarks").count(user_uid=_uid(name), target_uid=post_uid) == 1
def test_bookmark_toggle_off(app_server):
s, name = _session()
post_uid = _make_post(_uid(name), "Bookmark toggle off")
s.post(f"{BASE_URL}/bookmarks/post/{post_uid}", headers=AJAX)
r = s.post(f"{BASE_URL}/bookmarks/post/{post_uid}", headers=AJAX)
assert r.json() == {"saved": False}
assert get_table("bookmarks").count(user_uid=_uid(name), target_uid=post_uid) == 0
def test_saved_page_lists_post_and_gist(app_server):
s, name = _session()
owner_uid = _uid(name)
post_title = f"Saved post {int(time.time() * 1000)}"
gist_title = f"Saved gist {int(time.time() * 1000)}"
post_uid = _make_post(owner_uid, post_title)
gist_uid = _make_gist(owner_uid, gist_title)
s.post(f"{BASE_URL}/bookmarks/post/{post_uid}", headers=AJAX)
s.post(f"{BASE_URL}/bookmarks/gist/{gist_uid}", headers=AJAX)
html = s.get(f"{BASE_URL}/bookmarks/saved").text
assert post_title in html
assert gist_title in html
def test_invalid_target_type_returns_400(app_server):
s, name = _session()
post_uid = _make_post(_uid(name), "Bad target")
r = s.post(f"{BASE_URL}/bookmarks/widget/{post_uid}", headers=AJAX)
assert r.status_code == 400
def test_bookmark_requires_login(app_server):
owner_s, owner_name = _session()
post_uid = _make_post(_uid(owner_name), "Needs login")
anon = requests.Session()
r = anon.post(f"{BASE_URL}/bookmarks/post/{post_uid}", headers=AJAX, allow_redirects=False)
assert r.status_code == 303
assert get_table("bookmarks").count(target_uid=post_uid) == 0

View File

@ -0,0 +1,76 @@
import re
from playwright.sync_api import expect
from tests.conftest import BASE_URL
def _open_composer(page):
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
page.locator(".feed-fab").first.wait_for(state="visible", timeout=10000)
page.locator(".feed-fab").first.click()
def _create_plain_post(page, content):
_open_composer(page)
page.fill("#post-content", content)
page.locator("#create-post-modal button.btn-primary:has-text('Post')").click()
page.wait_for_url(f"{BASE_URL}/posts/*", wait_until="domcontentloaded")
def test_poll_builder_hidden_until_add_poll(alice):
page, _ = alice
_open_composer(page)
expect(page.locator("[data-poll-builder]")).to_be_hidden()
page.locator("[data-poll-toggle]").click()
expect(page.locator("[data-poll-builder]")).to_be_visible()
def test_create_poll_and_vote(alice):
page, _ = alice
_open_composer(page)
page.fill("#post-content", "Post that carries a poll for the UI test.")
page.locator("[data-poll-toggle]").click()
page.fill("#create-post-modal input[name='poll_question']", "Tabs or spaces?")
options = page.locator("#create-post-modal input[name='poll_options']")
options.nth(0).fill("Tabs")
options.nth(1).fill("Spaces")
page.locator("#create-post-modal button.btn-primary:has-text('Post')").click()
page.wait_for_url(f"{BASE_URL}/posts/*", wait_until="domcontentloaded")
expect(page.locator(".poll-question")).to_have_text("Tabs or spaces?")
expect(page.locator(".poll-option")).to_have_count(2)
page.locator(".poll-option").first.click()
expect(page.locator(".poll-option").first).to_have_class(re.compile(r"\bchosen\b"))
expect(page.locator(".poll-option").first.locator(".poll-option-pct")).to_be_visible()
def test_remove_poll_does_not_create_poll(alice):
page, _ = alice
_open_composer(page)
page.fill("#post-content", "Post where the poll is added then removed before posting.")
page.locator("[data-poll-toggle]").click()
page.fill("#create-post-modal input[name='poll_question']", "Discarded question?")
options = page.locator("#create-post-modal input[name='poll_options']")
options.nth(0).fill("One")
options.nth(1).fill("Two")
page.locator("[data-poll-toggle]").click()
page.locator("#create-post-modal button.btn-primary:has-text('Post')").click()
page.wait_for_url(f"{BASE_URL}/posts/*", wait_until="domcontentloaded")
expect(page.locator(".poll")).to_have_count(0)
def test_reaction_palette_toggle_and_react(alice):
page, _ = alice
_create_plain_post(page, "Post to react to in the reaction UI test.")
expect(page.locator(".reaction-chip:visible")).to_have_count(0)
expect(page.locator(".reaction-palette").first).to_be_hidden()
page.locator(".reaction-add-btn").first.click()
expect(page.locator(".reaction-palette").first).to_be_visible()
page.locator(".reaction-palette-btn").first.click()
expect(page.locator(".reaction-chip.reacted").first).to_be_visible()
expect(page.locator(".reaction-chip.reacted").first.locator(".reaction-count")).to_have_text("1")

140
tests/test_operational.py Normal file
View File

@ -0,0 +1,140 @@
import time
import requests
from tests.conftest import BASE_URL
from devplacepy.database import get_table
DEFAULT_MAINTENANCE_MESSAGE = "DevPlace is undergoing scheduled maintenance. Please check back shortly."
OPERATIONAL_FIELDS = (
"rate_limit_per_minute",
"rate_limit_window_seconds",
"news_service_interval",
"session_max_age_days",
"session_remember_days",
"registration_open",
"maintenance_mode",
"maintenance_message",
)
def _save_settings(page, **fields):
page.goto(f"{BASE_URL}/admin/settings", wait_until="domcontentloaded")
for name, value in fields.items():
locator = page.locator(f"#{name}")
tag = locator.evaluate("el => el.tagName.toLowerCase()")
if tag == "select":
page.select_option(f"#{name}", value)
else:
locator.fill(value)
page.click("button:has-text('Save Settings')")
page.wait_for_url("**/admin/settings", wait_until="domcontentloaded")
def test_operational_fields_render(alice):
page, _ = alice
page.goto(f"{BASE_URL}/admin/settings", wait_until="domcontentloaded")
assert page.is_visible("text=Operational")
for field in OPERATIONAL_FIELDS:
assert page.is_visible(f"#{field}"), field
def test_operational_settings_persist(alice):
page, _ = alice
try:
_save_settings(
page,
news_service_interval="1800",
session_remember_days="14",
maintenance_message="Custom maintenance text",
)
assert page.locator("#news_service_interval").input_value() == "1800"
assert page.locator("#session_remember_days").input_value() == "14"
assert page.locator("#maintenance_message").input_value() == "Custom maintenance text"
finally:
_save_settings(
page,
news_service_interval="3600",
session_remember_days="30",
maintenance_message=DEFAULT_MAINTENANCE_MESSAGE,
)
def test_maintenance_mode_blocks_guests(alice):
page, _ = alice
try:
_save_settings(page, maintenance_mode="1", maintenance_message="Down for tests")
response = requests.get(f"{BASE_URL}/feed")
assert response.status_code == 503
assert "Down for tests" in response.text
finally:
_save_settings(page, maintenance_mode="0", maintenance_message=DEFAULT_MAINTENANCE_MESSAGE)
def test_maintenance_mode_admin_retains_access(alice):
page, _ = alice
try:
_save_settings(page, maintenance_mode="1")
page.goto(f"{BASE_URL}/admin/settings", wait_until="domcontentloaded")
assert page.is_visible("#maintenance_mode")
login = requests.get(f"{BASE_URL}/auth/login")
assert login.status_code == 200
finally:
_save_settings(page, maintenance_mode="0")
def test_registration_closed_blocks_signup(alice):
page, _ = alice
try:
_save_settings(page, registration_open="0")
signup_page = requests.get(f"{BASE_URL}/auth/signup")
assert signup_page.status_code == 200
assert "Registration is currently closed" in signup_page.text
attempt = requests.post(
f"{BASE_URL}/auth/signup",
data={
"username": "closed_signup",
"email": "closed_signup@test.devplace",
"password": "secret123",
"confirm_password": "secret123",
},
)
assert "Registration is currently closed" in attempt.text
assert get_table("users").find_one(username="closed_signup") is None
finally:
_save_settings(page, registration_open="1")
def test_registration_open_allows_signup(alice):
page, _ = alice
_save_settings(page, registration_open="1")
signup_page = requests.get(f"{BASE_URL}/auth/signup")
assert "Registration is currently closed" not in signup_page.text
assert 'name="username"' in signup_page.text
def test_session_length_setting_applies(alice, browser):
page, _ = alice
context = browser.new_context(viewport={"width": 1400, "height": 900})
guest = context.new_page()
guest.set_default_timeout(15000)
try:
_save_settings(page, session_max_age_days="1")
username = f"sesslen_{int(time.time() * 1000)}"
guest.goto(f"{BASE_URL}/auth/signup", wait_until="domcontentloaded")
guest.fill("#username", username)
guest.fill("#email", f"{username}@test.devplace")
guest.fill("#password", "secret123")
guest.fill("#confirm_password", "secret123")
guest.click("button:has-text('Create account')")
guest.wait_for_url("**/feed", wait_until="domcontentloaded")
session_cookie = next(c for c in context.cookies() if c["name"] == "session")
remaining = session_cookie["expires"] - time.time()
assert 0 < remaining < 2 * 86400, remaining
finally:
guest.close()
context.close()
_save_settings(page, session_max_age_days="7")

128
tests/test_polls.py Normal file
View File

@ -0,0 +1,128 @@
import time
import requests
from tests.conftest import BASE_URL
from devplacepy.database import get_table
_counter = [0]
AJAX = {"X-Requested-With": "fetch"}
def _session():
_counter[0] += 1
name = f"pol{int(time.time() * 1000)}{_counter[0]}"
s = requests.Session()
s.post(f"{BASE_URL}/auth/signup", data={
"username": name, "email": f"{name}@t.dev",
"password": "secret123", "confirm_password": "secret123",
}, allow_redirects=True)
return s, name
def _create_post(session, title, question, options):
fields = [
("content", "Poll host post content for tests."),
("title", title),
("topic", "question"),
("poll_question", question),
]
fields.extend(("poll_options", option) for option in options)
r = session.post(f"{BASE_URL}/posts/create", data=fields, allow_redirects=False)
slug = r.headers["location"].split("/posts/")[-1]
return get_table("posts").find_one(slug=slug)["uid"]
def _poll_for(post_uid):
poll = get_table("polls").find_one(post_uid=post_uid)
options = list(get_table("poll_options").find(poll_uid=poll["uid"], order_by=["position"]))
return poll, options
def test_valid_poll_persists(app_server):
s, _ = _session()
title = f"valid-poll-{int(time.time() * 1000)}"
post_uid = _create_post(s, title, "Tabs or spaces?", ["Tabs", "Spaces", "Both"])
poll, options = _poll_for(post_uid)
assert poll is not None
assert len(options) == 3
def test_poll_dropped_with_one_option(app_server):
s, _ = _session()
title = f"one-option-{int(time.time() * 1000)}"
post_uid = _create_post(s, title, "Only one?", ["Solo"])
assert get_table("polls").find_one(post_uid=post_uid) is None
def test_poll_dropped_without_question(app_server):
s, _ = _session()
title = f"no-question-{int(time.time() * 1000)}"
post_uid = _create_post(s, title, "", ["Tabs", "Spaces"])
assert get_table("polls").find_one(post_uid=post_uid) is None
def test_vote_records_choice(app_server):
s, _ = _session()
title = f"vote-{int(time.time() * 1000)}"
post_uid = _create_post(s, title, "Pick one", ["A", "B"])
poll, options = _poll_for(post_uid)
r = s.post(f"{BASE_URL}/polls/{poll['uid']}/vote", data={"option_uid": options[0]["uid"]}, headers=AJAX)
payload = r.json()
assert payload["total"] == 1
assert payload["my_choice"] == options[0]["uid"]
assert get_table("poll_votes").count(poll_uid=poll["uid"]) == 1
def test_switch_vote_moves_choice(app_server):
s, _ = _session()
title = f"switch-{int(time.time() * 1000)}"
post_uid = _create_post(s, title, "Pick one", ["A", "B"])
poll, options = _poll_for(post_uid)
s.post(f"{BASE_URL}/polls/{poll['uid']}/vote", data={"option_uid": options[0]["uid"]}, headers=AJAX)
r = s.post(f"{BASE_URL}/polls/{poll['uid']}/vote", data={"option_uid": options[1]["uid"]}, headers=AJAX)
payload = r.json()
assert payload["my_choice"] == options[1]["uid"]
assert payload["total"] == 1
def test_repeat_same_option_retracts(app_server):
s, _ = _session()
title = f"retract-{int(time.time() * 1000)}"
post_uid = _create_post(s, title, "Pick one", ["A", "B"])
poll, options = _poll_for(post_uid)
s.post(f"{BASE_URL}/polls/{poll['uid']}/vote", data={"option_uid": options[0]["uid"]}, headers=AJAX)
r = s.post(f"{BASE_URL}/polls/{poll['uid']}/vote", data={"option_uid": options[0]["uid"]}, headers=AJAX)
payload = r.json()
assert payload["my_choice"] is None
assert payload["total"] == 0
assert get_table("poll_votes").count(poll_uid=poll["uid"]) == 0
def test_vote_invalid_option_returns_400(app_server):
s, _ = _session()
title = f"badopt-{int(time.time() * 1000)}"
post_uid = _create_post(s, title, "Pick one", ["A", "B"])
poll, _ = _poll_for(post_uid)
r = s.post(f"{BASE_URL}/polls/{poll['uid']}/vote", data={"option_uid": "not-a-real-option"}, headers=AJAX)
assert r.status_code == 400
def test_vote_requires_login(app_server):
s, _ = _session()
title = f"authvote-{int(time.time() * 1000)}"
post_uid = _create_post(s, title, "Pick one", ["A", "B"])
poll, options = _poll_for(post_uid)
anon = requests.Session()
r = anon.post(f"{BASE_URL}/polls/{poll['uid']}/vote", data={"option_uid": options[0]["uid"]}, headers=AJAX, allow_redirects=False)
assert r.status_code == 303
assert get_table("poll_votes").count(poll_uid=poll["uid"]) == 0
def test_feed_shows_poll_results_without_voting(app_server):
s, _ = _session()
title = f"feedpoll-{int(time.time() * 1000)}"
_create_post(s, title, "Visible results question?", ["Yes", "No"])
html = s.get(f"{BASE_URL}/feed").text
assert "Visible results question?" in html
assert "poll-option-bar" in html

View File

@ -2,9 +2,15 @@ import devplacepy.main as m
from starlette.testclient import TestClient from starlette.testclient import TestClient
def test_rate_limit_blocks_excess(monkeypatch): def _patch_limits(monkeypatch, limit, window=60):
monkeypatch.setattr(m, "RATE_LIMIT", 3) def fake_get_int_setting(key, default):
return {"rate_limit_per_minute": limit, "rate_limit_window_seconds": window}.get(key, default)
monkeypatch.setattr(m, "get_int_setting", fake_get_int_setting)
m._rate_limit_store.clear() m._rate_limit_store.clear()
def test_rate_limit_blocks_excess(monkeypatch):
_patch_limits(monkeypatch, 3)
client = TestClient(m.app) client = TestClient(m.app)
codes = [client.post("/", headers={"X-Real-IP": "9.9.9.9"}).status_code for _ in range(6)] codes = [client.post("/", headers={"X-Real-IP": "9.9.9.9"}).status_code for _ in range(6)]
assert 429 in codes, codes assert 429 in codes, codes
@ -12,8 +18,7 @@ def test_rate_limit_blocks_excess(monkeypatch):
def test_rate_limit_is_per_ip(monkeypatch): def test_rate_limit_is_per_ip(monkeypatch):
monkeypatch.setattr(m, "RATE_LIMIT", 2) _patch_limits(monkeypatch, 2)
m._rate_limit_store.clear()
client = TestClient(m.app) client = TestClient(m.app)
for _ in range(2): for _ in range(2):
client.post("/", headers={"X-Real-IP": "1.1.1.1"}) client.post("/", headers={"X-Real-IP": "1.1.1.1"})
@ -24,8 +29,16 @@ def test_rate_limit_is_per_ip(monkeypatch):
def test_get_requests_not_rate_limited(monkeypatch): def test_get_requests_not_rate_limited(monkeypatch):
monkeypatch.setattr(m, "RATE_LIMIT", 2) _patch_limits(monkeypatch, 2)
m._rate_limit_store.clear()
client = TestClient(m.app) client = TestClient(m.app)
codes = [client.get("/robots.txt", headers={"X-Real-IP": "3.3.3.3"}).status_code for _ in range(5)] codes = [client.get("/robots.txt", headers={"X-Real-IP": "3.3.3.3"}).status_code for _ in range(5)]
assert all(c == 200 for c in codes), codes assert all(c == 200 for c in codes), codes
def test_rate_limit_reads_configured_setting(monkeypatch):
_patch_limits(monkeypatch, 1)
client = TestClient(m.app)
first = client.post("/", headers={"X-Real-IP": "7.7.7.7"}).status_code
second = client.post("/", headers={"X-Real-IP": "7.7.7.7"}).status_code
assert first != 429
assert second == 429

155
tests/test_reactions.py Normal file
View File

@ -0,0 +1,155 @@
import sqlite3
import time
from datetime import datetime, timezone
import requests
from tests.conftest import BASE_URL
from devplacepy.config import DATABASE_URL
from devplacepy.database import get_table
from devplacepy.utils import generate_uid
from devplacepy.constants import REACTION_EMOJI
_counter = [0]
AJAX = {"X-Requested-With": "fetch"}
ROCKET = REACTION_EMOJI[2]
HEART = REACTION_EMOJI[1]
def _session():
_counter[0] += 1
name = f"rxn{int(time.time() * 1000)}{_counter[0]}"
s = requests.Session()
s.post(f"{BASE_URL}/auth/signup", data={
"username": name, "email": f"{name}@t.dev",
"password": "secret123", "confirm_password": "secret123",
}, allow_redirects=True)
return s, name
def _uid(username):
return get_table("users").find_one(username=username)["uid"]
def _live_query(sql, params):
conn = sqlite3.connect(DATABASE_URL.replace("sqlite:///", "", 1))
try:
return conn.execute(sql, params).fetchone()
finally:
conn.close()
def _live_reaction_count(target_type, target_uid):
return _live_query(
"SELECT COUNT(*) FROM reactions WHERE target_type=? AND target_uid=?",
(target_type, target_uid),
)[0]
def _live_post_uid(slug):
row = _live_query("SELECT uid FROM posts WHERE slug=?", (slug,))
return row[0] if row else None
def _make_post(owner_uid):
uid = generate_uid()
get_table("posts").insert({
"uid": uid, "user_uid": owner_uid, "slug": f"{uid[:8]}-reaction-post",
"title": None, "content": "reaction target content", "topic": "random",
"project_uid": None, "image": None, "stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
})
return uid
def _make_comment(post_uid, owner_uid):
uid = generate_uid()
get_table("comments").insert({
"uid": uid, "target_type": "post", "target_uid": post_uid, "post_uid": post_uid,
"user_uid": owner_uid, "content": "reaction target comment", "parent_uid": None,
"created_at": datetime.now(timezone.utc).isoformat(),
})
return uid
def test_add_reaction_returns_counts(app_server):
s, name = _session()
post_uid = _make_post(_uid(name))
r = s.post(f"{BASE_URL}/reactions/post/{post_uid}", data={"emoji": ROCKET}, headers=AJAX)
payload = r.json()
assert payload["counts"][ROCKET] == 1
assert ROCKET in payload["mine"]
assert get_table("reactions").count(target_type="post", target_uid=post_uid) == 1
def test_same_emoji_toggles_off(app_server):
s, name = _session()
post_uid = _make_post(_uid(name))
s.post(f"{BASE_URL}/reactions/post/{post_uid}", data={"emoji": ROCKET}, headers=AJAX)
r = s.post(f"{BASE_URL}/reactions/post/{post_uid}", data={"emoji": ROCKET}, headers=AJAX)
payload = r.json()
assert payload["counts"].get(ROCKET, 0) == 0
assert payload["mine"] == []
assert get_table("reactions").count(target_type="post", target_uid=post_uid) == 0
def test_two_distinct_emoji_both_counted(app_server):
s, name = _session()
post_uid = _make_post(_uid(name))
s.post(f"{BASE_URL}/reactions/post/{post_uid}", data={"emoji": ROCKET}, headers=AJAX)
r = s.post(f"{BASE_URL}/reactions/post/{post_uid}", data={"emoji": HEART}, headers=AJAX)
payload = r.json()
assert payload["counts"][ROCKET] == 1
assert payload["counts"][HEART] == 1
assert set(payload["mine"]) == {ROCKET, HEART}
def test_emoji_outside_palette_rejected(app_server):
s, name = _session()
post_uid = _make_post(_uid(name))
r = s.post(f"{BASE_URL}/reactions/post/{post_uid}", data={"emoji": "notanemoji"}, headers=AJAX, allow_redirects=False)
assert r.status_code == 303
assert get_table("reactions").count(target_type="post", target_uid=post_uid) == 0
def test_invalid_target_type_returns_400(app_server):
s, name = _session()
post_uid = _make_post(_uid(name))
r = s.post(f"{BASE_URL}/reactions/news/{post_uid}", data={"emoji": ROCKET}, headers=AJAX)
assert r.status_code == 400
def test_reaction_requires_login(app_server):
owner_s, owner_name = _session()
post_uid = _make_post(_uid(owner_name))
anon = requests.Session()
r = anon.post(f"{BASE_URL}/reactions/post/{post_uid}", data={"emoji": ROCKET}, headers=AJAX, allow_redirects=False)
assert r.status_code == 303
assert get_table("reactions").count(target_type="post", target_uid=post_uid) == 0
def test_react_on_comment_target(app_server):
s, name = _session()
owner_uid = _uid(name)
post_uid = _make_post(owner_uid)
comment_uid = _make_comment(post_uid, owner_uid)
r = s.post(f"{BASE_URL}/reactions/comment/{comment_uid}", data={"emoji": HEART}, headers=AJAX)
assert r.json()["counts"][HEART] == 1
assert get_table("reactions").count(target_type="comment", target_uid=comment_uid) == 1
def test_delete_post_cascades_reactions(app_server):
s, name = _session()
title = f"cascade-{int(time.time() * 1000)}"
r = s.post(f"{BASE_URL}/posts/create", data={
"content": "Post created via the server for the reaction cascade test.",
"title": title, "topic": "random",
}, allow_redirects=False)
slug = r.headers["location"].split("/posts/")[-1]
post_uid = _live_post_uid(slug)
s.post(f"{BASE_URL}/reactions/post/{post_uid}", data={"emoji": ROCKET}, headers=AJAX)
assert _live_reaction_count("post", post_uid) == 1
s.post(f"{BASE_URL}/posts/delete/{slug}", allow_redirects=False)
assert s.get(f"{BASE_URL}/posts/{slug}").status_code == 404
assert _live_reaction_count("post", post_uid) == 0

111
tests/test_streaks.py Normal file
View File

@ -0,0 +1,111 @@
import time
from datetime import datetime, timedelta, timezone
import requests
from tests.conftest import BASE_URL
from devplacepy.database import get_table, get_activity_calendar, get_streaks, get_activity_heatmap
from devplacepy.utils import generate_uid, check_milestone_badges
_counter = [0]
def _make_user():
_counter[0] += 1
uid = generate_uid()
name = f"stk{int(time.time() * 1000)}{_counter[0]}"
get_table("users").insert({
"uid": uid, "username": name, "email": f"{name}@t.dev",
"role": "Member", "is_active": True, "xp": 0, "level": 1,
"created_at": datetime.now(timezone.utc).isoformat(),
})
return uid
def _insert_post(user_uid, dt):
uid = generate_uid()
get_table("posts").insert({
"uid": uid, "user_uid": user_uid, "slug": f"{uid[:8]}-streak",
"title": None, "content": "streak activity", "topic": "random",
"project_uid": None, "image": None, "stars": 0,
"created_at": dt.isoformat(),
})
return uid
def _insert_comment(user_uid, post_uid, dt):
uid = generate_uid()
get_table("comments").insert({
"uid": uid, "target_type": "post", "target_uid": post_uid, "post_uid": post_uid,
"user_uid": user_uid, "content": "streak comment", "parent_uid": None,
"created_at": dt.isoformat(),
})
return uid
def test_consecutive_days_build_streak(app_server):
uid = _make_user()
today = datetime.now(timezone.utc)
for offset in (0, 1, 2):
_insert_post(uid, today - timedelta(days=offset))
streaks = get_streaks(uid)
assert streaks["current"] == 3
assert streaks["longest"] >= 3
def test_gap_resets_current_streak(app_server):
uid = _make_user()
today = datetime.now(timezone.utc)
_insert_post(uid, today)
_insert_post(uid, today - timedelta(days=3))
streaks = get_streaks(uid)
assert streaks["current"] == 1
def test_yesterday_only_counts_as_current(app_server):
uid = _make_user()
yesterday = datetime.now(timezone.utc) - timedelta(days=1)
_insert_post(uid, yesterday)
assert get_streaks(uid)["current"] == 1
def test_calendar_sums_across_sources(app_server):
uid = _make_user()
today = datetime.now(timezone.utc)
post_uid = _insert_post(uid, today)
_insert_comment(uid, post_uid, today)
calendar = get_activity_calendar(uid)
assert calendar.get(today.date().isoformat()) == 2
def test_heatmap_shape(app_server):
uid = _make_user()
weeks = get_activity_heatmap(uid)
assert 52 <= len(weeks) <= 54
assert all(len(week) <= 7 for week in weeks)
def test_seven_day_streak_awards_on_fire_badge(app_server):
uid = _make_user()
today = datetime.now(timezone.utc)
for offset in range(7):
_insert_post(uid, today - timedelta(days=offset))
check_milestone_badges(uid)
assert get_table("badges").count(user_uid=uid, badge_name="On Fire") == 1
def test_profile_renders_heatmap_and_streak(app_server):
_counter[0] += 1
name = f"stkp{int(time.time() * 1000)}{_counter[0]}"
s = requests.Session()
s.post(f"{BASE_URL}/auth/signup", data={
"username": name, "email": f"{name}@t.dev",
"password": "secret123", "confirm_password": "secret123",
}, allow_redirects=True)
s.post(f"{BASE_URL}/posts/create", data={
"content": "A post created today for the streak heatmap.",
"title": "Streak heatmap post", "topic": "devlog",
}, allow_redirects=True)
html = s.get(f"{BASE_URL}/profile/{name}").text
assert "heatmap-grid" in html
assert "1 day streak" in html