Compare commits

...
24 Commits
Author SHA1 Message Date
retoor 7cd7ce2f10 Update
DevPlace CI / test (push) Successful in 6m17s
2026-06-05 17:44:12 +02:00
retoor 83234b8c9f Update
DevPlace CI / test (push) Failing after 18m34s
2026-06-05 10:14:40 +02:00
retoor c6c0ec6c39 Update
DevPlace CI / test (push) Successful in 6m31s
2026-06-05 05:36:18 +02:00
retoor c826096843 Update
DevPlace CI / test (push) Failing after 5m28s
2026-06-05 04:43:06 +02:00
retoor 503aab26ac Update
DevPlace CI / test (push) Failing after 4m52s
2026-06-02 23:17:51 +02:00
retoor c3a8347cc0 iUpdate
DevPlace CI / test (push) Successful in 6m46s
2026-05-30 20:16:39 +02:00
retoor 3f900b4002 Updatex
DevPlace CI / test (push) Successful in 6m41s
2026-05-29 00:49:37 +02:00
retoor 3a4c6b14d5 Update
DevPlace CI / test (push) Has been cancelled
2026-05-29 00:45:07 +02:00
retoor 51832664c4 Updatex
DevPlace CI / test (push) Successful in 6m22s
2026-05-27 22:03:12 +02:00
retoor 4e26ad740e Update. 2026-05-27 21:07:02 +02:00
retoor 0cc22eb889 Update
DevPlace CI / test (push) Failing after 1m30s
2026-05-27 21:06:18 +02:00
retoor a5c71fd2f8 Update
DevPlace CI / test (push) Successful in 6m10s
2026-05-25 16:16:53 +02:00
retoor 347e5f0f31 Update
DevPlace CI / test (push) Successful in 5m33s
2026-05-23 10:54:45 +02:00
retoor fe0ed5b7e6 Upate
DevPlace CI / test (push) Successful in 5m27s
2026-05-23 10:35:40 +02:00
retoor df5e8cdca0 Update
DevPlace CI / test (push) Successful in 5m32s
2026-05-23 10:24:54 +02:00
retoor cb12887b12 Upate
DevPlace CI / test (push) Failing after 3m15s
2026-05-23 10:16:56 +02:00
retoor 895cca26d0 Upate
DevPlace CI / test (push) Failing after 1m54s
2026-05-23 10:08:26 +02:00
retoor d50003ce50 Update
DevPlace CI / test (push) Failing after 1m12s
2026-05-23 10:03:55 +02:00
retoor 8029050df4 Update 2026-05-23 10:03:27 +02:00
retoor cc703e3a5d Update
DevPlace CI / test (push) Failing after 5m16s
2026-05-23 09:01:11 +02:00
retoor ccc0ee4d61 Update.
DevPlace CI / test (push) Failing after 5m19s
2026-05-23 08:45:53 +02:00
retoor 0e61d42cf9 iUpdate 2026-05-23 08:45:53 +02:00
retoor c01dff4c00 Done
DevPlace CI / test (push) Has been cancelled
2026-05-23 08:41:47 +02:00
retoor 8c9da9df98 Refactor..
DevPlace CI / test (push) Failing after 1m22s
2026-05-23 08:34:13 +02:00
113 changed files with 3799 additions and 1303 deletions
+1
View File
@@ -31,3 +31,4 @@ jobs:
with:
name: failure-screenshots
path: /tmp/devplace_test_screenshots/
+4
View File
@@ -3,6 +3,10 @@ __pycache__/
*.egg-info/
.env
devplace.db*
devplace-services.lock
notification-private.pem
notification-private.pkcs8.pem
notification-public.pem
.pytest_cache/
.opencode
devplacepy/static/uploads/attachments/
+63 -14
View File
@@ -8,7 +8,6 @@ make dev # uvicorn --reload on port 10500, backlog 4096
make prod # uvicorn with 2 workers, backlog 8192 (production)
make test # Playwright integration + unit tests (fail-fast -x)
make test-headed # same tests in visible browser
make demo # full-journey GUI demo (headed)
make locust # Locust load test (interactive web UI)
make locust-headless # Locust in headless CLI mode (for CI)
```
@@ -43,6 +42,7 @@ make locust-headless # Locust in headless CLI mode (for CI)
| `/votes` | `routers/votes.py` |
| `/avatar` | `routers/avatar.py` |
| `/follow` | `routers/follow.py` |
| `/leaderboard` | `routers/leaderboard.py` |
| `/admin` | `routers/admin.py` |
| `/bugs` | `routers/bugs.py` |
| `/gists` | `routers/gists.py` |
@@ -55,10 +55,13 @@ make locust-headless # Locust in headless CLI mode (for CI)
1. **Emoji shortcodes** → Unicode emoji (`:fire:` → 🔥, 80+ shortcodes)
2. **Markdown parse** → via `marked` with GFM tables, line breaks
3. **Code syntax highlight**`highlight.js` on all `<pre><code>` blocks
4. **Image URLs** → standalone `.jpg/.png/.gif` URLs become `<img>` tags
5. **YouTube URLs**`youtube.com/watch?v=` or `youtu.be/` become embedded iframe players
6. **All URLs** → become `<a>` links with `target="_blank"` and `rel="noopener"`
3. **Sanitize**`DOMPurify.sanitize` strips script/event-handler/iframe/`javascript:` payloads from the marked output
4. **Code syntax highlight**`highlight.js` on all `<pre><code>` blocks
5. **Image URLs**standalone `.jpg/.png/.gif` URLs become `<img>` tags
6. **YouTube URLs**`youtube.com/watch?v=` or `youtu.be/` become embedded iframe players
7. **All URLs** → become `<a>` links with `target="_blank"` and `rel="noopener"`
**Sanitization is the XSS control.** Content is rendered client-side from `element.textContent`, so Jinja autoescaping does not protect it. `DOMPurify.sanitize` (vendored at `static/vendor/purify.min.js`, loaded `defer` in `base.html`) runs on the raw `marked` output before `processMedia` injects the trusted YouTube iframes, so user payloads are removed while our embeds survive. It is fail-closed: `render()` throws if `DOMPurify` is missing rather than emitting unsanitized HTML — never relax this into a `typeof` skip.
**Code blocks are protected** - `NodeIterator` skips `CODE`, `PRE`, `SCRIPT`, `STYLE` elements during URL/media processing, so source code in markdown code blocks is never touched.
@@ -71,6 +74,7 @@ Loaded via `<script>` tags in `base.html`. ALL must use `defer` to avoid blockin
```html
<script defer src="https://cdn.jsdelivr.net/npm/marked/lib/marked.umd.js"></script>
<script defer src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.11.1/build/highlight.min.js"></script>
<script defer src="/static/vendor/purify.min.js"></script>
<script type="module" src="https://cdn.jsdelivr.net/npm/emoji-picker-element@^1/index.js"></script>
<script defer src="/static/js/ContentRenderer.js"></script>
<script defer src="/static/js/EmojiPicker.js"></script>
@@ -107,6 +111,26 @@ trigger.addEventListener("click", (e) => {
The `modal-close` class is handled by `Application.js` - no inline JS needed in templates for basic modals.
Do NOT hand-write the overlay/header markup. Use the shared macro in `templates/_macros.html`:
```jinja
{% from "_macros.html" import modal %}
{% call modal('create-post-modal', 'Create New Post') %}{# wide=true for modal-card-wide #}
<form ...> ... <div class="modal-footer">...</div> </form>
{% endcall %}
```
## Shared template partials
Reuse these via `{% set _x = ... %}{% include %}` (the `_avatar_link.html` convention) instead of copy-pasting markup:
- `_post_votes.html` — post +/- vote bar. Locals: `_uid`, `_my_vote`, `_count`.
- `_star_vote.html` — project/gist star button. Locals: `_type` (`project`|`gist`), `_uid`, `_my_vote`, `_count`, `_btn_class`, optional `_stop` (adds `data-stop-propagation`). The star glyph (`☆``★` when `.voted`) comes from the `vote-star` CSS class via `::before` (`base.css`) — do not put a literal star in markup.
- `_post_header.html` — post author/avatar/time header (`.post-header`). Locals: `_author`, `_time`.
- `_topic_selector.html` — topic radio group. Locals: `_topics`, `_selected`.
Vote button styles live ONCE in `feed.css` (`.post-action-btn`) — never redefine them in `post.css`. Page-specific CSS goes in a `static/css/*.css` file referenced from `{% block extra_head %}`, never an inline `<style>` block.
## Database
SQLite via `dataset` with these pragmas on every connection:
@@ -175,6 +199,8 @@ if "comments" not in db.tables:
- **Never create your own `Jinja2Templates` instance.** Import the shared one: `from devplacepy.templating import templates`.
- **Register new routers in `main.py`:** `app.include_router(router_instance, prefix="/{path}")`
- **`require_user(request)` raises 303 redirect to `/`** if not authenticated. Only post/comment/vote/etc. routes use this - the feed is public.
- **For a missing detail resource, `raise not_found("X not found")`** (`utils.py`) — it returns an `HTTPException(404)` that the global handler renders as `error.html`. Do not return a bare `HTMLResponse(..., status_code=404)`.
- **Detail pages reuse `load_detail(table, target_type, slug, user)`** (`content.py`) for item+author+comments+attachments+`star_count`+`my_vote`; list pages reuse `enrich_items(items, key, authors, extra_maps, user=...)`. Prefer these over manual per-row loading (posts/projects/gists detail and feed/gists/profile lists already do).
- **`get_current_user(request)` is cached** in `_user_cache` dict by session token (per-process, no TTL). Use this for pages viewable by both auth guests (feed, news detail, projects).
- **Post deletion must cascade:** delete comments and votes first, then the post. Always check ownership: `post["user_uid"] == user["uid"]`.
- **Message deduplication needed** when `sender_uid == receiver_uid` (messaging yourself): `seen = set()` of message UIDs before appending to result list.
@@ -309,6 +335,10 @@ Notifications are created server-side in the route handlers and stored in the `n
| `vote` | Upvote on post or comment | `votes.py` | `value == 1` AND voter != owner |
| `follow` | Follow another user | `follow.py` | Always (self-follow blocked upstream) |
| `message` | Send a message | `messages.py` | Always (different user) |
| `level` | Reach a new level | `utils.py` `award_xp` | `new_level > current_level` |
| `badge` | Earn any badge | `utils.py` `notify_badge` | First grant only (`award_badge` returns `True`) |
`level`/`badge` notifications have `related_uid == user_uid` (self), so the notifications template renders them with the recipient's own avatar; the template is type-agnostic (driven by `message`/`actor`), so no per-type handling is needed.
### Time-grouped display
@@ -327,6 +357,33 @@ f"{user['username']} ++'d your post"
f"{user['username']} ++'d your comment"
```
## Gamification (XP, levels, badges, leaderboard)
The progression engine lives in `utils.py` and is wired into the existing content-creation, vote, and follow hooks. Do NOT scatter XP/badge logic — go through these helpers.
### XP and levels (`utils.py`)
- `award_xp(user_uid, amount)` — adds XP (clamped to `>= 0`), recomputes and stores `level`, invalidates the per-process user cache (`clear_user_cache`), and on level-up fires a `level` notification plus any level-milestone badge. Returns `{"xp", "level", "leveled_up"}`.
- `level_for_xp(xp)``1 + max(0, xp) // LEVEL_XP` (`LEVEL_XP = 100`). `level` is stored on the user (not derived in the template) so `profile.html`'s `xp % 100` progress bar keeps working.
- XP amounts are constants in `utils.py`: `XP_POST=10`, `XP_COMMENT=2`, `XP_PROJECT=15`, `XP_GIST=5`, `XP_UPVOTE=5`, `XP_FOLLOW=5`. Awards for received upvotes/followers go to the content owner / followed user and live **inside** the existing `owner_uid != user["uid"]` guards (`votes.py`, `follow.py`).
### Badges (`utils.py`)
- `award_badge(user_uid, name)` is idempotent (returns `True` only on first grant) — reuse it; never insert into `badges` directly.
- `check_milestone_badges(user_uid)` recomputes count/star thresholds and awards + notifies any newly crossed badge. Call it after content creation and after an upvote/follow that changes the recipient's totals.
- `award_rewards(user_uid, amount, first_badge=None)` is the single entry point for the create/upvote/follow reward sequence: it awards the optional first-time badge, calls `award_xp`, then `check_milestone_badges`. Use it instead of calling the three separately (posts/projects/gists/comments/follow/votes all go through it) so milestone checks are never skipped.
- Badge catalog + display metadata is `BADGE_CATALOG` in `utils.py`, exposed to templates as the `badge_info(name)` global. Names: Member, First Post, First Comment, First Project, First Gist, Prolific (10 posts), Rising Star (25 stars), Star Author (100 stars), Popular (10 followers), Level 5, Level 10. Unknown names fall back to a default icon and the name as description.
### Rank and leaderboard (`database.py`)
- `_ranked_authors()` builds (and 60s-caches in `_authors_cache`) the full list of authors with positive total stars, ordered desc, enriched via `get_users_by_uids`. `get_top_authors(limit)`, `get_leaderboard(limit, offset)` (adds a 1-based `rank`), and `get_user_rank(user_uid)` all slice/scan this one list — keep them consistent.
- `update_target_stars()` clears `_authors_cache` so a vote is reflected on the leaderboard and profile rank immediately (also keeps integration tests deterministic).
- The leaderboard page (`/leaderboard`, `routers/leaderboard.py`) shows the **top 50 only — no pagination** (`TOP_LIMIT = 50`). It is public (`get_current_user`), highlights the current user's row (`.leaderboard-row-self`), and is listed in `sitemap.xml`.
### Backfill
`_backfill_gamification()` runs at the end of `init_db()`. It computes XP from prior activity (same amounts as above) for users still at the default `xp=0`, sets `level`, then runs `check_milestone_badges` per user. Guarded on `xp=0` so it is idempotent across restarts.
## Inline Comment on Feed Cards
Every post card on the feed now has an inline comment form (`.feed-comment-form`) beneath the post actions. It posts to `/comments/create` like the detail page comment form. Tests must scope Post button clicks to `#create-post-modal button.btn-primary:has-text('Post')` to avoid matching the inline comment's Post button.
@@ -563,19 +620,11 @@ All tests must pass. Tests stop at first failure (`-x`).
### Step 7: Run full suite again (only if asked by user)
```bash
hawk .
make test
make test-headed # visual confirmation
```
### Step 8: Visual verification (if UI changed)
```bash
falcon take --output /tmp/verify.png
falcon describe /tmp/verify.png
```
### Step 9: Document
### Step 8: Document
- Update `AGENTS.md` if new conventions introduced
- Update `README.md` if new routes, config, or dependencies added
+14 -6
View File
@@ -7,7 +7,7 @@ LOCUST_SPAWN_RATE ?= 5
LOCUST_RUN_TIME ?= 120s
DEVPLACE_RATE_LIMIT ?= 1000000
.PHONY: install dev clean test test-headed demo locust locust-headless
.PHONY: install dev clean test test-headed locust locust-headless
install:
pip install -e .
@@ -24,17 +24,17 @@ test:
test-headed:
PLAYWRIGHT_HEADLESS=0 python -m pytest tests/ -v --tb=line -x
demo:
PLAYWRIGHT_HEADLESS=0 python -m pytest tests/test_demo.py -v -s --tb=line -x
locust:
export DEVPLACE_DATABASE_URL="sqlite:///$(LOCUST_DB)"; \
export DEVPLACE_RATE_LIMIT=$(DEVPLACE_RATE_LIMIT); \
fuser -k $(LOCUST_PORT)/tcp 2>/dev/null || true; \
sleep 1; \
mkdir -p $(LOCUST_DB_DIR); \
rm -f $(LOCUST_DB); \
uvicorn devplacepy.main:app --host 127.0.0.1 --port $(LOCUST_PORT) --backlog 8192 > /tmp/devplace_locust_server.log 2>&1 & \
PID=$$!; \
while ! curl -s http://127.0.0.1:$(LOCUST_PORT)/ > /dev/null 2>&1; do sleep 0.5; done; \
while kill -0 $$PID 2>/dev/null && ! curl -s http://127.0.0.1:$(LOCUST_PORT)/ > /dev/null 2>&1; do sleep 0.5; done; \
if ! kill -0 $$PID 2>/dev/null; then echo "Server failed to start (port $(LOCUST_PORT) busy?). See /tmp/devplace_locust_server.log"; exit 1; fi; \
locust --host http://127.0.0.1:$(LOCUST_PORT) --web-port $(LOCUST_WEB_PORT); \
kill $$PID 2>/dev/null || true; \
rm -rf $(LOCUST_DB_DIR)
@@ -42,11 +42,14 @@ locust:
locust-headless:
export DEVPLACE_DATABASE_URL="sqlite:///$(LOCUST_DB)"; \
export DEVPLACE_RATE_LIMIT=$(DEVPLACE_RATE_LIMIT); \
fuser -k $(LOCUST_PORT)/tcp 2>/dev/null || true; \
sleep 1; \
mkdir -p $(LOCUST_DB_DIR); \
rm -f $(LOCUST_DB); \
uvicorn devplacepy.main:app --host 127.0.0.1 --port $(LOCUST_PORT) --backlog 8192 > /tmp/devplace_locust_server.log 2>&1 & \
PID=$$!; \
while ! curl -s http://127.0.0.1:$(LOCUST_PORT)/ > /dev/null 2>&1; do sleep 0.5; done; \
while kill -0 $$PID 2>/dev/null && ! curl -s http://127.0.0.1:$(LOCUST_PORT)/ > /dev/null 2>&1; do sleep 0.5; done; \
if ! kill -0 $$PID 2>/dev/null; then echo "Server failed to start (port $(LOCUST_PORT) busy?). See /tmp/devplace_locust_server.log"; exit 1; fi; \
locust --host http://127.0.0.1:$(LOCUST_PORT) --headless -u $(LOCUST_USERS) -r $(LOCUST_SPAWN_RATE) --run-time $(LOCUST_RUN_TIME) --html=$(LOCUST_DB_DIR)/report.html; \
kill $$PID 2>/dev/null || true; \
rm -rf $(LOCUST_DB_DIR)
@@ -73,3 +76,8 @@ docker-logs:
docker-clean:
docker compose down -v
deploy:
git checkout production
git merge master
git push origin production
+81 -6
View File
@@ -11,7 +11,6 @@ make install # pip install -e .
make dev # uvicorn --reload on port 10500
make test # Playwright integration + unit tests, headless, fail-fast
make test-headed # same tests in visible browser
make demo # full-journey GUI demo (headed)
```
Open `http://localhost:10500`.
@@ -20,11 +19,11 @@ Open `http://localhost:10500`.
| Layer | Technology |
|-------|-----------|
| Backend | Python 3.13+, FastAPI, Uvicorn (single worker) |
| Backend | Python 3.13+, FastAPI, Uvicorn (multi-worker in production) |
| Templates | Jinja2 (server-side rendered) |
| Frontend | Pure ES6 JavaScript, one class per file |
| Database | SQLite via `dataset` (auto-sync schema, `uid` PKs, WAL mode, 30s busy timeout) |
| Auth | Session cookies, SHA256+SALT via passlib |
| Auth | Session cookies, PBKDF2-SHA256 via passlib |
| Avatars | Multiavatar (local SVG generation, no external API, <5ms) |
| Validation | `hawk` (Python/JS/CSS/HTML) |
| Load testing | Locust (locustfile.py) |
@@ -38,9 +37,10 @@ devplacepy/
database.py # dataset connection, index creation
templating.py # Shared Jinja2 environment + globals
avatar.py # Multiavatar generation, URL builder
utils.py # Password hashing, session mgmt, time_ago
utils.py # Password hashing, session mgmt, time_ago, notification hook
models.py # Pydantic schemas
routers/ # One file per domain (auth, feed, posts, ...)
push.py # Web push crypto, VAPID keys, encrypt/send/register
routers/ # One file per domain (auth, feed, posts, push, ...)
templates/ # Jinja2 HTML templates
static/css/ # Page-specific CSS files
static/js/ # Application.js (ES6 module)
@@ -62,11 +62,25 @@ devplacepy/
| `/notifications` | Notification list, mark read |
| `/votes` | Upvote/downvote on posts, comments, projects |
| `/follow` | Follow/unfollow users |
| `/leaderboard` | Contributor ranking by total stars earned |
| `/avatar` | Multiavatar proxy with in-memory cache |
| `/bugs` | Bug reports listing, creation |
| `/services` | Background service monitoring (status, logs) |
| `/admin` | Admin panel (user management, news curation, settings) |
| `(none)` | `/robots.txt`, `/sitemap.xml` (SEO) |
| `(none)` | `/push.json` (VAPID key + subscribe), `/service-worker.js`, `/manifest.json` (push + PWA) |
## Gamification
Member progression is driven by activity and peer recognition.
- **Stars** are the net vote score (`upvotes - downvotes`) on a post, project, or gist. A member's total stars is the sum across all their content and is the basis for ranking.
- **XP and levels.** Members earn XP for contributing: posting (10), commenting (2), publishing a project (15) or gist (5), receiving an upvote (5), and gaining a follower (5). Each level requires 100 XP (`level = 1 + xp // 100`). The profile shows the current level and progress to the next.
- **Badges** are awarded once per milestone: first post/comment/project/gist, 10 posts (Prolific), 25 stars (Rising Star), 100 stars (Star Author), 10 followers (Popular), and reaching levels 5 and 10. Badges render with an icon and description on the profile.
- **Leaderboard** (`/leaderboard`) ranks the top 50 members by total stars (single page, no pagination); a member's own rank is shown on their profile.
- **Reward notifications** fire when a member levels up or earns a badge.
XP awards are wired at the existing content-creation, vote, and follow hook points in the routers and centralized in `award_xp()` / `check_milestone_badges()` (`devplacepy/utils.py`). Existing accounts have their XP and levels backfilled once from prior activity at startup (`init_db()`).
## Configuration
@@ -74,6 +88,7 @@ devplacepy/
|---------|---------|---------|
| `DEVPLACE_DATABASE_URL` | `sqlite:///devplace.db` | Database connection string |
| `SECRET_KEY` | hardcoded fallback | Session signing key |
| `DEVPLACE_VAPID_SUB` | `mailto:retoor@molodetz.nl` | Contact address in the VAPID JWT `sub` claim |
## Background Services
@@ -110,6 +125,66 @@ News articles have detail pages at `/news/{slug}` with full comment support (sam
CLI: `devplace news clear` - delete all news from local database.
## Push notifications & PWA
Authenticated users can receive native web push notifications, and the site is an
installable Progressive Web App. Push uses only standard libraries (`cryptography`,
`PyJWT`, `httpx`) against the Web Push Protocol — no third-party push wrapper.
### Events
Every event that already produces an in-app notification also sends a web push,
because both share a single funnel — `create_notification()` in `utils.py`:
| Event | Recipient |
|-------|-----------|
| Direct message received | receiver |
| Comment on your post | post author |
| Reply to your comment | comment author |
| `@mention` in any content | mentioned user |
| Upvote on your content | content owner |
| New follower | followed user |
`create_notification` schedules delivery as a fire-and-forget async task, so a dead
subscription or push-service error never blocks the triggering request. Delivery
(`push.notify_user`) iterates a user's subscriptions, encrypts the payload
(legacy `aesgcm` content encoding), and POSTs to each endpoint; subscriptions that
return `404`/`410` are soft-deleted.
### VAPID keys
The server identity is three PEM files generated once at startup in the repository
root: `notification-private.pem`, `notification-private.pkcs8.pem`,
`notification-public.pem`. They are git-ignored.
**These keys are the application's identity to the push services. If they are lost or
regenerated, every existing subscription becomes permanently undeliverable.** Persist
them across deployments and back them up; do not regenerate them.
### Opt-in
The browser requires the first permission prompt to originate from a user gesture, so
opt-in is exposed as a button in both the top navigation (bell-with-slash icon) and on
the `/notifications` page. After opt-in, the subscription is refreshed silently on
every page load. `PushManager.js` owns registration, subscription, and the opt-in UI.
### PWA
`manifest.json` (192/512 and maskable icons), `service-worker.js`, and an install
button (`PwaInstaller.js`) make the app installable. The service worker uses a
network-first strategy for navigations and falls back to `static/offline.html` when
offline. Installation requires a secure origin (HTTPS, or `localhost` for development).
| File | Role |
|------|------|
| `devplacepy/push.py` | VAPID keys, payload encryption, send, register |
| `devplacepy/routers/push.py` | `/push.json`, `/service-worker.js`, `/manifest.json` |
| `static/js/PushManager.js` | Service-worker registration + subscribe + opt-in UI |
| `static/js/PwaInstaller.js` | `beforeinstallprompt` capture + install button |
| `static/service-worker.js` | Receives push, shows notification, offline fallback |
| `static/manifest.json` | PWA manifest (icons, display, theme) |
| `static/offline.html` | Offline fallback page |
## Database
SQLite via `dataset` with production-oriented pragmas set on every connection:
@@ -127,7 +202,7 @@ All indexes are created via `CREATE INDEX IF NOT EXISTS` wrapped in try/except -
## Testing
- **148 tests** across 14 files: Playwright integration + unit tests
- **274 tests** across 23 files: Playwright integration + unit tests
- Playwright (NOT pytest-playwright plugin - conflicts, uninstall it)
- Server starts as subprocess on port 10501 with isolated temp database
- Test users `alice_test` / `bob_test` seeded via HTTP at session start
+61 -30
View File
@@ -168,6 +168,7 @@ def store_attachment(file_bytes, original_filename, user_uid):
"image_width": image_width,
"image_height": image_height,
"has_thumbnail": 1 if thumbnail else 0,
"thumbnail_name": thumbnail,
"created_at": datetime.now(timezone.utc).isoformat(),
})
return {
@@ -183,42 +184,66 @@ def store_attachment(file_bytes, original_filename, user_uid):
def link_attachments(uids, target_type, target_uid):
if not uids:
flat = [uid.strip() for raw in uids or [] for uid in str(raw).split(",") if uid.strip()]
if not flat:
return
attachments = get_table("attachments")
for uid in uids:
uid = uid.strip()
if not uid:
continue
existing = attachments.find_one(uid=uid)
if existing:
attachments.update({"id": existing["id"], "uid": uid, "target_type": target_type, "target_uid": target_uid}, ["id"])
placeholders = ",".join(f":p{i}" for i in range(len(flat)))
params = {f"p{i}": uid for i, uid in enumerate(flat)}
db.query(
f"UPDATE attachments SET target_type=:tt, target_uid=:tu WHERE uid IN ({placeholders})",
tt=target_type, tu=target_uid, **params,
)
def _unlink_attachment_files(row):
stored_name = row.get("stored_name", "")
directory = row.get("directory", "")
if not (stored_name and directory):
return
file_path = ATTACHMENTS_DIR / directory / stored_name
try:
file_path.unlink(missing_ok=True)
except Exception as e:
logger.warning(f"Failed to delete attachment file {file_path}: {e}")
for thumb_path in (ATTACHMENTS_DIR / directory).glob(f"{Path(stored_name).stem}_thumb.*"):
try:
thumb_path.unlink(missing_ok=True)
except Exception as e:
logger.warning(f"Failed to delete thumbnail {thumb_path}: {e}")
def _delete_attachment_row(row):
_unlink_attachment_files(row)
get_table("attachments").delete(id=row["id"])
def delete_attachment(uid):
attachments = get_table("attachments")
attachment = attachments.find_one(uid=uid)
if not attachment:
return
stored_name = attachment.get("stored_name", "")
directory = attachment.get("directory", "")
if stored_name and directory:
file_path = ATTACHMENTS_DIR / directory / stored_name
try:
file_path.unlink(missing_ok=True)
except Exception as e:
logger.warning(f"Failed to delete attachment file {file_path}: {e}")
for thumb_path in (ATTACHMENTS_DIR / directory).glob(f"{Path(stored_name).stem}_thumb.*"):
try:
thumb_path.unlink(missing_ok=True)
except Exception as e:
logger.warning(f"Failed to delete thumbnail {thumb_path}: {e}")
attachments.delete(id=attachment["id"])
row = get_table("attachments").find_one(uid=uid)
if row:
_delete_attachment_row(row)
def delete_target_attachments(target_type, target_uid):
for attachment in get_table("attachments").find(target_type=target_type, target_uid=target_uid):
delete_attachment(attachment["uid"])
for row in get_table("attachments").find(target_type=target_type, target_uid=target_uid):
_delete_attachment_row(row)
def delete_attachments_for(target_type, target_uids):
uids = [uid for uid in target_uids if uid]
if not uids or "attachments" not in db.tables:
return
placeholders = ",".join(f":p{i}" for i in range(len(uids)))
params = {f"p{i}": uid for i, uid in enumerate(uids)}
rows = list(db.query(
f"SELECT * FROM attachments WHERE target_type=:tt AND target_uid IN ({placeholders})",
tt=target_type, **params,
))
if not rows:
return
for row in rows:
_unlink_attachment_files(row)
ids = ",".join(str(row["id"]) for row in rows)
db.query(f"DELETE FROM attachments WHERE id IN ({ids})")
def get_attachments(target_type, target_uid):
@@ -249,7 +274,13 @@ def get_attachments_batch(target_type, uids):
def _row_to_attachment(row):
stored_name = row.get("stored_name", "")
directory = row.get("directory", "")
thumb_name = f"{Path(stored_name).stem}_thumb.jpg" if row.get("has_thumbnail") else None
thumb_name = None
if row.get("has_thumbnail"):
thumb_name = row.get("thumbnail_name")
if not thumb_name:
stem = Path(stored_name).stem
png = f"{stem}_thumb.png"
thumb_name = png if (ATTACHMENTS_DIR / directory / png).exists() else f"{stem}_thumb.jpg"
return {
"uid": row["uid"],
"original_filename": row.get("original_filename", ""),
+14 -35
View File
@@ -58,44 +58,22 @@ def cmd_news_sanitize(args):
def cmd_attachments_prune(args):
from datetime import datetime, timezone, timedelta
from devplacepy.database import db
from devplacepy.config import STATIC_DIR
import os
deleted_records = 0
deleted_files = 0
freed_bytes = 0
from devplacepy.attachments import delete_attachment
if "attachments" in db.tables:
orphans = list(db["attachments"].find(resource_uid=""))
orphans += list(db["attachments"].find(resource_type=""))
seen = set()
unique_orphans = []
for o in orphans:
if o["uid"] not in seen:
seen.add(o["uid"])
unique_orphans.append(o)
if "attachments" not in db.tables:
print("Attachments table does not exist")
return
for att in unique_orphans:
sp = att.get("storage_path", "")
if sp:
fp = STATIC_DIR / "uploads" / sp
try:
if fp.exists():
freed_bytes += fp.stat().st_size
fp.unlink()
deleted_files += 1
parent = fp.parent
if parent.exists() and not any(parent.iterdir()):
parent.rmdir()
grandparent = parent.parent
if grandparent.exists() and not any(grandparent.iterdir()):
grandparent.rmdir()
except Exception as e:
print(f" Error deleting {sp}: {e}")
db["attachments"].delete(id=att["id"])
deleted_records += 1
print(f"Pruned {deleted_records} orphan records, {deleted_files} files, {freed_bytes / 1024:.1f} KB freed")
cutoff = (datetime.now(timezone.utc) - timedelta(hours=args.hours)).isoformat()
orphans = [
att for att in db["attachments"].find(target_type="", target_uid="")
if att.get("created_at", "") < cutoff
]
for att in orphans:
delete_attachment(att["uid"])
print(f"Pruned {len(orphans)} orphan attachment(s) older than {args.hours}h")
def main():
@@ -124,6 +102,7 @@ def main():
attachments = sub.add_parser("attachments", help="Attachment management")
att_sub = attachments.add_subparsers(title="action", dest="action")
att_prune = att_sub.add_parser("prune", help="Remove orphaned attachment records and files")
att_prune.add_argument("--hours", type=int, default=24, help="Only prune orphans older than this many hours")
att_prune.set_defaults(func=cmd_attachments_prune)
args = parser.parse_args()
+7
View File
@@ -12,3 +12,10 @@ SECRET_KEY = environ.get("SECRET_KEY", "devplace-secret-key-change-in-production
SESSION_MAX_AGE = 86400 * 7
PORT = 10500
SITE_URL = environ.get("DEVPLACE_SITE_URL", "").rstrip("/")
SERVICE_LOCK_FILE = BASE_DIR / "devplace-services.lock"
VAPID_PRIVATE_KEY_FILE = BASE_DIR / "notification-private.pem"
VAPID_PRIVATE_KEY_PKCS8_FILE = BASE_DIR / "notification-private.pkcs8.pem"
VAPID_PUBLIC_KEY_FILE = BASE_DIR / "notification-public.pem"
VAPID_SUB = environ.get("DEVPLACE_VAPID_SUB", "mailto:retoor@molodetz.nl")
+128
View File
@@ -0,0 +1,128 @@
# retoor <retoor@molodetz.nl>
import logging
from typing import Any
from datetime import datetime, timezone
from fastapi.responses import RedirectResponse
from devplacepy.database import (
get_table,
resolve_by_slug,
get_users_by_uids,
get_vote_counts,
get_user_votes,
load_comments,
db,
)
from devplacepy.attachments import delete_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
logger = logging.getLogger(__name__)
def is_owner(item: dict | None, user: dict | None) -> bool:
return bool(item and user and item["user_uid"] == user["uid"])
def create_content_item(table_name: str, target_type: str, user: dict, fields: dict, slug_source: str, xp: int, badge: str, mention_text: str, attachment_uids: list | None) -> tuple[str, str]:
uid = generate_uid()
slug = make_combined_slug(slug_source, uid)
get_table(table_name).insert({
"uid": uid,
"user_uid": user["uid"],
"slug": slug,
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
**fields,
})
award_rewards(user["uid"], xp, badge)
if attachment_uids:
link_attachments(attachment_uids, target_type, uid)
create_mention_notifications(mention_text, user["uid"], f"/{table_name}/{slug}")
logger.info(f"{target_type} {uid} created by {user['username']}")
return uid, slug
def detail_context(request, user: dict | None, detail: dict, key: str, seo_ctx: dict, extra: dict | None = None) -> dict:
context = {
**seo_ctx,
"request": request,
"user": user,
key: detail["item"],
"author": detail["author"],
"is_owner": detail["is_owner"],
"star_count": detail["star_count"],
"my_vote": detail["my_vote"],
"time_ago": detail["time_ago"],
"comments": detail["comments"],
"attachments": detail["attachments"],
}
if extra:
context.update(extra)
return context
def edit_content_item(table_name: str, user: dict, slug: str, update_fields: dict, redirect_fail: str) -> RedirectResponse:
table = get_table(table_name)
item = resolve_by_slug(table, slug)
if not is_owner(item, user):
return RedirectResponse(url=redirect_fail, status_code=302)
table.update({"uid": item["uid"], **update_fields}, ["uid"])
logger.info(f"{table_name} {item['uid']} edited by {user['username']}")
return RedirectResponse(url=f"/{table_name}/{item['slug'] or item['uid']}", status_code=302)
def delete_content_item(table_name: str, target_type: str, user: dict, slug: str, redirect_url: str, inline_image_field: str | None = None) -> RedirectResponse:
table = get_table(table_name)
item = resolve_by_slug(table, slug)
if is_owner(item, user):
delete_attachments_for(target_type, [item["uid"]])
comment_uids = []
if "comments" in db.tables:
comments = get_table("comments")
comment_uids = [comment["uid"] for comment in comments.find(target_uid=item["uid"])]
delete_attachments_for("comment", comment_uids)
comments.delete(target_uid=item["uid"])
if "votes" in db.tables:
votes = get_table("votes")
votes.delete(target_uid=item["uid"])
if comment_uids:
votes.delete(votes.table.columns.target_uid.in_(comment_uids), target_type="comment")
if inline_image_field:
delete_inline_image(item.get(inline_image_field))
table.delete(id=item["id"])
logger.info(f"{table_name} {item['uid']} deleted by {user['username']}")
return RedirectResponse(url=redirect_url, status_code=302)
def load_detail(table_name: str, target_type: str, slug: str, user: dict | None) -> dict | None:
item = resolve_by_slug(get_table(table_name), slug)
if not item:
return None
author = get_users_by_uids([item["user_uid"]]).get(item["user_uid"])
ups, downs = get_vote_counts([item["uid"]])
return {
"item": item,
"author": author,
"is_owner": bool(user and user["uid"] == item["user_uid"]),
"star_count": ups.get(item["uid"], 0) - downs.get(item["uid"], 0),
"my_vote": get_user_votes(user["uid"], [item["uid"]]).get(item["uid"], 0) if user else 0,
"comments": load_comments(target_type, item["uid"], user),
"attachments": get_attachments(target_type, item["uid"]),
"time_ago": time_ago(item["created_at"]),
}
def enrich_items(items: list, key: str, authors: dict, extra_maps: dict[str, Any] | None = None, ts_field: str = "created_at", user: dict | None = None) -> list:
extra_maps = extra_maps or {}
my_votes = get_user_votes(user["uid"], [item["uid"] for item in items]) if user else {}
enriched = []
for item in items:
entry = {
key: item,
"author": authors.get(item["user_uid"]),
"time_ago": time_ago(item[ts_field]),
"my_vote": my_votes.get(item["uid"], 0),
}
for name, source in extra_maps.items():
entry[name] = source(item) if callable(source) else source.get(item["uid"], 0)
enriched.append(entry)
return enriched
+243 -7
View File
@@ -1,5 +1,6 @@
import dataset
import logging
from collections import defaultdict
from datetime import datetime, timezone
from devplacepy.cache import TTLCache
from devplacepy.config import DATABASE_URL
@@ -50,6 +51,7 @@ def init_db():
_index(db, "messages", "idx_messages_receiver", ["receiver_uid"])
_index(db, "notifications", "idx_notifications_user", ["user_uid"])
_index(db, "notifications", "idx_notifications_user_read", ["user_uid", "read"])
_index(db, "push_registration", "idx_push_registration_user", ["user_uid"])
_index(db, "sessions", "idx_sessions_token", ["session_token"])
_index(db, "projects", "idx_projects_user", ["user_uid"])
_index(db, "badges", "idx_badges_user", ["user_uid"])
@@ -57,6 +59,7 @@ def init_db():
_index(db, "follows", "idx_follows_following", ["following_uid"])
_index(db, "password_resets", "idx_password_resets_token", ["token"])
_index(db, "gists", "idx_gists_user_uid", ["user_uid"])
_index(db, "gists", "idx_gists_language", ["language"])
_index(db, "attachments", "idx_attachments_resource", ["resource_type", "resource_uid"])
_index(db, "attachments", "idx_attachments_target", ["target_type", "target_uid"])
@@ -124,13 +127,74 @@ def init_db():
if not existing:
db["site_settings"].insert({"uid": f"default_{key}", "key": key, "value": value})
_backfill_gamification()
logger.info("Database initialized")
def _backfill_gamification():
if "users" not in db.tables:
return
from devplacepy.utils import (
level_for_xp, check_milestone_badges,
XP_POST, XP_COMMENT, XP_PROJECT, XP_GIST, XP_UPVOTE, XP_FOLLOW,
)
pending = list(db["users"].find(xp=0))
if not pending:
return
xp_by_user = defaultdict(int)
def add_counts(table, column, points):
if table not in db.tables:
return
for row in db.query(f"SELECT {column} AS uid, COUNT(*) AS c FROM {table} GROUP BY {column}"):
if row["uid"]:
xp_by_user[row["uid"]] += row["c"] * points
add_counts("posts", "user_uid", XP_POST)
add_counts("comments", "user_uid", XP_COMMENT)
add_counts("projects", "user_uid", XP_PROJECT)
add_counts("gists", "user_uid", XP_GIST)
add_counts("follows", "following_uid", XP_FOLLOW)
if "votes" in db.tables:
for content_table, target_type in (("posts", "post"), ("projects", "project"), ("gists", "gist"), ("comments", "comment")):
if content_table not in db.tables:
continue
rows = db.query(
f"SELECT c.user_uid AS uid, COUNT(*) AS c "
f"FROM votes v JOIN {content_table} c ON v.target_uid = c.uid "
f"WHERE v.target_type = :t AND v.value = 1 GROUP BY c.user_uid",
t=target_type,
)
for row in rows:
if row["uid"]:
xp_by_user[row["uid"]] += row["c"] * XP_UPVOTE
for user in pending:
xp = xp_by_user.get(user["uid"], 0)
if xp <= 0:
continue
db["users"].update({"uid": user["uid"], "xp": xp, "level": level_for_xp(xp)}, ["uid"])
_authors_cache.clear()
for user in pending:
check_milestone_badges(user["uid"])
logger.info(f"Gamification backfill processed {len(pending)} users")
def get_table(name):
return db[name]
def _in_clause(uids, prefix="p"):
placeholders = ", ".join(f":{prefix}{i}" for i in range(len(uids)))
params = {f"{prefix}{i}": uid for i, uid in enumerate(uids)}
return placeholders, params
def get_users_by_uids(uids):
if not uids:
return {}
@@ -142,8 +206,7 @@ def get_users_by_uids(uids):
def get_comment_counts_by_post_uids(post_uids):
if not post_uids or "comments" not in db.tables:
return {}
placeholders = ", ".join(f":p{i}" for i in range(len(post_uids)))
params = {f"p{i}": u for i, u in enumerate(post_uids)}
placeholders, params = _in_clause(post_uids)
rows = db.query(f"SELECT target_uid, COUNT(*) as c FROM comments WHERE target_type='post' AND target_uid IN ({placeholders}) GROUP BY target_uid", **params)
return {r["target_uid"]: r["c"] for r in rows}
@@ -151,8 +214,7 @@ def get_comment_counts_by_post_uids(post_uids):
def get_post_counts_by_user_uids(user_uids):
if not user_uids or "posts" not in db.tables:
return {}
placeholders = ", ".join(f":p{i}" for i in range(len(user_uids)))
params = {f"p{i}": u for i, u in enumerate(user_uids)}
placeholders, params = _in_clause(user_uids)
rows = db.query(f"SELECT user_uid, COUNT(*) as c FROM posts WHERE user_uid IN ({placeholders}) GROUP BY user_uid", **params)
return {r["user_uid"]: r["c"] for r in rows}
@@ -160,8 +222,7 @@ def get_post_counts_by_user_uids(user_uids):
def get_vote_counts(target_uids):
if not target_uids or "votes" not in db.tables:
return {}, {}
placeholders = ", ".join(f":p{i}" for i in range(len(target_uids)))
params = {f"p{i}": u for i, u in enumerate(target_uids)}
placeholders, params = _in_clause(target_uids)
rows = db.query(f"SELECT target_uid, value, COUNT(*) as c FROM votes WHERE target_uid IN ({placeholders}) GROUP BY target_uid, value", **params)
ups = {}
downs = {}
@@ -173,7 +234,16 @@ def get_vote_counts(target_uids):
return ups, downs
def load_comments(target_type, target_uid):
def get_user_votes(user_uid, target_uids):
if not user_uid or not target_uids or "votes" not in db.tables:
return {}
placeholders, params = _in_clause(target_uids)
params["uid"] = user_uid
rows = db.query(f"SELECT target_uid, value FROM votes WHERE user_uid = :uid AND target_uid IN ({placeholders})", **params)
return {r["target_uid"]: r["value"] for r in rows}
def load_comments(target_type, target_uid, user=None):
if "comments" not in db.tables:
return []
comments_table = db["comments"]
@@ -186,6 +256,7 @@ def load_comments(target_type, target_uid):
cids = [c["uid"] for c in raw]
users = get_users_by_uids(uids)
ups, downs = get_vote_counts(cids)
my_votes = get_user_votes(user["uid"], cids) if user else {}
from devplacepy.utils import time_ago
from devplacepy.attachments import get_attachments_batch as _gab
atts_map = _gab("comment", cids) if "attachments" in db.tables else {}
@@ -196,6 +267,7 @@ def load_comments(target_type, target_uid):
"author": users.get(c["user_uid"]),
"time_ago": time_ago(c["created_at"]),
"votes": {"up": ups.get(c["uid"], 0), "down": downs.get(c["uid"], 0)},
"my_vote": my_votes.get(c["uid"], 0),
"children": [],
"attachments": atts_map.get(c["uid"], []),
}
@@ -319,6 +391,84 @@ def get_site_stats() -> dict:
return stats
_gist_languages_cache = TTLCache(ttl=60)
def get_gist_languages() -> set[str]:
cached = _gist_languages_cache.get("codes")
if cached is not None:
return cached
codes: set[str] = set()
if "gists" in db.tables:
for row in db.query("SELECT DISTINCT language FROM gists"):
language = row.get("language")
if language:
codes.add(language)
_gist_languages_cache.set("codes", codes)
return codes
_STARRED_CONTENT_TABLES = ("posts", "projects", "gists")
_authors_cache = TTLCache(ttl=60)
def _ranked_authors() -> list:
cached = _authors_cache.get("ranked")
if cached is not None:
return cached
sources = [table for table in _STARRED_CONTENT_TABLES if table in db.tables]
if not sources:
_authors_cache.set("ranked", [])
return []
union = " UNION ALL ".join(f"SELECT user_uid, stars FROM {table}" for table in sources)
rows = db.query(
f"SELECT user_uid, SUM(stars) AS total FROM ({union}) "
f"GROUP BY user_uid HAVING total > 0 ORDER BY total DESC"
)
ranked = [(row["user_uid"], row["total"]) for row in rows]
users_map = get_users_by_uids([uid for uid, _ in ranked])
authors = []
for uid, total in ranked:
user = users_map.get(uid)
if user:
author = dict(user)
author["stars"] = total
authors.append(author)
_authors_cache.set("ranked", authors)
return authors
def get_top_authors(limit: int = 5) -> list:
return _ranked_authors()[:limit]
def get_leaderboard(limit: int = 50, offset: int = 0) -> list:
sliced = _ranked_authors()[offset:offset + limit]
leaderboard = []
for position, author in enumerate(sliced, start=offset + 1):
entry = dict(author)
entry["rank"] = position
leaderboard.append(entry)
return leaderboard
def get_user_rank(user_uid: str):
for position, author in enumerate(_ranked_authors(), start=1):
if author["uid"] == user_uid:
return position
return None
def get_user_stars(user_uid: str) -> int:
total = 0
for table in _STARRED_CONTENT_TABLES:
if table in db.tables:
for row in db.query(f"SELECT COALESCE(SUM(stars), 0) AS s FROM {table} WHERE user_uid = :u", u=user_uid):
total += row["s"] or 0
return total
def resolve_by_slug(table, slug):
entry = table.find_one(slug=slug)
if not entry:
@@ -326,6 +476,73 @@ def resolve_by_slug(table, slug):
return entry
def resolve_object_url(target_type: str, target_uid: str) -> str:
if target_type == "post":
post = resolve_by_slug(get_table("posts"), target_uid)
return f"/posts/{post['slug'] or post['uid']}" if post else "/feed"
if target_type == "project":
project = resolve_by_slug(get_table("projects"), target_uid)
return f"/projects/{project['slug'] or project['uid']}" if project else "/projects"
if target_type == "news":
article = resolve_by_slug(get_table("news"), target_uid)
if article:
return f"/news/{article.get('slug', '') or article['uid']}"
return "/news"
if target_type == "bug":
return f"/bugs?highlight={target_uid}"
if target_type == "gist":
gist = resolve_by_slug(get_table("gists"), target_uid)
return f"/gists/{gist['slug'] or gist['uid']}" if gist else "/gists"
if target_type == "comment":
comment = get_table("comments").find_one(uid=target_uid)
if not comment:
return "/feed"
parent_url = resolve_object_url(comment.get("target_type", "post"), comment.get("target_uid") or comment.get("post_uid", ""))
return f"{parent_url}#comment-{target_uid}"
return "/feed"
VOTABLE_TARGETS: dict[str, str] = {
"post": "posts",
"project": "projects",
"gist": "gists",
"comment": "comments",
}
STAR_TARGETS: set[str] = {"post", "project", "gist"}
def update_target_stars(target_type: str, target_uid: str, net_stars: int) -> None:
table_name = VOTABLE_TARGETS.get(target_type)
if not table_name or target_type not in STAR_TARGETS:
return
get_table(table_name).update({"uid": target_uid, "stars": net_stars}, ["uid"])
_authors_cache.clear()
def get_target_owner_uid(target_type: str, target_uid: str) -> str | None:
table_name = VOTABLE_TARGETS.get(target_type)
if not table_name:
return None
row = get_table(table_name).find_one(uid=target_uid)
return row["user_uid"] if row else None
PAGE_SIZE = 25
def paginate(table, *clauses, before=None, order=None, cursor_field="created_at", **filters):
order = order or ["-" + cursor_field]
clauses = list(clauses)
if before:
clauses.append(table.table.columns[cursor_field] < before)
rows = list(table.find(*clauses, **filters, order_by=order, _limit=PAGE_SIZE + 1))
has_more = len(rows) > PAGE_SIZE
rows = rows[:PAGE_SIZE]
next_cursor = rows[-1][cursor_field] if has_more and rows else None
return rows, next_cursor
def build_pagination(page, total, per_page=25):
total_pages = max(1, __import__("math").ceil(total / per_page))
page = max(1, min(page, total_pages))
@@ -353,3 +570,22 @@ def get_daily_topic():
"url": article.get("url", ""),
}
return {"title": "Welcome to DevPlace", "summary": "Stay tuned for the latest dev news."}
def get_featured_news(limit=5):
if "news" not in db.tables:
return []
from devplacepy.utils import time_ago
rows = list(db["news"].find(show_on_landing=1, order_by=["-synced_at"], _limit=limit))
articles = []
for article in rows:
summary = (article.get("description") or "")[:120] or (article.get("content") or "")[:120]
articles.append({
"title": article.get("title", ""),
"summary": summary,
"slug": article.get("slug", ""),
"url": article.get("url", ""),
"source_name": article.get("source_name", ""),
"time_ago": time_ago(article["synced_at"]) if article.get("synced_at") else "",
})
return articles
+28 -6
View File
@@ -1,4 +1,5 @@
import asyncio
import fcntl
import logging
import os
import time
@@ -7,12 +8,12 @@ from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.exceptions import RequestValidationError
from devplacepy.config import STATIC_DIR, PORT
from devplacepy.config import STATIC_DIR, PORT, SERVICE_LOCK_FILE
from devplacepy.database import init_db, get_table, db, get_users_by_uids, get_comment_counts_by_post_uids, get_vote_counts, get_news_images_by_uids
from devplacepy.templating import templates
from devplacepy.utils import get_current_user, time_ago
from devplacepy.seo import base_seo_context, site_url, website_schema, breadcrumb_schema, combine
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
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.services.manager import service_manager
from devplacepy.services.news import NewsService
@@ -26,6 +27,20 @@ _rate_limit_store = defaultdict(list)
RATE_LIMIT = int(os.environ.get("DEVPLACE_RATE_LIMIT", "60"))
RATE_WINDOW = 60
_service_lock_handle = None
def acquire_service_lock() -> bool:
global _service_lock_handle
handle = open(SERVICE_LOCK_FILE, "w")
try:
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
handle.close()
return False
_service_lock_handle = handle
return True
class UploadStaticFiles(StaticFiles):
async def get_response(self, path, scope):
response = await super().get_response(path, scope)
@@ -106,8 +121,10 @@ app.include_router(notifications.router, prefix="/notifications")
app.include_router(votes.router, prefix="/votes")
app.include_router(avatar.router, prefix="/avatar")
app.include_router(follow.router, prefix="/follow")
app.include_router(leaderboard.router, prefix="/leaderboard")
app.include_router(admin.router, prefix="/admin")
app.include_router(seo.router)
app.include_router(push.router)
app.include_router(bugs.router, prefix="/bugs")
app.include_router(gists.router, prefix="/gists")
app.include_router(news.router, prefix="/news")
@@ -140,10 +157,15 @@ async def rate_limit_middleware(request: Request, call_next):
@app.on_event("startup")
async def startup():
init_db()
from devplacepy.push import ensure_certificates
ensure_certificates()
if not os.environ.get("DEVPLACE_DISABLE_SERVICES"):
news_service = NewsService()
service_manager.register(news_service)
asyncio.create_task(service_manager.start_all())
if acquire_service_lock():
service_manager.register(NewsService())
asyncio.create_task(service_manager.start_all())
logger.info(f"Background services started in worker pid {os.getpid()}")
else:
logger.info(f"Worker pid {os.getpid()} declined service lock; another worker owns background services")
logger.info(f"DevPlace started on port {PORT}")
+276
View File
@@ -0,0 +1,276 @@
# retoor <retoor@molodetz.nl>
import base64
import json
import logging
import os
import random
import time
from datetime import datetime, timezone
from typing import Any
from urllib.parse import urlparse
import httpx
import jwt
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.hashes import SHA256
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from devplacepy.config import (
VAPID_PRIVATE_KEY_FILE,
VAPID_PRIVATE_KEY_PKCS8_FILE,
VAPID_PUBLIC_KEY_FILE,
VAPID_SUB,
)
from devplacepy.database import get_table
from devplacepy.utils import generate_uid
logger = logging.getLogger(__name__)
JWT_LIFETIME_SECONDS = 60 * 60
PUSH_TTL_SECONDS = "86400"
DEAD_SUBSCRIPTION_STATUSES = (404, 410)
ACCEPTED_STATUSES = (200, 201)
def generate_private_key() -> None:
if not VAPID_PRIVATE_KEY_FILE.exists():
private_key = ec.generate_private_key(ec.SECP256R1(), default_backend())
pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption(),
)
VAPID_PRIVATE_KEY_FILE.write_bytes(pem)
logger.info("Generated VAPID private key at %s", VAPID_PRIVATE_KEY_FILE)
def generate_pkcs8_private_key() -> None:
if not VAPID_PRIVATE_KEY_PKCS8_FILE.exists():
private_key = serialization.load_pem_private_key(
VAPID_PRIVATE_KEY_FILE.read_bytes(), password=None, backend=default_backend()
)
pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
)
VAPID_PRIVATE_KEY_PKCS8_FILE.write_bytes(pem)
logger.info("Generated VAPID PKCS8 private key at %s", VAPID_PRIVATE_KEY_PKCS8_FILE)
def generate_public_key() -> None:
if not VAPID_PUBLIC_KEY_FILE.exists():
private_key = serialization.load_pem_private_key(
VAPID_PRIVATE_KEY_FILE.read_bytes(), password=None, backend=default_backend()
)
pem = private_key.public_key().public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
VAPID_PUBLIC_KEY_FILE.write_bytes(pem)
logger.info("Generated VAPID public key at %s", VAPID_PUBLIC_KEY_FILE)
def ensure_certificates() -> None:
generate_private_key()
generate_pkcs8_private_key()
generate_public_key()
def hkdf(input_key: bytes, salt: bytes, info: bytes, length: int) -> bytes:
return HKDF(
algorithm=SHA256(),
length=length,
salt=salt,
info=info,
backend=default_backend(),
).derive(input_key)
def browser_base64(data: bytes) -> str:
return base64.urlsafe_b64encode(data).decode("utf-8").rstrip("=")
_keys: dict[str, Any] = {}
def _load_keys() -> dict[str, Any]:
if _keys:
return _keys
ensure_certificates()
private_key = serialization.load_pem_private_key(
VAPID_PRIVATE_KEY_FILE.read_bytes(), password=None, backend=default_backend()
)
public_key = serialization.load_pem_public_key(
VAPID_PUBLIC_KEY_FILE.read_bytes(), backend=default_backend()
)
uncompressed_point = public_key.public_bytes(
encoding=serialization.Encoding.X962,
format=serialization.PublicFormat.UncompressedPoint,
)
_keys["private_key_pem"] = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption(),
)
_keys["public_key_point"] = uncompressed_point
_keys["public_key_base64"] = browser_base64(uncompressed_point)
logger.debug("Loaded VAPID key material into cache")
return _keys
def public_key_standard_b64() -> str:
point = _load_keys()["public_key_point"]
return base64.b64encode(point).decode("utf-8").rstrip("=")
def create_notification_authorization(push_url: str) -> str:
target = urlparse(push_url)
audience = f"{target.scheme}://{target.netloc}"
issued_at = int(time.time())
return jwt.encode(
{
"sub": VAPID_SUB,
"aud": audience,
"exp": issued_at + JWT_LIFETIME_SECONDS,
"nbf": issued_at,
"iat": issued_at,
"jti": generate_uid(),
},
_load_keys()["private_key_pem"],
algorithm="ES256",
)
def create_notification_info_with_payload(
endpoint: str, auth: str, p256dh: str, payload: str
) -> dict[str, Any]:
message_private_key = ec.generate_private_key(ec.SECP256R1(), default_backend())
message_public_key_bytes = message_private_key.public_key().public_bytes(
encoding=serialization.Encoding.X962,
format=serialization.PublicFormat.UncompressedPoint,
)
salt = os.urandom(16)
user_key_bytes = base64.urlsafe_b64decode(p256dh + "==")
shared_secret = message_private_key.exchange(
ec.ECDH(),
ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), user_key_bytes),
)
encryption_key = hkdf(
shared_secret,
base64.urlsafe_b64decode(auth + "=="),
b"Content-Encoding: auth\x00",
32,
)
context = (
b"P-256\x00"
+ len(user_key_bytes).to_bytes(2, "big")
+ user_key_bytes
+ len(message_public_key_bytes).to_bytes(2, "big")
+ message_public_key_bytes
)
nonce = hkdf(encryption_key, salt, b"Content-Encoding: nonce\x00" + context, 12)
content_encryption_key = hkdf(
encryption_key, salt, b"Content-Encoding: aesgcm\x00" + context, 16
)
padding_length = random.randint(0, 16)
padding = padding_length.to_bytes(2, "big") + b"\x00" * padding_length
data = AESGCM(content_encryption_key).encrypt(
nonce, padding + payload.encode("utf-8"), None
)
return {
"headers": {
"Authorization": f"WebPush {create_notification_authorization(endpoint)}",
"Crypto-Key": f"dh={browser_base64(message_public_key_bytes)}; p256ecdsa={_load_keys()['public_key_base64']}",
"Encryption": f"salt={browser_base64(salt)}",
"Content-Encoding": "aesgcm",
"Content-Length": str(len(data)),
"Content-Type": "application/octet-stream",
},
"data": data,
}
def _mark_subscription_dead(subscription_id: int) -> None:
get_table("push_registration").update(
{"id": subscription_id, "deleted_at": datetime.now(timezone.utc).isoformat()},
["id"],
)
logger.info("Soft-deleted dead push subscription id=%s", subscription_id)
async def notify_user(user_uid: str, payload: dict[str, Any]) -> None:
registrations = list(
get_table("push_registration").find(user_uid=user_uid, deleted_at=None)
)
if not registrations:
logger.debug("No active push subscriptions for user %s", user_uid)
return
body = json.dumps(payload)
async with httpx.AsyncClient(timeout=10.0) as client:
for subscription in registrations:
endpoint = subscription["endpoint"]
try:
notification_info = create_notification_info_with_payload(
endpoint,
subscription["key_auth"],
subscription["key_p256dh"],
body,
)
headers = {**notification_info["headers"], "TTL": PUSH_TTL_SECONDS}
response = await client.post(
endpoint, headers=headers, content=notification_info["data"]
)
except (httpx.HTTPError, ValueError) as exc:
logger.warning("Push error for %s via %s: %s", user_uid, endpoint, exc)
continue
if response.status_code in ACCEPTED_STATUSES:
logger.debug("Push delivered to %s via %s", user_uid, endpoint)
elif response.status_code in DEAD_SUBSCRIPTION_STATUSES:
_mark_subscription_dead(subscription["id"])
else:
logger.warning(
"Push rejected (%s) for %s via %s", response.status_code, user_uid, endpoint
)
async def register(
user_uid: str, endpoint: str, key_auth: str, key_p256dh: str
) -> tuple[dict[str, Any], bool]:
table = get_table("push_registration")
existing = table.find_one(
user_uid=user_uid,
endpoint=endpoint,
key_auth=key_auth,
key_p256dh=key_p256dh,
deleted_at=None,
)
if existing:
logger.debug("Push subscription already registered for user %s", user_uid)
return existing, False
record = {
"uid": generate_uid(),
"user_uid": user_uid,
"endpoint": endpoint,
"key_auth": key_auth,
"key_p256dh": key_p256dh,
"created_at": datetime.now(timezone.utc).isoformat(),
"deleted_at": None,
}
table.insert(record)
logger.info("Registered push subscription for user %s", user_uid)
return record, True
+1 -2
View File
@@ -1,10 +1,9 @@
import logging
from typing import Annotated
from datetime import datetime
from fastapi import APIRouter, Request, Form
from devplacepy.models import AdminRoleForm, AdminPasswordForm, AdminSettingsForm
from fastapi.responses import HTMLResponse, RedirectResponse
from devplacepy.database import get_table, db, build_pagination, get_post_counts_by_user_uids, get_news_images_by_uids, clear_settings_cache
from devplacepy.database import get_table, build_pagination, get_post_counts_by_user_uids, get_news_images_by_uids, clear_settings_cache
from devplacepy.templating import templates
from devplacepy.utils import require_admin, hash_password, generate_uid, time_ago, clear_user_cache
from devplacepy.seo import base_seo_context, site_url, website_schema
+3 -8
View File
@@ -7,7 +7,7 @@ from fastapi import APIRouter, Request, Form
from fastapi.responses import RedirectResponse, HTMLResponse
from devplacepy.database import get_table
from devplacepy.templating import templates
from devplacepy.utils import hash_password, verify_password, create_session, generate_uid, get_current_user
from devplacepy.utils import hash_password, verify_password, create_session, generate_uid, get_current_user, award_badge, clear_session_cache
from devplacepy.seo import base_seo_context
from devplacepy.models import SignupForm, LoginForm, ForgotPasswordForm, ResetPasswordForm
@@ -82,13 +82,7 @@ async def signup(request: Request, data: Annotated[SignupForm, Form()]):
"created_at": datetime.now(timezone.utc).isoformat(),
})
badges = get_table("badges")
badges.insert({
"uid": generate_uid(),
"user_uid": uid,
"badge_name": "Member",
"created_at": datetime.now(timezone.utc).isoformat(),
})
award_badge(uid, "Member")
token = create_session(uid)
response = RedirectResponse(url="/feed", status_code=302)
@@ -211,6 +205,7 @@ async def logout(request: Request):
session = sessions.find_one(session_token=token)
if session:
sessions.delete(id=session["id"])
clear_session_cache(token)
response = RedirectResponse(url="/", status_code=302)
response.delete_cookie("session")
return response
+1 -1
View File
@@ -32,7 +32,7 @@ async def bugs_page(request: Request):
"bug": b,
"author": users_map.get(b["user_uid"]),
"time_ago": time_ago(b["created_at"]),
"comments": load_comments("bug", b["uid"]),
"comments": load_comments("bug", b["uid"], user),
"attachments": attachments_map.get(b["uid"], []),
})
+13 -62
View File
@@ -3,36 +3,16 @@ from typing import Annotated
from datetime import datetime, timezone
from fastapi import APIRouter, Request, Form
from fastapi.responses import RedirectResponse
from devplacepy.database import get_table, resolve_by_slug
from devplacepy.database import get_table, resolve_object_url
from devplacepy.attachments import link_attachments, delete_target_attachments
from devplacepy.templating import clear_unread_cache
from devplacepy.utils import generate_uid, require_user, create_mention_notifications
from devplacepy.content import is_owner
from devplacepy.utils import generate_uid, require_user, create_mention_notifications, create_notification, award_rewards, XP_COMMENT
from devplacepy.models import CommentForm
logger = logging.getLogger(__name__)
router = APIRouter()
def resolve_target_redirect(target_type, target_uid):
if target_type == "post":
post = resolve_by_slug(get_table("posts"), target_uid)
return f"/posts/{post['slug'] or post['uid']}" if post else "/feed"
if target_type == "project":
project = resolve_by_slug(get_table("projects"), target_uid)
return f"/projects/{project['slug'] or project['uid']}" if project else "/projects"
if target_type == "news":
article = resolve_by_slug(get_table("news"), target_uid)
if article:
return f"/news/{article.get('slug', '') or article['uid']}"
return "/news"
if target_type == "bug":
return f"/bugs?highlight={target_uid}"
if target_type == "gist":
gist = resolve_by_slug(get_table("gists"), target_uid)
return f"/gists/{gist['slug'] or gist['uid']}" if gist else "/gists"
return "/bugs"
@router.post("/create")
async def create_comment(request: Request, data: Annotated[CommentForm, Form()]):
user = require_user(request)
@@ -41,7 +21,7 @@ async def create_comment(request: Request, data: Annotated[CommentForm, Form()])
target_type = data.target_type
parent_uid = data.parent_uid
redirect_url = resolve_target_redirect(target_type, target_uid)
redirect_url = resolve_object_url(target_type, target_uid)
comment_uid = generate_uid()
insert = {
@@ -60,51 +40,24 @@ async def create_comment(request: Request, data: Annotated[CommentForm, Form()])
if data.attachment_uids:
link_attachments(data.attachment_uids, "comment", comment_uid)
badges = get_table("badges")
existing = badges.find_one(user_uid=user["uid"], badge_name="First Comment")
if not existing:
badges.insert({
"uid": generate_uid(),
"user_uid": user["uid"],
"badge_name": "First Comment",
"created_at": datetime.now(timezone.utc).isoformat(),
})
award_rewards(user["uid"], XP_COMMENT, "First Comment")
comment_url = f"{redirect_url}#comment-{comment_uid}"
if target_type == "post":
if parent_uid:
comments_table = get_table("comments")
parent = comments_table.find_one(uid=parent_uid)
parent = get_table("comments").find_one(uid=parent_uid)
if parent and parent["user_uid"] != user["uid"]:
notifications = get_table("notifications")
notifications.insert({
"uid": generate_uid(),
"user_uid": parent["user_uid"],
"type": "reply",
"message": f"{user['username']} replied to your comment",
"related_uid": user["uid"],
"read": False,
"created_at": datetime.now(timezone.utc).isoformat(),
})
clear_unread_cache(parent["user_uid"])
create_notification(parent["user_uid"], "reply", f"{user['username']} replied to your comment", user["uid"], comment_url)
else:
posts = get_table("posts")
post = posts.find_one(uid=target_uid)
if not post:
post = posts.find_one(slug=target_uid)
if post and post["user_uid"] != user["uid"]:
notifications = get_table("notifications")
notifications.insert({
"uid": generate_uid(),
"user_uid": post["user_uid"],
"type": "comment",
"message": f"{user['username']} commented on your post",
"related_uid": user["uid"],
"read": False,
"created_at": datetime.now(timezone.utc).isoformat(),
})
clear_unread_cache(post["user_uid"])
create_notification(post["user_uid"], "comment", f"{user['username']} commented on your post", user["uid"], comment_url)
create_mention_notifications(content, user["uid"], redirect_url)
create_mention_notifications(content, user["uid"], comment_url)
logger.info(f"Comment by {user['username']} on {target_type} {target_uid}")
return RedirectResponse(url=redirect_url, status_code=302)
@@ -114,9 +67,7 @@ async def delete_comment(request: Request, comment_uid: str):
user = require_user(request)
comments = get_table("comments")
comment = comments.find_one(uid=comment_uid)
if not comment:
return RedirectResponse(url="/feed", status_code=302)
if comment["user_uid"] != user["uid"]:
if not is_owner(comment, user):
return RedirectResponse(url="/feed", status_code=302)
target_type = comment.get("target_type", "post")
target_uid = comment.get("target_uid") or comment.get("post_uid", "")
@@ -124,5 +75,5 @@ async def delete_comment(request: Request, comment_uid: str):
get_table("votes").delete(target_uid=comment_uid, target_type="comment")
comments.delete(id=comment["id"])
logger.info(f"Comment {comment_uid} deleted by {user['username']}")
redirect_url = resolve_target_redirect(target_type, target_uid)
redirect_url = resolve_object_url(target_type, target_uid)
return RedirectResponse(url=redirect_url, status_code=302)
+13 -42
View File
@@ -1,24 +1,20 @@
import logging
from fastapi import APIRouter, Request
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_site_stats
from devplacepy.database import get_table, get_daily_topic, get_users_by_uids, get_comment_counts_by_post_uids, get_site_stats, get_top_authors, paginate
from devplacepy.attachments import get_attachments_batch
from devplacepy.content import enrich_items
from devplacepy.templating import templates
from devplacepy.utils import get_current_user, time_ago
from devplacepy.seo import base_seo_context, site_url, website_schema, breadcrumb_schema, combine
from devplacepy.utils import get_current_user
from devplacepy.seo import list_page_seo
logger = logging.getLogger(__name__)
router = APIRouter()
PAGE_SIZE = 25
def get_feed_posts(user, tab: str = "all", topic: str = None, before: str = None):
posts_table = get_table("posts")
filters = {}
if topic:
filters["topic"] = topic
order = ["-stars", "-created_at"] if tab == "trending" else ["-created_at"]
if tab == "following":
if not user:
@@ -27,40 +23,18 @@ def get_feed_posts(user, tab: str = "all", topic: str = None, before: str = None
following = [f["following_uid"] for f in follows.find(follower_uid=user["uid"])]
if not following:
return [], None
posts = list(posts_table.find(posts_table.table.columns.user_uid.in_(following), order_by=["-created_at"], _limit=PAGE_SIZE))
posts, next_cursor = paginate(posts_table, posts_table.table.columns.user_uid.in_(following), before=before, order=order)
else:
order = ["-created_at"]
if tab == "trending":
order = ["-stars", "-created_at"]
if before:
posts = list(posts_table.find(**filters, order_by=order, _limit=PAGE_SIZE + 1))
posts = [p for p in posts if p["created_at"] < before][:PAGE_SIZE]
else:
posts = list(posts_table.find(**filters, order_by=order, _limit=PAGE_SIZE + 1))
has_more = len(posts) > PAGE_SIZE
posts = posts[:PAGE_SIZE]
next_cursor = None
if has_more and posts:
next_cursor = posts[-1]["created_at"]
filters = {"topic": topic} if topic else {}
posts, next_cursor = paginate(posts_table, before=before, order=order, **filters)
if not posts:
return [], next_cursor
uids = [p["user_uid"] for p in posts]
post_uids = [p["uid"] for p in posts]
authors = get_users_by_uids(uids)
counts = get_comment_counts_by_post_uids(post_uids)
authors = get_users_by_uids([p["user_uid"] for p in posts])
counts = get_comment_counts_by_post_uids([p["uid"] for p in posts])
result = []
for post in posts:
result.append({
"post": post,
"author": authors.get(post["user_uid"]),
"time_ago": time_ago(post["created_at"]),
"comment_count": counts.get(post["uid"], 0),
})
result = enrich_items(posts, "post", authors, {"comment_count": counts}, user=user)
return result, next_cursor
@@ -68,9 +42,8 @@ def get_feed_posts(user, tab: str = "all", topic: str = None, before: str = None
async def feed_page(request: Request, tab: str = "all", topic: str = None, before: str = None):
user = get_current_user(request)
posts, next_cursor = get_feed_posts(user, tab, topic, before)
users_table = get_table("users")
stats = get_site_stats()
top_authors = list(users_table.find(stars={">": 0}, order_by=["-stars"], _limit=5))
top_authors = get_top_authors(5)
daily_topic = get_daily_topic()
post_uids_list = [item["post"]["uid"] for item in posts]
@@ -78,13 +51,11 @@ async def feed_page(request: Request, tab: str = "all", topic: str = None, befor
for item in posts:
item["attachments"] = attachments_map.get(item["post"]["uid"], [])
base = site_url(request)
seo_ctx = base_seo_context(
seo_ctx = list_page_seo(
request,
title="Feed",
description="Discover the latest developer discussions, projects, and community activity on DevPlace.",
breadcrumbs=[{"name": "Home", "url": "/feed"}, {"name": "Feed", "url": "/feed"}],
schemas=[website_schema(base)],
)
return templates.TemplateResponse(request, "feed.html", {
**seo_ctx,
+3 -13
View File
@@ -3,8 +3,7 @@ from datetime import datetime, timezone
from fastapi import APIRouter, Request
from fastapi.responses import RedirectResponse
from devplacepy.database import get_table
from devplacepy.templating import clear_unread_cache
from devplacepy.utils import generate_uid, require_user
from devplacepy.utils import generate_uid, require_user, create_notification, award_rewards, XP_FOLLOW
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -30,17 +29,8 @@ async def follow_user(request: Request, username: str):
"created_at": datetime.now(timezone.utc).isoformat(),
})
notifications = get_table("notifications")
notifications.insert({
"uid": generate_uid(),
"user_uid": target["uid"],
"type": "follow",
"message": f"{user['username']} started following you",
"related_uid": user["uid"],
"read": False,
"created_at": datetime.now(timezone.utc).isoformat(),
})
clear_unread_cache(target["uid"])
create_notification(target["uid"], "follow", f"{user['username']} started following you", user["uid"], f"/profile/{user['username']}")
award_rewards(target["uid"], XP_FOLLOW)
logger.info(f"{user['username']} followed {username}")
return RedirectResponse(url=f"/profile/{username}", status_code=302)
+32 -110
View File
@@ -1,14 +1,13 @@
import logging
from typing import Annotated
from datetime import datetime, timezone
from fastapi import APIRouter, Request, HTTPException, Form
from fastapi import APIRouter, Request, Form
from devplacepy.models import GistForm, GistEditForm
from fastapi.responses import HTMLResponse, RedirectResponse
from devplacepy.database import get_table, load_comments, get_vote_counts, resolve_by_slug, db
from devplacepy.attachments import get_attachments, delete_target_attachments
from devplacepy.database import get_table, get_users_by_uids, get_gist_languages, paginate
from devplacepy.content import load_detail, edit_content_item, delete_content_item, enrich_items, create_content_item, detail_context
from devplacepy.templating import templates
from devplacepy.utils import generate_uid, get_current_user, require_user, time_ago, make_combined_slug, create_mention_notifications
from devplacepy.seo import base_seo_context, site_url, website_schema, breadcrumb_schema, combine, software_source_code_schema
from devplacepy.utils import get_current_user, require_user, not_found, XP_GIST
from devplacepy.seo import base_seo_context, site_url, website_schema, software_source_code_schema, list_page_seo
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -24,7 +23,7 @@ LANGUAGES = [
]
def get_gists_list(user_uid=None, language=None):
def get_gists_list(user_uid=None, language=None, before=None, viewer=None):
gists_table = get_table("gists")
filters = {}
if user_uid:
@@ -32,33 +31,21 @@ def get_gists_list(user_uid=None, language=None):
if language:
filters["language"] = language
all_gists = list(gists_table.find(**filters, order_by=["-created_at"]))
total = gists_table.count(**filters)
gists, next_cursor = paginate(gists_table, before=before, **filters)
if not all_gists:
return []
if not gists:
return [], next_cursor, total
from devplacepy.database import get_users_by_uids
uids = [g["user_uid"] for g in all_gists]
users_map = get_users_by_uids(uids)
result = []
for g in all_gists:
author = users_map.get(g["user_uid"])
result.append({
"gist": g,
"author": author,
"time_ago": time_ago(g["created_at"]),
})
return result
users_map = get_users_by_uids([g["user_uid"] for g in gists])
return enrich_items(gists, "gist", users_map, user=viewer), next_cursor, total
@router.get("", response_class=HTMLResponse)
async def gists_page(request: Request, language: str = None, user_uid: str = None):
async def gists_page(request: Request, language: str = None, user_uid: str = None, before: str = None):
user = get_current_user(request)
gists_data = get_gists_list(user_uid, language)
total_count = len(gists_data)
base = site_url(request)
seo_ctx = base_seo_context(
gists_data, next_cursor, total_count = get_gists_list(user_uid, language, before, viewer=user)
seo_ctx = list_page_seo(
request,
title="Gists",
description=f"Browse {total_count} code snippets on DevPlace.",
@@ -66,7 +53,6 @@ async def gists_page(request: Request, language: str = None, user_uid: str = Non
{"name": "Home", "url": "/feed"},
{"name": "Gists", "url": "/gists"},
],
schemas=[website_schema(base)],
)
return templates.TemplateResponse(request, "gists.html", {
**seo_ctx,
@@ -74,31 +60,20 @@ async def gists_page(request: Request, language: str = None, user_uid: str = Non
"user": user,
"gists": gists_data,
"total_count": total_count,
"next_cursor": next_cursor,
"current_language": language,
"languages": LANGUAGES,
"gist_language_codes": get_gist_languages(),
})
@router.get("/{gist_slug}", response_class=HTMLResponse)
async def gist_detail(request: Request, gist_slug: str):
user = get_current_user(request)
gists = get_table("gists")
gist = resolve_by_slug(gists, gist_slug)
if not gist:
raise HTTPException(status_code=404, detail="Gist not found")
from devplacepy.database import get_users_by_uids
users_map = get_users_by_uids([gist["user_uid"]])
author = users_map.get(gist["user_uid"])
is_owner = user and user["uid"] == gist["user_uid"]
ups, downs = get_vote_counts([gist["uid"]])
star_count = ups.get(gist["uid"], 0) - downs.get(gist["uid"], 0)
comments = load_comments("gist", gist["uid"])
gist_attachments = get_attachments("gist", gist["uid"])
detail = load_detail("gists", "gist", gist_slug, user)
if not detail:
raise not_found("Gist not found")
gist = detail["item"]
base = site_url(request)
seo_ctx = base_seo_context(
@@ -113,19 +88,9 @@ async def gist_detail(request: Request, gist_slug: str):
],
schemas=[website_schema(base), software_source_code_schema(gist, base)],
)
return templates.TemplateResponse(request, "gist_detail.html", {
**seo_ctx,
"request": request,
"user": user,
"gist": gist,
"author": author,
"is_owner": is_owner,
"star_count": star_count,
"time_ago": time_ago(gist["created_at"]),
"comments": comments,
return templates.TemplateResponse(request, "gist_detail.html", detail_context(request, user, detail, "gist", seo_ctx, {
"languages": LANGUAGES,
"attachments": gist_attachments,
})
}))
@router.post("/create")
@@ -140,73 +105,30 @@ async def create_gist(request: Request, data: Annotated[GistForm, Form()]):
if language not in valid_languages:
language = "plaintext"
gists = get_table("gists")
uid = generate_uid()
gist_slug = make_combined_slug(title, uid)
gists.insert({
"uid": uid,
"user_uid": user["uid"],
uid, gist_slug = create_content_item("gists", "gist", user, {
"title": title,
"slug": gist_slug,
"description": description or None,
"source_code": source_code,
"language": language,
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
})
from devplacepy.attachments import link_attachments
link_attachments(data.attachment_uids, "gist", uid)
create_mention_notifications(description or "", user["uid"], f"/gists/{gist_slug}")
logger.info(f"Gist {uid} created by {user['username']}")
}, title, XP_GIST, "First Gist", description or "", data.attachment_uids)
return RedirectResponse(url=f"/gists/{gist_slug}", status_code=302)
@router.post("/edit/{gist_slug}")
async def edit_gist(request: Request, gist_slug: str, data: Annotated[GistEditForm, Form()]):
user = require_user(request)
gists = get_table("gists")
gist = resolve_by_slug(gists, gist_slug)
if not gist or gist["user_uid"] != user["uid"]:
return RedirectResponse(url="/gists", status_code=302)
title = data.title.strip()
description = data.description.strip()
source_code = data.source_code.strip()
language = data.language
valid_languages = {l[0] for l in LANGUAGES}
if language not in valid_languages:
if language not in {l[0] for l in LANGUAGES}:
language = "plaintext"
gists.update({
"uid": gist["uid"],
"title": title,
"description": description or None,
"source_code": source_code,
return edit_content_item("gists", user, gist_slug, {
"title": data.title.strip(),
"description": data.description.strip() or None,
"source_code": data.source_code.strip(),
"language": language,
}, ["uid"])
logger.info(f"Gist {gist['uid']} edited by {user['username']}")
return RedirectResponse(url=f"/gists/{gist['slug'] or gist['uid']}", status_code=302)
}, "/gists")
@router.post("/delete/{gist_slug}")
async def delete_gist(request: Request, gist_slug: str):
user = require_user(request)
gists = get_table("gists")
gist = resolve_by_slug(gists, gist_slug)
if gist and gist["user_uid"] == user["uid"]:
from devplacepy.attachments import delete_target_attachments
delete_target_attachments("gist", gist["uid"])
if "comments" in db.tables:
for c in get_table("comments").find(target_type="gist", target_uid=gist["uid"]):
delete_target_attachments("comment", c["uid"])
get_table("comments").delete(target_type="gist", target_uid=gist["uid"])
if "votes" in db.tables:
get_table("votes").delete(target_type="gist", target_uid=gist["uid"])
gists.delete(id=gist["id"])
logger.info(f"Gist {gist['uid']} deleted by {user['username']}")
return RedirectResponse(url="/gists", status_code=302)
return delete_content_item("gists", "gist", user, gist_slug, "/gists")
+44
View File
@@ -0,0 +1,44 @@
import logging
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from devplacepy.database import get_leaderboard, get_user_rank, get_site_stats, get_top_authors, get_featured_news
from devplacepy.templating import templates
from devplacepy.utils import get_current_user
from devplacepy.seo import list_page_seo
logger = logging.getLogger(__name__)
router = APIRouter()
TOP_LIMIT = 50
@router.get("", response_class=HTMLResponse)
async def leaderboard_page(request: Request):
entries = get_leaderboard(TOP_LIMIT, 0)
user = get_current_user(request)
user_rank = get_user_rank(user["uid"]) if user else None
stats = get_site_stats()
top_authors = get_top_authors(5)
featured_news = get_featured_news(5)
seo_ctx = list_page_seo(
request,
title="Leaderboard",
description="Top contributors on DevPlace ranked by the stars their posts, projects, and gists have earned.",
breadcrumbs=[{"name": "Home", "url": "/feed"}, {"name": "Leaderboard", "url": "/leaderboard"}],
)
return templates.TemplateResponse(request, "leaderboard.html", {
**seo_ctx,
"request": request,
"user": user,
"entries": entries,
"user_rank": user_rank,
"total_members": stats["total_members"],
"posts_today": stats["posts_today"],
"total_projects": stats["total_projects"],
"total_gists": stats["total_gists"],
"top_authors": top_authors,
"featured_news": featured_news,
})
+7 -2
View File
@@ -68,8 +68,9 @@ def get_conversation_messages(user_uid: str, other_uid: str):
msgs.append(m)
msgs.sort(key=lambda m: m["created_at"])
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)
if "messages" in db.tables:
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)
from devplacepy.database import get_users_by_uids
user_ids = list({m["sender_uid"] for m in msgs} | {other_uid})
@@ -151,6 +152,9 @@ async def send_message(request: Request, data: Annotated[MessageForm, Form()]):
content = data.content.strip()
receiver_uid = data.receiver_uid
if not get_table("users").find_one(uid=receiver_uid):
return RedirectResponse(url="/messages", status_code=302)
messages_table = get_table("messages")
msg_uid = generate_uid()
messages_table.insert({
@@ -173,6 +177,7 @@ async def send_message(request: Request, data: Annotated[MessageForm, Form()]):
"type": "message",
"message": f"{user['username']} sent you a message",
"related_uid": user["uid"],
"target_url": f"/messages?with_uid={user['uid']}",
"read": False,
"created_at": datetime.now(timezone.utc).isoformat(),
})
+14 -12
View File
@@ -2,10 +2,10 @@ import logging
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from devplacepy.database import get_table, db, load_comments, resolve_by_slug, get_news_images_by_uids
from devplacepy.database import get_table, db, load_comments, resolve_by_slug, get_news_images_by_uids, paginate
from devplacepy.templating import templates
from devplacepy.utils import get_current_user, time_ago
from devplacepy.seo import base_seo_context, website_schema, site_url, discussion_forum_posting, combine, news_article_schema
from devplacepy.utils import get_current_user, time_ago, not_found
from devplacepy.seo import base_seo_context, website_schema, site_url, news_article_schema, list_page_seo
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -14,16 +14,19 @@ NEWS_MAX_AGE_DAYS = 4
@router.get("", response_class=HTMLResponse)
async def news_page(request: Request):
async def news_page(request: Request, before: str = None):
user = get_current_user(request)
cutoff = (datetime.now(timezone.utc) - timedelta(days=NEWS_MAX_AGE_DAYS)).isoformat()
news_table = get_table("news")
articles = list(news_table.find(
articles, next_cursor = paginate(
news_table,
before=before,
order=["-grade", "-synced_at"],
cursor_field="synced_at",
status="published",
synced_at={">=": cutoff},
order_by=["-grade", "-synced_at"],
))
)
article_uids = [a["uid"] for a in articles]
images_by_news = get_news_images_by_uids(article_uids)
@@ -37,13 +40,11 @@ async def news_page(request: Request):
"grade": a.get("grade", 0),
})
base = site_url(request)
seo_ctx = base_seo_context(
seo_ctx = list_page_seo(
request,
title="Developer News",
description="Curated developer news and industry signals. Stay ahead with hand-picked articles.",
breadcrumbs=[{"name": "Home", "url": "/feed"}, {"name": "News", "url": "/news"}],
schemas=[website_schema(base)],
)
return templates.TemplateResponse(request, "news.html", {
@@ -51,6 +52,7 @@ async def news_page(request: Request):
"request": request,
"user": user,
"articles": enriched,
"next_cursor": next_cursor,
})
@@ -60,7 +62,7 @@ async def news_detail_page(request: Request, news_slug: str):
news_table = get_table("news")
article = resolve_by_slug(news_table, news_slug)
if not article:
return HTMLResponse("News article not found", status_code=404)
raise not_found("News article not found")
image_url = ""
if "news_images" in db.tables:
@@ -69,7 +71,7 @@ async def news_detail_page(request: Request, news_slug: str):
image_url = img["url"]
canonical_slug = article.get("slug", "") or article["uid"]
comments = load_comments("news", article["uid"])
comments = load_comments("news", article["uid"], user)
base = site_url(request)
page_url = f"{base}/news/{canonical_slug}"
+27 -2
View File
@@ -10,6 +10,8 @@ from devplacepy.seo import base_seo_context
logger = logging.getLogger(__name__)
router = APIRouter()
PAGE_SIZE = 25
def _group_label(created_at: str) -> str:
try:
@@ -29,15 +31,24 @@ def _group_label(created_at: str) -> str:
@router.get("", response_class=HTMLResponse)
async def notifications_page(request: Request):
async def notifications_page(request: Request, before: str = None):
user = require_user(request)
next_cursor = None
try:
notifications_table = get_table("notifications")
filters = {"user_uid": user["uid"]}
if before:
filters["created_at"] = {"<": before}
raw_notifications = list(
notifications_table.find(user_uid=user["uid"], order_by=["-created_at"])
notifications_table.find(**filters, order_by=["-created_at"], _limit=PAGE_SIZE + 1)
)
has_more = len(raw_notifications) > PAGE_SIZE
raw_notifications = raw_notifications[:PAGE_SIZE]
if has_more and raw_notifications:
next_cursor = raw_notifications[-1]["created_at"]
enriched = []
if raw_notifications:
from devplacepy.database import get_users_by_uids
@@ -83,9 +94,23 @@ async def notifications_page(request: Request):
"request": request,
"user": user,
"notification_groups": groups,
"next_cursor": next_cursor,
})
@router.get("/open/{notification_uid}")
async def open_notification(request: Request, notification_uid: str):
user = require_user(request)
notifications_table = get_table("notifications")
n = notifications_table.find_one(uid=notification_uid)
if not n or n["user_uid"] != user["uid"]:
return RedirectResponse(url="/notifications", status_code=302)
if not n["read"]:
notifications_table.update({"id": n["id"], "read": True}, ["id"])
clear_unread_cache(user["uid"])
return RedirectResponse(url=n.get("target_url") or "/notifications", status_code=302)
@router.post("/mark-read/{notification_uid}")
async def mark_read(request: Request, notification_uid: str):
user = require_user(request)
+20 -76
View File
@@ -1,14 +1,14 @@
import logging
from typing import Annotated
from datetime import datetime, timezone
from fastapi import APIRouter, Request, HTTPException, Form
from fastapi import APIRouter, Request, Form
from fastapi.responses import RedirectResponse, HTMLResponse
from devplacepy.constants import TOPICS
from devplacepy.database import get_table, get_comment_counts_by_post_uids, load_comments, db, resolve_by_slug
from devplacepy.database import db
from devplacepy.templating import templates
from devplacepy.utils import generate_uid, get_current_user, require_user, time_ago, make_combined_slug, create_mention_notifications
from devplacepy.seo import base_seo_context, site_url, website_schema, breadcrumb_schema, discussion_forum_posting, combine, truncate
from devplacepy.attachments import get_attachments, link_attachments, delete_target_attachments, save_inline_image, delete_inline_image
from devplacepy.utils import get_current_user, require_user, time_ago, not_found, XP_POST
from devplacepy.content import load_detail, edit_content_item, delete_content_item, create_content_item, detail_context
from devplacepy.seo import base_seo_context, site_url, website_schema, discussion_forum_posting, truncate
from devplacepy.attachments import save_inline_image
from devplacepy.models import PostForm, PostEditForm
logger = logging.getLogger(__name__)
@@ -31,53 +31,26 @@ async def create_post(request: Request, data: Annotated[PostForm, Form()]):
if image_filename:
content += f"\n\n![](/static/uploads/{image_filename})"
posts = get_table("posts")
uid = generate_uid()
slug_text = title if title else content[:50]
post_slug = make_combined_slug(slug_text, uid)
posts.insert({
"uid": uid,
"user_uid": user["uid"],
uid, post_slug = create_content_item("posts", "post", user, {
"title": title or None,
"slug": post_slug,
"content": content,
"topic": topic,
"project_uid": project_uid or None,
"image": image_filename,
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
})
badges = get_table("badges")
existing = badges.find_one(user_uid=user["uid"], badge_name="First Post")
if not existing:
badges.insert({
"uid": generate_uid(),
"user_uid": user["uid"],
"badge_name": "First Post",
"created_at": datetime.now(timezone.utc).isoformat(),
})
if data.attachment_uids:
link_attachments(data.attachment_uids, "post", uid)
create_mention_notifications(content, user["uid"], f"/posts/{post_slug}")
logger.info(f"Post {uid} created by {user['username']}")
}, slug_text, XP_POST, "First Post", content, data.attachment_uids)
return RedirectResponse(url=f"/posts/{post_slug}", status_code=302)
@router.get("/{post_slug}", response_class=HTMLResponse)
async def view_post(request: Request, post_slug: str):
user = get_current_user(request)
posts = get_table("posts")
post = resolve_by_slug(posts, post_slug)
if not post:
raise HTTPException(status_code=404, detail="Post not found")
users_table = get_table("users")
author = users_table.find_one(uid=post["user_uid"])
top_level = load_comments("post", post["uid"])
detail = load_detail("posts", "post", post_slug, user)
if not detail:
raise not_found("Post not found")
post = detail["item"]
author = detail["author"]
top_level = detail["comments"]
def count_all(items):
total = len(items)
@@ -85,7 +58,6 @@ async def view_post(request: Request, post_slug: str):
total += count_all(item.get("children", []))
return total
comment_count = count_all(top_level)
star_count = post.get("stars", 0)
base = site_url(request)
seo_ctx = base_seo_context(
request,
@@ -99,7 +71,7 @@ async def view_post(request: Request, post_slug: str):
og_type="article",
schemas=[
website_schema(base),
discussion_forum_posting(post, author, comment_count, star_count, base),
discussion_forum_posting(post, author, comment_count, detail["star_count"], base),
],
)
@@ -116,52 +88,24 @@ async def view_post(request: Request, post_slug: str):
"time_ago": time_ago(r["created_at"]),
})
post_attachments = get_attachments("post", post["uid"])
return templates.TemplateResponse(request, "post.html", {
**seo_ctx,
"request": request,
"user": user,
"post": post,
"author": author,
"comments": top_level,
"time_ago": time_ago(post["created_at"]),
return templates.TemplateResponse(request, "post.html", detail_context(request, user, detail, "post", seo_ctx, {
"comment_count": comment_count,
"related_posts": related_posts,
"topics": list(TOPICS),
"attachments": post_attachments,
})
}))
@router.post("/edit/{post_slug}")
async def edit_post(request: Request, post_slug: str, data: Annotated[PostEditForm, Form()]):
user = require_user(request)
posts = get_table("posts")
post = resolve_by_slug(posts, post_slug)
if not post or post["user_uid"] != user["uid"]:
return RedirectResponse(url="/feed", status_code=302)
posts.update({
"uid": post["uid"],
return edit_content_item("posts", user, post_slug, {
"content": data.content.strip(),
"title": data.title.strip() or None,
"topic": data.topic,
}, ["uid"])
logger.info(f"Post {post['uid']} edited by {user['username']}")
return RedirectResponse(url=f"/posts/{post['slug'] or post['uid']}", status_code=302)
}, "/feed")
@router.post("/delete/{post_slug}")
async def delete_post(request: Request, post_slug: str):
user = require_user(request)
posts = get_table("posts")
post = resolve_by_slug(posts, post_slug)
if post and post["user_uid"] == user["uid"]:
delete_target_attachments("post", post["uid"])
get_table("comments").delete(post_uid=post["uid"])
get_table("votes").delete(target_uid=post["uid"])
delete_inline_image(post.get("image"))
posts.delete(id=post["id"])
logger.info(f"Post {post['uid']} deleted by {user['username']}")
return RedirectResponse(url="/feed", status_code=302)
return delete_content_item("posts", "post", user, post_slug, "/feed", inline_image_field="image")
+11 -15
View File
@@ -3,10 +3,11 @@ from typing import Annotated
from fastapi import APIRouter, Request, Form
from devplacepy.models import ProfileForm
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
from devplacepy.database import get_table, db
from devplacepy.database import get_table, db, get_user_stars, get_user_rank, get_comment_counts_by_post_uids
from devplacepy.content import enrich_items
from devplacepy.templating import templates
from devplacepy.utils import get_current_user, require_user, time_ago, clear_user_cache
from devplacepy.seo import base_seo_context, site_url, website_schema, breadcrumb_schema, profile_page_schema, combine
from devplacepy.utils import get_current_user, require_user, require_user_api, time_ago, clear_user_cache
from devplacepy.seo import base_seo_context, site_url, website_schema, profile_page_schema
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -14,7 +15,7 @@ router = APIRouter()
@router.get("/search")
async def search_users(request: Request, q: str = ""):
require_user(request)
require_user_api(request)
if not q or len(q) < 1:
return JSONResponse({"results": []})
if "users" in db.tables:
@@ -35,22 +36,16 @@ async def profile_page(request: Request, username: str, tab: str = "posts"):
profile_user = users.find_one(username=username)
if not profile_user:
return RedirectResponse(url="/feed", status_code=302)
profile_user["stars"] = get_user_stars(profile_user["uid"])
rank = get_user_rank(profile_user["uid"])
posts = []
if tab == "posts":
posts_table = get_table("posts")
raw_posts = list(posts_table.find(user_uid=profile_user["uid"], order_by=["-created_at"]))
if raw_posts:
from devplacepy.database import get_comment_counts_by_post_uids
counts = get_comment_counts_by_post_uids([p["uid"] for p in raw_posts])
else:
counts = {}
for p in raw_posts:
posts.append({
"post": p,
"time_ago": time_ago(p["created_at"]),
"comment_count": counts.get(p["uid"], 0),
})
counts = get_comment_counts_by_post_uids([p["uid"] for p in raw_posts]) if raw_posts else {}
authors = {profile_user["uid"]: profile_user}
posts = enrich_items(raw_posts, "post", authors, {"comment_count": counts}, user=current_user)
badges = list(get_table("badges").find(user_uid=profile_user["uid"]))
projects = list(get_table("projects").find(user_uid=profile_user["uid"]))
@@ -110,6 +105,7 @@ async def profile_page(request: Request, username: str, tab: str = "posts"):
"posts_count": posts_count,
"is_following": is_following,
"activities": activities,
"rank": rank,
})
+29 -68
View File
@@ -1,21 +1,20 @@
import logging
from typing import Annotated
from datetime import datetime, timezone
from sqlalchemy import or_
from fastapi import APIRouter, Request, HTTPException, Form
from fastapi import APIRouter, Request, Form
from devplacepy.models import ProjectForm
from fastapi.responses import HTMLResponse, RedirectResponse
from devplacepy.database import get_table, get_vote_counts, load_comments, resolve_by_slug, get_users_by_uids, get_site_stats
from devplacepy.attachments import link_attachments, get_attachments, delete_target_attachments
from devplacepy.database import get_table, get_users_by_uids, get_site_stats, get_user_votes, paginate
from devplacepy.content import load_detail, delete_content_item, create_content_item, detail_context
from devplacepy.templating import templates
from devplacepy.utils import generate_uid, get_current_user, require_user, time_ago, make_combined_slug, create_mention_notifications
from devplacepy.seo import base_seo_context, site_url, website_schema, breadcrumb_schema, combine, software_application_schema
from devplacepy.utils import get_current_user, require_user, not_found, XP_PROJECT
from devplacepy.seo import base_seo_context, site_url, website_schema, software_application_schema, list_page_seo
logger = logging.getLogger(__name__)
router = APIRouter()
def get_projects_list(tab: str = "recent", search: str = "", user_uid: str = None, project_type: str = None):
def get_projects_list(tab: str = "recent", search: str = "", user_uid: str = None, project_type: str = None, before: str = None, viewer: dict = None):
projects = get_table("projects")
filters = {}
@@ -32,15 +31,18 @@ def get_projects_list(tab: str = "recent", search: str = "", user_uid: str = Non
clauses.append(or_(projects.table.columns.title.ilike(like), projects.table.columns.description.ilike(like)))
order = ["-stars", "-created_at"] if tab == "popular" else ["-created_at"]
all_projects = list(projects.find(*clauses, **filters, order_by=order))
total = projects.count(*clauses, **filters)
page, next_cursor = paginate(projects, *clauses, before=before, order=order, **filters)
if all_projects:
users_map = get_users_by_uids([p["user_uid"] for p in all_projects])
for p in all_projects:
if page:
users_map = get_users_by_uids([p["user_uid"] for p in page])
my_votes = get_user_votes(viewer["uid"], [p["uid"] for p in page]) if viewer else {}
for p in page:
author = users_map.get(p["user_uid"])
p["author_name"] = author["username"] if author else "Unknown"
p["my_vote"] = my_votes.get(p["uid"], 0)
return all_projects
return page, next_cursor, total
@router.get("", response_class=HTMLResponse)
@@ -50,21 +52,20 @@ async def projects_page(
search: str = "",
user_uid: str = None,
project_type: str = None,
before: str = None,
):
user = get_current_user(request)
projects = get_projects_list(tab, search, user_uid, project_type)
projects, next_cursor, total_count = get_projects_list(tab, search, user_uid, project_type, before, viewer=user)
total_members = get_site_stats()["total_members"]
base = site_url(request)
seo_ctx = base_seo_context(
seo_ctx = list_page_seo(
request,
title="Projects",
description=f"Explore {len(projects)} developer projects on DevPlace. Games, software, mobile apps and more.",
description=f"Explore {total_count} developer projects on DevPlace. Games, software, mobile apps and more.",
breadcrumbs=[
{"name": "Home", "url": "/feed"},
{"name": "Projects", "url": "/projects"},
],
schemas=[website_schema(base)],
)
return templates.TemplateResponse(request, "projects.html", {
**seo_ctx,
@@ -74,7 +75,8 @@ async def projects_page(
"current_tab": tab,
"search": search,
"project_type": project_type,
"total_count": len(projects),
"total_count": total_count,
"next_cursor": next_cursor,
"total_members": total_members,
})
@@ -82,22 +84,10 @@ async def projects_page(
@router.get("/{project_slug}", response_class=HTMLResponse)
async def project_detail(request: Request, project_slug: str):
user = get_current_user(request)
projects = get_table("projects")
project = resolve_by_slug(projects, project_slug)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
users_map = get_users_by_uids([project["user_uid"]])
author = users_map.get(project["user_uid"])
is_owner = user and user["uid"] == project["user_uid"]
ups, downs = get_vote_counts([project["uid"]])
star_count = ups.get(project["uid"], 0) - downs.get(project["uid"], 0)
comments = load_comments("project", project["uid"])
project_attachments = get_attachments("project", project["uid"])
detail = load_detail("projects", "project", project_slug, user)
if not detail:
raise not_found("Project not found")
project = detail["item"]
base = site_url(request)
seo_ctx = base_seo_context(
@@ -111,30 +101,15 @@ async def project_detail(request: Request, project_slug: str):
],
schemas=[website_schema(base), software_application_schema(project, base)],
)
return templates.TemplateResponse(request, "project_detail.html", {
**seo_ctx,
"request": request,
"user": user,
"project": project,
"author": author,
"is_owner": is_owner,
"star_count": star_count,
return templates.TemplateResponse(request, "project_detail.html", detail_context(request, user, detail, "project", seo_ctx, {
"platforms": project.get("platforms", "").split(",") if project.get("platforms") else [],
"comments": comments,
"attachments": project_attachments,
})
}))
@router.post("/delete/{project_slug}")
async def delete_project(request: Request, project_slug: str):
user = require_user(request)
projects = get_table("projects")
project = resolve_by_slug(projects, project_slug)
if project and project["user_uid"] == user["uid"]:
delete_target_attachments("project", project["uid"])
projects.delete(id=project["id"])
logger.info(f"Project {project['uid']} deleted by {user['username']}")
return RedirectResponse(url="/projects", status_code=302)
return delete_content_item("projects", "project", user, project_slug, "/projects")
@router.post("/create")
@@ -143,27 +118,13 @@ async def create_project(request: Request, data: Annotated[ProjectForm, Form()])
title = data.title.strip()
description = data.description.strip()
projects = get_table("projects")
uid = generate_uid()
project_slug = make_combined_slug(title, uid)
projects.insert({
"uid": uid,
"user_uid": user["uid"],
uid, project_slug = create_content_item("projects", "project", user, {
"title": title,
"slug": project_slug,
"description": description,
"release_date": data.release_date or None,
"demo_date": data.demo_date or None,
"project_type": data.project_type,
"platforms": data.platforms.strip(),
"status": data.status,
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
})
if data.attachment_uids:
link_attachments(data.attachment_uids, "project", uid)
create_mention_notifications(description, user["uid"], f"/projects/{project_slug}")
logger.info(f"Project {uid} created by {user['username']}")
}, title, XP_PROJECT, "First Project", description, data.attachment_uids)
return RedirectResponse(url=f"/projects/{project_slug}", status_code=302)
+65
View File
@@ -0,0 +1,65 @@
# retoor <retoor@molodetz.nl>
import logging
from fastapi import APIRouter, Request
from fastapi.responses import FileResponse, JSONResponse
from devplacepy import push
from devplacepy.config import STATIC_DIR
from devplacepy.utils import require_user_api
logger = logging.getLogger(__name__)
router = APIRouter()
WELCOME_PAYLOAD = {
"title": "DevPlace",
"message": "Push notifications enabled.",
"icon": "/static/apple-touch-icon.png",
"url": "/notifications",
}
@router.get("/push.json")
async def push_public_key() -> JSONResponse:
return JSONResponse({"publicKey": push.public_key_standard_b64()})
@router.post("/push.json")
async def push_register(request: Request) -> JSONResponse:
user = require_user_api(request)
try:
body = await request.json()
except ValueError:
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
keys = body.get("keys") if isinstance(body, dict) else None
if not (isinstance(keys, dict) and body.get("endpoint") and keys.get("p256dh") and keys.get("auth")):
return JSONResponse({"error": "Invalid request"}, status_code=400)
_, created = await push.register(
user_uid=user["uid"],
endpoint=body["endpoint"],
key_auth=keys["auth"],
key_p256dh=keys["p256dh"],
)
if created:
try:
await push.notify_user(user["uid"], WELCOME_PAYLOAD)
except Exception as exc:
logger.warning("Welcome push failed for %s: %s", user["uid"], exc)
return JSONResponse({"registered": True})
@router.get("/service-worker.js")
async def service_worker() -> FileResponse:
return FileResponse(
STATIC_DIR / "service-worker.js",
media_type="application/javascript",
headers={"Service-Worker-Allowed": "/", "Cache-Control": "no-cache"},
)
@router.get("/manifest.json")
async def manifest() -> FileResponse:
return FileResponse(STATIC_DIR / "manifest.json", media_type="application/manifest+json")
+3 -3
View File
@@ -3,7 +3,7 @@ from pathlib import Path
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse
from devplacepy.database import get_table, get_setting, get_int_setting
from devplacepy.utils import require_user
from devplacepy.utils import require_user_api
from devplacepy.attachments import store_attachment, delete_attachment as _delete_attachment, ALLOWED_UPLOAD_TYPES
logger = logging.getLogger(__name__)
@@ -12,7 +12,7 @@ router = APIRouter()
@router.post("/upload")
async def upload_file(request: Request):
user = require_user(request)
user = require_user_api(request)
form = await request.form()
file = form.get("file")
@@ -49,7 +49,7 @@ async def upload_file(request: Request):
@router.delete("/delete/{attachment_uid}")
async def delete_attachment_route(request: Request, attachment_uid: str):
user = require_user(request)
user = require_user_api(request)
att = get_table("attachments").find_one(uid=attachment_uid)
if not att:
return JSONResponse({"error": "Attachment not found"}, status_code=404)
+22 -44
View File
@@ -2,15 +2,16 @@ import logging
from typing import Annotated
from datetime import datetime, timezone
from fastapi import APIRouter, Request, Form
from fastapi.responses import RedirectResponse
from devplacepy.database import get_table
from devplacepy.templating import clear_unread_cache
from devplacepy.utils import generate_uid, require_user
from fastapi.responses import RedirectResponse, JSONResponse
from devplacepy.database import get_table, update_target_stars, get_target_owner_uid, resolve_object_url
from devplacepy.utils import generate_uid, require_user, create_notification, award_rewards, XP_UPVOTE
from devplacepy.models import VoteForm
logger = logging.getLogger(__name__)
router = APIRouter()
NOTIFY_ON_VOTE: set[str] = {"post", "comment", "gist", "project"}
@router.post("/{target_type}/{target_uid}")
async def vote(request: Request, target_type: str, target_uid: str, data: Annotated[VoteForm, Form()]):
@@ -20,11 +21,13 @@ async def vote(request: Request, target_type: str, target_uid: str, data: Annota
votes = get_table("votes")
existing = votes.find_one(user_uid=user["uid"], target_uid=target_uid, target_type=target_type)
did_upvote = False
if existing:
if int(existing["value"]) == value:
votes.delete(id=existing["id"])
else:
votes.update({"id": existing["id"], "value": value}, ["id"])
did_upvote = value == 1
else:
votes.insert({
"uid": generate_uid(),
@@ -34,51 +37,26 @@ async def vote(request: Request, target_type: str, target_uid: str, data: Annota
"value": value,
"created_at": datetime.now(timezone.utc).isoformat(),
})
did_upvote = value == 1
up_count = votes.count(target_uid=target_uid, value=1)
down_count = votes.count(target_uid=target_uid, value=-1)
up_count = votes.count(target_uid=target_uid, target_type=target_type, value=1)
down_count = votes.count(target_uid=target_uid, target_type=target_type, value=-1)
net = up_count - down_count
if target_type == "post":
posts = get_table("posts")
posts.update({"uid": target_uid, "stars": net}, ["uid"])
elif target_type == "project":
projects = get_table("projects")
projects.update({"uid": target_uid, "stars": net}, ["uid"])
elif target_type == "gist":
gists = get_table("gists")
gists.update({"uid": target_uid, "stars": net}, ["uid"])
update_target_stars(target_type, target_uid, net)
if value == 1:
target_owner_uid = None
if target_type == "post":
target_owner = posts.find_one(uid=target_uid)
if target_owner:
target_owner_uid = target_owner["user_uid"]
elif target_type == "comment":
comments = get_table("comments")
target_comment = comments.find_one(uid=target_uid)
if target_comment:
target_owner_uid = target_comment["user_uid"]
elif target_type == "gist":
gists = get_table("gists")
target_gist = gists.find_one(uid=target_uid)
if target_gist:
target_owner_uid = target_gist["user_uid"]
if did_upvote and target_type in NOTIFY_ON_VOTE:
owner_uid = get_target_owner_uid(target_type, target_uid)
if owner_uid and owner_uid != user["uid"]:
target_url = resolve_object_url(target_type, target_uid)
create_notification(owner_uid, "vote", f"{user['username']} ++'d your {target_type}", user["uid"], target_url)
award_rewards(owner_uid, XP_UPVOTE)
if target_owner_uid and target_owner_uid != user["uid"]:
label = {"post": "post", "comment": "comment", "gist": "gist"}.get(target_type, "gist")
notifications = get_table("notifications")
notifications.insert({
"uid": generate_uid(),
"user_uid": target_owner_uid,
"type": "vote",
"message": f"{user['username']} ++'d your {label}",
"related_uid": user["uid"],
"read": False,
"created_at": datetime.now(timezone.utc).isoformat(),
})
clear_unread_cache(target_owner_uid)
if request.headers.get("x-requested-with") == "fetch":
current = votes.find_one(user_uid=user["uid"], target_uid=target_uid, target_type=target_type)
current_value = int(current["value"]) if current else 0
logger.debug("ajax vote response target=%s/%s net=%s value=%s", target_type, target_uid, net, current_value)
return JSONResponse({"net": net, "up": up_count, "down": down_count, "value": current_value})
referer = request.headers.get("Referer", "/feed")
return RedirectResponse(url=referer, status_code=302)
+29 -4
View File
@@ -1,6 +1,5 @@
import json
import logging
from datetime import datetime
from xml.etree.ElementTree import Element, tostring
from xml.dom import minidom
from devplacepy.config import SITE_URL
@@ -152,6 +151,17 @@ def software_source_code_schema(gist, base_url):
}
def _json_ld_dumps(payload):
raw = json.dumps(payload, ensure_ascii=False)
return (
raw.replace("<", "\\u003c")
.replace(">", "\\u003e")
.replace("&", "\\u0026")
.replace("", "\\u2028")
.replace("", "\\u2029")
)
def combine(schemas):
if not schemas:
return None
@@ -162,9 +172,12 @@ def combine(schemas):
cleaned.append(s)
if not cleaned:
return None
if len(cleaned) == 1:
return json.dumps({"@context": "https://schema.org", **cleaned[0]}, ensure_ascii=False)
return json.dumps({"@context": "https://schema.org", "@graph": cleaned}, ensure_ascii=False)
payload = (
{"@context": "https://schema.org", **cleaned[0]}
if len(cleaned) == 1
else {"@context": "https://schema.org", "@graph": cleaned}
)
return _json_ld_dumps(payload)
DEFAULT_OG_IMAGE = "/static/og-default.png"
@@ -193,6 +206,17 @@ def base_seo_context(request, title="", description="", robots="index,follow", o
}
def list_page_seo(request, title="", description="", breadcrumbs=None):
base = site_url(request)
return base_seo_context(
request,
title=title,
description=description,
breadcrumbs=breadcrumbs,
schemas=[website_schema(base)],
)
def make_sitemap(base_url):
from devplacepy.database import get_table, db
@@ -223,6 +247,7 @@ def make_sitemap(base_url):
urlset.append(url_element(f"{base_url}/news", changefreq="hourly", priority="0.9"))
urlset.append(url_element(f"{base_url}/projects", changefreq="daily", priority="0.8"))
urlset.append(url_element(f"{base_url}/gists", changefreq="daily", priority="0.8"))
urlset.append(url_element(f"{base_url}/leaderboard", changefreq="daily", priority="0.7"))
if "posts" in db.tables:
posts = list(get_table("posts").find(order_by=["-created_at"], _limit=500))
+2 -1
View File
@@ -15,6 +15,7 @@ NEWS_API_URL_DEFAULT = "https://news.app.molodetz.nl/api"
AI_URL_DEFAULT = "https://openai.app.molodetz.nl/v1/chat/completions"
AI_MODEL_DEFAULT = "molodetz"
GRADE_THRESHOLD_DEFAULT = 7
GRADE_MAX_TOKENS = 2000
def _get_ai_key() -> str:
@@ -216,7 +217,7 @@ class NewsService(BaseService):
payload = {
"model": ai_model,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 10,
"max_tokens": GRADE_MAX_TOKENS,
"temperature": 0.0,
}
+3 -7
View File
@@ -126,12 +126,12 @@
.admin-btn-sm {
font-size: 0.6875rem;
padding: 0.2rem 0.4rem;
padding: 0.25rem 0.375rem;
}
.admin-select {
font-size: 0.75rem;
padding: 0.2rem 0.4rem;
padding: 0.25rem 0.375rem;
border-radius: var(--radius);
background: var(--bg-input);
color: var(--text-primary);
@@ -152,7 +152,7 @@
.admin-input-sm {
width: 110px;
font-size: 0.75rem;
padding: 0.2rem 0.4rem;
padding: 0.25rem 0.375rem;
border-radius: var(--radius);
background: var(--bg-input);
color: var(--text-primary);
@@ -265,10 +265,6 @@
transform: translateX(18px);
}
.hidden {
display: none !important;
}
.pagination {
display: flex;
align-items: center;
+57 -20
View File
@@ -81,6 +81,9 @@
.inline-form {
display: inline;
}
.hidden {
display: none !important;
}
.text-muted {
color: var(--text-muted);
}
@@ -115,6 +118,14 @@
color: var(--text-muted);
padding: 0.5rem 0;
}
.top-authors-link {
display: block;
margin-top: 0.5rem;
font-size: 0.75rem;
font-weight: 600;
color: var(--accent);
text-align: center;
}
.no-comments-msg {
color: var(--text-muted);
font-size: 0.875rem;
@@ -408,12 +419,12 @@ img {
.btn-primary {
background: var(--accent);
color: #fff;
color: var(--white);
}
.btn-primary:hover {
background: var(--accent-hover);
color: #fff;
color: var(--white);
}
.btn-secondary {
@@ -461,13 +472,13 @@ img {
letter-spacing: 0.05em;
}
.badge-devlog { background: var(--topic-devlog); color: #fff; }
.badge-showcase { background: var(--topic-showcase); color: #fff; }
.badge-question { background: var(--topic-question); color: #fff; }
.badge-rant { background: var(--topic-rant); color: #fff; }
.badge-devlog { background: var(--topic-devlog); color: var(--white); }
.badge-showcase { background: var(--topic-showcase); color: var(--white); }
.badge-question { background: var(--topic-question); color: var(--white); }
.badge-rant { background: var(--topic-rant); color: var(--white); }
.badge-fun { background: var(--topic-fun); color: #000; }
.badge-random { background: var(--border-light); color: var(--text-secondary); }
.badge-signals { background: var(--topic-signals); color: #fff; }
.badge-signals { background: var(--topic-signals); color: var(--white); }
.avatar {
width: 40px;
@@ -479,7 +490,7 @@ img {
justify-content: center;
font-weight: 700;
font-size: 1rem;
color: #fff;
color: var(--white);
flex-shrink: 0;
overflow: hidden;
}
@@ -531,11 +542,11 @@ img {
.topnav-link:hover { color: var(--text-primary); background: var(--bg-card); }
.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-icon { position: relative; padding: 0.375rem; color: var(--text-secondary); font-size: 1.25rem; transition: color 0.2s; }
.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:hover { color: var(--text-primary); }
.nav-badge {
position: absolute; top: 0; right: 0; min-width: 16px; height: 16px;
padding: 0 4px; border-radius: 8px; background: var(--accent); color: #fff;
padding: 0 4px; border-radius: 8px; background: var(--accent); color: var(--white);
font-size: 0.6875rem; font-weight: 700;
display: flex; align-items: center; justify-content: center;
}
@@ -723,16 +734,6 @@ img {
to { opacity: 1; transform: translateY(0); }
}
.post-author-link {
font-weight: 600;
font-size: 0.875rem;
color: var(--text-primary);
}
.post-author-link:hover {
color: var(--accent);
}
.icon {
font-size: 1rem;
width: 20px;
@@ -979,3 +980,39 @@ img {
font-size: 0.8125rem;
}
}
.card-link-host {
position: relative;
}
.card-link {
position: absolute;
inset: 0;
z-index: 1;
}
.card-link-host a:not(.card-link),
.card-link-host button,
.card-link-host form {
position: relative;
z-index: 2;
}
.vote-star {
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.vote-star::before {
content: "\2606";
}
.vote-star.voted::before {
content: "\2605";
}
.vote-star.voted {
color: var(--warning);
font-weight: 700;
}
+58
View File
@@ -0,0 +1,58 @@
.bugs-layout {
max-width: 720px;
margin: 0 auto;
}
.bugs-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.bugs-header h1 {
font-size: 1.5rem;
font-weight: 700;
}
.bug-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1.25rem;
margin-bottom: 1rem;
}
.bug-card-header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.bug-title {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
}
.bug-status {
font-size: 0.6875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.125rem 0.5rem;
border-radius: 999px;
}
.bug-status.open {
background: var(--accent-light);
color: var(--accent);
}
.bug-status.closed {
background: rgba(76, 175, 80, 0.1);
color: var(--success);
}
.bug-desc {
font-size: 0.875rem;
color: var(--text-secondary);
line-height: 1.5;
margin-bottom: 0.5rem;
}
.bug-meta {
font-size: 0.75rem;
color: var(--text-muted);
}
+175 -7
View File
@@ -17,7 +17,9 @@
border: 1px solid var(--border);
}
.feed-nav-btn {
.feed-nav-btn,
.projects-tab,
.profile-tab {
padding: 0.5rem 1rem;
border-radius: var(--radius);
font-size: 0.8125rem;
@@ -71,7 +73,7 @@
.post-card:hover {
border-color: var(--border-light);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
box-shadow: var(--shadow-sm);
}
.post-header {
@@ -178,15 +180,24 @@
color: var(--text-secondary);
}
.post-action-btn.voted,
.post-action-btn.vote-up {
.post-action-btn.vote-up:hover {
color: var(--accent);
}
.post-action-btn.vote-down {
.post-action-btn.vote-down:hover {
color: var(--danger);
}
.post-action-btn.vote-up.voted {
color: var(--accent);
font-weight: 700;
}
.post-action-btn.vote-down.voted {
color: var(--danger);
font-weight: 700;
}
.post-votes {
display: inline-flex;
align-items: center;
@@ -287,6 +298,14 @@
display: flex;
align-items: center;
gap: 0.375rem;
min-width: 0;
}
.top-author-name {
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.stat-row .value {
@@ -302,8 +321,8 @@
width: 56px;
height: 56px;
border-radius: 50%;
background: #e53935;
color: #fff;
background: var(--danger);
color: var(--white);
font-size: 1.5rem;
display: flex;
align-items: center;
@@ -355,6 +374,12 @@
.feed-right {
display: none;
}
.leaderboard-page {
grid-template-columns: 1fr;
}
.leaderboard-page > aside {
display: none;
}
}
@media (max-width: 768px) {
@@ -428,3 +453,146 @@
width: 100%;
}
}
.leaderboard-page {
display: grid;
grid-template-columns: 280px 1fr 280px;
gap: 1.5rem;
align-items: start;
}
.leaderboard-page > aside {
position: sticky;
top: calc(var(--nav-height) + 1rem);
}
.leaderboard-main {
min-width: 0;
}
.featured-news {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1.25rem;
}
.featured-news h3 {
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-muted);
margin-bottom: 0.75rem;
}
.featured-news-item {
display: flex;
flex-direction: column;
gap: 0.125rem;
padding: 0.5rem 0;
}
.featured-news-item:not(:last-child) {
border-bottom: 1px solid var(--border);
}
.featured-news-title {
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-primary);
line-height: 1.4;
}
.featured-news-meta {
font-size: 0.6875rem;
color: var(--text-muted);
}
.leaderboard-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.25rem;
}
.leaderboard-header h1 {
font-size: 1.5rem;
font-weight: 700;
}
.leaderboard-you {
font-size: 0.8125rem;
font-weight: 600;
color: var(--accent);
background: var(--accent-light);
padding: 0.25rem 0.625rem;
border-radius: 999px;
}
.leaderboard-intro {
font-size: 0.8125rem;
color: var(--text-muted);
margin-bottom: 1rem;
}
.leaderboard-list {
list-style: none;
margin: 0;
padding: 0;
}
.leaderboard-row {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: 0.5rem;
}
.leaderboard-row-self {
border-color: var(--accent);
background: var(--accent-light);
}
.leaderboard-rank {
font-weight: 700;
font-size: 0.9375rem;
color: var(--text-muted);
min-width: 2.5rem;
}
.leaderboard-name {
flex: 1;
min-width: 0;
font-weight: 600;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.leaderboard-level {
font-size: 0.75rem;
color: var(--text-muted);
}
.leaderboard-stars {
font-weight: 700;
color: var(--text-primary);
}
.leaderboard-star-icon {
color: var(--accent);
}
.leaderboard-empty {
list-style: none;
padding: 2rem 1rem;
text-align: center;
color: var(--text-muted);
font-size: 0.875rem;
}
+1 -1
View File
@@ -39,7 +39,7 @@
.gist-card:hover {
border-color: var(--border-light);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
box-shadow: var(--shadow-sm);
}
.gist-card-header {
+3 -3
View File
@@ -56,7 +56,7 @@
}
.news-card-body {
padding: 1.125rem;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.625rem;
@@ -104,7 +104,7 @@
}
.news-card-title {
font-size: 1.0625rem;
font-size: 1.125rem;
font-weight: 700;
line-height: 1.4;
margin: 0;
@@ -270,7 +270,7 @@
color: var(--accent);
}
@media (max-width: 680px) {
@media (max-width: 768px) {
.news-grid {
grid-template-columns: 1fr;
}
+1
View File
@@ -31,6 +31,7 @@
border-radius: var(--radius-lg);
padding: 1.25rem;
transition: all 0.2s;
cursor: pointer;
}
.notification-card.unread {
+31 -44
View File
@@ -100,10 +100,24 @@
transition: color 0.2s;
}
.comment-vote-btn:hover {
.comment-vote-btn.vote-up:hover {
color: var(--accent);
}
.comment-vote-btn.vote-down:hover {
color: var(--danger);
}
.comment-vote-btn.vote-up.voted {
color: var(--accent);
font-weight: 700;
}
.comment-vote-btn.vote-down.voted {
color: var(--danger);
font-weight: 700;
}
.comment-vote-count {
font-size: 0.75rem;
font-weight: 700;
@@ -115,6 +129,22 @@
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 {
display: flex;
align-items: center;
@@ -214,49 +244,6 @@ button.comment-form-submit:hover {
background: var(--accent-hover);
}
.post-action-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border-radius: var(--radius);
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-muted);
transition: all 0.2s;
background: none;
border: none;
cursor: pointer;
line-height: 1;
}
.post-action-btn:hover {
background: var(--bg-card-hover);
color: var(--text-secondary);
}
.post-action-btn.vote-up {
color: var(--accent);
}
.post-action-btn.vote-down {
color: var(--danger);
}
.post-votes {
display: inline-flex;
align-items: center;
gap: 0.125rem;
}
.post-vote-count {
font-weight: 600;
font-size: 0.8125rem;
color: var(--text-secondary);
min-width: 1.25rem;
text-align: center;
}
.comment-thread-line {
position: absolute;
left: 16px;
+14 -9
View File
@@ -56,6 +56,15 @@
display: block;
}
a.profile-stat-value {
color: var(--text-primary);
text-decoration: none;
}
a.profile-stat-value:hover {
color: var(--accent);
}
.profile-stat-label {
font-size: 0.75rem;
color: var(--text-muted);
@@ -109,6 +118,11 @@
color: var(--accent);
}
.profile-badge-icon {
font-size: 0.875rem;
line-height: 1;
}
.profile-info {
background: var(--bg-card);
border: 1px solid var(--border);
@@ -161,15 +175,6 @@
border: 1px solid var(--border);
}
.profile-tab {
padding: 0.5rem 1rem;
border-radius: var(--radius);
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-secondary);
transition: all 0.2s;
}
.profile-tab:hover {
color: var(--text-primary);
}
+78 -9
View File
@@ -16,15 +16,6 @@
flex-wrap: wrap;
}
.projects-tab {
padding: 0.5rem 1rem;
border-radius: var(--radius);
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-secondary);
transition: all 0.2s;
}
.projects-tab:hover {
color: var(--text-primary);
}
@@ -202,3 +193,81 @@
font-size: 1rem;
}
}
.project-detail-page {
max-width: 720px;
margin: 0 auto;
}
.project-detail {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1.5rem;
}
.project-detail-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 1rem;
}
.project-detail-title {
font-size: 1.5rem;
font-weight: 700;
}
.project-detail-meta {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-bottom: 1rem;
font-size: 0.8125rem;
color: var(--text-muted);
}
.project-detail-meta span {
display: flex;
align-items: center;
gap: 0.25rem;
}
.project-detail-author {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border);
}
.project-detail-author a {
font-weight: 600;
font-size: 0.875rem;
color: var(--text-primary);
}
.project-detail-desc {
font-size: 0.9375rem;
color: var(--text-secondary);
line-height: 1.7;
margin-bottom: 1.5rem;
}
.project-detail-actions {
display: flex;
align-items: center;
gap: 0.5rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
}
.project-star-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border-radius: var(--radius);
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-muted);
background: none;
border: none;
cursor: pointer;
transition: all 0.2s;
}
.project-star-btn:hover {
background: var(--bg-card-hover);
color: var(--warning);
}
+5 -5
View File
@@ -18,9 +18,9 @@
.service-card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 16px;
border: 1px solid var(--border);
border-radius: var(--radius);
padding: var(--space-lg);
}
.service-header {
@@ -32,7 +32,7 @@
.service-name {
font-weight: 600;
font-size: 1.1rem;
font-size: 1.125rem;
color: var(--text-primary);
text-transform: capitalize;
}
@@ -60,7 +60,7 @@
flex-wrap: wrap;
gap: 12px;
margin-bottom: 12px;
font-size: 0.8rem;
font-size: 0.8125rem;
color: var(--text-secondary);
}
+14
View File
@@ -28,6 +28,20 @@
--shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.4);
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.2);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.15);
--white: #fff;
--overlay-dark: rgba(0, 0, 0, 0.7);
--overlay-light: rgba(255, 255, 255, 0.05);
--space-xs: 0.25rem;
--space-sm: 0.375rem;
--space-base: 0.5rem;
--space-md: 0.75rem;
--space-lg: 1rem;
--space-xl: 1.25rem;
--space-2xl: 1.5rem;
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-mono: "SF Mono", Monaco, "Cascadia Code", monospace;
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

+6
View File
@@ -1,24 +1,30 @@
import { ModalManager } from "./ModalManager.js";
import { FormManager } from "./FormManager.js";
import { VoteManager } from "./VoteManager.js";
import { NotificationManager } from "./NotificationManager.js";
import { MessageSearch } from "./MessageSearch.js";
import { ProfileEditor } from "./ProfileEditor.js";
import { MobileNav } from "./MobileNav.js";
import { CommentManager } from "./CommentManager.js";
import { ContentEnhancer } from "./ContentEnhancer.js";
import { DomUtils } from "./DomUtils.js";
import { PushManager } from "./PushManager.js";
import { PwaInstaller } from "./PwaInstaller.js";
class Application {
constructor() {
this.modals = new ModalManager();
this.forms = new FormManager();
this.votes = new VoteManager();
this.notifications = new NotificationManager();
this.messageSearch = new MessageSearch();
this.profile = new ProfileEditor();
this.mobileNav = new MobileNav();
this.comments = new CommentManager();
this.content = new ContentEnhancer();
this.dom = new DomUtils();
this.push = new PushManager();
this.pwa = new PwaInstaller();
}
}
+3 -2
View File
@@ -1,3 +1,5 @@
import { Toast } from "./Toast.js";
export class AttachmentUploader {
constructor(form) {
this.form = form;
@@ -172,8 +174,7 @@ export class AttachmentUploader {
}
showError(msg) {
this.errorEl.textContent = msg;
setTimeout(() => { if (this.errorEl.textContent === msg) this.errorEl.textContent = ""; }, 5000);
Toast.flash(this.errorEl, msg, 5000, "");
}
}
+15
View File
@@ -0,0 +1,15 @@
export class Avatar {
static imgElement(username, size = 24) {
const img = document.createElement("img");
img.src = `/avatar/multiavatar/${encodeURIComponent(username)}?size=${size}`;
img.className = "avatar-img";
img.style.width = `${size}px`;
img.style.height = `${size}px`;
img.style.borderRadius = "50%";
img.alt = "";
img.loading = "lazy";
return img;
}
}
window.Avatar = Avatar;
+5
View File
@@ -51,6 +51,11 @@ export class ContentRenderer {
html = "<p>" + text.replace(/\n/g, "<br>") + "</p>";
}
if (typeof DOMPurify === "undefined") {
throw new Error("DOMPurify not loaded; refusing to render untrusted HTML");
}
html = DOMPurify.sanitize(html);
html = this.processMedia(html);
return html;
+47 -46
View File
@@ -1,69 +1,70 @@
import { Toast } from "./Toast.js";
export class DomUtils {
constructor() {
this.initClipboardCopy();
this.initShareButtons();
this.initTogglers();
this.initStopPropagation();
this.initCardLinks();
}
static onDataAttr(attr, event, handler) {
document.querySelectorAll(`[data-${attr}]`).forEach((el) => {
el.addEventListener(event, (e) => handler(el, e));
});
}
static show(el) {
el.style.display = "block";
}
static hide(el) {
el.style.display = "none";
}
static toggle(el) {
el.style.display = el.style.display === "none" ? "block" : "none";
}
static isShown(el) {
return el.style.display === "block";
}
initClipboardCopy() {
document.querySelectorAll("[data-copy]").forEach((btn) => {
btn.addEventListener("click", async () => {
const source = document.getElementById(btn.dataset.copy);
if (!source) return;
try {
await navigator.clipboard.writeText(source.textContent);
const original = btn.textContent;
btn.textContent = "Copied!";
setTimeout(() => { btn.textContent = original; }, 2000);
} catch {
// silently fail
}
});
DomUtils.onDataAttr("copy", "click", async (btn) => {
const source = document.getElementById(btn.dataset.copy);
if (!source) return;
try {
await navigator.clipboard.writeText(source.textContent);
Toast.flash(btn, "Copied!", 2000);
} catch {
// silently fail
}
});
}
initShareButtons() {
document.querySelectorAll("[data-share]").forEach((btn) => {
btn.addEventListener("click", async (e) => {
e.preventDefault();
e.stopPropagation();
const url = new URL(btn.dataset.share || window.location.href, window.location.href).href;
try {
await navigator.clipboard.writeText(url);
const original = btn.textContent;
btn.textContent = "Copied!";
setTimeout(() => { btn.textContent = original; }, 1000);
} catch {
// silently fail
}
});
DomUtils.onDataAttr("share", "click", async (btn, e) => {
e.preventDefault();
e.stopPropagation();
const url = new URL(btn.dataset.share || window.location.href, window.location.href).href;
try {
await navigator.clipboard.writeText(url);
Toast.flash(btn, "Copied!", 1000);
} catch {
// silently fail
}
});
}
initTogglers() {
document.querySelectorAll("[data-toggle]").forEach((btn) => {
btn.addEventListener("click", () => {
const target = document.getElementById(btn.dataset.toggle);
if (target) target.classList.toggle("hidden");
});
DomUtils.onDataAttr("toggle", "click", (btn) => {
const target = document.getElementById(btn.dataset.toggle);
if (target) target.classList.toggle("hidden");
});
}
initStopPropagation() {
document.querySelectorAll("[data-stop-propagation]").forEach((el) => {
el.addEventListener("click", (e) => e.stopPropagation());
});
}
initCardLinks() {
document.querySelectorAll("[data-href]").forEach((el) => {
el.addEventListener("click", (e) => {
if (e.target.closest("[data-stop-propagation]")) return;
const href = el.dataset.href;
if (href) window.location.href = href;
});
});
DomUtils.onDataAttr("stop-propagation", "click", (el, e) => e.stopPropagation());
}
}
+6 -15
View File
@@ -1,3 +1,6 @@
import { TextInput } from "./TextInput.js";
import { DomUtils } from "./DomUtils.js";
export class EmojiPicker {
constructor(textarea) {
this.textarea = textarea;
@@ -35,27 +38,15 @@ export class EmojiPicker {
}
insert(unicode) {
const ta = this.textarea;
const start = ta.selectionStart;
const end = ta.selectionEnd;
const text = ta.value;
ta.value = text.substring(0, start) + unicode + text.substring(end);
const newPos = start + unicode.length;
ta.setSelectionRange(newPos, newPos);
ta.focus();
ta.dispatchEvent(new Event("input", { bubbles: true }));
TextInput.insertAtCursor(this.textarea, unicode);
}
toggle() {
if (this.wrapper.style.display === "none") {
this.wrapper.style.display = "block";
} else {
this.wrapper.style.display = "none";
}
DomUtils.toggle(this.wrapper);
}
hide() {
this.wrapper.style.display = "none";
DomUtils.hide(this.wrapper);
}
}
window.EmojiPicker = EmojiPicker;
+50
View File
@@ -0,0 +1,50 @@
export class Http {
static async getJson(url) {
const response = await fetch(url);
return response.json();
}
static async sendForm(url, params = {}) {
const response = await fetch(url, {
method: "POST",
headers: {
"X-Requested-With": "fetch",
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams(params),
});
if (!response.ok) {
throw new Error(`request failed with status ${response.status}`);
}
return response.json();
}
static async postJson(url, body) {
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!response.ok) {
throw new Error(`request failed with status ${response.status}`);
}
return response.json();
}
static postForm(action, data = {}) {
const form = document.createElement("form");
form.method = "POST";
form.action = action;
for (const [name, value] of Object.entries(data)) {
const input = document.createElement("input");
input.type = "hidden";
input.name = name;
input.value = value;
form.appendChild(input);
}
document.body.appendChild(form);
form.submit();
}
}
window.Http = Http;
+19 -17
View File
@@ -1,3 +1,8 @@
import { Http } from "./Http.js";
import { Avatar } from "./Avatar.js";
import { TextInput } from "./TextInput.js";
import { DomUtils } from "./DomUtils.js";
export class MentionInput {
constructor(element) {
this.input = element;
@@ -21,7 +26,7 @@ export class MentionInput {
this.input.addEventListener("input", () => this.onInput());
this.input.addEventListener("keydown", (e) => this.onKeydown(e));
this.input.addEventListener("blur", () => {
setTimeout(() => { this.dropdown.style.display = "none"; }, 200);
setTimeout(() => DomUtils.hide(this.dropdown), 200);
});
}
@@ -32,7 +37,7 @@ export class MentionInput {
let match = text.match(/(?:^|\s|\x28)@([a-zA-Z0-9_-]*)$/);
if (!match) {
this.dropdown.style.display = "none";
DomUtils.hide(this.dropdown);
this.lastMatch = null;
return;
}
@@ -41,7 +46,7 @@ export class MentionInput {
this.lastMatch = { query, index: match.index + (text[match.index] === "@" ? 0 : 1) };
if (query.length < 1) {
this.dropdown.style.display = "none";
DomUtils.hide(this.dropdown);
return;
}
@@ -50,16 +55,15 @@ export class MentionInput {
async fetch(query) {
try {
const resp = await fetch("/profile/search?q=" + encodeURIComponent(query));
const data = await resp.json();
const data = await Http.getJson("/profile/search?q=" + encodeURIComponent(query));
const results = data.results || [];
if (results.length === 0) {
this.dropdown.style.display = "none";
DomUtils.hide(this.dropdown);
return;
}
this.render(results);
} catch (e) {
this.dropdown.style.display = "none";
DomUtils.hide(this.dropdown);
}
}
@@ -71,19 +75,21 @@ export class MentionInput {
item.type = "button";
item.className = "mention-dropdown-item";
item.dataset.username = r.username;
item.innerHTML = '<img src="/avatar/multiavatar/' + encodeURIComponent(r.username) + '?size=24" class="avatar-img" style="width:24px;height:24px;border-radius:50%" alt="" loading="lazy"><span>@' + r.username + '</span>';
const label = document.createElement("span");
label.textContent = "@" + r.username;
item.append(Avatar.imgElement(r.username), label);
item.addEventListener("mousedown", (e) => {
e.preventDefault();
this.insert(r.username);
});
this.dropdown.appendChild(item);
}
this.dropdown.style.display = "block";
DomUtils.show(this.dropdown);
}
onKeydown(e) {
const items = this.dropdown.querySelectorAll(".mention-dropdown-item");
if (this.dropdown.style.display !== "block" || items.length === 0) {
if (!DomUtils.isShown(this.dropdown) || items.length === 0) {
return;
}
@@ -101,7 +107,7 @@ export class MentionInput {
this.insert(items[this.selectedIndex].dataset.username);
}
} else if (e.key === "Escape") {
this.dropdown.style.display = "none";
DomUtils.hide(this.dropdown);
}
}
@@ -121,12 +127,8 @@ export class MentionInput {
let after = val.substring(this.lastMatch.index + this.lastMatch.query.length + 1);
before = before.replace(/@+$/, "");
after = after.replace(/^@+/, "");
this.input.value = before + "@" + username + " " + after;
const newPos = before.length + username.length + 2;
this.input.setSelectionRange(newPos, newPos);
this.input.focus();
this.input.dispatchEvent(new Event("input", { bubbles: true }));
this.dropdown.style.display = "none";
TextInput.applyValue(this.input, before + "@" + username + " " + after, before.length + username.length + 2);
DomUtils.hide(this.dropdown);
this.lastMatch = null;
}
}
+12 -7
View File
@@ -1,3 +1,7 @@
import { Http } from "./Http.js";
import { Avatar } from "./Avatar.js";
import { DomUtils } from "./DomUtils.js";
export class MessageSearch {
constructor() {
this.initMessageSearch();
@@ -21,16 +25,15 @@ export class MessageSearch {
const q = searchInput.value.trim();
if (q.length < 1) {
dropdown.innerHTML = "";
dropdown.style.display = "none";
DomUtils.hide(dropdown);
return;
}
debounceTimer = setTimeout(async () => {
try {
const resp = await fetch(`/messages/search?q=${encodeURIComponent(q)}`);
const data = await resp.json();
const data = await Http.getJson(`/messages/search?q=${encodeURIComponent(q)}`);
const results = data.results || [];
if (results.length === 0) {
dropdown.style.display = "none";
DomUtils.hide(dropdown);
return;
}
dropdown.innerHTML = "";
@@ -38,10 +41,12 @@ export class MessageSearch {
const item = document.createElement("a");
item.className = "search-dropdown-item";
item.href = `/messages?with_uid=${r.uid}`;
item.innerHTML = `<img src="/avatar/multiavatar/${encodeURIComponent(r.username)}?size=24" class="avatar-img" style="width:24px;height:24px;border-radius:50%" alt="" loading="lazy"><span>${r.username}</span>`;
const label = document.createElement("span");
label.textContent = r.username;
item.append(Avatar.imgElement(r.username), label);
dropdown.appendChild(item);
}
dropdown.style.display = "block";
DomUtils.show(dropdown);
} catch (e) {
// silently fail - no suggestions
}
@@ -59,7 +64,7 @@ export class MessageSearch {
document.addEventListener("click", (e) => {
if (!wrap.contains(e.target)) {
dropdown.style.display = "none";
DomUtils.hide(dropdown);
}
});
}
@@ -0,0 +1,23 @@
export class NotificationManager {
constructor() {
this.initHashScroll();
}
initHashScroll() {
const hash = window.location.hash;
if (!hash.startsWith("#comment-")) {
return;
}
window.addEventListener("load", () => {
const target = document.getElementById(hash.slice(1));
if (!target) {
return;
}
requestAnimationFrame(() => {
target.scrollIntoView({ behavior: "smooth", block: "center" });
target.classList.add("comment-highlight");
setTimeout(() => target.classList.remove("comment-highlight"), 2000);
});
});
}
}
+2 -1
View File
@@ -34,6 +34,7 @@ export class ProfileEditor {
const tag = document.createElement("span");
tag.className = "platform-tag";
tag.textContent = val;
tag.dataset.value = val;
const remove = document.createElement("button");
remove.type = "button";
remove.textContent = "x";
@@ -57,7 +58,7 @@ export class ProfileEditor {
const updatePlatforms = () => {
const values = [];
tagsContainer.querySelectorAll(".platform-tag").forEach((t) => {
values.push(t.textContent.replace("x", "").trim());
values.push(t.dataset.value);
});
hiddenInput.value = values.join(",");
};
+69
View File
@@ -0,0 +1,69 @@
// retoor <retoor@molodetz.nl>
import { Http } from "./Http.js";
export class PushManager {
constructor() {
this.supported = "serviceWorker" in navigator && "PushManager" in window && "Notification" in window;
if (!this.supported) {
return;
}
this.triggers = Array.from(document.querySelectorAll("[data-push-enable]"));
this.bindTriggers();
this.refreshTriggerVisibility();
this.register(true).catch((error) => console.error("Push silent register failed:", error));
}
bindTriggers() {
this.triggers.forEach((trigger) => {
trigger.addEventListener("click", (event) => {
event.preventDefault();
this.optIn();
});
});
}
refreshTriggerVisibility() {
const granted = Notification.permission === "granted";
this.triggers.forEach((trigger) => {
trigger.hidden = granted;
});
}
async optIn() {
const permission = await Notification.requestPermission();
if (permission === "granted") {
await this.register(false);
}
}
async register(silent) {
try {
const registration = await navigator.serviceWorker.register("/service-worker.js");
await registration.update();
await navigator.serviceWorker.ready;
if (Notification.permission !== "granted") {
this.refreshTriggerVisibility();
return;
}
const keyData = await Http.getJson("/push.json");
const applicationServerKey = Uint8Array.from(atob(keyData.publicKey), (c) => c.charCodeAt(0));
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: applicationServerKey,
});
await Http.postJson("/push.json", subscription.toJSON());
this.refreshTriggerVisibility();
} catch (error) {
console.error("Error registering push notifications:", error);
if (!silent) {
alert("Enabling push notifications failed. Please check your browser settings and try again.\n\n" + error);
}
}
}
}
+51
View File
@@ -0,0 +1,51 @@
// retoor <retoor@molodetz.nl>
export class PwaInstaller {
constructor() {
this.deferredPrompt = null;
this.triggers = Array.from(document.querySelectorAll("[data-pwa-install]"));
if (!this.triggers.length) {
return;
}
this.bindTriggers();
window.addEventListener("beforeinstallprompt", (event) => this.onPromptAvailable(event));
window.addEventListener("appinstalled", () => this.hideTriggers());
}
bindTriggers() {
this.triggers.forEach((trigger) => {
trigger.addEventListener("click", (event) => {
event.preventDefault();
this.install();
});
});
}
onPromptAvailable(event) {
event.preventDefault();
this.deferredPrompt = event;
this.showTriggers();
}
showTriggers() {
this.triggers.forEach((trigger) => {
trigger.hidden = false;
});
}
hideTriggers() {
this.triggers.forEach((trigger) => {
trigger.hidden = true;
});
}
async install() {
if (!this.deferredPrompt) {
return;
}
this.deferredPrompt.prompt();
await this.deferredPrompt.userChoice;
this.deferredPrompt = null;
this.hideTriggers();
}
}
+3 -2
View File
@@ -1,3 +1,5 @@
import { Http } from "./Http.js";
class ServiceMonitor {
constructor() {
this.pollInterval = 5000;
@@ -29,8 +31,7 @@ class ServiceMonitor {
async pollServices() {
try {
const resp = await fetch("/admin/services/data");
const data = await resp.json();
const data = await Http.getJson("/admin/services/data");
const container = document.getElementById("services-list");
if (!container) return;
for (const svc of data.services) {
+17
View File
@@ -0,0 +1,17 @@
export class TextInput {
static applyValue(element, value, caretPos) {
element.value = value;
element.setSelectionRange(caretPos, caretPos);
element.focus();
element.dispatchEvent(new Event("input", { bubbles: true }));
}
static insertAtCursor(element, text) {
const start = element.selectionStart;
const end = element.selectionEnd;
const value = element.value.substring(0, start) + text + element.value.substring(end);
TextInput.applyValue(element, value, start + text.length);
}
}
window.TextInput = TextInput;
+13
View File
@@ -0,0 +1,13 @@
export class Toast {
static flash(element, message, ms = 2000, revertTo = null) {
const original = revertTo === null ? element.textContent : revertTo;
element.textContent = message;
setTimeout(() => {
if (element.textContent === message) {
element.textContent = original;
}
}, ms);
}
}
window.Toast = Toast;
+29 -30
View File
@@ -1,43 +1,42 @@
import { Toast } from "./Toast.js";
import { Http } from "./Http.js";
export class VoteManager {
constructor() {
this.initVoteButtons();
this.initNotificationDismiss();
}
initVoteButtons() {
document.querySelectorAll(".post-action-btn[data-vote]").forEach((btn) => {
btn.addEventListener("click", async () => {
const targetUid = btn.dataset.target;
const targetType = btn.dataset.type || "post";
const value = btn.dataset.vote;
const form = document.createElement("form");
form.method = "POST";
form.action = `/votes/${targetType}/${targetUid}`;
const input = document.createElement("input");
input.type = "hidden";
input.name = "value";
input.value = value;
form.appendChild(input);
document.body.appendChild(form);
form.submit();
document.querySelectorAll('form[action^="/votes/"] button[type="submit"]').forEach((button) => {
const form = button.closest("form");
button.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
this.cast(form, button);
});
});
}
initNotificationDismiss() {
document.querySelectorAll(".notification-dismiss").forEach((btn) => {
btn.addEventListener("click", async () => {
const uid = btn.dataset.uid;
if (!uid) {
return;
}
const form = document.createElement("form");
form.method = "POST";
form.action = `/notifications/mark-read/${uid}`;
document.body.appendChild(form);
form.submit();
});
async cast(form, button) {
const action = form.getAttribute("action");
const value = form.querySelector('input[name="value"]').value;
try {
const result = await Http.sendForm(action, { value });
this.render(action, result);
} catch (error) {
console.error("vote failed", error);
Toast.flash(button, "Error", 1500);
}
}
render(action, result) {
const targetUid = action.split("/").pop();
document.querySelectorAll(`[data-vote-count="${targetUid}"]`).forEach((counter) => {
counter.textContent = result.net;
});
document.querySelectorAll(`form[action="${action}"] button[type="submit"]`).forEach((button) => {
const formValue = parseInt(button.closest("form").querySelector('input[name="value"]').value, 10);
button.classList.toggle("voted", result.value !== 0 && formValue === result.value);
});
}
}
+34
View File
@@ -0,0 +1,34 @@
{
"id": "/?source=pwa",
"name": "DevPlace",
"short_name": "DevPlace",
"description": "The Developer Social Network",
"start_url": "/feed?source=pwa",
"scope": "/",
"display": "standalone",
"orientation": "any",
"lang": "en",
"dir": "ltr",
"background_color": "#0f0a1a",
"theme_color": "#0f0a1a",
"icons": [
{
"src": "/static/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/static/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/static/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
+69
View File
@@ -0,0 +1,69 @@
<!DOCTYPE html>
<!-- retoor <retoor@molodetz.nl> -->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Offline - DevPlace</title>
<style>
:root {
--bg-primary: #0f0a1a;
--bg-card: #221436;
--accent: #ff6b35;
--text-primary: #f0e8f8;
--text-muted: #9a8db0;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-primary);
color: var(--text-primary);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
padding: 1.5rem;
}
.offline-card {
max-width: 360px;
text-align: center;
background: var(--bg-card);
border-radius: 16px;
padding: 2.5rem 2rem;
}
.offline-mark {
width: 72px;
height: 72px;
margin: 0 auto 1.25rem;
border-radius: 16px;
background: var(--accent);
color: var(--bg-primary);
font-size: 2.75rem;
font-weight: 700;
line-height: 72px;
}
h1 { font-size: 1.375rem; margin: 0 0 0.5rem; }
p { color: var(--text-muted); line-height: 1.5; margin: 0 0 1.5rem; }
button {
background: var(--accent);
color: var(--bg-primary);
border: none;
border-radius: 8px;
padding: 0.75rem 1.5rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
font-family: inherit;
}
</style>
</head>
<body>
<div class="offline-card">
<div class="offline-mark">D</div>
<h1>You are offline</h1>
<p>DevPlace could not reach the network. Check your connection and try again.</p>
<button type="button" onclick="location.reload()">Retry</button>
</div>
</body>
</html>
+76
View File
@@ -0,0 +1,76 @@
// retoor <retoor@molodetz.nl>
const CACHE_NAME = "devplace-shell-v1";
const OFFLINE_URL = "/static/offline.html";
const PRECACHE_URLS = [
OFFLINE_URL,
"/manifest.json",
"/static/icon-192.png",
"/static/icon-512.png",
];
const DEFAULT_URL = "/notifications";
const DEFAULT_ICON = "/static/icon-192.png";
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_URLS)).then(() => self.skipWaiting())
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys()
.then((names) => Promise.all(names.filter((name) => name !== CACHE_NAME).map((name) => caches.delete(name))))
.then(() => self.clients.claim())
);
});
self.addEventListener("fetch", (event) => {
if (event.request.mode !== "navigate") {
return;
}
event.respondWith(
fetch(event.request).catch(() => caches.match(OFFLINE_URL))
);
});
function isClientOpen(url) {
return clients.matchAll().then((matchedClients) =>
matchedClients.some((client) => client.url === url && "focus" in client)
);
}
self.addEventListener("push", (event) => {
event.waitUntil(handlePush(event));
});
async function handlePush(event) {
if (!self.Notification || self.Notification.permission !== "granted") {
return;
}
const data = event.data ? event.data.json() : {};
const url = data.url || DEFAULT_URL;
if (await isClientOpen(url)) {
return;
}
const title = data.title || "DevPlace";
const message = data.message || "You have a new notification.";
const icon = data.icon || DEFAULT_ICON;
await self.registration.showNotification(title, {
body: message,
icon: icon,
badge: icon,
tag: "devplace-notification",
data: data,
});
}
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const url = event.notification.data && event.notification.data.url ? event.notification.data.url : DEFAULT_URL;
event.waitUntil(clients.openWindow(url));
});
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
<a class="card-link" href="{{ _href }}" aria-label="{{ _label | default('', true) }}"></a>
+8 -8
View File
@@ -6,21 +6,21 @@
<div class="comment-votes">
<form method="POST" action="/votes/comment/{{ item.comment['uid'] }}">
<input type="hidden" name="value" value="1">
<button type="submit" class="comment-vote-btn">+</button>
<button type="submit" class="comment-vote-btn vote-up{% if item.my_vote == 1 %} voted{% endif %}">+</button>
</form>
<span class="comment-vote-count">{{ 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'] }}">
<input type="hidden" name="value" value="-1">
<button type="submit" class="comment-vote-btn">-</button>
<button type="submit" class="comment-vote-btn vote-down{% if item.my_vote == -1 %} voted{% endif %}">-</button>
</form>
</div>
<div class="comment-body" data-comment-uid="{{ item.comment['uid'] }}">
<div class="comment-body" id="comment-{{ item.comment['uid'] }}" data-comment-uid="{{ item.comment['uid'] }}">
<div class="comment-header">
<a href="/profile/{{ item.author['username'] if item.author else '#' }}">
<img src="{{ avatar_url('multiavatar', item.author['username'] if item.author else '?', 32) }}" class="avatar-img avatar-sm" alt="{{ item.author['username'] if item.author else '?' }}" loading="lazy">
</a>
<a href="/profile/{{ item.author['username'] if item.author else '#' }}" class="comment-author">{{ item.author['username'] if item.author else 'Unknown' }}</a>
{% set _user = item.author %}{% set _class = "comment-author" %}{% include "_user_link.html" %}
<span class="comment-time">{{ item.time_ago }}</span>
</div>
<div class="comment-text rendered-content" data-render>{{ item.comment['content'] }}</div>
@@ -29,10 +29,10 @@
{% include "_attachment_display.html" %}
{% endif %}
<div class="comment-actions">
<button class="comment-action-btn" data-action="reply"><span class="icon">&#x1F4AC;</span>Reply</button>
<button class="comment-action-btn" data-action="reply"><span class="icon">&#x1F4AC;</span> Reply</button>
{% if user and item.comment['user_uid'] == user['uid'] %}
<form method="POST" action="/comments/delete/{{ item.comment['uid'] }}" class="inline-form">
<button type="submit" class="comment-action-btn"><span class="icon">&#x1F5D1;&#xFE0F;</span>Delete</button>
<button type="submit" class="comment-action-btn"><span class="icon">&#x1F5D1;&#xFE0F;</span> Delete</button>
</form>
{% endif %}
</div>
@@ -67,7 +67,7 @@
data-max-size="{{ max_upload_size_mb() }}"
data-max-files="{{ max_attachments_per_resource() }}"
data-allowed-types="{{ allowed_file_types() }}"></div>
<button type="submit" class="comment-form-submit"><span class="icon">&#x1F4E4;</span>Post</button>
<button type="submit" class="comment-form-submit"><span class="icon">&#x1F4E4;</span> Post</button>
</div>
</form>
{% endif %}
+5
View File
@@ -0,0 +1,5 @@
{% if next_cursor %}
<div class="load-more-wrap">
<a href="{{ request.url.path }}?{{ request.url.include_query_params(before=next_cursor).query }}" class="btn btn-secondary btn-sm"><span class="icon">&#x1F4C4;</span>Load More</a>
</div>
{% endif %}
+13
View File
@@ -0,0 +1,13 @@
{% macro content_url(item, kind) %}/{{ kind }}/{{ item['slug'] or item['uid'] }}{% endmacro %}
{% macro modal(modal_id, title, wide=false) %}
<div class="modal-overlay" id="{{ modal_id }}">
<div class="card modal-card{% if wide %} modal-card-wide{% endif %}">
<div class="modal-header">
<h3>{{ title }}</h3>
<button type="button" class="modal-close btn-ghost btn-icon modal-close-btn">&times;</button>
</div>
{{ caller() }}
</div>
</div>
{% endmacro %}
+42
View File
@@ -0,0 +1,42 @@
{% from "_macros.html" import content_url %}
<article class="post-card fade-in">
{% include "_post_header.html" %}
<div class="post-topic">
<span class="badge badge-{{ item.post['topic'] }}">{{ item.post['topic'] }}</span>
</div>
{% if item.post.get('title') %}
<a href="{{ content_url(item.post, 'posts') }}" class="post-title-link">
<h3 class="post-title">{{ item.post['title'] }}</h3>
</a>
{% endif %}
<div class="post-content rendered-content" data-render onclick="if (!event.target.closest('a')) window.location.href='{{ content_url(item.post, 'posts') }}'">{{ item.post['content'][:300] }}{% if item.post['content']|length > 300 %}...{% endif %}</div>
{% if item.attachments %}
{% include "_attachment_display.html" %}
{% endif %}
<div class="post-actions">
{% set _uid = item.post['uid'] %}{% set _my_vote = item.my_vote %}{% set _count = item.post.get('stars', 0) %}{% include "_post_votes.html" %}
<a href="{{ content_url(item.post, 'posts') }}" class="post-action-btn">
&#x1F4AC; {{ item.comment_count }}
</a>
<a href="{{ content_url(item.post, 'posts') }}" class="post-action-btn share">
&#x2197;&#xFE0E; Open
</a>
{% if _show_share %}
<button type="button" class="post-action-btn" data-share="{{ content_url(item.post, 'posts') }}">&#x1F517; Share</button>
{% endif %}
</div>
{% if _show_comment_form and user %}
<form class="feed-comment-form" method="POST" action="/comments/create">
<input type="hidden" name="post_uid" value="{{ item.post['uid'] }}">
<img src="{{ avatar_url('multiavatar', user['username'], 24) }}" class="avatar-img avatar-24" alt="" loading="lazy">
<input type="text" name="content" placeholder="Your opinion goes here..." maxlength="1000" autocomplete="off" data-mention>
<button type="submit" class="feed-comment-submit">Post</button>
</form>
{% endif %}
</article>
+10
View File
@@ -0,0 +1,10 @@
<div class="post-header">
{% set _user = _author %}{% set _size = 32 %}{% set _size_class = "sm" %}{% include "_avatar_link.html" %}
<div class="post-author-wrap">
{% set _user = _author %}{% set _class = "post-author-link" %}{% include "_user_link.html" %}
{% if _author and _author.get('role') %}
<span class="post-author-role">{{ _author['role'] }}</span>
{% endif %}
</div>
<span class="post-time">{{ _time }}</span>
</div>
+11
View File
@@ -0,0 +1,11 @@
<div class="post-votes">
<form method="POST" action="/votes/post/{{ _uid }}" class="inline-form">
<input type="hidden" name="value" value="1">
<button type="submit" class="post-action-btn vote-up{% if _my_vote == 1 %} voted{% endif %}">+</button>
</form>
<span class="post-vote-count" data-vote-count="{{ _uid }}">{{ _count }}</span>
<form method="POST" action="/votes/post/{{ _uid }}" class="inline-form">
<input type="hidden" name="value" value="-1">
<button type="submit" class="post-action-btn vote-down{% if _my_vote == -1 %} voted{% endif %}"></button>
</form>
</div>
+4
View File
@@ -0,0 +1,4 @@
<form method="POST" action="/votes/{{ _type }}/{{ _uid }}" class="inline-form"{% if _stop %} data-stop-propagation{% endif %}>
<input type="hidden" name="value" value="1">
<button type="submit" class="{{ _btn_class }} vote-star{% if _my_vote == 1 %} voted{% endif %}"><span class="vote-count-value" data-vote-count="{{ _uid }}">{{ _count }}</span></button>
</form>
@@ -0,0 +1,8 @@
<div class="form-row">
{% for t in _topics %}
<label class="topic-label">
<input type="radio" name="topic" value="{{ t }}" {% if t == _selected %}checked{% endif %} class="topic-radio">
{{ t|capitalize }}
</label>
{% endfor %}
</div>
+5
View File
@@ -0,0 +1,5 @@
{% if _user %}
<a href="/profile/{{ _user['username'] }}"{% if _class %} class="{{ _class }}"{% endif %}>{{ _user['username'] }}</a>
{% else %}
<a href="#"{% if _class %} class="{{ _class }}"{% endif %}>Unknown</a>
{% endif %}
+3 -3
View File
@@ -52,13 +52,13 @@
{% if u['uid'] != user['uid'] %}
<form method="POST" action="/admin/users/{{ u['uid'] }}/toggle" class="admin-inline-form">
<button type="submit" class="admin-btn admin-btn-sm">
<span class="icon">{% if u.get('is_active', True) %}&#x26A1;{% else %}&#x1F512;{% endif %}</span>{% if u.get('is_active', True) %}Disable{% else %}Enable{% endif %}
<span class="icon">{% if u.get('is_active', True) %}&#x26A1;{% else %}&#x1F512;{% endif %}</span> {% if u.get('is_active', True) %}Disable{% else %}Enable{% endif %}
</button>
</form>
<button type="button" class="admin-btn admin-btn-sm" data-toggle="pw-{{ u['uid'] }}"><span class="icon">&#x1F511;</span>Password</button>
<button type="button" class="admin-btn admin-btn-sm" data-toggle="pw-{{ u['uid'] }}"><span class="icon">&#x1F511;</span> Password</button>
<form id="pw-{{ u['uid'] }}" method="POST" action="/admin/users/{{ u['uid'] }}/password" class="admin-pw-form hidden">
<input type="password" name="password" placeholder="New password" minlength="6" class="admin-input-sm">
<button type="submit" class="admin-btn admin-btn-sm"><span class="icon">&#x1F4BE;</span>Set</button>
<button type="submit" class="admin-btn admin-btn-sm"><span class="icon">&#x1F4BE;</span> Set</button>
</form>
{% else %}
<span class="admin-text-muted">You</span>
+16 -2
View File
@@ -24,6 +24,11 @@
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>&#x1f4bb;</text></svg>">
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png">
<link rel="manifest" href="/manifest.json">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="DevPlace">
<link rel="stylesheet" href="/static/css/variables.css">
<link rel="stylesheet" href="/static/css/base.css">
@@ -49,6 +54,7 @@
<a href="/news" class="topnav-link {% if 'news' in request.url.path %}active{% endif %}"><span class="icon">📰</span> News</a>
<a href="/gists" class="topnav-link {% if 'gists' in request.url.path %}active{% endif %}"><span class="icon">📄</span> Gists</a>
<a href="/projects" class="topnav-link {% if 'projects' in request.url.path %}active{% endif %}"><span class="icon">🚀</span> Projects</a>
<a href="/leaderboard" class="topnav-link {% if 'leaderboard' in request.url.path %}active{% endif %}"><span class="icon">🏆</span> Leaderboard</a>
{% if user %}
<a href="/messages" class="topnav-link {% if 'messages' in request.url.path %}active{% endif %}"><span class="icon">✉️</span> Messages</a>
{% if user.get('role') == 'Admin' %}
@@ -58,6 +64,12 @@
</div>
<div class="topnav-right">
{% if user %}
<button type="button" class="topnav-icon" data-pwa-install hidden title="Install DevPlace app" aria-label="Install DevPlace app">
<span class="nav-bell">&#x2B07;&#xFE0F;</span>
</button>
<button type="button" class="topnav-icon" data-push-enable hidden title="Enable push notifications" aria-label="Enable push notifications">
<span class="nav-bell">&#x1F515;</span>
</button>
<a href="/notifications" class="topnav-icon">
<span class="nav-bell">&#x1F514;</span>
{% set unread_count = get_unread_count(user["uid"]) %}
@@ -74,11 +86,11 @@
</div>
</a>
<div class="dropdown-menu">
<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>
{% else %}
<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>
{% endif %}
<button class="topnav-hamburger" id="hamburger-btn" aria-label="Toggle menu">&#x2630;</button>
@@ -94,6 +106,7 @@
<a href="/news" class="topnav-mobile-link {% if 'news' in request.url.path %}active{% endif %}"><span class="icon">📰</span> News</a>
<a href="/gists" class="topnav-mobile-link {% if 'gists' in request.url.path %}active{% endif %}"><span class="icon">📄</span> Gists</a>
<a href="/projects" class="topnav-mobile-link {% if 'projects' in request.url.path %}active{% endif %}"><span class="icon">🚀</span> Projects</a>
<a href="/leaderboard" class="topnav-mobile-link {% if 'leaderboard' in request.url.path %}active{% endif %}"><span class="icon">🏆</span> Leaderboard</a>
{% if user %}
<a href="/messages" class="topnav-mobile-link {% if 'messages' in request.url.path %}active{% endif %}"><span class="icon">✉️</span> Messages</a>
<a href="/notifications" class="topnav-mobile-link {% if 'notifications' in request.url.path %}active{% endif %}"><span class="icon">&#x1F514;</span> Notifications</a>
@@ -156,6 +169,7 @@
<script defer src="/static/vendor/marked.umd.js"></script>
<script defer src="/static/vendor/highlight.min.js"></script>
<script defer src="/static/vendor/purify.min.js"></script>
<script type="module" src="/static/vendor/emoji-picker-element/index.js"></script>
<script type="module" src="/static/js/Application.js"></script>
{% block extra_js %}{% endblock %}
+8 -72
View File
@@ -1,67 +1,9 @@
{% extends "base.html" %}
{% from "_macros.html" import modal %}
{% block extra_head %}
<link rel="stylesheet" href="/static/css/feed.css">
<link rel="stylesheet" href="/static/css/post.css">
<style>
.bugs-layout {
max-width: 720px;
margin: 0 auto;
}
.bugs-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.bugs-header h1 {
font-size: 1.5rem;
font-weight: 700;
}
.bug-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1.25rem;
margin-bottom: 1rem;
}
.bug-card-header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.bug-title {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
}
.bug-status {
font-size: 0.6875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.125rem 0.5rem;
border-radius: 999px;
}
.bug-status.open {
background: var(--accent-light);
color: var(--accent);
}
.bug-status.closed {
background: rgba(76, 175, 80, 0.1);
color: var(--success);
}
.bug-desc {
font-size: 0.875rem;
color: var(--text-secondary);
line-height: 1.5;
margin-bottom: 0.5rem;
}
.bug-meta {
font-size: 0.75rem;
color: var(--text-muted);
}
</style>
<link rel="stylesheet" href="/static/css/bugs.css">
{% endblock %}
{% block content %}
<div class="bugs-layout">
@@ -101,28 +43,22 @@
</div>
{% if user %}
<div class="modal-overlay" id="create-bug-modal">
<div class="card" style="width: 100%; max-width: 560px; margin: 1rem;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h3 style="font-size: 1.125rem; font-weight: 700;">Report a Bug</h3>
<button type="button" class="modal-close btn-ghost btn-icon" style="font-size: 1.25rem;">&times;</button>
</div>
{% call modal('create-bug-modal', 'Report a Bug') %}
<form method="POST" action="/bugs/create">
<div class="auth-field" style="margin-bottom: 0.75rem;">
<div class="auth-field auth-field-gap">
<label for="bug-title">Title</label>
<input type="text" id="bug-title" name="title" required maxlength="200" placeholder="Brief description of the issue">
</div>
<div class="auth-field" style="margin-bottom: 1rem;">
<div class="auth-field auth-field-gap">
<label for="bug-description">Description</label>
<textarea id="bug-description" name="description" required maxlength="5000" placeholder="Detailed steps to reproduce..." style="min-height: 120px;" data-mention></textarea>
<textarea id="bug-description" name="description" required maxlength="5000" placeholder="Detailed steps to reproduce..." class="min-h-120" data-mention></textarea>
</div>
{% include "_attachment_form.html" %}
<div style="display: flex; gap: 0.75rem; justify-content: flex-end;">
<div class="modal-footer">
<button type="button" class="modal-close btn btn-secondary">Cancel</button>
<button type="submit" class="btn btn-primary"><span class="icon">&#x1F4E4;</span> Submit Report</button>
</div>
</form>
</div>
</div>
{% endcall %}
{% endif %}
{% endblock %}
+9 -83
View File
@@ -1,4 +1,5 @@
{% extends "base.html" %}
{% from "_macros.html" import modal %}
{% block extra_head %}
<link rel="stylesheet" href="/static/css/feed.css">
<link rel="stylesheet" href="/static/css/sidebar.css">
@@ -54,74 +55,13 @@
<div class="feed-posts">
{% for item in posts %}
<article class="post-card fade-in">
<div class="post-header">
{% set _user = item.author %}{% set _size = 32 %}{% set _size_class = "sm" %}{% include "_avatar_link.html" %}
<div class="post-author-wrap">
<a href="/profile/{{ item.author['username'] if item.author else '#' }}" class="post-author-link">{{ item.author['username'] if item.author else 'Unknown' }}</a>
{% if item.author and item.author.get('role') %}
<span class="post-author-role">{{ item.author['role'] }}</span>
{% endif %}
</div>
<span class="post-time">{{ item.time_ago }}</span>
</div>
<div class="post-topic">
<span class="badge badge-{{ item.post['topic'] }}">{{ item.post['topic'] }}</span>
</div>
{% if item.post.get('title') %}
<a href="/posts/{{ item.post['slug'] or item.post['uid'] }}" class="post-title-link">
<h3 class="post-title">{{ item.post['title'] }}</h3>
</a>
{% endif %}
<div class="post-content rendered-content" data-render onclick="if (!event.target.closest('a')) window.location.href='/posts/{{ item.post['slug'] or item.post['uid'] }}'">{{ item.post['content'][:300] }}{% if item.post['content']|length > 300 %}...{% endif %}</div>
{% if item.attachments %}
{% include "_attachment_display.html" %}
{% endif %}
<div class="post-actions">
<div class="post-votes">
<form method="POST" action="/votes/post/{{ item.post['uid'] }}" class="inline-form">
<input type="hidden" name="value" value="1">
<button type="submit" class="post-action-btn vote-up" data-vote="1" data-target="{{ item.post['uid'] }}" data-type="post">+</button>
</form>
<span class="post-vote-count">{{ item.post.get('stars', 0) }}</span>
<form method="POST" action="/votes/post/{{ item.post['uid'] }}" class="inline-form">
<input type="hidden" name="value" value="-1">
<button type="submit" class="post-action-btn vote-down" data-vote="-1" data-target="{{ item.post['uid'] }}" data-type="post"></button>
</form>
</div>
<a href="/posts/{{ item.post['slug'] or item.post['uid'] }}" class="post-action-btn">
&#x1F4AC; {{ item.comment_count }}
</a>
<a href="/posts/{{ item.post['slug'] or item.post['uid'] }}" class="post-action-btn share">
&#x2197;&#xFE0E; Open
</a>
<button type="button" class="post-action-btn" data-share="/posts/{{ item.post['slug'] or item.post['uid'] }}">&#x1F517; Share</button>
</div>
{% if user %}
<form class="feed-comment-form" method="POST" action="/comments/create">
<input type="hidden" name="post_uid" value="{{ item.post['uid'] }}">
<img src="{{ avatar_url('multiavatar', user['username'], 24) }}" class="avatar-img avatar-24" alt="" loading="lazy">
<input type="text" name="content" placeholder="Your opinion goes here..." maxlength="1000" autocomplete="off">
<button type="submit" class="feed-comment-submit">Post</button>
</form>
{% endif %}
</article>
{% set _author = item.author %}{% set _time = item.time_ago %}{% set _show_share = true %}{% set _show_comment_form = true %}{% include "_post_card.html" %}
{% else %}
<div class="empty-state">No posts yet. Be the first!</div>
{% endfor %}
</div>
{% if next_cursor %}
<div class="load-more-wrap">
<a href="/feed?before={{ next_cursor }}{% if current_tab %}&tab={{ current_tab }}{% endif %}{% if current_topic %}&topic={{ current_topic }}{% endif %}" class="btn btn-secondary btn-sm"><span class="icon">&#x1F4C4;</span>Load More</a>
</div>
{% endif %}
{% include "_load_more.html" %}
</div>
<aside class="feed-right">
@@ -160,15 +100,15 @@
{% for author in top_authors %}
<div class="stat-row">
<span class="label">
<a href="/profile/{{ author['username'] }}">
<img src="{{ avatar_url('multiavatar', author['username'], 20) }}" class="avatar-img avatar-xs" alt="{{ author['username'] }}" loading="lazy">
{{ author['username'] }}</a>
{% set _user = author %}{% set _size = 20 %}{% set _size_class = "xs" %}{% include "_avatar_link.html" %}
{% set _user = author %}{% set _class = "top-author-name" %}{% include "_user_link.html" %}
</span>
<span class="value">{{ author.get('stars', 0) }}</span>
</div>
{% else %}
<div class="no-authors-msg">No top authors yet</div>
{% endfor %}
<a href="/leaderboard" class="top-authors-link">View full leaderboard</a>
</div>
</div>
</aside>
@@ -177,22 +117,9 @@
{% if user %}
<a href="#" class="feed-fab" data-modal="create-post-modal" title="Create New Post">+</a>
<div class="modal-overlay" id="create-post-modal">
<div class="card modal-card">
<div class="modal-header">
<h3>Create New Post</h3>
<button type="button" class="modal-close btn-ghost btn-icon modal-close-btn">&times;</button>
</div>
{% call modal('create-post-modal', 'Create New Post') %}
<form id="create-post-form" method="POST" action="/posts/create" enctype="multipart/form-data">
<div class="form-row">
{% for t in TOPICS %}
<label class="topic-label">
<input type="radio" name="topic" value="{{ t }}" {% if t == 'random' %}checked{% endif %} class="topic-radio">
{{ t|capitalize }}
</label>
{% endfor %}
</div>
{% set _topics = TOPICS %}{% set _selected = 'random' %}{% include "_topic_selector.html" %}
<div class="auth-field auth-field-gap">
<label for="post-content">What are you sharing?</label>
@@ -226,8 +153,7 @@
<button type="submit" class="btn btn-primary">Post</button>
</div>
</form>
</div>
</div>
{% endcall %}
{% endif %}
{% endblock %}
+1 -1
View File
@@ -30,7 +30,7 @@
<label for="email">Email address</label>
<input type="email" id="email" name="email" required maxlength="255" placeholder="you@example.com">
</div>
<button type="submit" class="auth-submit"><span class="icon">&#x1F4E7;</span>Send Reset Link</button>
<button type="submit" class="auth-submit"><span class="icon">&#x1F4E7;</span> Send Reset Link</button>
</form>
<div class="auth-footer">
+5 -13
View File
@@ -1,4 +1,5 @@
{% extends "base.html" %}
{% from "_macros.html" import modal %}
{% block extra_head %}
<link rel="stylesheet" href="/static/css/gists.css">
<link rel="stylesheet" href="/static/css/post.css">
@@ -18,7 +19,7 @@
<div class="gist-detail-author">
{% set _size = 32 %}{% set _size_class = "sm" %}{% set _user = author %}{% include "_avatar_link.html" %}
<div>
<a href="/profile/{{ author['username'] if author else '#' }}">{{ author['username'] if author else 'Unknown' }}</a>
{% set _user = author %}{% set _class = none %}{% include "_user_link.html" %}
{% if author and author.get('role') %}
<span style="font-size: 0.75rem; color: var(--text-muted);">&middot; {{ author['role'] }}</span>
{% endif %}
@@ -45,10 +46,7 @@
<div class="gist-detail-actions">
<button type="button" class="gist-star-btn" data-share="/gists/{{ gist['slug'] or gist['uid'] }}">&#x1F517; Share</button>
{% if user %}
<form method="POST" action="/votes/gist/{{ gist['uid'] }}" style="display:inline;">
<input type="hidden" name="value" value="1">
<button type="submit" class="gist-star-btn">&#x2606; {{ star_count }}</button>
</form>
{% set _type = "gist" %}{% set _uid = gist['uid'] %}{% set _my_vote = my_vote %}{% set _count = star_count %}{% set _btn_class = "gist-star-btn" %}{% include "_star_vote.html" %}
{% endif %}
{% if is_owner %}
<button class="gist-star-btn" data-modal="edit-gist-modal"><span class="icon">&#x270F;&#xFE0F;</span>Edit</button>
@@ -60,12 +58,7 @@
</article>
{% if is_owner %}
<div class="modal-overlay" id="edit-gist-modal">
<div class="card modal-card modal-card-wide">
<div class="modal-header">
<h3>Edit Gist</h3>
<button type="button" class="modal-close btn-ghost btn-icon modal-close-btn">&times;</button>
</div>
{% call modal('edit-gist-modal', 'Edit Gist', wide=true) %}
<form method="POST" action="/gists/edit/{{ gist['slug'] or gist['uid'] }}">
<div class="auth-field auth-field-gap">
<label for="edit-gist-title">Title</label>
@@ -93,8 +86,7 @@
<button type="submit" class="btn btn-primary"><span class="icon">&#x1F4BE;</span>Save Changes</button>
</div>
</form>
</div>
</div>
{% endcall %}
{% endif %}
{% with target_uid=gist['uid'], target_type="gist" %}
+13 -15
View File
@@ -1,4 +1,5 @@
{% extends "base.html" %}
{% from "_macros.html" import modal %}
{% block extra_head %}
<link rel="stylesheet" href="/static/css/feed.css">
<link rel="stylesheet" href="/static/css/gists.css">
@@ -15,15 +16,17 @@
<span class="icon">&#x1F4CB;</span>All
</a>
{% for code, name in languages %}
{% if code != 'plaintext' %}
{% if code != 'plaintext' and code in gist_language_codes %}
<a href="/gists?language={{ code }}" class="sidebar-link {% if current_language == code %}active{% endif %}">
<span class="icon">&#x1F4DD;</span>{{ name }}
</a>
{% endif %}
{% endfor %}
{% if 'plaintext' in gist_language_codes %}
<a href="/gists?language=plaintext" class="sidebar-link {% if current_language == 'plaintext' %}active{% endif %}">
<span class="icon">&#x1F4DD;</span>Plain Text
</a>
{% endif %}
</div>
{% if user %}
@@ -47,13 +50,13 @@
<div class="gists-grid">
{% for item in gists %}
<div class="gist-card fade-in" data-href="/gists/{{ item.gist['slug'] or item.gist['uid'] }}">
<div class="gist-card fade-in card-link-host">
{% set _href = "/gists/" ~ (item.gist['slug'] or item.gist['uid']) %}
{% set _label = item.gist['title'] %}
{% include "_card_link.html" %}
<div class="gist-card-header">
<h3 class="gist-card-title">{{ item.gist['title'] }}</h3>
<form method="POST" action="/votes/gist/{{ item.gist['uid'] }}" class="inline-form" data-stop-propagation>
<input type="hidden" name="value" value="1">
<button type="submit" class="gist-card-star">&#x2606; {{ item.gist.get('stars', 0) }}</button>
</form>
{% set _type = "gist" %}{% set _uid = item.gist['uid'] %}{% set _my_vote = item.my_vote %}{% set _count = item.gist.get('stars', 0) %}{% set _btn_class = "gist-card-star" %}{% set _stop = True %}{% include "_star_vote.html" %}
</div>
<div class="gist-card-meta">
@@ -76,19 +79,15 @@
<div class="empty-state empty-state-full">No gists found. Create one!</div>
{% endfor %}
</div>
{% include "_load_more.html" %}
</div>
</div>
{% if user %}
<button class="feed-fab" id="create-gist-btn" title="Create Gist" data-modal="create-gist-modal">+</button>
<div class="modal-overlay" id="create-gist-modal">
<div class="card modal-card modal-card-wide">
<div class="modal-header">
<h3>Create Gist</h3>
<button type="button" class="modal-close btn-ghost btn-icon modal-close-btn">&times;</button>
</div>
{% call modal('create-gist-modal', 'Create Gist', wide=true) %}
<form method="POST" action="/gists/create">
<div class="auth-field auth-field-gap">
<label for="gist-title">Title</label>
@@ -121,8 +120,7 @@
<button type="submit" class="btn btn-primary">Create Gist</button>
</div>
</form>
</div>
</div>
{% endcall %}
{% endif %}
{% endblock %}
+7 -7
View File
@@ -9,7 +9,7 @@
<section class="landing-hero">
<h1>Devplace.net &mdash; The Developer <span>Social Network</span></h1>
<p>Track industry shifts. Discover bold releases. Share what you're building in an open, uncensored environment.</p>
<a href="/auth/signup" class="landing-cta"><span class="icon">&#x2728;</span>Join DevPlace Free</a>
<a href="/auth/signup" class="landing-cta"><span class="icon">&#x2728;</span> Join DevPlace Free</a>
<div class="landing-features">
<div class="landing-feature">
@@ -50,7 +50,7 @@
{% set _size_class = "sm" %}
{% include "_avatar_link.html" %}
<div class="landing-post-author-wrap">
<a href="/profile/{{ item.author['username'] }}" class="landing-post-author">{{ item.author['username'] }}</a>
{% set _user = item.author %}{% set _class = "landing-post-author" %}{% include "_user_link.html" %}
<span class="landing-post-time">{{ item.time_ago }}</span>
</div>
<span class="badge badge-{{ item.post['topic'] }}">{{ item.post['topic'] }}</span>
@@ -117,11 +117,11 @@
<footer class="landing-footer">
<p>&copy; DevPlace &mdash; The Developer Social Network</p>
<div class="landing-footer-links">
<a href="/feed"><span class="icon">&#x1F4DD;</span>Posts</a>
<a href="/news"><span class="icon">&#x1F4F0;</span>News</a>
<a href="/projects"><span class="icon">&#x1F680;</span>Projects</a>
<a href="/auth/login"><span class="icon">&#x1F511;</span>Login</a>
<a href="/auth/signup"><span class="icon">&#x2728;</span>Sign Up</a>
<a href="/feed"><span class="icon">&#x1F4DD;</span> Posts</a>
<a href="/news"><span class="icon">&#x1F4F0;</span> News</a>
<a href="/projects"><span class="icon">&#x1F680;</span> Projects</a>
<a href="/auth/login"><span class="icon">&#x1F511;</span> Login</a>
<a href="/auth/signup"><span class="icon">&#x2728;</span> Sign Up</a>
<a href="/bugs"><span class="icon">&#x1F41B;</span> Bug Report</a>
</div>
</footer>
+79
View File
@@ -0,0 +1,79 @@
{% extends "base.html" %}
{% block extra_head %}
<link rel="stylesheet" href="/static/css/feed.css">
{% endblock %}
{% block content %}
<div class="leaderboard-page">
<aside class="community-stats">
<h3>Community</h3>
<div class="stat-row">
<span class="label">Total Members</span>
<span class="value">{{ total_members }}</span>
</div>
<div class="stat-row">
<span class="label">Posts Today</span>
<span class="value">{{ posts_today }}</span>
</div>
<div class="stat-row">
<span class="label">Total Projects</span>
<span class="value">{{ total_projects }}</span>
</div>
<div class="stat-row">
<span class="label">Total Gists</span>
<span class="value">{{ total_gists }}</span>
</div>
<div class="top-authors-section">
<h3 class="top-authors-title">Top Authors</h3>
{% for author in top_authors %}
<div class="stat-row">
<span class="label">
{% set _user = author %}{% set _size = 20 %}{% set _size_class = "xs" %}{% include "_avatar_link.html" %}
{% set _user = author %}{% set _class = "top-author-name" %}{% include "_user_link.html" %}
</span>
<span class="value">{{ author.get('stars', 0) }}</span>
</div>
{% else %}
<div class="no-authors-msg">No top authors yet</div>
{% endfor %}
</div>
</aside>
<div class="leaderboard-main">
<div class="leaderboard-header">
<h1>Leaderboard</h1>
{% if user_rank %}
<span class="leaderboard-you">Your rank: #{{ user_rank }}</span>
{% endif %}
</div>
<p class="leaderboard-intro">Ranked by total stars earned across posts, projects, and gists.</p>
<ol class="leaderboard-list">
{% for entry in entries %}
<li class="leaderboard-row{% if user and entry['uid'] == user['uid'] %} leaderboard-row-self{% endif %}">
<span class="leaderboard-rank">#{{ entry['rank'] }}</span>
{% set _user = entry %}{% set _size = 32 %}{% set _size_class = "sm" %}{% include "_avatar_link.html" %}
{% set _user = entry %}{% set _class = "leaderboard-name" %}{% include "_user_link.html" %}
<span class="leaderboard-level">Level {{ entry.get('level', 1) }}</span>
<span class="leaderboard-stars">{{ entry['stars'] }} <span class="leaderboard-star-icon">&#x2605;</span></span>
</li>
{% else %}
<li class="leaderboard-empty">No ranked contributors yet. Earn stars on your posts, projects, and gists to appear here.</li>
{% endfor %}
</ol>
</div>
<aside class="feed-right">
<div class="featured-news">
<h3>Featured</h3>
{% for article in featured_news %}
<a class="featured-news-item" href="/news/{{ article['slug'] }}">
<span class="featured-news-title">{{ article['title'] }}</span>
<span class="featured-news-meta">{{ article['source_name'] }}{% if article['time_ago'] %} &middot; {{ article['time_ago'] }}{% endif %}</span>
</a>
{% else %}
<div class="no-authors-msg">No featured articles yet</div>
{% endfor %}
</div>
</aside>
</div>
{% endblock %}
+1 -1
View File
@@ -40,7 +40,7 @@
<a href="/auth/forgot-password">Forgot your password?</a>
</div>
<button type="submit" class="auth-submit"><span class="icon">&#x1F511;</span>Sign in</button>
<button type="submit" class="auth-submit"><span class="icon">&#x1F511;</span> Sign in</button>
</form>
<div class="auth-footer">
+1 -1
View File
@@ -34,7 +34,7 @@
<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">
</a>
<a href="/profile/{{ other_user['username'] }}"><h3>{{ other_user['username'] }}</h3></a>
<h3>{% set _user = other_user %}{% set _class = none %}{% include "_user_link.html" %}</h3>
</div>
<div class="messages-thread">
+2
View File
@@ -43,6 +43,8 @@
</article>
{% endfor %}
</div>
{% include "_load_more.html" %}
{% else %}
<div class="news-empty">
<div class="news-empty-icon">&#x1F4F0;</div>
+13 -4
View File
@@ -6,6 +6,7 @@
<div class="notifications-page">
<div class="notifications-header">
<h2>Notifications</h2>
<button type="button" class="btn btn-ghost btn-sm" data-push-enable hidden><span class="icon">&#x1F514;</span>Enable push</button>
<form method="POST" action="/notifications/mark-all-read" style="display:inline;">
<button type="submit" class="btn btn-ghost btn-sm"><span class="icon">&#x2705;</span>Clear</button>
</form>
@@ -16,18 +17,20 @@
<div class="notification-group">
<div class="notification-group-label">{{ group.label }}</div>
{% for item in group.entries %}
{% set target_url = item.notification.get('target_url', '') %}
<div class="notification-card {% if not item.notification['read'] %}unread{% endif %}">
<div class="notification-card card-link-host {% if not item.notification['read'] %}unread{% endif %}">
{% set actor_username = item.actor['username'] if item.actor else '#' %}
{% set _href = "/notifications/open/" ~ item.notification['uid'] %}
{% set _label = item.notification['message'] %}
{% include "_card_link.html" %}
<a href="/profile/{{ actor_username }}" style="flex-shrink:0">
<img src="{{ avatar_url('multiavatar', actor_username, 32) }}" class="avatar-img avatar-sm" alt="{{ actor_username }}" loading="lazy">
</a>
<div class="notification-body">
<div class="notification-text">{% if target_url %}<a href="{{ target_url }}" style="color:inherit;text-decoration:none">{% endif %}{{ item.notification['message'] }}{% if target_url %}</a>{% endif %}</div>
<div class="notification-text">{{ item.notification['message'] }}</div>
<div class="notification-time">{{ item.time_ago }}</div>
</div>
<form method="POST" action="/notifications/mark-read/{{ item.notification['uid'] }}" style="display:inline;">
<button type="submit" class="notification-dismiss" data-uid="{{ item.notification['uid'] }}">&times;</button>
<button type="submit" class="notification-dismiss">&times;</button>
</form>
</div>
{% endfor %}
@@ -36,5 +39,11 @@
<div class="empty-state">No notifications yet</div>
{% endfor %}
</div>
{% if next_cursor %}
<div class="load-more-wrap">
<a href="/notifications?before={{ next_cursor }}" class="btn btn-secondary btn-sm"><span class="icon">&#x1F4C4;</span>Load More</a>
</div>
{% endif %}
</div>
{% endblock %}
+6 -28
View File
@@ -1,4 +1,5 @@
{% extends "base.html" %}
{% from "_macros.html" import modal %}
{% block extra_head %}
<link rel="stylesheet" href="/static/css/feed.css">
<link rel="stylesheet" href="/static/css/post.css">
@@ -13,7 +14,7 @@
<img src="{{ avatar_url('multiavatar', author['username'] if author else '?', 40) }}" class="avatar-img avatar-md" alt="{{ author['username'] if author else '?' }}" loading="lazy">
</a>
<div>
<a href="/profile/{{ author['username'] if author else '#' }}" class="post-detail-author">{{ author['username'] if author else 'Unknown' }}</a>
{% set _user = author %}{% set _class = "post-detail-author" %}{% include "_user_link.html" %}
{% if author and author.get('role') %}
<span class="post-detail-role">&middot; {{ author['role'] }}</span>
{% endif %}
@@ -36,17 +37,7 @@
{% endif %}
<div class="post-detail-actions">
<div class="post-votes">
<form method="POST" action="/votes/post/{{ post['uid'] }}" class="inline-form">
<input type="hidden" name="value" value="1">
<button type="submit" class="post-action-btn vote-up">+</button>
</form>
<span class="post-vote-count">{{ post.get('stars', 0) }}</span>
<form method="POST" action="/votes/post/{{ post['uid'] }}" class="inline-form">
<input type="hidden" name="value" value="-1">
<button type="submit" class="post-action-btn vote-down"></button>
</form>
</div>
{% set _uid = post['uid'] %}{% set _my_vote = my_vote %}{% set _count = post.get('stars', 0) %}{% include "_post_votes.html" %}
<button type="button" class="post-action-btn" data-share="/posts/{{ post['slug'] or post['uid'] }}">&#x1F517; Share</button>
{% if user and post['user_uid'] == user['uid'] %}
<button class="post-action-btn" data-modal="edit-post-modal"><span class="icon">&#x270F;&#xFE0F;</span>Edit</button>
@@ -58,21 +49,9 @@
</article>
{% if user and post['user_uid'] == user['uid'] %}
<div class="modal-overlay" id="edit-post-modal">
<div class="card modal-card">
<div class="modal-header">
<h3>Edit Post</h3>
<button type="button" class="modal-close btn-ghost btn-icon modal-close-btn">&times;</button>
</div>
{% call modal('edit-post-modal', 'Edit Post') %}
<form id="edit-post-form" method="POST" action="/posts/edit/{{ post['slug'] or post['uid'] }}">
<div class="form-row">
{% for t in topics %}
<label class="topic-label">
<input type="radio" name="topic" value="{{ t }}" {% if t == post['topic'] %}checked{% endif %} class="topic-radio">
{{ t|capitalize }}
</label>
{% endfor %}
</div>
{% set _topics = topics %}{% set _selected = post['topic'] %}{% include "_topic_selector.html" %}
<div class="auth-field auth-field-gap">
<label for="edit-title">Title</label>
<input type="text" id="edit-title" name="title" maxlength="500" value="{{ post.get('title', '') }}">
@@ -87,8 +66,7 @@
<button type="submit" class="btn btn-primary"><span class="icon">&#x1F4BE;</span>Save Changes</button>
</div>
</form>
</div>
</div>
{% endcall %}
{% endif %}
{% with target_uid=post['uid'], target_type="post" %}
+13 -49
View File
@@ -4,9 +4,6 @@
<link rel="stylesheet" href="/static/css/profile.css">
<link rel="stylesheet" href="/static/css/projects.css">
<link rel="stylesheet" href="/static/css/gists.css">
<style>
.hidden { display: none !important; }
</style>
{% endblock %}
{% block content %}
<div class="profile-layout">
@@ -33,6 +30,10 @@
<span class="profile-stat-value">{{ profile_user.get('stars', 0) }}</span>
<span class="profile-stat-label">Stars</span>
</div>
<div class="profile-stat">
<a href="/leaderboard" class="profile-stat-value">{% if rank %}#{{ rank }}{% else %}&mdash;{% endif %}</a>
<span class="profile-stat-label">Rank</span>
</div>
</div>
<div class="profile-level-bar">
@@ -47,9 +48,11 @@
<div class="profile-badges">
{% for badge in badges %}
<span class="profile-badge">{{ badge['badge_name'] }}</span>
{% set meta = badge_info(badge['badge_name']) %}
<span class="profile-badge" title="{{ meta['description'] }}"><span class="profile-badge-icon">{{ meta['icon'] }}</span>{{ badge['badge_name'] }}</span>
{% else %}
<span class="profile-badge">Member</span>
{% set meta = badge_info('Member') %}
<span class="profile-badge" title="{{ meta['description'] }}"><span class="profile-badge-icon">{{ meta['icon'] }}</span>Member</span>
{% endfor %}
</div>
</div>
@@ -145,56 +148,17 @@
<a href="/feed" class="back-link">&larr; Back</a>
<div class="profile-tabs">
<a href="/profile/{{ profile_user['username'] }}?tab=posts" class="profile-tab {% if current_tab == 'posts' %}active{% endif %}"><span class="icon">&#x1F4DD;</span>Posts</a>
<a href="/profile/{{ profile_user['username'] }}?tab=projects" class="profile-tab {% if current_tab == 'projects' %}active{% endif %}"><span class="icon">&#x1F680;</span>Projects</a>
<a href="/profile/{{ profile_user['username'] }}?tab=gists" class="profile-tab {% if current_tab == 'gists' %}active{% endif %}"><span class="icon">&#x1F4DD;</span>Gists</a>
<a href="/profile/{{ profile_user['username'] }}?tab=activity" class="profile-tab {% if current_tab == 'activity' %}active{% endif %}"><span class="icon">&#x1F4CA;</span>Activity</a>
<a href="/profile/{{ profile_user['username'] }}?tab=posts" class="profile-tab {% if current_tab == 'posts' %}active{% endif %}"><span class="icon">&#x1F4DD;</span> Posts</a>
<a href="/profile/{{ profile_user['username'] }}?tab=projects" class="profile-tab {% if current_tab == 'projects' %}active{% endif %}"><span class="icon">&#x1F680;</span> Projects</a>
<a href="/profile/{{ profile_user['username'] }}?tab=gists" class="profile-tab {% if current_tab == 'gists' %}active{% endif %}"><span class="icon">&#x1F4DD;</span> Gists</a>
<a href="/profile/{{ profile_user['username'] }}?tab=activity" class="profile-tab {% if current_tab == 'activity' %}active{% endif %}"><span class="icon">&#x1F4CA;</span> Activity</a>
<button class="btn-ghost btn-icon profile-tab-btn">&#x25B3;</button>
</div>
<div class="profile-posts">
{% if current_tab == 'posts' %}
{% for item in posts %}
<article class="post-card fade-in">
<div class="post-header">
<a href="/profile/{{ profile_user['username'] }}">
<img src="{{ avatar_url('multiavatar', profile_user['username'], 32) }}" class="avatar-img avatar-sm" alt="{{ profile_user['username'] }}" loading="lazy">
</a>
<div class="post-author-wrap">
<a href="/profile/{{ profile_user['username'] }}" class="post-author-link">{{ profile_user['username'] }}</a>
{% if profile_user.get('role') %}
<span class="post-author-role">{{ profile_user['role'] }}</span>
{% endif %}
</div>
<span class="post-time">{{ item.time_ago }}</span>
</div>
<div class="post-topic">
<span class="badge badge-{{ item.post['topic'] }}">{{ item.post['topic'] }}</span>
</div>
{% if item.post.get('title') %}
<a href="/posts/{{ item.post['slug'] or item.post['uid'] }}" class="post-title-link">
<h3 class="post-title">{{ item.post['title'] }}</h3>
</a>
{% endif %}
<div class="post-content rendered-content" data-render onclick="if (!event.target.closest('a')) window.location.href='/posts/{{ item.post['slug'] or item.post['uid'] }}'">{{ item.post['content'][:300] }}{% if item.post['content']|length > 300 %}...{% endif %}</div>
<div class="post-actions">
<div class="post-votes">
<form method="POST" action="/votes/post/{{ item.post['uid'] }}" class="inline-form">
<input type="hidden" name="value" value="1">
<button type="submit" class="post-action-btn vote-up">+</button>
</form>
<span class="post-vote-count">{{ item.post.get('stars', 0) }}</span>
<form method="POST" action="/votes/post/{{ item.post['uid'] }}" class="inline-form">
<input type="hidden" name="value" value="-1">
<button type="submit" class="post-action-btn vote-down"></button>
</form>
</div>
<a href="/posts/{{ item.post['slug'] or item.post['uid'] }}" class="post-action-btn">
&#x1F4AC; {{ item.comment_count }}
</a>
<a href="/posts/{{ item.post['slug'] or item.post['uid'] }}" class="post-action-btn share">&#x2197;&#xFE0E; Open</a>
</div>
</article>
{% set _author = profile_user %}{% set _time = item.time_ago %}{% set _show_share = false %}{% set _show_comment_form = false %}{% include "_post_card.html" %}
{% else %}
<div class="empty-state">No posts yet.</div>
{% endfor %}
+3 -85
View File
@@ -2,85 +2,6 @@
{% block extra_head %}
<link rel="stylesheet" href="/static/css/projects.css">
<link rel="stylesheet" href="/static/css/post.css">
<style>
.project-detail-page {
max-width: 720px;
margin: 0 auto;
}
.project-detail {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1.5rem;
}
.project-detail-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 1rem;
}
.project-detail-title {
font-size: 1.5rem;
font-weight: 700;
}
.project-detail-meta {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-bottom: 1rem;
font-size: 0.8125rem;
color: var(--text-muted);
}
.project-detail-meta span {
display: flex;
align-items: center;
gap: 0.25rem;
}
.project-detail-author {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border);
}
.project-detail-author a {
font-weight: 600;
font-size: 0.875rem;
color: var(--text-primary);
}
.project-detail-desc {
font-size: 0.9375rem;
color: var(--text-secondary);
line-height: 1.7;
margin-bottom: 1.5rem;
}
.project-detail-actions {
display: flex;
align-items: center;
gap: 0.5rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
}
.project-star-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border-radius: var(--radius);
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-muted);
background: none;
border: none;
cursor: pointer;
transition: all 0.2s;
}
.project-star-btn:hover {
background: var(--bg-card-hover);
color: var(--warning);
}
</style>
{% endblock %}
{% block content %}
<div class="project-detail-page">
@@ -107,7 +28,7 @@
<div class="project-detail-author">
{% set _size = 32 %}{% set _size_class = "sm" %}{% set _user = author %}{% include "_avatar_link.html" %}
<div>
<a href="/profile/{{ author['username'] if author else '#' }}">{{ author['username'] if author else 'Unknown' }}</a>
{% set _user = author %}{% set _class = none %}{% include "_user_link.html" %}
{% if author and author.get('role') %}
<span style="font-size: 0.75rem; color: var(--text-muted);">&middot; {{ author['role'] }}</span>
{% endif %}
@@ -134,14 +55,11 @@
<div class="project-detail-actions">
<button type="button" class="project-star-btn" data-share="/projects/{{ project['slug'] or project['uid'] }}">&#x1F517; Share</button>
{% if user %}
<form method="POST" action="/votes/project/{{ project['uid'] }}" style="display:inline;">
<input type="hidden" name="value" value="1">
<button type="submit" class="project-star-btn">&#x2606; {{ star_count }}</button>
</form>
{% set _type = "project" %}{% set _uid = project['uid'] %}{% set _my_vote = my_vote %}{% set _count = star_count %}{% set _btn_class = "project-star-btn" %}{% include "_star_vote.html" %}
{% endif %}
{% if is_owner %}
<form method="POST" action="/projects/delete/{{ project['slug'] or project['uid'] }}" style="display:inline;">
<button type="submit" class="project-star-btn" data-confirm="Delete this project?"><span class="icon">&#x1F5D1;&#xFE0F;</span>Delete</button>
<button type="submit" class="project-star-btn" data-confirm="Delete this project?"><span class="icon">&#x1F5D1;&#xFE0F;</span> Delete</button>
</form>
{% endif %}
</div>
+11 -15
View File
@@ -1,4 +1,5 @@
{% extends "base.html" %}
{% from "_macros.html" import modal %}
{% block extra_head %}
<link rel="stylesheet" href="/static/css/feed.css">
<link rel="stylesheet" href="/static/css/projects.css">
@@ -39,7 +40,7 @@
<h1>Projects</h1>
<span class="subtitle">Discover amazing projects from the DevPlace community</span>
</div>
<div class="projects-count">Showing {{ total_count }} of {{ total_count }} projects</div>
<div class="projects-count">Showing {{ projects|length }} of {{ total_count }} projects</div>
</div>
<div class="projects-tabs">
@@ -53,13 +54,13 @@
<div class="projects-grid">
{% for project in projects %}
<div class="project-card fade-in" data-href="/projects/{{ project['slug'] or project['uid'] }}">
<div class="project-card fade-in card-link-host">
{% set _href = "/projects/" ~ (project['slug'] or project['uid']) %}
{% set _label = project['title'] %}
{% include "_card_link.html" %}
<div class="project-card-header">
<h3 class="project-card-title">{{ project['title'] }}</h3>
<form method="POST" action="/votes/project/{{ project['uid'] }}" class="inline-form">
<input type="hidden" name="value" value="1">
<button type="submit" class="project-card-star">&#x2606;</button>
</form>
{% set _type = "project" %}{% set _uid = project['uid'] %}{% set _my_vote = project.my_vote %}{% set _count = project.get('stars', 0) %}{% set _btn_class = "project-card-star" %}{% include "_star_vote.html" %}
</div>
<div class="project-card-meta">
@@ -89,19 +90,15 @@
<div class="empty-state empty-state-full">No projects found. Create one!</div>
{% endfor %}
</div>
{% include "_load_more.html" %}
</div>
</div>
{% if user %}
<button class="feed-fab" id="create-project-btn" title="Add Project" data-modal="create-project-modal">+</button>
<div class="modal-overlay" id="create-project-modal">
<div class="card modal-card">
<div class="modal-header">
<h3>Create Project</h3>
<button type="button" class="modal-close btn-ghost btn-icon modal-close-btn">&times;</button>
</div>
{% call modal('create-project-modal', 'Create Project') %}
<form method="POST" action="/projects/create">
<div class="auth-field auth-field-gap">
<label for="title">Title</label>
@@ -167,7 +164,6 @@
<button type="submit" class="btn btn-primary">Create Project</button>
</div>
</form>
</div>
</div>
{% endcall %}
{% endif %}
{% endblock %}
+1 -1
View File
@@ -34,7 +34,7 @@
<button type="button" class="auth-toggle-pw" aria-label="Toggle password visibility">&#x1F441;</button>
</div>
</div>
<button type="submit" class="auth-submit"><span class="icon">&#x1F512;</span>Reset Password</button>
<button type="submit" class="auth-submit"><span class="icon">&#x1F512;</span> Reset Password</button>
</form>
</div>
</div>
+1 -1
View File
@@ -47,7 +47,7 @@
</div>
</div>
<button type="submit" class="auth-submit"><span class="icon">&#x2728;</span>Create account</button>
<button type="submit" class="auth-submit"><span class="icon">&#x2728;</span> Create account</button>
</form>
<div class="auth-footer">
+2 -6
View File
@@ -5,12 +5,7 @@ from devplacepy.constants import TOPICS
from devplacepy.database import get_table
from devplacepy.avatar import avatar_url
from devplacepy.utils import format_date as _format_date
from devplacepy.seo import (
site_url, combine,
website_schema, breadcrumb_schema,
discussion_forum_posting, profile_page_schema,
software_application_schema, truncate,
)
from devplacepy.utils import badge_info
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
_unread_cache = TTLCache(ttl=60)
@@ -36,6 +31,7 @@ templates.env.globals["get_unread_count"] = jinja_unread_count
templates.env.globals["get_user_projects"] = jinja_user_projects
templates.env.globals["avatar_url"] = avatar_url
templates.env.globals["format_date"] = _format_date
templates.env.globals["badge_info"] = badge_info
templates.env.globals["TOPICS"] = TOPICS
def jinja_max_upload_size_mb():
from devplacepy.database import get_int_setting
+174 -25
View File
@@ -1,3 +1,4 @@
import asyncio
import html
import re
import secrets
@@ -6,8 +7,8 @@ from datetime import datetime, timedelta, timezone
from passlib.hash import pbkdf2_sha256
from fastapi import Request, HTTPException, status
from devplacepy.cache import TTLCache
from devplacepy.database import get_table
from devplacepy.config import SECRET_KEY, SESSION_MAX_AGE
from devplacepy.database import get_table, get_user_stars
from devplacepy.config import SESSION_MAX_AGE
logger = logging.getLogger(__name__)
@@ -42,6 +43,10 @@ def clear_user_cache(user_uid: str) -> None:
_user_cache.pop(token)
def clear_session_cache(token: str) -> None:
_user_cache.pop(token)
def get_current_user(request: Request):
token = request.cookies.get("session")
if not token:
@@ -86,6 +91,17 @@ def require_admin(request: Request):
return user
def not_found(detail: str = "Not found") -> HTTPException:
return HTTPException(status_code=404, detail=detail)
def require_user_api(request: Request):
user = get_current_user(request)
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required")
return user
def strip_html(text: str) -> str:
if not text:
return ""
@@ -102,8 +118,8 @@ def slugify(text: str) -> str:
def generate_uid() -> str:
import uuid
return str(uuid.uuid4())
import uuid_utils
return str(uuid_utils.uuid7())
def make_combined_slug(text: str, uid: str) -> str:
@@ -140,31 +156,164 @@ def extract_mentions(content: str) -> list[str]:
return re.findall(r"(?:^|[\s(])@([a-zA-Z0-9_-]+)", content)
PUSH_ICON = "/static/apple-touch-icon.png"
DEFAULT_PUSH_URL = "/notifications"
_push_tasks: set[asyncio.Task] = set()
async def _safe_notify(user_uid: str, payload: dict[str, str]) -> None:
from devplacepy import push
try:
await push.notify_user(user_uid, payload)
except Exception as e:
logger.warning("Push delivery failed for %s: %s", user_uid, e)
def _schedule_push(user_uid: str, message: str, target_url: str | None) -> None:
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return
payload = {
"title": "DevPlace",
"message": message,
"icon": PUSH_ICON,
"url": target_url or DEFAULT_PUSH_URL,
}
task = loop.create_task(_safe_notify(user_uid, payload))
_push_tasks.add(task)
task.add_done_callback(_push_tasks.discard)
def create_notification(user_uid: str, notification_type: str, message: str, related_uid: str, target_url: str | None = None) -> None:
from devplacepy.templating import clear_unread_cache
get_table("notifications").insert({
"uid": generate_uid(),
"user_uid": user_uid,
"type": notification_type,
"message": message,
"related_uid": related_uid,
"target_url": target_url,
"read": False,
"created_at": datetime.now(timezone.utc).isoformat(),
})
clear_unread_cache(user_uid)
_schedule_push(user_uid, message, target_url)
def award_badge(user_uid: str, badge_name: str) -> bool:
badges = get_table("badges")
if badges.find_one(user_uid=user_uid, badge_name=badge_name):
return False
badges.insert({
"uid": generate_uid(),
"user_uid": user_uid,
"badge_name": badge_name,
"created_at": datetime.now(timezone.utc).isoformat(),
})
return True
LEVEL_XP = 100
XP_POST = 10
XP_COMMENT = 2
XP_PROJECT = 15
XP_GIST = 5
XP_UPVOTE = 5
XP_FOLLOW = 5
LEVEL_BADGES = {5: "Level 5", 10: "Level 10"}
BADGE_CATALOG = {
"Member": {"icon": "", "description": "Joined DevPlace"},
"First Post": {"icon": "", "description": "Published a first post"},
"First Comment": {"icon": "", "description": "Wrote a first comment"},
"First Project": {"icon": "", "description": "Shared a first project"},
"First Gist": {"icon": "", "description": "Shared a first gist"},
"Prolific": {"icon": "", "description": "Published 10 posts"},
"Rising Star": {"icon": "", "description": "Earned 25 stars"},
"Star Author": {"icon": "", "description": "Earned 100 stars"},
"Popular": {"icon": "", "description": "Reached 10 followers"},
"Level 5": {"icon": "", "description": "Reached level 5"},
"Level 10": {"icon": "", "description": "Reached level 10"},
}
def badge_info(badge_name: str) -> dict:
return BADGE_CATALOG.get(badge_name, {"icon": "", "description": badge_name})
def level_for_xp(xp: int) -> int:
return 1 + max(0, xp) // LEVEL_XP
def notify_badge(user_uid: str, badge_name: str) -> None:
user = get_table("users").find_one(uid=user_uid)
if not user:
return
create_notification(user_uid, "badge", f"You earned the {badge_name} badge", user_uid, f"/profile/{user['username']}")
def award_xp(user_uid: str, amount: int) -> dict:
users = get_table("users")
user = users.find_one(uid=user_uid)
if not user or amount <= 0:
return {"xp": user.get("xp", 0) if user else 0, "level": user.get("level", 1) if user else 1, "leveled_up": False}
current_xp = user.get("xp", 0) or 0
current_level = user.get("level", 1) or 1
new_xp = max(0, current_xp + amount)
new_level = level_for_xp(new_xp)
users.update({"uid": user_uid, "xp": new_xp, "level": new_level}, ["uid"])
clear_user_cache(user_uid)
leveled_up = new_level > current_level
if leveled_up:
create_notification(user_uid, "level", f"You reached level {new_level}", user_uid, f"/profile/{user['username']}")
for level in range(current_level + 1, new_level + 1):
badge_name = LEVEL_BADGES.get(level)
if badge_name and award_badge(user_uid, badge_name):
notify_badge(user_uid, badge_name)
return {"xp": new_xp, "level": new_level, "leveled_up": leveled_up}
def check_milestone_badges(user_uid: str) -> list:
held = {row["badge_name"] for row in get_table("badges").find(user_uid=user_uid)}
awarded = []
if "Prolific" not in held and get_table("posts").count(user_uid=user_uid) >= 10 and award_badge(user_uid, "Prolific"):
awarded.append("Prolific")
if {"Star Author", "Rising Star"} - held:
stars = get_user_stars(user_uid)
if "Star Author" not in held and stars >= 100 and award_badge(user_uid, "Star Author"):
awarded.append("Star Author")
if "Rising Star" not in held and stars >= 25 and award_badge(user_uid, "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"):
awarded.append("Popular")
for badge_name in awarded:
notify_badge(user_uid, badge_name)
return awarded
def award_rewards(user_uid: str, amount: int, first_badge: str | None = None) -> None:
if first_badge:
award_badge(user_uid, first_badge)
award_xp(user_uid, amount)
check_milestone_badges(user_uid)
def create_mention_notifications(content: str, actor_uid: str, target_url: str) -> None:
usernames = extract_mentions(content)
usernames = list(dict.fromkeys(extract_mentions(content)))
if not usernames:
return
from devplacepy.templating import clear_unread_cache
users = get_table("users")
notifs = get_table("notifications")
seen = set()
for username in usernames:
if username in seen:
continue
seen.add(username)
mentioned = users.find_one(username=username)
if mentioned and mentioned["uid"] != actor_uid:
notifs.insert({
"uid": generate_uid(),
"user_uid": mentioned["uid"],
"type": "mention",
"message": f"@{username} mentioned you",
"related_uid": actor_uid,
"target_url": target_url,
"read": False,
"created_at": datetime.now(timezone.utc).isoformat(),
})
clear_unread_cache(mentioned["uid"])
actor = users.find_one(uid=actor_uid)
if not actor:
return
actor_username = actor["username"]
for mentioned in users.find(users.table.columns.username.in_(usernames)):
if mentioned["uid"] != actor_uid:
create_notification(mentioned["uid"], "mention", f"@{actor_username} mentioned you", actor_uid, target_url)
def format_date(dt_str: str, include_time: bool = False) -> str:
+199 -33
View File
@@ -23,6 +23,7 @@ NEWS_UIDS = []
NEWS_SLUGS = []
GIST_SLUGS = []
GIST_UIDS = []
BUG_UIDS = []
NOTIFICATION_UIDS = []
ADMIN_USER = {}
ADMIN_TARGETS = {}
@@ -30,10 +31,6 @@ TOPICS = ["devlog", "showcase", "question", "rant", "fun", "random"]
PROJECT_TYPES = ["game", "game_asset", "software", "mobile_app", "website"]
GIST_LANGUAGES = ["python", "javascript", "go", "rust", "bash", "sql", "json"]
UPLOAD_FILE = ("load_test.txt", b"locust upload payload", "text/plain")
AVATAR_STYLES = [
"adventurer", "adventurer-neutral", "avataaars", "bottts", "identicon",
"initials", "lorelei", "micah", "open-peeps", "pixel-art", "shapes",
]
UUID_RE = r'[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}'
PASSWORD = "testpass123"
@@ -75,7 +72,10 @@ def seed_data(environment, **kwargs):
import http.cookiejar
created = 0
while created < 5:
attempts = 0
max_attempts = 25
while created < 5 and attempts < max_attempts:
attempts += 1
username = random_username()
email = f"{username}@locust.devplace"
body = urllib.parse.urlencode({
@@ -91,6 +91,13 @@ def seed_data(environment, **kwargs):
except Exception as e:
logger.warning(f"Create seed user failed: {e}")
if created < 5:
logger.error(
f"Seeding aborted: only {created}/5 users created after "
f"{attempts} attempts. Is the server healthy at {host}?"
)
return
logger.info(f"Created {len(SEED_USERS)} seed users")
def logged_in_opener(email):
@@ -182,6 +189,20 @@ def seed_data(environment, **kwargs):
logger.info(f"Created {len(GIST_SLUGS)} seed gists")
for seed in SEED_USERS[:2]:
try:
opener = logged_in_opener(seed["email"])
body = urllib.parse.urlencode({
"title": f"Bug {uuid.uuid4().hex[:6]}",
"description": "Seed bug report for load testing.",
}).encode()
opener.open(
urllib.request.Request(f"{host}/bugs/create", data=body),
timeout=10,
)
except Exception as e:
logger.warning(f"Create seed bug failed: {e}")
# ── Seed news articles (direct DB insert) ───────────────────
try:
from devplacepy.database import get_table, init_db
@@ -298,11 +319,20 @@ def seed_data(environment, **kwargs):
except Exception as e:
logger.warning(f"Harvest comment UIDs failed: {e}")
try:
opener = logged_in_opener(SEED_USERS[0]["email"])
resp = opener.open(urllib.request.Request(f"{host}/bugs"), timeout=10)
html = resp.read().decode("utf-8", errors="replace")
bug_matches = re.findall(rf'/votes/bug/{uuid_pat}', html)
BUG_UIDS.extend(b for b in bug_matches if b not in BUG_UIDS)
except Exception as e:
logger.warning(f"Harvest bug UIDs failed: {e}")
logger.info(
f"Seed complete: {len(SEED_USERS)} users, {len(POST_UIDS)} posts, "
f"{len(PROJECT_UIDS)} projects ({len(PROJECT_SLUGS)} slugs), "
f"{len(GIST_UIDS)} gists, {len(COMMENT_UIDS)} comments, "
f"{len(USER_UIDS)} uids"
f"{len(BUG_UIDS)} bugs, {len(USER_UIDS)} uids"
)
@@ -334,7 +364,11 @@ class DevPlaceUser(HttpUser):
params = {"tab": tab}
if topic:
params["topic"] = topic
self.client.get("/feed", params=params, name="feed")
resp = self.client.get("/feed", params=params, name="feed")
cursor = re.search(r'/feed\?before=([^"&]+)', resp.text)
if cursor:
params["before"] = cursor.group(1)
self.client.get("/feed", params=params, name="feed?before")
@task(4)
def view_post(self):
@@ -350,14 +384,19 @@ class DevPlaceUser(HttpUser):
if not ALL_USERNAMES:
return
self.client.get(
f"/profile/{random.choice(ALL_USERNAMES)}", name="profile/[username]"
f"/profile/{random.choice(ALL_USERNAMES)}",
params={"tab": random.choice(["posts", "activity"])},
name="profile/[username]",
)
@task(3)
def view_projects(self):
self.client.get("/projects", params={
"tab": random.choice(["recent", "released", "popular", "new"]),
}, name="projects")
params = {"tab": random.choice(["recent", "released", "popular", "new"])}
if random.random() < 0.3:
params["project_type"] = random.choice(PROJECT_TYPES)
if random.random() < 0.2 and USER_UIDS:
params["user_uid"] = random.choice(USER_UIDS)
self.client.get("/projects", params=params, name="projects")
@task(1)
def view_landing(self):
@@ -396,7 +435,23 @@ class DevPlaceUser(HttpUser):
@task(2)
def view_gists(self):
self.client.get("/gists", name="gists")
params = {}
if random.random() < 0.3:
params["language"] = random.choice(GIST_LANGUAGES)
if random.random() < 0.2 and USER_UIDS:
params["user_uid"] = random.choice(USER_UIDS)
self.client.get("/gists", params=params, name="gists")
@task(1)
def view_leaderboard(self):
self.client.get("/leaderboard", name="leaderboard")
@task(1)
def view_pwa(self):
self.client.get(
random.choice(["/push.json", "/service-worker.js", "/manifest.json"]),
name="pwa",
)
@task(2)
def view_gist_detail(self):
@@ -551,45 +606,58 @@ class DevPlaceUser(HttpUser):
# ── social interaction ──────────────────────────────────────
def _vote(self, target_type, uid):
data = {"value": random.choice([1, -1])}
name = f"votes/{target_type}"
if random.random() < 0.5:
self.client.post(
f"/votes/{target_type}/{uid}", data=data, name=name
)
return
with self.client.post(
f"/votes/{target_type}/{uid}", data=data,
headers={"x-requested-with": "fetch"},
catch_response=True, name=f"{name}/ajax",
) as resp:
try:
payload = resp.json()
except ValueError:
resp.failure("vote ajax response not JSON")
return
if all(k in payload for k in ("net", "up", "down", "value")):
resp.success()
else:
resp.failure(f"vote ajax missing keys: {payload}")
@task(2)
def vote_on_post(self):
if not POST_UIDS:
return
self.client.post(
f"/votes/post/{random.choice(POST_UIDS)}",
data={"value": random.choice([1, -1])},
name="votes/post",
)
self._vote("post", random.choice(POST_UIDS))
@task(1)
def vote_on_project(self):
if not PROJECT_UIDS:
return
self.client.post(
f"/votes/project/{random.choice(PROJECT_UIDS)}",
data={"value": random.choice([1, -1])},
name="votes/project",
)
self._vote("project", random.choice(PROJECT_UIDS))
@task(1)
def vote_on_comment(self):
if not COMMENT_UIDS:
return
self.client.post(
f"/votes/comment/{random.choice(COMMENT_UIDS)}",
data={"value": random.choice([1, -1])},
name="votes/comment",
)
self._vote("comment", random.choice(COMMENT_UIDS))
@task(1)
def vote_on_gist(self):
if not GIST_UIDS:
return
self.client.post(
f"/votes/gist/{random.choice(GIST_UIDS)}",
data={"value": random.choice([1, -1])},
name="votes/gist",
)
self._vote("gist", random.choice(GIST_UIDS))
@task(1)
def vote_on_bug(self):
if not BUG_UIDS:
return
self._vote("bug", random.choice(BUG_UIDS))
@task(1)
def follow_user(self):
@@ -632,6 +700,15 @@ class DevPlaceUser(HttpUser):
def view_messages(self):
self.client.get("/messages", name="messages")
@task(1)
def view_conversation(self):
if not USER_UIDS:
return
self.client.get(
"/messages", params={"with_uid": random.choice(USER_UIDS)},
name="messages?with_uid",
)
@task(1)
def view_notifications(self):
resp = self.client.get("/notifications", name="notifications")
@@ -639,6 +716,21 @@ class DevPlaceUser(HttpUser):
NOTIFICATION_UIDS.extend(
m for m in matches if m not in NOTIFICATION_UIDS
)
cursor = re.search(r'/notifications\?before=([^"]+)', resp.text)
if cursor:
self.client.get(
"/notifications", params={"before": cursor.group(1)},
name="notifications?before",
)
@task(1)
def open_notification(self):
if not NOTIFICATION_UIDS:
return
self.client.get(
f"/notifications/open/{random.choice(NOTIFICATION_UIDS)}",
name="notifications/open",
)
@task(1)
def mark_all_notifications_read(self):
@@ -741,14 +833,45 @@ class DevPlaceUser(HttpUser):
else:
resp.failure(f"upload failed: status={resp.status_code}")
@task(1)
def register_push(self):
token = uuid.uuid4().hex
body = {
"endpoint": f"https://push.locust/endpoint/{token}",
"keys": {
"auth": uuid.uuid4().hex[:22],
"p256dh": (uuid.uuid4().hex + uuid.uuid4().hex)[:43],
},
}
with self.client.post(
"/push.json", json=body,
catch_response=True, name="push.json/register",
) as resp:
if resp.status_code in (200, 400):
resp.success()
else:
resp.failure(f"push register: status={resp.status_code}")
# ── static / media ──────────────────────────────────────────
@task(2)
def view_multiavatar(self):
seed = random.choice(ALL_USERNAMES) if ALL_USERNAMES else "anon"
self.client.get(
resp = self.client.get(
f"/avatar/multiavatar/{seed}?size=32", name="avatar/multiavatar"
)
etag = resp.headers.get("ETag")
if not etag:
return
with self.client.get(
f"/avatar/multiavatar/{seed}?size=32",
headers={"If-None-Match": etag},
catch_response=True, name="avatar/multiavatar/304",
) as cached:
if cached.status_code == 304:
cached.success()
else:
cached.failure(f"expected 304, got {cached.status_code}")
@task(1)
def comment_on_news(self):
@@ -760,6 +883,49 @@ class DevPlaceUser(HttpUser):
"target_type": "news",
}, name="comments/news")
@task(1)
def comment_on_target(self):
pools = []
if PROJECT_UIDS:
pools.append(("project", PROJECT_UIDS))
if GIST_UIDS:
pools.append(("gist", GIST_UIDS))
if BUG_UIDS:
pools.append(("bug", BUG_UIDS))
if not pools:
return
target_type, uids = random.choice(pools)
self.client.post("/comments/create", data={
"content": f"{target_type} comment {uuid.uuid4().hex[:6]} " * 2,
"target_uid": random.choice(uids),
"target_type": target_type,
}, name=f"comments/{target_type}")
@task(1)
def reply_to_comment(self):
if not POST_UIDS or not COMMENT_UIDS:
return
self.client.post("/comments/create", data={
"content": f"Reply {uuid.uuid4().hex[:6]} " * 2,
"post_uid": random.choice(POST_UIDS),
"parent_uid": random.choice(COMMENT_UIDS),
}, name="comments/reply")
@task(2)
def browse_and_engage(self):
slugs = POST_SLUGS if POST_SLUGS else POST_UIDS
if not slugs:
return
resp = self.client.get(
f"/posts/{random.choice(slugs)}", name="posts/[uid]"
)
vote_uid = re.search(rf'/votes/post/({UUID_RE})', resp.text)
if vote_uid:
self._vote("post", vote_uid.group(1))
comment_uid = re.search(rf'/comments/delete/({UUID_RE})', resp.text)
if comment_uid and comment_uid.group(1) not in COMMENT_UIDS:
COMMENT_UIDS.append(comment_uid.group(1))
class AdminUser(HttpUser):
weight = 1
+3
View File
@@ -13,9 +13,12 @@ dependencies = [
"python-dotenv",
"aiofiles",
"httpx",
"cryptography",
"PyJWT",
"multiavatar",
"locust",
"Pillow",
"uuid_utils",
]
[project.scripts]

Some files were not shown because too many files have changed in this diff Show More