This commit is contained in:
parent
2eb1e6a004
commit
61c24f7d1e
68
AGENTS.md
68
AGENTS.md
@ -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 0–4); `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`.
|
||||||
|
|||||||
33
README.md
33
README.md
@ -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
|
||||||
|
|||||||
@ -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("/")
|
||||||
|
|
||||||
|
|||||||
@ -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"]
|
||||||
|
|||||||
@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
@ -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}")
|
||||||
|
|||||||
87
devplacepy/routers/bookmarks.py
Normal file
87
devplacepy/routers/bookmarks.py
Normal 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)
|
||||||
@ -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)
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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}")
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
44
devplacepy/routers/polls.py
Normal file
44
devplacepy/routers/polls.py
Normal 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)
|
||||||
@ -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)
|
||||||
|
|||||||
@ -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,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
46
devplacepy/routers/reactions.py
Normal file
46
devplacepy/routers/reactions.py
Normal 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)
|
||||||
@ -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)
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
318
devplacepy/static/css/engagement.css
Normal file
318
devplacepy/static/css/engagement.css
Normal 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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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(",");
|
||||||
|
|||||||
27
devplacepy/static/js/BookmarkManager.js
Normal file
27
devplacepy/static/js/BookmarkManager.js
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
44
devplacepy/static/js/CounterManager.js
Normal file
44
devplacepy/static/js/CounterManager.js
Normal 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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}";
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
182
devplacepy/static/js/PollManager.js
Normal file
182
devplacepy/static/js/PollManager.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
65
devplacepy/static/js/ReactionBar.js
Normal file
65
devplacepy/static/js/ReactionBar.js
Normal 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));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
5
devplacepy/templates/_bookmark_button.html
Normal file
5
devplacepy/templates/_bookmark_button.html
Normal 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">🔖</span> <span class="bookmark-label">{% if _bookmarked %}Saved{% else %}Save{% endif %}</span>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
@ -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">💬</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">🗑️</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 %}
|
||||||
|
|||||||
18
devplacepy/templates/_poll.html
Normal file
18
devplacepy/templates/_poll.html
Normal 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 %}>✓</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 %} · Log in to vote{% endif %}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
@ -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">
|
||||||
💬 {{ item.comment_count }}
|
💬 {{ 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">
|
||||||
↗︎ Open
|
↗︎ Open
|
||||||
</a>
|
</a>
|
||||||
{% if _show_share %}
|
{% if _show_share %}
|
||||||
<button type="button" class="post-action-btn" data-share="{{ content_url(item.post, 'posts') }}">🔗 Share</button>
|
<button type="button" class="post-action-btn" data-share="{{ content_url(item.post, 'posts') }}">🔗 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 %}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
24
devplacepy/templates/_reaction_bar.html
Normal file
24
devplacepy/templates/_reaction_bar.html
Normal 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">🙂</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 %}
|
||||||
@ -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">💾</span>Save Settings</button>
|
<button type="submit" class="btn btn-primary"><span class="icon">💾</span>Save Settings</button>
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@ -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">⬇️</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">🔕</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">🔔</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">☰</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">🔔</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">👥</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">📰</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">⚙️</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">🔧</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">👤</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">🚪</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">🔑</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">✨</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>
|
||||||
|
|||||||
@ -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">🐛</span> Report Bug</button>
|
<button type="button" class="btn btn-primary btn-sm" data-modal="create-bug-modal"><span class="icon">🐛</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>
|
||||||
|
|||||||
@ -49,8 +49,8 @@
|
|||||||
<a href="/feed?tab=recent" class="feed-nav-btn {% if current_tab == 'recent' %}active{% endif %}"><span class="icon">🕐</span> Recent</a>
|
<a href="/feed?tab=recent" class="feed-nav-btn {% if current_tab == 'recent' %}active{% endif %}"><span class="icon">🕐</span> Recent</a>
|
||||||
<a href="/feed?tab=following" class="feed-nav-btn {% if current_tab == 'following' %}active{% endif %}"><span class="icon">👥</span> Following</a>
|
<a href="/feed?tab=following" class="feed-nav-btn {% if current_tab == 'following' %}active{% endif %}"><span class="icon">👥</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()">↻</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'">🔔</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">📊</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>
|
||||||
|
|||||||
@ -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">✏️</span>Edit</button>
|
<button type="button" class="gist-star-btn" data-modal="edit-gist-modal"><span class="icon">✏️</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">🗑️</span>Delete</button>
|
<button type="submit" class="gist-star-btn" data-confirm="Delete this gist?"><span class="icon">🗑️</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>
|
||||||
|
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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">←</button>
|
<button type="button" class="messages-back-btn" id="messages-back-btn" aria-label="Back to conversations">←</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>
|
||||||
|
|||||||
@ -40,6 +40,7 @@
|
|||||||
Read on {{ article['source_name'] }} ↗
|
Read on {{ article['source_name'] }} ↗
|
||||||
</a>
|
</a>
|
||||||
<button type="button" class="news-detail-back" data-share="/news/{{ article['slug'] or article['uid'] }}">🔗 Share</button>
|
<button type="button" class="news-detail-back" data-share="/news/{{ article['slug'] or article['uid'] }}">🔗 Share</button>
|
||||||
|
{% set _type = "news" %}{% set _uid = article['uid'] %}{% set _bookmarked = bookmarked %}{% include "_bookmark_button.html" %}
|
||||||
<a href="/news" class="news-detail-back">← Back to News</a>
|
<a href="/news" class="news-detail-back">← Back to News</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -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">🔔</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">✅</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">
|
||||||
|
|||||||
@ -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'] }}">🔗 Share</button>
|
<button type="button" class="post-action-btn" data-share="/posts/{{ post['slug'] or post['uid'] }}">🔗 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">✏️</span>Edit</button>
|
<button type="button" class="post-action-btn" data-modal="edit-post-modal"><span class="icon">✏️</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">🗑️</span>Delete</button>
|
<button type="submit" class="post-action-btn" data-confirm="Delete this post?"><span class="icon">🗑️</span>Delete</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@ -147,6 +147,22 @@
|
|||||||
<div class="profile-content">
|
<div class="profile-content">
|
||||||
<a href="/feed" class="back-link">← Back</a>
|
<a href="/feed" class="back-link">← Back</a>
|
||||||
|
|
||||||
|
<div class="profile-heatmap-card">
|
||||||
|
<div class="heatmap-header">
|
||||||
|
<h3 class="heatmap-title">Contributions</h3>
|
||||||
|
<span class="streak-info"><span class="icon">🔥</span> {{ streak.current }} day streak · 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">📝</span> Posts</a>
|
<a href="/profile/{{ profile_user['username'] }}?tab=posts" class="profile-tab {% if current_tab == 'posts' %}active{% endif %}"><span class="icon">📝</span> Posts</a>
|
||||||
<a href="/profile/{{ profile_user['username'] }}?tab=projects" class="profile-tab {% if current_tab == 'projects' %}active{% endif %}"><span class="icon">🚀</span> Projects</a>
|
<a href="/profile/{{ profile_user['username'] }}?tab=projects" class="profile-tab {% if current_tab == 'projects' %}active{% endif %}"><span class="icon">🚀</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">☆ {{ g.get('stars', 0) }}</span>
|
<span class="gist-card-star">☆ {{ 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' %}📝{% else %}💬{% endif %}
|
{% if act['type'] == 'post' %}📝{% else %}💬{% endif %}
|
||||||
|
|||||||
@ -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">🗑️</span> Delete</button>
|
<button type="submit" class="project-star-btn" data-confirm="Delete this project?"><span class="icon">🗑️</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>
|
||||||
|
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
27
devplacepy/templates/saved.html
Normal file
27
devplacepy/templates/saved.html
Normal 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 %}
|
||||||
@ -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 %}
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
95
tests/test_bookmarks.py
Normal 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
|
||||||
76
tests/test_engagement_ui.py
Normal file
76
tests/test_engagement_ui.py
Normal 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
140
tests/test_operational.py
Normal 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
128
tests/test_polls.py
Normal 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
|
||||||
@ -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
155
tests/test_reactions.py
Normal 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
111
tests/test_streaks.py
Normal 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
|
||||||
Loading…
Reference in New Issue
Block a user