Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49853e079b | ||
|
|
2afb2038e5 |
@@ -32,3 +32,6 @@ jobs:
|
||||
name: failure-screenshots
|
||||
path: /tmp/devplace_test_screenshots/
|
||||
|
||||
- name: Deploy to production
|
||||
if: success() && github.event_name == 'push' && github.ref == 'refs/heads/master'
|
||||
run: make deploy
|
||||
|
||||
@@ -3,10 +3,6 @@ __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/
|
||||
|
||||
@@ -8,6 +8,7 @@ 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)
|
||||
```
|
||||
@@ -42,7 +43,6 @@ 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,13 +55,10 @@ 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. **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.
|
||||
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"`
|
||||
|
||||
**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.
|
||||
|
||||
@@ -74,7 +71,6 @@ 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>
|
||||
@@ -111,26 +107,6 @@ 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:
|
||||
@@ -199,8 +175,6 @@ 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.
|
||||
@@ -335,10 +309,6 @@ 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
|
||||
|
||||
@@ -357,33 +327,6 @@ 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.
|
||||
@@ -620,11 +563,19 @@ 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: Document
|
||||
### Step 8: Visual verification (if UI changed)
|
||||
|
||||
```bash
|
||||
falcon take --output /tmp/verify.png
|
||||
falcon describe /tmp/verify.png
|
||||
```
|
||||
|
||||
### Step 9: Document
|
||||
|
||||
- Update `AGENTS.md` if new conventions introduced
|
||||
- Update `README.md` if new routes, config, or dependencies added
|
||||
|
||||
@@ -7,7 +7,7 @@ LOCUST_SPAWN_RATE ?= 5
|
||||
LOCUST_RUN_TIME ?= 120s
|
||||
DEVPLACE_RATE_LIMIT ?= 1000000
|
||||
|
||||
.PHONY: install dev clean test test-headed locust locust-headless
|
||||
.PHONY: install dev clean test test-headed demo 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 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; \
|
||||
while ! curl -s http://127.0.0.1:$(LOCUST_PORT)/ > /dev/null 2>&1; do sleep 0.5; done; \
|
||||
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,14 +42,11 @@ 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 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; \
|
||||
while ! curl -s http://127.0.0.1:$(LOCUST_PORT)/ > /dev/null 2>&1; do sleep 0.5; done; \
|
||||
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)
|
||||
|
||||
@@ -11,6 +11,7 @@ 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`.
|
||||
@@ -19,11 +20,11 @@ Open `http://localhost:10500`.
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|-----------|
|
||||
| Backend | Python 3.13+, FastAPI, Uvicorn (multi-worker in production) |
|
||||
| Backend | Python 3.13+, FastAPI, Uvicorn (single worker) |
|
||||
| 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, PBKDF2-SHA256 via passlib |
|
||||
| Auth | Session cookies, SHA256+SALT via passlib |
|
||||
| Avatars | Multiavatar (local SVG generation, no external API, <5ms) |
|
||||
| Validation | `hawk` (Python/JS/CSS/HTML) |
|
||||
| Load testing | Locust (locustfile.py) |
|
||||
@@ -37,10 +38,9 @@ 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, notification hook
|
||||
utils.py # Password hashing, session mgmt, time_ago
|
||||
models.py # Pydantic schemas
|
||||
push.py # Web push crypto, VAPID keys, encrypt/send/register
|
||||
routers/ # One file per domain (auth, feed, posts, push, ...)
|
||||
routers/ # One file per domain (auth, feed, posts, ...)
|
||||
templates/ # Jinja2 HTML templates
|
||||
static/css/ # Page-specific CSS files
|
||||
static/js/ # Application.js (ES6 module)
|
||||
@@ -62,25 +62,11 @@ 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
|
||||
|
||||
@@ -88,7 +74,6 @@ XP awards are wired at the existing content-creation, vote, and follow hook poin
|
||||
|---------|---------|---------|
|
||||
| `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
|
||||
|
||||
@@ -125,66 +110,6 @@ 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:
|
||||
@@ -202,7 +127,7 @@ All indexes are created via `CREATE INDEX IF NOT EXISTS` wrapped in try/except -
|
||||
|
||||
## Testing
|
||||
|
||||
- **274 tests** across 23 files: Playwright integration + unit tests
|
||||
- **148 tests** across 14 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
|
||||
|
||||
+30
-61
@@ -168,7 +168,6 @@ 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 {
|
||||
@@ -184,66 +183,42 @@ def store_attachment(file_bytes, original_filename, user_uid):
|
||||
|
||||
|
||||
def link_attachments(uids, target_type, target_uid):
|
||||
flat = [uid.strip() for raw in uids or [] for uid in str(raw).split(",") if uid.strip()]
|
||||
if not flat:
|
||||
if not uids:
|
||||
return
|
||||
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"])
|
||||
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"])
|
||||
|
||||
|
||||
def delete_attachment(uid):
|
||||
row = get_table("attachments").find_one(uid=uid)
|
||||
if row:
|
||||
_delete_attachment_row(row)
|
||||
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"])
|
||||
|
||||
|
||||
def delete_target_attachments(target_type, target_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})")
|
||||
for attachment in get_table("attachments").find(target_type=target_type, target_uid=target_uid):
|
||||
delete_attachment(attachment["uid"])
|
||||
|
||||
|
||||
def get_attachments(target_type, target_uid):
|
||||
@@ -274,13 +249,7 @@ def get_attachments_batch(target_type, uids):
|
||||
def _row_to_attachment(row):
|
||||
stored_name = row.get("stored_name", "")
|
||||
directory = row.get("directory", "")
|
||||
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"
|
||||
thumb_name = f"{Path(stored_name).stem}_thumb.jpg" if row.get("has_thumbnail") else None
|
||||
return {
|
||||
"uid": row["uid"],
|
||||
"original_filename": row.get("original_filename", ""),
|
||||
|
||||
+35
-14
@@ -58,22 +58,44 @@ def cmd_news_sanitize(args):
|
||||
|
||||
|
||||
def cmd_attachments_prune(args):
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from devplacepy.database import db
|
||||
from devplacepy.attachments import delete_attachment
|
||||
from devplacepy.config import STATIC_DIR
|
||||
import os
|
||||
deleted_records = 0
|
||||
deleted_files = 0
|
||||
freed_bytes = 0
|
||||
|
||||
if "attachments" not in db.tables:
|
||||
print("Attachments table does not exist")
|
||||
return
|
||||
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)
|
||||
|
||||
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")
|
||||
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")
|
||||
|
||||
|
||||
def main():
|
||||
@@ -102,7 +124,6 @@ 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()
|
||||
|
||||
@@ -12,10 +12,3 @@ 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")
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
# 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
|
||||
+7
-243
@@ -1,6 +1,5 @@
|
||||
import dataset
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timezone
|
||||
from devplacepy.cache import TTLCache
|
||||
from devplacepy.config import DATABASE_URL
|
||||
@@ -51,7 +50,6 @@ 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"])
|
||||
@@ -59,7 +57,6 @@ 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"])
|
||||
|
||||
@@ -127,74 +124,13 @@ 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 {}
|
||||
@@ -206,7 +142,8 @@ 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, params = _in_clause(post_uids)
|
||||
placeholders = ", ".join(f":p{i}" for i in range(len(post_uids)))
|
||||
params = {f"p{i}": u for i, u in enumerate(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}
|
||||
|
||||
@@ -214,7 +151,8 @@ 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, params = _in_clause(user_uids)
|
||||
placeholders = ", ".join(f":p{i}" for i in range(len(user_uids)))
|
||||
params = {f"p{i}": u for i, u in enumerate(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}
|
||||
|
||||
@@ -222,7 +160,8 @@ 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, params = _in_clause(target_uids)
|
||||
placeholders = ", ".join(f":p{i}" for i in range(len(target_uids)))
|
||||
params = {f"p{i}": u for i, u in enumerate(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 = {}
|
||||
@@ -234,16 +173,7 @@ def get_vote_counts(target_uids):
|
||||
return ups, downs
|
||||
|
||||
|
||||
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):
|
||||
def load_comments(target_type, target_uid):
|
||||
if "comments" not in db.tables:
|
||||
return []
|
||||
comments_table = db["comments"]
|
||||
@@ -256,7 +186,6 @@ def load_comments(target_type, target_uid, user=None):
|
||||
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 {}
|
||||
@@ -267,7 +196,6 @@ def load_comments(target_type, target_uid, user=None):
|
||||
"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"], []),
|
||||
}
|
||||
@@ -391,84 +319,6 @@ 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:
|
||||
@@ -476,73 +326,6 @@ 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))
|
||||
@@ -570,22 +353,3 @@ 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
|
||||
|
||||
+6
-28
@@ -1,5 +1,4 @@
|
||||
import asyncio
|
||||
import fcntl
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
@@ -8,12 +7,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, SERVICE_LOCK_FILE
|
||||
from devplacepy.config import STATIC_DIR, PORT
|
||||
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
|
||||
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.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.services.manager import service_manager
|
||||
from devplacepy.services.news import NewsService
|
||||
|
||||
@@ -27,20 +26,6 @@ _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)
|
||||
@@ -121,10 +106,8 @@ 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")
|
||||
@@ -157,15 +140,10 @@ 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"):
|
||||
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")
|
||||
news_service = NewsService()
|
||||
service_manager.register(news_service)
|
||||
asyncio.create_task(service_manager.start_all())
|
||||
logger.info(f"DevPlace started on port {PORT}")
|
||||
|
||||
|
||||
|
||||
@@ -1,276 +0,0 @@
|
||||
# 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,9 +1,10 @@
|
||||
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, build_pagination, get_post_counts_by_user_uids, get_news_images_by_uids, clear_settings_cache
|
||||
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.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
|
||||
|
||||
@@ -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, award_badge, clear_session_cache
|
||||
from devplacepy.utils import hash_password, verify_password, create_session, generate_uid, get_current_user
|
||||
from devplacepy.seo import base_seo_context
|
||||
from devplacepy.models import SignupForm, LoginForm, ForgotPasswordForm, ResetPasswordForm
|
||||
|
||||
@@ -82,7 +82,13 @@ async def signup(request: Request, data: Annotated[SignupForm, Form()]):
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
})
|
||||
|
||||
award_badge(uid, "Member")
|
||||
badges = get_table("badges")
|
||||
badges.insert({
|
||||
"uid": generate_uid(),
|
||||
"user_uid": uid,
|
||||
"badge_name": "Member",
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
})
|
||||
|
||||
token = create_session(uid)
|
||||
response = RedirectResponse(url="/feed", status_code=302)
|
||||
@@ -205,7 +211,6 @@ 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
|
||||
|
||||
@@ -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"], user),
|
||||
"comments": load_comments("bug", b["uid"]),
|
||||
"attachments": attachments_map.get(b["uid"], []),
|
||||
})
|
||||
|
||||
|
||||
@@ -3,16 +3,36 @@ 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_object_url
|
||||
from devplacepy.database import get_table, resolve_by_slug
|
||||
from devplacepy.attachments import link_attachments, delete_target_attachments
|
||||
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.templating import clear_unread_cache
|
||||
from devplacepy.utils import generate_uid, require_user, create_mention_notifications
|
||||
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)
|
||||
@@ -21,7 +41,7 @@ async def create_comment(request: Request, data: Annotated[CommentForm, Form()])
|
||||
target_type = data.target_type
|
||||
parent_uid = data.parent_uid
|
||||
|
||||
redirect_url = resolve_object_url(target_type, target_uid)
|
||||
redirect_url = resolve_target_redirect(target_type, target_uid)
|
||||
|
||||
comment_uid = generate_uid()
|
||||
insert = {
|
||||
@@ -40,24 +60,51 @@ async def create_comment(request: Request, data: Annotated[CommentForm, Form()])
|
||||
if data.attachment_uids:
|
||||
link_attachments(data.attachment_uids, "comment", comment_uid)
|
||||
|
||||
award_rewards(user["uid"], XP_COMMENT, "First Comment")
|
||||
|
||||
comment_url = f"{redirect_url}#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(),
|
||||
})
|
||||
|
||||
if target_type == "post":
|
||||
if parent_uid:
|
||||
parent = get_table("comments").find_one(uid=parent_uid)
|
||||
comments_table = get_table("comments")
|
||||
parent = comments_table.find_one(uid=parent_uid)
|
||||
if parent and parent["user_uid"] != user["uid"]:
|
||||
create_notification(parent["user_uid"], "reply", f"{user['username']} replied to your comment", user["uid"], comment_url)
|
||||
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"])
|
||||
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"]:
|
||||
create_notification(post["user_uid"], "comment", f"{user['username']} commented on your post", user["uid"], comment_url)
|
||||
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_mention_notifications(content, user["uid"], comment_url)
|
||||
create_mention_notifications(content, user["uid"], redirect_url)
|
||||
logger.info(f"Comment by {user['username']} on {target_type} {target_uid}")
|
||||
return RedirectResponse(url=redirect_url, status_code=302)
|
||||
|
||||
@@ -67,7 +114,9 @@ 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 is_owner(comment, user):
|
||||
if not comment:
|
||||
return RedirectResponse(url="/feed", status_code=302)
|
||||
if comment["user_uid"] != user["uid"]:
|
||||
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", "")
|
||||
@@ -75,5 +124,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_object_url(target_type, target_uid)
|
||||
redirect_url = resolve_target_redirect(target_type, target_uid)
|
||||
return RedirectResponse(url=redirect_url, status_code=302)
|
||||
|
||||
+42
-13
@@ -1,20 +1,24 @@
|
||||
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, get_top_authors, paginate
|
||||
from devplacepy.database import get_table, get_daily_topic, get_users_by_uids, get_comment_counts_by_post_uids, get_site_stats
|
||||
from devplacepy.attachments import get_attachments_batch
|
||||
from devplacepy.content import enrich_items
|
||||
from devplacepy.templating import templates
|
||||
from devplacepy.utils import get_current_user
|
||||
from devplacepy.seo import list_page_seo
|
||||
from devplacepy.utils import get_current_user, time_ago
|
||||
from devplacepy.seo import base_seo_context, site_url, website_schema, breadcrumb_schema, combine
|
||||
|
||||
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")
|
||||
order = ["-stars", "-created_at"] if tab == "trending" else ["-created_at"]
|
||||
|
||||
filters = {}
|
||||
if topic:
|
||||
filters["topic"] = topic
|
||||
|
||||
if tab == "following":
|
||||
if not user:
|
||||
@@ -23,18 +27,40 @@ 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, next_cursor = paginate(posts_table, posts_table.table.columns.user_uid.in_(following), before=before, order=order)
|
||||
posts = list(posts_table.find(posts_table.table.columns.user_uid.in_(following), order_by=["-created_at"], _limit=PAGE_SIZE))
|
||||
else:
|
||||
filters = {"topic": topic} if topic else {}
|
||||
posts, next_cursor = paginate(posts_table, before=before, order=order, **filters)
|
||||
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"]
|
||||
|
||||
if not posts:
|
||||
return [], next_cursor
|
||||
|
||||
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])
|
||||
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)
|
||||
|
||||
result = enrich_items(posts, "post", authors, {"comment_count": counts}, user=user)
|
||||
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),
|
||||
})
|
||||
return result, next_cursor
|
||||
|
||||
|
||||
@@ -42,8 +68,9 @@ 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 = get_top_authors(5)
|
||||
top_authors = list(users_table.find(stars={">": 0}, order_by=["-stars"], _limit=5))
|
||||
daily_topic = get_daily_topic()
|
||||
|
||||
post_uids_list = [item["post"]["uid"] for item in posts]
|
||||
@@ -51,11 +78,13 @@ 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"], [])
|
||||
|
||||
seo_ctx = list_page_seo(
|
||||
base = site_url(request)
|
||||
seo_ctx = base_seo_context(
|
||||
request,
|
||||
title="Feed",
|
||||
description="Discover the latest developer discussions, projects, and community activity on DevPlace.",
|
||||
breadcrumbs=[{"name": "Home", "url": "/feed"}, {"name": "Feed", "url": "/feed"}],
|
||||
schemas=[website_schema(base)],
|
||||
)
|
||||
return templates.TemplateResponse(request, "feed.html", {
|
||||
**seo_ctx,
|
||||
|
||||
@@ -3,7 +3,8 @@ from datetime import datetime, timezone
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from devplacepy.database import get_table
|
||||
from devplacepy.utils import generate_uid, require_user, create_notification, award_rewards, XP_FOLLOW
|
||||
from devplacepy.templating import clear_unread_cache
|
||||
from devplacepy.utils import generate_uid, require_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
@@ -29,8 +30,17 @@ async def follow_user(request: Request, username: str):
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
})
|
||||
|
||||
create_notification(target["uid"], "follow", f"{user['username']} started following you", user["uid"], f"/profile/{user['username']}")
|
||||
award_rewards(target["uid"], XP_FOLLOW)
|
||||
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"])
|
||||
|
||||
logger.info(f"{user['username']} followed {username}")
|
||||
return RedirectResponse(url=f"/profile/{username}", status_code=302)
|
||||
|
||||
+110
-32
@@ -1,13 +1,14 @@
|
||||
import logging
|
||||
from typing import Annotated
|
||||
from fastapi import APIRouter, Request, Form
|
||||
from datetime import datetime, timezone
|
||||
from fastapi import APIRouter, Request, HTTPException, Form
|
||||
from devplacepy.models import GistForm, GistEditForm
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from devplacepy.database import get_table, get_users_by_uids, get_gist_languages, paginate
|
||||
from devplacepy.content import load_detail, edit_content_item, delete_content_item, enrich_items, create_content_item, detail_context
|
||||
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.templating import templates
|
||||
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
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
@@ -23,7 +24,7 @@ LANGUAGES = [
|
||||
]
|
||||
|
||||
|
||||
def get_gists_list(user_uid=None, language=None, before=None, viewer=None):
|
||||
def get_gists_list(user_uid=None, language=None):
|
||||
gists_table = get_table("gists")
|
||||
filters = {}
|
||||
if user_uid:
|
||||
@@ -31,21 +32,33 @@ def get_gists_list(user_uid=None, language=None, before=None, viewer=None):
|
||||
if language:
|
||||
filters["language"] = language
|
||||
|
||||
total = gists_table.count(**filters)
|
||||
gists, next_cursor = paginate(gists_table, before=before, **filters)
|
||||
all_gists = list(gists_table.find(**filters, order_by=["-created_at"]))
|
||||
|
||||
if not gists:
|
||||
return [], next_cursor, total
|
||||
if not all_gists:
|
||||
return []
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
async def gists_page(request: Request, language: str = None, user_uid: str = None, before: str = None):
|
||||
async def gists_page(request: Request, language: str = None, user_uid: str = None):
|
||||
user = get_current_user(request)
|
||||
gists_data, next_cursor, total_count = get_gists_list(user_uid, language, before, viewer=user)
|
||||
seo_ctx = list_page_seo(
|
||||
gists_data = get_gists_list(user_uid, language)
|
||||
total_count = len(gists_data)
|
||||
base = site_url(request)
|
||||
seo_ctx = base_seo_context(
|
||||
request,
|
||||
title="Gists",
|
||||
description=f"Browse {total_count} code snippets on DevPlace.",
|
||||
@@ -53,6 +66,7 @@ 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,
|
||||
@@ -60,20 +74,31 @@ 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)
|
||||
detail = load_detail("gists", "gist", gist_slug, user)
|
||||
if not detail:
|
||||
raise not_found("Gist not found")
|
||||
gist = detail["item"]
|
||||
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"])
|
||||
|
||||
base = site_url(request)
|
||||
seo_ctx = base_seo_context(
|
||||
@@ -88,9 +113,19 @@ 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", detail_context(request, user, detail, "gist", seo_ctx, {
|
||||
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,
|
||||
"languages": LANGUAGES,
|
||||
}))
|
||||
"attachments": gist_attachments,
|
||||
})
|
||||
|
||||
|
||||
@router.post("/create")
|
||||
@@ -105,30 +140,73 @@ async def create_gist(request: Request, data: Annotated[GistForm, Form()]):
|
||||
if language not in valid_languages:
|
||||
language = "plaintext"
|
||||
|
||||
uid, gist_slug = create_content_item("gists", "gist", user, {
|
||||
gists = get_table("gists")
|
||||
uid = generate_uid()
|
||||
gist_slug = make_combined_slug(title, uid)
|
||||
gists.insert({
|
||||
"uid": uid,
|
||||
"user_uid": user["uid"],
|
||||
"title": title,
|
||||
"slug": gist_slug,
|
||||
"description": description or None,
|
||||
"source_code": source_code,
|
||||
"language": language,
|
||||
}, title, XP_GIST, "First Gist", description or "", data.attachment_uids)
|
||||
"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']}")
|
||||
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
|
||||
if language not in {l[0] for l in LANGUAGES}:
|
||||
|
||||
valid_languages = {l[0] for l in LANGUAGES}
|
||||
if language not in valid_languages:
|
||||
language = "plaintext"
|
||||
return edit_content_item("gists", user, gist_slug, {
|
||||
"title": data.title.strip(),
|
||||
"description": data.description.strip() or None,
|
||||
"source_code": data.source_code.strip(),
|
||||
|
||||
gists.update({
|
||||
"uid": gist["uid"],
|
||||
"title": title,
|
||||
"description": description or None,
|
||||
"source_code": source_code,
|
||||
"language": language,
|
||||
}, "/gists")
|
||||
}, ["uid"])
|
||||
|
||||
logger.info(f"Gist {gist['uid']} edited by {user['username']}")
|
||||
return RedirectResponse(url=f"/gists/{gist['slug'] or gist['uid']}", status_code=302)
|
||||
|
||||
|
||||
@router.post("/delete/{gist_slug}")
|
||||
async def delete_gist(request: Request, gist_slug: str):
|
||||
user = require_user(request)
|
||||
return delete_content_item("gists", "gist", user, gist_slug, "/gists")
|
||||
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)
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
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,
|
||||
})
|
||||
@@ -68,9 +68,8 @@ def get_conversation_messages(user_uid: str, other_uid: str):
|
||||
msgs.append(m)
|
||||
msgs.sort(key=lambda m: m["created_at"])
|
||||
|
||||
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)
|
||||
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})
|
||||
@@ -152,9 +151,6 @@ 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({
|
||||
@@ -177,7 +173,6 @@ 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(),
|
||||
})
|
||||
|
||||
+12
-14
@@ -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, paginate
|
||||
from devplacepy.database import get_table, db, load_comments, resolve_by_slug, get_news_images_by_uids
|
||||
from devplacepy.templating import templates
|
||||
from devplacepy.utils import get_current_user, time_ago, not_found
|
||||
from devplacepy.seo import base_seo_context, website_schema, site_url, news_article_schema, list_page_seo
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
@@ -14,19 +14,16 @@ NEWS_MAX_AGE_DAYS = 4
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
async def news_page(request: Request, before: str = None):
|
||||
async def news_page(request: Request):
|
||||
user = get_current_user(request)
|
||||
cutoff = (datetime.now(timezone.utc) - timedelta(days=NEWS_MAX_AGE_DAYS)).isoformat()
|
||||
|
||||
news_table = get_table("news")
|
||||
articles, next_cursor = paginate(
|
||||
news_table,
|
||||
before=before,
|
||||
order=["-grade", "-synced_at"],
|
||||
cursor_field="synced_at",
|
||||
articles = list(news_table.find(
|
||||
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)
|
||||
@@ -40,11 +37,13 @@ async def news_page(request: Request, before: str = None):
|
||||
"grade": a.get("grade", 0),
|
||||
})
|
||||
|
||||
seo_ctx = list_page_seo(
|
||||
base = site_url(request)
|
||||
seo_ctx = base_seo_context(
|
||||
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", {
|
||||
@@ -52,7 +51,6 @@ async def news_page(request: Request, before: str = None):
|
||||
"request": request,
|
||||
"user": user,
|
||||
"articles": enriched,
|
||||
"next_cursor": next_cursor,
|
||||
})
|
||||
|
||||
|
||||
@@ -62,7 +60,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:
|
||||
raise not_found("News article not found")
|
||||
return HTMLResponse("News article not found", status_code=404)
|
||||
|
||||
image_url = ""
|
||||
if "news_images" in db.tables:
|
||||
@@ -71,7 +69,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"], user)
|
||||
comments = load_comments("news", article["uid"])
|
||||
|
||||
base = site_url(request)
|
||||
page_url = f"{base}/news/{canonical_slug}"
|
||||
|
||||
@@ -10,8 +10,6 @@ from devplacepy.seo import base_seo_context
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
PAGE_SIZE = 25
|
||||
|
||||
|
||||
def _group_label(created_at: str) -> str:
|
||||
try:
|
||||
@@ -31,24 +29,15 @@ def _group_label(created_at: str) -> str:
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
async def notifications_page(request: Request, before: str = None):
|
||||
async def notifications_page(request: Request):
|
||||
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(**filters, order_by=["-created_at"], _limit=PAGE_SIZE + 1)
|
||||
notifications_table.find(user_uid=user["uid"], order_by=["-created_at"])
|
||||
)
|
||||
|
||||
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
|
||||
@@ -94,23 +83,9 @@ async def notifications_page(request: Request, before: str = None):
|
||||
"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)
|
||||
|
||||
+76
-20
@@ -1,14 +1,14 @@
|
||||
import logging
|
||||
from typing import Annotated
|
||||
from fastapi import APIRouter, Request, Form
|
||||
from datetime import datetime, timezone
|
||||
from fastapi import APIRouter, Request, HTTPException, Form
|
||||
from fastapi.responses import RedirectResponse, HTMLResponse
|
||||
from devplacepy.constants import TOPICS
|
||||
from devplacepy.database import db
|
||||
from devplacepy.database import get_table, get_comment_counts_by_post_uids, load_comments, db, resolve_by_slug
|
||||
from devplacepy.templating import templates
|
||||
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.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.models import PostForm, PostEditForm
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -31,26 +31,53 @@ async def create_post(request: Request, data: Annotated[PostForm, Form()]):
|
||||
if image_filename:
|
||||
content += f"\n\n"
|
||||
|
||||
posts = get_table("posts")
|
||||
uid = generate_uid()
|
||||
slug_text = title if title else content[:50]
|
||||
uid, post_slug = create_content_item("posts", "post", user, {
|
||||
post_slug = make_combined_slug(slug_text, uid)
|
||||
posts.insert({
|
||||
"uid": uid,
|
||||
"user_uid": user["uid"],
|
||||
"title": title or None,
|
||||
"slug": post_slug,
|
||||
"content": content,
|
||||
"topic": topic,
|
||||
"project_uid": project_uid or None,
|
||||
"image": image_filename,
|
||||
}, slug_text, XP_POST, "First Post", content, data.attachment_uids)
|
||||
"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']}")
|
||||
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)
|
||||
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"]
|
||||
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"])
|
||||
|
||||
def count_all(items):
|
||||
total = len(items)
|
||||
@@ -58,6 +85,7 @@ 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,
|
||||
@@ -71,7 +99,7 @@ async def view_post(request: Request, post_slug: str):
|
||||
og_type="article",
|
||||
schemas=[
|
||||
website_schema(base),
|
||||
discussion_forum_posting(post, author, comment_count, detail["star_count"], base),
|
||||
discussion_forum_posting(post, author, comment_count, star_count, base),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -88,24 +116,52 @@ async def view_post(request: Request, post_slug: str):
|
||||
"time_ago": time_ago(r["created_at"]),
|
||||
})
|
||||
|
||||
return templates.TemplateResponse(request, "post.html", detail_context(request, user, detail, "post", seo_ctx, {
|
||||
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"]),
|
||||
"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)
|
||||
return edit_content_item("posts", user, post_slug, {
|
||||
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"],
|
||||
"content": data.content.strip(),
|
||||
"title": data.title.strip() or None,
|
||||
"topic": data.topic,
|
||||
}, "/feed")
|
||||
}, ["uid"])
|
||||
|
||||
logger.info(f"Post {post['uid']} edited by {user['username']}")
|
||||
return RedirectResponse(url=f"/posts/{post['slug'] or post['uid']}", status_code=302)
|
||||
|
||||
|
||||
@router.post("/delete/{post_slug}")
|
||||
async def delete_post(request: Request, post_slug: str):
|
||||
user = require_user(request)
|
||||
return delete_content_item("posts", "post", user, post_slug, "/feed", inline_image_field="image")
|
||||
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)
|
||||
|
||||
@@ -3,11 +3,10 @@ from typing import Annotated
|
||||
from fastapi import APIRouter, Request, Form
|
||||
from devplacepy.models import ProfileForm
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
|
||||
from devplacepy.database import get_table, db, get_user_stars, get_user_rank, get_comment_counts_by_post_uids
|
||||
from devplacepy.content import enrich_items
|
||||
from devplacepy.database import get_table, db
|
||||
from devplacepy.templating import templates
|
||||
from devplacepy.utils import get_current_user, require_user, require_user_api, time_ago, clear_user_cache
|
||||
from devplacepy.seo import base_seo_context, site_url, website_schema, profile_page_schema
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
@@ -15,7 +14,7 @@ router = APIRouter()
|
||||
|
||||
@router.get("/search")
|
||||
async def search_users(request: Request, q: str = ""):
|
||||
require_user_api(request)
|
||||
require_user(request)
|
||||
if not q or len(q) < 1:
|
||||
return JSONResponse({"results": []})
|
||||
if "users" in db.tables:
|
||||
@@ -36,16 +35,22 @@ 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"]))
|
||||
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)
|
||||
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),
|
||||
})
|
||||
|
||||
badges = list(get_table("badges").find(user_uid=profile_user["uid"]))
|
||||
projects = list(get_table("projects").find(user_uid=profile_user["uid"]))
|
||||
@@ -105,7 +110,6 @@ async def profile_page(request: Request, username: str, tab: str = "posts"):
|
||||
"posts_count": posts_count,
|
||||
"is_following": is_following,
|
||||
"activities": activities,
|
||||
"rank": rank,
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import logging
|
||||
from typing import Annotated
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import or_
|
||||
from fastapi import APIRouter, Request, Form
|
||||
from fastapi import APIRouter, Request, HTTPException, Form
|
||||
from devplacepy.models import ProjectForm
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from devplacepy.database import get_table, get_users_by_uids, get_site_stats, get_user_votes, paginate
|
||||
from devplacepy.content import load_detail, delete_content_item, create_content_item, detail_context
|
||||
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.templating import templates
|
||||
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
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def get_projects_list(tab: str = "recent", search: str = "", user_uid: str = None, project_type: str = None, before: str = None, viewer: dict = None):
|
||||
def get_projects_list(tab: str = "recent", search: str = "", user_uid: str = None, project_type: str = None):
|
||||
projects = get_table("projects")
|
||||
|
||||
filters = {}
|
||||
@@ -31,18 +32,15 @@ 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"]
|
||||
total = projects.count(*clauses, **filters)
|
||||
page, next_cursor = paginate(projects, *clauses, before=before, order=order, **filters)
|
||||
all_projects = list(projects.find(*clauses, **filters, order_by=order))
|
||||
|
||||
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:
|
||||
if all_projects:
|
||||
users_map = get_users_by_uids([p["user_uid"] for p in all_projects])
|
||||
for p in all_projects:
|
||||
author = users_map.get(p["user_uid"])
|
||||
p["author_name"] = author["username"] if author else "Unknown"
|
||||
p["my_vote"] = my_votes.get(p["uid"], 0)
|
||||
|
||||
return page, next_cursor, total
|
||||
return all_projects
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
@@ -52,20 +50,21 @@ async def projects_page(
|
||||
search: str = "",
|
||||
user_uid: str = None,
|
||||
project_type: str = None,
|
||||
before: str = None,
|
||||
):
|
||||
user = get_current_user(request)
|
||||
projects, next_cursor, total_count = get_projects_list(tab, search, user_uid, project_type, before, viewer=user)
|
||||
projects = get_projects_list(tab, search, user_uid, project_type)
|
||||
total_members = get_site_stats()["total_members"]
|
||||
|
||||
seo_ctx = list_page_seo(
|
||||
base = site_url(request)
|
||||
seo_ctx = base_seo_context(
|
||||
request,
|
||||
title="Projects",
|
||||
description=f"Explore {total_count} developer projects on DevPlace. Games, software, mobile apps and more.",
|
||||
description=f"Explore {len(projects)} 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,
|
||||
@@ -75,8 +74,7 @@ async def projects_page(
|
||||
"current_tab": tab,
|
||||
"search": search,
|
||||
"project_type": project_type,
|
||||
"total_count": total_count,
|
||||
"next_cursor": next_cursor,
|
||||
"total_count": len(projects),
|
||||
"total_members": total_members,
|
||||
})
|
||||
|
||||
@@ -84,10 +82,22 @@ 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)
|
||||
detail = load_detail("projects", "project", project_slug, user)
|
||||
if not detail:
|
||||
raise not_found("Project not found")
|
||||
project = detail["item"]
|
||||
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"])
|
||||
|
||||
base = site_url(request)
|
||||
seo_ctx = base_seo_context(
|
||||
@@ -101,15 +111,30 @@ 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", detail_context(request, user, detail, "project", seo_ctx, {
|
||||
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,
|
||||
"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)
|
||||
return delete_content_item("projects", "project", user, project_slug, "/projects")
|
||||
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)
|
||||
|
||||
|
||||
@router.post("/create")
|
||||
@@ -118,13 +143,27 @@ async def create_project(request: Request, data: Annotated[ProjectForm, Form()])
|
||||
title = data.title.strip()
|
||||
description = data.description.strip()
|
||||
|
||||
uid, project_slug = create_content_item("projects", "project", user, {
|
||||
projects = get_table("projects")
|
||||
uid = generate_uid()
|
||||
project_slug = make_combined_slug(title, uid)
|
||||
projects.insert({
|
||||
"uid": uid,
|
||||
"user_uid": user["uid"],
|
||||
"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,
|
||||
}, title, XP_PROJECT, "First Project", description, data.attachment_uids)
|
||||
"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']}")
|
||||
return RedirectResponse(url=f"/projects/{project_slug}", status_code=302)
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
# 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,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_api
|
||||
from devplacepy.utils import require_user
|
||||
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_api(request)
|
||||
user = require_user(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_api(request)
|
||||
user = require_user(request)
|
||||
att = get_table("attachments").find_one(uid=attachment_uid)
|
||||
if not att:
|
||||
return JSONResponse({"error": "Attachment not found"}, status_code=404)
|
||||
|
||||
+44
-22
@@ -2,16 +2,15 @@ import logging
|
||||
from typing import Annotated
|
||||
from datetime import datetime, timezone
|
||||
from fastapi import APIRouter, Request, Form
|
||||
from fastapi.responses import RedirectResponse, JSONResponse
|
||||
from devplacepy.database import get_table, 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 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.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()]):
|
||||
@@ -21,13 +20,11 @@ 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(),
|
||||
@@ -37,26 +34,51 @@ 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, target_type=target_type, value=1)
|
||||
down_count = votes.count(target_uid=target_uid, target_type=target_type, value=-1)
|
||||
up_count = votes.count(target_uid=target_uid, value=1)
|
||||
down_count = votes.count(target_uid=target_uid, value=-1)
|
||||
net = up_count - down_count
|
||||
|
||||
update_target_stars(target_type, target_uid, net)
|
||||
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"])
|
||||
|
||||
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 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 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})
|
||||
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)
|
||||
|
||||
referer = request.headers.get("Referer", "/feed")
|
||||
return RedirectResponse(url=referer, status_code=302)
|
||||
|
||||
+4
-29
@@ -1,5 +1,6 @@
|
||||
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
|
||||
@@ -151,17 +152,6 @@ 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
|
||||
@@ -172,12 +162,9 @@ def combine(schemas):
|
||||
cleaned.append(s)
|
||||
if not cleaned:
|
||||
return None
|
||||
payload = (
|
||||
{"@context": "https://schema.org", **cleaned[0]}
|
||||
if len(cleaned) == 1
|
||||
else {"@context": "https://schema.org", "@graph": cleaned}
|
||||
)
|
||||
return _json_ld_dumps(payload)
|
||||
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)
|
||||
|
||||
|
||||
DEFAULT_OG_IMAGE = "/static/og-default.png"
|
||||
@@ -206,17 +193,6 @@ 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
|
||||
|
||||
@@ -247,7 +223,6 @@ def make_sitemap(base_url):
|
||||
urlset.append(url_element(f"{base_url}/news", changefreq="hourly", priority="0.9"))
|
||||
urlset.append(url_element(f"{base_url}/projects", changefreq="daily", priority="0.8"))
|
||||
urlset.append(url_element(f"{base_url}/gists", changefreq="daily", priority="0.8"))
|
||||
urlset.append(url_element(f"{base_url}/leaderboard", changefreq="daily", priority="0.7"))
|
||||
|
||||
if "posts" in db.tables:
|
||||
posts = list(get_table("posts").find(order_by=["-created_at"], _limit=500))
|
||||
|
||||
@@ -15,7 +15,6 @@ 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:
|
||||
@@ -217,7 +216,7 @@ class NewsService(BaseService):
|
||||
payload = {
|
||||
"model": ai_model,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"max_tokens": GRADE_MAX_TOKENS,
|
||||
"max_tokens": 10,
|
||||
"temperature": 0.0,
|
||||
}
|
||||
|
||||
|
||||
@@ -126,12 +126,12 @@
|
||||
|
||||
.admin-btn-sm {
|
||||
font-size: 0.6875rem;
|
||||
padding: 0.25rem 0.375rem;
|
||||
padding: 0.2rem 0.4rem;
|
||||
}
|
||||
|
||||
.admin-select {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.375rem;
|
||||
padding: 0.2rem 0.4rem;
|
||||
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.25rem 0.375rem;
|
||||
padding: 0.2rem 0.4rem;
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
@@ -265,6 +265,10 @@
|
||||
transform: translateX(18px);
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -81,9 +81,6 @@
|
||||
.inline-form {
|
||||
display: inline;
|
||||
}
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
.text-muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
@@ -118,14 +115,6 @@
|
||||
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;
|
||||
@@ -419,12 +408,12 @@ img {
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: var(--white);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--accent-hover);
|
||||
color: var(--white);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@@ -472,13 +461,13 @@ img {
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.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-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-fun { background: var(--topic-fun); color: #000; }
|
||||
.badge-random { background: var(--border-light); color: var(--text-secondary); }
|
||||
.badge-signals { background: var(--topic-signals); color: var(--white); }
|
||||
.badge-signals { background: var(--topic-signals); color: #fff; }
|
||||
|
||||
.avatar {
|
||||
width: 40px;
|
||||
@@ -490,7 +479,7 @@ img {
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
color: var(--white);
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -542,11 +531,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; background: none; border: none; cursor: pointer; font-family: inherit; line-height: 1; }
|
||||
.topnav-icon { position: relative; padding: 0.375rem; color: var(--text-secondary); font-size: 1.25rem; transition: color 0.2s; }
|
||||
.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: var(--white);
|
||||
padding: 0 4px; border-radius: 8px; background: var(--accent); color: #fff;
|
||||
font-size: 0.6875rem; font-weight: 700;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
@@ -734,6 +723,16 @@ 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;
|
||||
@@ -980,39 +979,3 @@ 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;
|
||||
}
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
.bugs-layout {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.bugs-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.bugs-header h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.bug-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.bug-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.bug-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.bug-status {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.bug-status.open {
|
||||
background: var(--accent-light);
|
||||
color: var(--accent);
|
||||
}
|
||||
.bug-status.closed {
|
||||
background: rgba(76, 175, 80, 0.1);
|
||||
color: var(--success);
|
||||
}
|
||||
.bug-desc {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.bug-meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
@@ -17,9 +17,7 @@
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.feed-nav-btn,
|
||||
.projects-tab,
|
||||
.profile-tab {
|
||||
.feed-nav-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.8125rem;
|
||||
@@ -73,7 +71,7 @@
|
||||
|
||||
.post-card:hover {
|
||||
border-color: var(--border-light);
|
||||
box-shadow: var(--shadow-sm);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.post-header {
|
||||
@@ -180,24 +178,15 @@
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.post-action-btn.vote-up:hover {
|
||||
.post-action-btn.voted,
|
||||
.post-action-btn.vote-up {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.post-action-btn.vote-down:hover {
|
||||
.post-action-btn.vote-down {
|
||||
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;
|
||||
@@ -298,14 +287,6 @@
|
||||
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 {
|
||||
@@ -321,8 +302,8 @@
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background: var(--danger);
|
||||
color: var(--white);
|
||||
background: #e53935;
|
||||
color: #fff;
|
||||
font-size: 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -374,12 +355,6 @@
|
||||
.feed-right {
|
||||
display: none;
|
||||
}
|
||||
.leaderboard-page {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.leaderboard-page > aside {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@@ -453,146 +428,3 @@
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
|
||||
.gist-card:hover {
|
||||
border-color: var(--border-light);
|
||||
box-shadow: var(--shadow-sm);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.gist-card-header {
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
}
|
||||
|
||||
.news-card-body {
|
||||
padding: 1rem;
|
||||
padding: 1.125rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.625rem;
|
||||
@@ -104,7 +104,7 @@
|
||||
}
|
||||
|
||||
.news-card-title {
|
||||
font-size: 1.125rem;
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
margin: 0;
|
||||
@@ -270,7 +270,7 @@
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@media (max-width: 680px) {
|
||||
.news-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -31,7 +31,6 @@
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.25rem;
|
||||
transition: all 0.2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.notification-card.unread {
|
||||
|
||||
@@ -100,24 +100,10 @@
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.comment-vote-btn.vote-up:hover {
|
||||
.comment-vote-btn: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;
|
||||
@@ -129,22 +115,6 @@
|
||||
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;
|
||||
@@ -244,6 +214,49 @@ button.comment-form-submit:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.post-action-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
transition: all 0.2s;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.post-action-btn:hover {
|
||||
background: var(--bg-card-hover);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.post-action-btn.vote-up {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.post-action-btn.vote-down {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.post-votes {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.post-vote-count {
|
||||
font-weight: 600;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary);
|
||||
min-width: 1.25rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.comment-thread-line {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
|
||||
@@ -56,15 +56,6 @@
|
||||
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);
|
||||
@@ -118,11 +109,6 @@ a.profile-stat-value:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.profile-badge-icon {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.profile-info {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
@@ -175,6 +161,15 @@ a.profile-stat-value:hover {
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.profile-tab {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.profile-tab:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,15 @@
|
||||
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);
|
||||
}
|
||||
@@ -193,81 +202,3 @@
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -18,9 +18,9 @@
|
||||
|
||||
.service-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--space-lg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.service-header {
|
||||
@@ -32,7 +32,7 @@
|
||||
|
||||
.service-name {
|
||||
font-weight: 600;
|
||||
font-size: 1.125rem;
|
||||
font-size: 1.1rem;
|
||||
color: var(--text-primary);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
@@ -60,7 +60,7 @@
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 0.8125rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
|
||||
@@ -28,20 +28,6 @@
|
||||
|
||||
--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.
|
Before Width: | Height: | Size: 2.4 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 6.9 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 5.4 KiB |
@@ -1,30 +1,24 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { Toast } from "./Toast.js";
|
||||
|
||||
export class AttachmentUploader {
|
||||
constructor(form) {
|
||||
this.form = form;
|
||||
@@ -174,7 +172,8 @@ export class AttachmentUploader {
|
||||
}
|
||||
|
||||
showError(msg) {
|
||||
Toast.flash(this.errorEl, msg, 5000, "");
|
||||
this.errorEl.textContent = msg;
|
||||
setTimeout(() => { if (this.errorEl.textContent === msg) this.errorEl.textContent = ""; }, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
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;
|
||||
@@ -51,11 +51,6 @@ 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;
|
||||
|
||||
@@ -1,70 +1,69 @@
|
||||
import { Toast } from "./Toast.js";
|
||||
|
||||
export class DomUtils {
|
||||
constructor() {
|
||||
this.initClipboardCopy();
|
||||
this.initShareButtons();
|
||||
this.initTogglers();
|
||||
this.initStopPropagation();
|
||||
}
|
||||
|
||||
static onDataAttr(attr, event, handler) {
|
||||
document.querySelectorAll(`[data-${attr}]`).forEach((el) => {
|
||||
el.addEventListener(event, (e) => handler(el, e));
|
||||
});
|
||||
}
|
||||
|
||||
static show(el) {
|
||||
el.style.display = "block";
|
||||
}
|
||||
|
||||
static hide(el) {
|
||||
el.style.display = "none";
|
||||
}
|
||||
|
||||
static toggle(el) {
|
||||
el.style.display = el.style.display === "none" ? "block" : "none";
|
||||
}
|
||||
|
||||
static isShown(el) {
|
||||
return el.style.display === "block";
|
||||
this.initCardLinks();
|
||||
}
|
||||
|
||||
initClipboardCopy() {
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
initShareButtons() {
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
initTogglers() {
|
||||
DomUtils.onDataAttr("toggle", "click", (btn) => {
|
||||
const target = document.getElementById(btn.dataset.toggle);
|
||||
if (target) target.classList.toggle("hidden");
|
||||
document.querySelectorAll("[data-toggle]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const target = document.getElementById(btn.dataset.toggle);
|
||||
if (target) target.classList.toggle("hidden");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
initStopPropagation() {
|
||||
DomUtils.onDataAttr("stop-propagation", "click", (el, e) => e.stopPropagation());
|
||||
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;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import { TextInput } from "./TextInput.js";
|
||||
import { DomUtils } from "./DomUtils.js";
|
||||
|
||||
export class EmojiPicker {
|
||||
constructor(textarea) {
|
||||
this.textarea = textarea;
|
||||
@@ -38,15 +35,27 @@ export class EmojiPicker {
|
||||
}
|
||||
|
||||
insert(unicode) {
|
||||
TextInput.insertAtCursor(this.textarea, 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 }));
|
||||
}
|
||||
|
||||
toggle() {
|
||||
DomUtils.toggle(this.wrapper);
|
||||
if (this.wrapper.style.display === "none") {
|
||||
this.wrapper.style.display = "block";
|
||||
} else {
|
||||
this.wrapper.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
hide() {
|
||||
DomUtils.hide(this.wrapper);
|
||||
this.wrapper.style.display = "none";
|
||||
}
|
||||
}
|
||||
window.EmojiPicker = EmojiPicker;
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
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;
|
||||
@@ -1,8 +1,3 @@
|
||||
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;
|
||||
@@ -26,7 +21,7 @@ export class MentionInput {
|
||||
this.input.addEventListener("input", () => this.onInput());
|
||||
this.input.addEventListener("keydown", (e) => this.onKeydown(e));
|
||||
this.input.addEventListener("blur", () => {
|
||||
setTimeout(() => DomUtils.hide(this.dropdown), 200);
|
||||
setTimeout(() => { this.dropdown.style.display = "none"; }, 200);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -37,7 +32,7 @@ export class MentionInput {
|
||||
let match = text.match(/(?:^|\s|\x28)@([a-zA-Z0-9_-]*)$/);
|
||||
|
||||
if (!match) {
|
||||
DomUtils.hide(this.dropdown);
|
||||
this.dropdown.style.display = "none";
|
||||
this.lastMatch = null;
|
||||
return;
|
||||
}
|
||||
@@ -46,7 +41,7 @@ export class MentionInput {
|
||||
this.lastMatch = { query, index: match.index + (text[match.index] === "@" ? 0 : 1) };
|
||||
|
||||
if (query.length < 1) {
|
||||
DomUtils.hide(this.dropdown);
|
||||
this.dropdown.style.display = "none";
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -55,15 +50,16 @@ export class MentionInput {
|
||||
|
||||
async fetch(query) {
|
||||
try {
|
||||
const data = await Http.getJson("/profile/search?q=" + encodeURIComponent(query));
|
||||
const resp = await fetch("/profile/search?q=" + encodeURIComponent(query));
|
||||
const data = await resp.json();
|
||||
const results = data.results || [];
|
||||
if (results.length === 0) {
|
||||
DomUtils.hide(this.dropdown);
|
||||
this.dropdown.style.display = "none";
|
||||
return;
|
||||
}
|
||||
this.render(results);
|
||||
} catch (e) {
|
||||
DomUtils.hide(this.dropdown);
|
||||
this.dropdown.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,21 +71,19 @@ export class MentionInput {
|
||||
item.type = "button";
|
||||
item.className = "mention-dropdown-item";
|
||||
item.dataset.username = r.username;
|
||||
const label = document.createElement("span");
|
||||
label.textContent = "@" + r.username;
|
||||
item.append(Avatar.imgElement(r.username), label);
|
||||
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>';
|
||||
item.addEventListener("mousedown", (e) => {
|
||||
e.preventDefault();
|
||||
this.insert(r.username);
|
||||
});
|
||||
this.dropdown.appendChild(item);
|
||||
}
|
||||
DomUtils.show(this.dropdown);
|
||||
this.dropdown.style.display = "block";
|
||||
}
|
||||
|
||||
onKeydown(e) {
|
||||
const items = this.dropdown.querySelectorAll(".mention-dropdown-item");
|
||||
if (!DomUtils.isShown(this.dropdown) || items.length === 0) {
|
||||
if (this.dropdown.style.display !== "block" || items.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -107,7 +101,7 @@ export class MentionInput {
|
||||
this.insert(items[this.selectedIndex].dataset.username);
|
||||
}
|
||||
} else if (e.key === "Escape") {
|
||||
DomUtils.hide(this.dropdown);
|
||||
this.dropdown.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,8 +121,12 @@ export class MentionInput {
|
||||
let after = val.substring(this.lastMatch.index + this.lastMatch.query.length + 1);
|
||||
before = before.replace(/@+$/, "");
|
||||
after = after.replace(/^@+/, "");
|
||||
TextInput.applyValue(this.input, before + "@" + username + " " + after, before.length + username.length + 2);
|
||||
DomUtils.hide(this.dropdown);
|
||||
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";
|
||||
this.lastMatch = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import { Http } from "./Http.js";
|
||||
import { Avatar } from "./Avatar.js";
|
||||
import { DomUtils } from "./DomUtils.js";
|
||||
|
||||
export class MessageSearch {
|
||||
constructor() {
|
||||
this.initMessageSearch();
|
||||
@@ -25,15 +21,16 @@ export class MessageSearch {
|
||||
const q = searchInput.value.trim();
|
||||
if (q.length < 1) {
|
||||
dropdown.innerHTML = "";
|
||||
DomUtils.hide(dropdown);
|
||||
dropdown.style.display = "none";
|
||||
return;
|
||||
}
|
||||
debounceTimer = setTimeout(async () => {
|
||||
try {
|
||||
const data = await Http.getJson(`/messages/search?q=${encodeURIComponent(q)}`);
|
||||
const resp = await fetch(`/messages/search?q=${encodeURIComponent(q)}`);
|
||||
const data = await resp.json();
|
||||
const results = data.results || [];
|
||||
if (results.length === 0) {
|
||||
DomUtils.hide(dropdown);
|
||||
dropdown.style.display = "none";
|
||||
return;
|
||||
}
|
||||
dropdown.innerHTML = "";
|
||||
@@ -41,12 +38,10 @@ export class MessageSearch {
|
||||
const item = document.createElement("a");
|
||||
item.className = "search-dropdown-item";
|
||||
item.href = `/messages?with_uid=${r.uid}`;
|
||||
const label = document.createElement("span");
|
||||
label.textContent = r.username;
|
||||
item.append(Avatar.imgElement(r.username), label);
|
||||
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>`;
|
||||
dropdown.appendChild(item);
|
||||
}
|
||||
DomUtils.show(dropdown);
|
||||
dropdown.style.display = "block";
|
||||
} catch (e) {
|
||||
// silently fail - no suggestions
|
||||
}
|
||||
@@ -64,7 +59,7 @@ export class MessageSearch {
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
if (!wrap.contains(e.target)) {
|
||||
DomUtils.hide(dropdown);
|
||||
dropdown.style.display = "none";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,6 @@ 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";
|
||||
@@ -58,7 +57,7 @@ export class ProfileEditor {
|
||||
const updatePlatforms = () => {
|
||||
const values = [];
|
||||
tagsContainer.querySelectorAll(".platform-tag").forEach((t) => {
|
||||
values.push(t.dataset.value);
|
||||
values.push(t.textContent.replace("x", "").trim());
|
||||
});
|
||||
hiddenInput.value = values.join(",");
|
||||
};
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
import { Http } from "./Http.js";
|
||||
|
||||
class ServiceMonitor {
|
||||
constructor() {
|
||||
this.pollInterval = 5000;
|
||||
@@ -31,7 +29,8 @@ class ServiceMonitor {
|
||||
|
||||
async pollServices() {
|
||||
try {
|
||||
const data = await Http.getJson("/admin/services/data");
|
||||
const resp = await fetch("/admin/services/data");
|
||||
const data = await resp.json();
|
||||
const container = document.getElementById("services-list");
|
||||
if (!container) return;
|
||||
for (const svc of data.services) {
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
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;
|
||||
@@ -1,13 +0,0 @@
|
||||
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;
|
||||
@@ -1,42 +1,43 @@
|
||||
import { Toast } from "./Toast.js";
|
||||
import { Http } from "./Http.js";
|
||||
|
||||
export class VoteManager {
|
||||
constructor() {
|
||||
this.initVoteButtons();
|
||||
this.initNotificationDismiss();
|
||||
}
|
||||
|
||||
initVoteButtons() {
|
||||
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);
|
||||
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();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
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();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
<!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>
|
||||
@@ -1,76 +0,0 @@
|
||||
// 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));
|
||||
});
|
||||
-3
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
<a class="card-link" href="{{ _href }}" aria-label="{{ _label | default('', true) }}"></a>
|
||||
@@ -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 vote-up{% if item.my_vote == 1 %} voted{% endif %}">+</button>
|
||||
<button type="submit" class="comment-vote-btn">+</button>
|
||||
</form>
|
||||
<span class="comment-vote-count" data-vote-count="{{ item.comment['uid'] }}">{{ item.votes.up - item.votes.down }}</span>
|
||||
<span class="comment-vote-count">{{ 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 vote-down{% if item.my_vote == -1 %} voted{% endif %}">-</button>
|
||||
<button type="submit" class="comment-vote-btn">-</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="comment-body" id="comment-{{ item.comment['uid'] }}" data-comment-uid="{{ item.comment['uid'] }}">
|
||||
<div class="comment-body" 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>
|
||||
{% set _user = item.author %}{% set _class = "comment-author" %}{% include "_user_link.html" %}
|
||||
<a href="/profile/{{ item.author['username'] if item.author else '#' }}" class="comment-author">{{ item.author['username'] if item.author else 'Unknown' }}</a>
|
||||
<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">💬</span> Reply</button>
|
||||
<button class="comment-action-btn" data-action="reply"><span class="icon">💬</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">🗑️</span> Delete</button>
|
||||
<button type="submit" class="comment-action-btn"><span class="icon">🗑️</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">📤</span> Post</button>
|
||||
<button type="submit" class="comment-form-submit"><span class="icon">📤</span>Post</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
{% 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">📄</span>Load More</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -1,13 +0,0 @@
|
||||
{% macro content_url(item, kind) %}/{{ kind }}/{{ item['slug'] or item['uid'] }}{% endmacro %}
|
||||
|
||||
{% macro modal(modal_id, title, wide=false) %}
|
||||
<div class="modal-overlay" id="{{ modal_id }}">
|
||||
<div class="card modal-card{% if wide %} modal-card-wide{% endif %}">
|
||||
<div class="modal-header">
|
||||
<h3>{{ title }}</h3>
|
||||
<button type="button" class="modal-close btn-ghost btn-icon modal-close-btn">×</button>
|
||||
</div>
|
||||
{{ caller() }}
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
@@ -1,42 +0,0 @@
|
||||
{% from "_macros.html" import content_url %}
|
||||
<article class="post-card fade-in">
|
||||
{% include "_post_header.html" %}
|
||||
|
||||
<div class="post-topic">
|
||||
<span class="badge badge-{{ item.post['topic'] }}">{{ item.post['topic'] }}</span>
|
||||
</div>
|
||||
|
||||
{% if item.post.get('title') %}
|
||||
<a href="{{ content_url(item.post, 'posts') }}" class="post-title-link">
|
||||
<h3 class="post-title">{{ item.post['title'] }}</h3>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<div class="post-content rendered-content" data-render onclick="if (!event.target.closest('a')) window.location.href='{{ content_url(item.post, 'posts') }}'">{{ item.post['content'][:300] }}{% if item.post['content']|length > 300 %}...{% endif %}</div>
|
||||
|
||||
{% if item.attachments %}
|
||||
{% include "_attachment_display.html" %}
|
||||
{% endif %}
|
||||
|
||||
<div class="post-actions">
|
||||
{% set _uid = item.post['uid'] %}{% set _my_vote = item.my_vote %}{% set _count = item.post.get('stars', 0) %}{% include "_post_votes.html" %}
|
||||
<a href="{{ content_url(item.post, 'posts') }}" class="post-action-btn">
|
||||
💬 {{ item.comment_count }}
|
||||
</a>
|
||||
<a href="{{ content_url(item.post, 'posts') }}" class="post-action-btn share">
|
||||
↗︎ Open
|
||||
</a>
|
||||
{% if _show_share %}
|
||||
<button type="button" class="post-action-btn" data-share="{{ content_url(item.post, 'posts') }}">🔗 Share</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if _show_comment_form and user %}
|
||||
<form class="feed-comment-form" method="POST" action="/comments/create">
|
||||
<input type="hidden" name="post_uid" value="{{ item.post['uid'] }}">
|
||||
<img src="{{ avatar_url('multiavatar', user['username'], 24) }}" class="avatar-img avatar-24" alt="" loading="lazy">
|
||||
<input type="text" name="content" placeholder="Your opinion goes here..." maxlength="1000" autocomplete="off" data-mention>
|
||||
<button type="submit" class="feed-comment-submit">Post</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</article>
|
||||
@@ -1,10 +0,0 @@
|
||||
<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>
|
||||
@@ -1,11 +0,0 @@
|
||||
<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>
|
||||
@@ -1,4 +0,0 @@
|
||||
<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>
|
||||
@@ -1,8 +0,0 @@
|
||||
<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>
|
||||
@@ -1,5 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -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) %}⚡{% else %}🔒{% endif %}</span> {% if u.get('is_active', True) %}Disable{% else %}Enable{% endif %}
|
||||
<span class="icon">{% if u.get('is_active', True) %}⚡{% else %}🔒{% 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">🔑</span> Password</button>
|
||||
<button type="button" class="admin-btn admin-btn-sm" data-toggle="pw-{{ u['uid'] }}"><span class="icon">🔑</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">💾</span> Set</button>
|
||||
<button type="submit" class="admin-btn admin-btn-sm"><span class="icon">💾</span>Set</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<span class="admin-text-muted">You</span>
|
||||
|
||||
@@ -24,11 +24,6 @@
|
||||
|
||||
<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>💻</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">
|
||||
@@ -54,7 +49,6 @@
|
||||
<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' %}
|
||||
@@ -64,12 +58,6 @@
|
||||
</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">⬇️</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">🔕</span>
|
||||
</button>
|
||||
<a href="/notifications" class="topnav-icon">
|
||||
<span class="nav-bell">🔔</span>
|
||||
{% set unread_count = get_unread_count(user["uid"]) %}
|
||||
@@ -86,11 +74,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">☰</button>
|
||||
@@ -106,7 +94,6 @@
|
||||
<a href="/news" class="topnav-mobile-link {% if 'news' in request.url.path %}active{% endif %}"><span class="icon">📰</span> News</a>
|
||||
<a href="/gists" class="topnav-mobile-link {% if 'gists' in request.url.path %}active{% endif %}"><span class="icon">📄</span> Gists</a>
|
||||
<a href="/projects" class="topnav-mobile-link {% if 'projects' in request.url.path %}active{% endif %}"><span class="icon">🚀</span> Projects</a>
|
||||
<a href="/leaderboard" class="topnav-mobile-link {% if 'leaderboard' in request.url.path %}active{% endif %}"><span class="icon">🏆</span> Leaderboard</a>
|
||||
{% if user %}
|
||||
<a href="/messages" class="topnav-mobile-link {% if 'messages' in request.url.path %}active{% endif %}"><span class="icon">✉️</span> Messages</a>
|
||||
<a href="/notifications" class="topnav-mobile-link {% if 'notifications' in request.url.path %}active{% endif %}"><span class="icon">🔔</span> Notifications</a>
|
||||
@@ -169,7 +156,6 @@
|
||||
|
||||
<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 %}
|
||||
|
||||
@@ -1,9 +1,67 @@
|
||||
{% 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">
|
||||
<link rel="stylesheet" href="/static/css/bugs.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>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="bugs-layout">
|
||||
@@ -43,22 +101,28 @@
|
||||
</div>
|
||||
|
||||
{% if user %}
|
||||
{% call modal('create-bug-modal', 'Report a Bug') %}
|
||||
<div class="modal-overlay" id="create-bug-modal">
|
||||
<div class="card" style="width: 100%; max-width: 560px; margin: 1rem;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||||
<h3 style="font-size: 1.125rem; font-weight: 700;">Report a Bug</h3>
|
||||
<button type="button" class="modal-close btn-ghost btn-icon" style="font-size: 1.25rem;">×</button>
|
||||
</div>
|
||||
<form method="POST" action="/bugs/create">
|
||||
<div class="auth-field auth-field-gap">
|
||||
<div class="auth-field" style="margin-bottom: 0.75rem;">
|
||||
<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 auth-field-gap">
|
||||
<div class="auth-field" style="margin-bottom: 1rem;">
|
||||
<label for="bug-description">Description</label>
|
||||
<textarea id="bug-description" name="description" required maxlength="5000" placeholder="Detailed steps to reproduce..." class="min-h-120" data-mention></textarea>
|
||||
<textarea id="bug-description" name="description" required maxlength="5000" placeholder="Detailed steps to reproduce..." style="min-height: 120px;" data-mention></textarea>
|
||||
</div>
|
||||
{% include "_attachment_form.html" %}
|
||||
<div class="modal-footer">
|
||||
<div style="display: flex; gap: 0.75rem; justify-content: flex-end;">
|
||||
<button type="button" class="modal-close btn btn-secondary">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary"><span class="icon">📤</span> Submit Report</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endcall %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
{% 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">
|
||||
@@ -55,13 +54,74 @@
|
||||
|
||||
<div class="feed-posts">
|
||||
{% for item in posts %}
|
||||
{% set _author = item.author %}{% set _time = item.time_ago %}{% set _show_share = true %}{% set _show_comment_form = true %}{% include "_post_card.html" %}
|
||||
<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">
|
||||
💬 {{ item.comment_count }}
|
||||
</a>
|
||||
<a href="/posts/{{ item.post['slug'] or item.post['uid'] }}" class="post-action-btn share">
|
||||
↗︎ Open
|
||||
</a>
|
||||
<button type="button" class="post-action-btn" data-share="/posts/{{ item.post['slug'] or item.post['uid'] }}">🔗 Share</button>
|
||||
</div>
|
||||
|
||||
{% if user %}
|
||||
<form class="feed-comment-form" method="POST" action="/comments/create">
|
||||
<input type="hidden" name="post_uid" value="{{ item.post['uid'] }}">
|
||||
<img src="{{ avatar_url('multiavatar', user['username'], 24) }}" class="avatar-img avatar-24" alt="" loading="lazy">
|
||||
<input type="text" name="content" placeholder="Your opinion goes here..." maxlength="1000" autocomplete="off">
|
||||
<button type="submit" class="feed-comment-submit">Post</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</article>
|
||||
{% else %}
|
||||
<div class="empty-state">No posts yet. Be the first!</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% include "_load_more.html" %}
|
||||
{% 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">📄</span>Load More</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<aside class="feed-right">
|
||||
@@ -100,15 +160,15 @@
|
||||
{% 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" %}
|
||||
<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>
|
||||
</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>
|
||||
@@ -117,9 +177,22 @@
|
||||
{% if user %}
|
||||
<a href="#" class="feed-fab" data-modal="create-post-modal" title="Create New Post">+</a>
|
||||
|
||||
{% call modal('create-post-modal', 'Create New Post') %}
|
||||
<div class="modal-overlay" id="create-post-modal">
|
||||
<div class="card modal-card">
|
||||
<div class="modal-header">
|
||||
<h3>Create New Post</h3>
|
||||
<button type="button" class="modal-close btn-ghost btn-icon modal-close-btn">×</button>
|
||||
</div>
|
||||
|
||||
<form id="create-post-form" method="POST" action="/posts/create" enctype="multipart/form-data">
|
||||
{% set _topics = TOPICS %}{% set _selected = 'random' %}{% include "_topic_selector.html" %}
|
||||
<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>
|
||||
|
||||
<div class="auth-field auth-field-gap">
|
||||
<label for="post-content">What are you sharing?</label>
|
||||
@@ -153,7 +226,8 @@
|
||||
<button type="submit" class="btn btn-primary">Post</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endcall %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -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">📧</span> Send Reset Link</button>
|
||||
<button type="submit" class="auth-submit"><span class="icon">📧</span>Send Reset Link</button>
|
||||
</form>
|
||||
|
||||
<div class="auth-footer">
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
{% 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">
|
||||
@@ -19,7 +18,7 @@
|
||||
<div class="gist-detail-author">
|
||||
{% set _size = 32 %}{% set _size_class = "sm" %}{% set _user = author %}{% include "_avatar_link.html" %}
|
||||
<div>
|
||||
{% set _user = author %}{% set _class = none %}{% include "_user_link.html" %}
|
||||
<a href="/profile/{{ author['username'] if author else '#' }}">{{ author['username'] if author else 'Unknown' }}</a>
|
||||
{% if author and author.get('role') %}
|
||||
<span style="font-size: 0.75rem; color: var(--text-muted);">· {{ author['role'] }}</span>
|
||||
{% endif %}
|
||||
@@ -46,7 +45,10 @@
|
||||
<div class="gist-detail-actions">
|
||||
<button type="button" class="gist-star-btn" data-share="/gists/{{ gist['slug'] or gist['uid'] }}">🔗 Share</button>
|
||||
{% if user %}
|
||||
{% set _type = "gist" %}{% set _uid = gist['uid'] %}{% set _my_vote = my_vote %}{% set _count = star_count %}{% set _btn_class = "gist-star-btn" %}{% include "_star_vote.html" %}
|
||||
<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">☆ {{ star_count }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if is_owner %}
|
||||
<button class="gist-star-btn" data-modal="edit-gist-modal"><span class="icon">✏️</span>Edit</button>
|
||||
@@ -58,7 +60,12 @@
|
||||
</article>
|
||||
|
||||
{% if is_owner %}
|
||||
{% call modal('edit-gist-modal', 'Edit Gist', wide=true) %}
|
||||
<div class="modal-overlay" id="edit-gist-modal">
|
||||
<div class="card modal-card modal-card-wide">
|
||||
<div class="modal-header">
|
||||
<h3>Edit Gist</h3>
|
||||
<button type="button" class="modal-close btn-ghost btn-icon modal-close-btn">×</button>
|
||||
</div>
|
||||
<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>
|
||||
@@ -86,7 +93,8 @@
|
||||
<button type="submit" class="btn btn-primary"><span class="icon">💾</span>Save Changes</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endcall %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% with target_uid=gist['uid'], target_type="gist" %}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
{% 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">
|
||||
@@ -16,17 +15,15 @@
|
||||
<span class="icon">📋</span>All
|
||||
</a>
|
||||
{% for code, name in languages %}
|
||||
{% if code != 'plaintext' and code in gist_language_codes %}
|
||||
{% if code != 'plaintext' %}
|
||||
<a href="/gists?language={{ code }}" class="sidebar-link {% if current_language == code %}active{% endif %}">
|
||||
<span class="icon">📝</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">📝</span>Plain Text
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if user %}
|
||||
@@ -50,13 +47,13 @@
|
||||
|
||||
<div class="gists-grid">
|
||||
{% for item in gists %}
|
||||
<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 fade-in" data-href="/gists/{{ item.gist['slug'] or item.gist['uid'] }}">
|
||||
<div class="gist-card-header">
|
||||
<h3 class="gist-card-title">{{ item.gist['title'] }}</h3>
|
||||
{% 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" %}
|
||||
<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">☆ {{ item.gist.get('stars', 0) }}</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="gist-card-meta">
|
||||
@@ -79,15 +76,19 @@
|
||||
<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>
|
||||
|
||||
{% call modal('create-gist-modal', 'Create Gist', wide=true) %}
|
||||
<div class="modal-overlay" id="create-gist-modal">
|
||||
<div class="card modal-card modal-card-wide">
|
||||
<div class="modal-header">
|
||||
<h3>Create Gist</h3>
|
||||
<button type="button" class="modal-close btn-ghost btn-icon modal-close-btn">×</button>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="/gists/create">
|
||||
<div class="auth-field auth-field-gap">
|
||||
<label for="gist-title">Title</label>
|
||||
@@ -120,7 +121,8 @@
|
||||
<button type="submit" class="btn btn-primary">Create Gist</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endcall %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<section class="landing-hero">
|
||||
<h1>Devplace.net — 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">✨</span> Join DevPlace Free</a>
|
||||
<a href="/auth/signup" class="landing-cta"><span class="icon">✨</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">
|
||||
{% set _user = item.author %}{% set _class = "landing-post-author" %}{% include "_user_link.html" %}
|
||||
<a href="/profile/{{ item.author['username'] }}" class="landing-post-author">{{ item.author['username'] }}</a>
|
||||
<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>© DevPlace — The Developer Social Network</p>
|
||||
<div class="landing-footer-links">
|
||||
<a href="/feed"><span class="icon">📝</span> Posts</a>
|
||||
<a href="/news"><span class="icon">📰</span> News</a>
|
||||
<a href="/projects"><span class="icon">🚀</span> Projects</a>
|
||||
<a href="/auth/login"><span class="icon">🔑</span> Login</a>
|
||||
<a href="/auth/signup"><span class="icon">✨</span> Sign Up</a>
|
||||
<a href="/feed"><span class="icon">📝</span>Posts</a>
|
||||
<a href="/news"><span class="icon">📰</span>News</a>
|
||||
<a href="/projects"><span class="icon">🚀</span>Projects</a>
|
||||
<a href="/auth/login"><span class="icon">🔑</span>Login</a>
|
||||
<a href="/auth/signup"><span class="icon">✨</span>Sign Up</a>
|
||||
<a href="/bugs"><span class="icon">🐛</span> Bug Report</a>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
{% 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">★</span></span>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="leaderboard-empty">No ranked contributors yet. Earn stars on your posts, projects, and gists to appear here.</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<aside class="feed-right">
|
||||
<div class="featured-news">
|
||||
<h3>Featured</h3>
|
||||
{% for article in featured_news %}
|
||||
<a class="featured-news-item" href="/news/{{ article['slug'] }}">
|
||||
<span class="featured-news-title">{{ article['title'] }}</span>
|
||||
<span class="featured-news-meta">{{ article['source_name'] }}{% if article['time_ago'] %} · {{ article['time_ago'] }}{% endif %}</span>
|
||||
</a>
|
||||
{% else %}
|
||||
<div class="no-authors-msg">No featured articles yet</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -40,7 +40,7 @@
|
||||
<a href="/auth/forgot-password">Forgot your password?</a>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="auth-submit"><span class="icon">🔑</span> Sign in</button>
|
||||
<button type="submit" class="auth-submit"><span class="icon">🔑</span>Sign in</button>
|
||||
</form>
|
||||
|
||||
<div class="auth-footer">
|
||||
|
||||
@@ -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>
|
||||
<h3>{% set _user = other_user %}{% set _class = none %}{% include "_user_link.html" %}</h3>
|
||||
<a href="/profile/{{ other_user['username'] }}"><h3>{{ other_user['username'] }}</h3></a>
|
||||
</div>
|
||||
|
||||
<div class="messages-thread">
|
||||
|
||||
@@ -43,8 +43,6 @@
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% include "_load_more.html" %}
|
||||
{% else %}
|
||||
<div class="news-empty">
|
||||
<div class="news-empty-icon">📰</div>
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
<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">🔔</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">✅</span>Clear</button>
|
||||
</form>
|
||||
@@ -17,20 +16,18 @@
|
||||
<div class="notification-group">
|
||||
<div class="notification-group-label">{{ group.label }}</div>
|
||||
{% for item in group.entries %}
|
||||
<div class="notification-card card-link-host {% if not item.notification['read'] %}unread{% endif %}">
|
||||
{% set target_url = item.notification.get('target_url', '') %}
|
||||
<div class="notification-card {% 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">{{ item.notification['message'] }}</div>
|
||||
<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-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">×</button>
|
||||
<button type="submit" class="notification-dismiss" data-uid="{{ item.notification['uid'] }}">×</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endfor %}
|
||||
@@ -39,11 +36,5 @@
|
||||
<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">📄</span>Load More</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,5 +1,4 @@
|
||||
{% 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">
|
||||
@@ -14,7 +13,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>
|
||||
{% set _user = author %}{% set _class = "post-detail-author" %}{% include "_user_link.html" %}
|
||||
<a href="/profile/{{ author['username'] if author else '#' }}" class="post-detail-author">{{ author['username'] if author else 'Unknown' }}</a>
|
||||
{% if author and author.get('role') %}
|
||||
<span class="post-detail-role">· {{ author['role'] }}</span>
|
||||
{% endif %}
|
||||
@@ -37,7 +36,17 @@
|
||||
{% endif %}
|
||||
|
||||
<div class="post-detail-actions">
|
||||
{% set _uid = post['uid'] %}{% set _my_vote = my_vote %}{% set _count = post.get('stars', 0) %}{% include "_post_votes.html" %}
|
||||
<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>
|
||||
<button type="button" class="post-action-btn" data-share="/posts/{{ post['slug'] or post['uid'] }}">🔗 Share</button>
|
||||
{% if user and post['user_uid'] == user['uid'] %}
|
||||
<button class="post-action-btn" data-modal="edit-post-modal"><span class="icon">✏️</span>Edit</button>
|
||||
@@ -49,9 +58,21 @@
|
||||
</article>
|
||||
|
||||
{% if user and post['user_uid'] == user['uid'] %}
|
||||
{% call modal('edit-post-modal', 'Edit Post') %}
|
||||
<div class="modal-overlay" id="edit-post-modal">
|
||||
<div class="card modal-card">
|
||||
<div class="modal-header">
|
||||
<h3>Edit Post</h3>
|
||||
<button type="button" class="modal-close btn-ghost btn-icon modal-close-btn">×</button>
|
||||
</div>
|
||||
<form id="edit-post-form" method="POST" action="/posts/edit/{{ post['slug'] or post['uid'] }}">
|
||||
{% set _topics = topics %}{% set _selected = post['topic'] %}{% include "_topic_selector.html" %}
|
||||
<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>
|
||||
<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', '') }}">
|
||||
@@ -66,7 +87,8 @@
|
||||
<button type="submit" class="btn btn-primary"><span class="icon">💾</span>Save Changes</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endcall %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% with target_uid=post['uid'], target_type="post" %}
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
<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">
|
||||
@@ -30,10 +33,6 @@
|
||||
<span class="profile-stat-value">{{ profile_user.get('stars', 0) }}</span>
|
||||
<span class="profile-stat-label">Stars</span>
|
||||
</div>
|
||||
<div class="profile-stat">
|
||||
<a href="/leaderboard" class="profile-stat-value">{% if rank %}#{{ rank }}{% else %}—{% endif %}</a>
|
||||
<span class="profile-stat-label">Rank</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="profile-level-bar">
|
||||
@@ -48,11 +47,9 @@
|
||||
|
||||
<div class="profile-badges">
|
||||
{% for badge in badges %}
|
||||
{% 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>
|
||||
<span class="profile-badge">{{ badge['badge_name'] }}</span>
|
||||
{% else %}
|
||||
{% set meta = badge_info('Member') %}
|
||||
<span class="profile-badge" title="{{ meta['description'] }}"><span class="profile-badge-icon">{{ meta['icon'] }}</span>Member</span>
|
||||
<span class="profile-badge">Member</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -148,17 +145,56 @@
|
||||
<a href="/feed" class="back-link">← 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">📝</span> Posts</a>
|
||||
<a href="/profile/{{ profile_user['username'] }}?tab=projects" class="profile-tab {% if current_tab == 'projects' %}active{% endif %}"><span class="icon">🚀</span> Projects</a>
|
||||
<a href="/profile/{{ profile_user['username'] }}?tab=gists" class="profile-tab {% if current_tab == 'gists' %}active{% endif %}"><span class="icon">📝</span> Gists</a>
|
||||
<a href="/profile/{{ profile_user['username'] }}?tab=activity" class="profile-tab {% if current_tab == 'activity' %}active{% endif %}"><span class="icon">📊</span> Activity</a>
|
||||
<a href="/profile/{{ profile_user['username'] }}?tab=posts" class="profile-tab {% if current_tab == 'posts' %}active{% endif %}"><span class="icon">📝</span>Posts</a>
|
||||
<a href="/profile/{{ profile_user['username'] }}?tab=projects" class="profile-tab {% if current_tab == 'projects' %}active{% endif %}"><span class="icon">🚀</span>Projects</a>
|
||||
<a href="/profile/{{ profile_user['username'] }}?tab=gists" class="profile-tab {% if current_tab == 'gists' %}active{% endif %}"><span class="icon">📝</span>Gists</a>
|
||||
<a href="/profile/{{ profile_user['username'] }}?tab=activity" class="profile-tab {% if current_tab == 'activity' %}active{% endif %}"><span class="icon">📊</span>Activity</a>
|
||||
<button class="btn-ghost btn-icon profile-tab-btn">△</button>
|
||||
</div>
|
||||
|
||||
<div class="profile-posts">
|
||||
{% if current_tab == 'posts' %}
|
||||
{% for item in posts %}
|
||||
{% set _author = profile_user %}{% set _time = item.time_ago %}{% set _show_share = false %}{% set _show_comment_form = false %}{% include "_post_card.html" %}
|
||||
<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">
|
||||
💬 {{ item.comment_count }}
|
||||
</a>
|
||||
<a href="/posts/{{ item.post['slug'] or item.post['uid'] }}" class="post-action-btn share">↗︎ Open</a>
|
||||
</div>
|
||||
</article>
|
||||
{% else %}
|
||||
<div class="empty-state">No posts yet.</div>
|
||||
{% endfor %}
|
||||
|
||||
@@ -2,6 +2,85 @@
|
||||
{% 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">
|
||||
@@ -28,7 +107,7 @@
|
||||
<div class="project-detail-author">
|
||||
{% set _size = 32 %}{% set _size_class = "sm" %}{% set _user = author %}{% include "_avatar_link.html" %}
|
||||
<div>
|
||||
{% set _user = author %}{% set _class = none %}{% include "_user_link.html" %}
|
||||
<a href="/profile/{{ author['username'] if author else '#' }}">{{ author['username'] if author else 'Unknown' }}</a>
|
||||
{% if author and author.get('role') %}
|
||||
<span style="font-size: 0.75rem; color: var(--text-muted);">· {{ author['role'] }}</span>
|
||||
{% endif %}
|
||||
@@ -55,11 +134,14 @@
|
||||
<div class="project-detail-actions">
|
||||
<button type="button" class="project-star-btn" data-share="/projects/{{ project['slug'] or project['uid'] }}">🔗 Share</button>
|
||||
{% if user %}
|
||||
{% set _type = "project" %}{% set _uid = project['uid'] %}{% set _my_vote = my_vote %}{% set _count = star_count %}{% set _btn_class = "project-star-btn" %}{% include "_star_vote.html" %}
|
||||
<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">☆ {{ star_count }}</button>
|
||||
</form>
|
||||
{% 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">🗑️</span> Delete</button>
|
||||
<button type="submit" class="project-star-btn" data-confirm="Delete this project?"><span class="icon">🗑️</span>Delete</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
{% 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">
|
||||
@@ -40,7 +39,7 @@
|
||||
<h1>Projects</h1>
|
||||
<span class="subtitle">Discover amazing projects from the DevPlace community</span>
|
||||
</div>
|
||||
<div class="projects-count">Showing {{ projects|length }} of {{ total_count }} projects</div>
|
||||
<div class="projects-count">Showing {{ total_count }} of {{ total_count }} projects</div>
|
||||
</div>
|
||||
|
||||
<div class="projects-tabs">
|
||||
@@ -54,13 +53,13 @@
|
||||
|
||||
<div class="projects-grid">
|
||||
{% for project in projects %}
|
||||
<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 fade-in" data-href="/projects/{{ project['slug'] or project['uid'] }}">
|
||||
<div class="project-card-header">
|
||||
<h3 class="project-card-title">{{ project['title'] }}</h3>
|
||||
{% 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" %}
|
||||
<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">☆</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="project-card-meta">
|
||||
@@ -90,15 +89,19 @@
|
||||
<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>
|
||||
|
||||
{% call modal('create-project-modal', 'Create Project') %}
|
||||
<div class="modal-overlay" id="create-project-modal">
|
||||
<div class="card modal-card">
|
||||
<div class="modal-header">
|
||||
<h3>Create Project</h3>
|
||||
<button type="button" class="modal-close btn-ghost btn-icon modal-close-btn">×</button>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="/projects/create">
|
||||
<div class="auth-field auth-field-gap">
|
||||
<label for="title">Title</label>
|
||||
@@ -164,6 +167,7 @@
|
||||
<button type="submit" class="btn btn-primary">Create Project</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endcall %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
<button type="button" class="auth-toggle-pw" aria-label="Toggle password visibility">👁</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="auth-submit"><span class="icon">🔒</span> Reset Password</button>
|
||||
<button type="submit" class="auth-submit"><span class="icon">🔒</span>Reset Password</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="auth-submit"><span class="icon">✨</span> Create account</button>
|
||||
<button type="submit" class="auth-submit"><span class="icon">✨</span>Create account</button>
|
||||
</form>
|
||||
|
||||
<div class="auth-footer">
|
||||
|
||||
@@ -5,7 +5,12 @@ from devplacepy.constants import TOPICS
|
||||
from devplacepy.database import get_table
|
||||
from devplacepy.avatar import avatar_url
|
||||
from devplacepy.utils import format_date as _format_date
|
||||
from devplacepy.utils import badge_info
|
||||
from devplacepy.seo import (
|
||||
site_url, combine,
|
||||
website_schema, breadcrumb_schema,
|
||||
discussion_forum_posting, profile_page_schema,
|
||||
software_application_schema, truncate,
|
||||
)
|
||||
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
||||
|
||||
_unread_cache = TTLCache(ttl=60)
|
||||
@@ -31,7 +36,6 @@ 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
|
||||
|
||||
+25
-174
@@ -1,4 +1,3 @@
|
||||
import asyncio
|
||||
import html
|
||||
import re
|
||||
import secrets
|
||||
@@ -7,8 +6,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, get_user_stars
|
||||
from devplacepy.config import SESSION_MAX_AGE
|
||||
from devplacepy.database import get_table
|
||||
from devplacepy.config import SECRET_KEY, SESSION_MAX_AGE
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -43,10 +42,6 @@ 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:
|
||||
@@ -91,17 +86,6 @@ 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 ""
|
||||
@@ -118,8 +102,8 @@ def slugify(text: str) -> str:
|
||||
|
||||
|
||||
def generate_uid() -> str:
|
||||
import uuid_utils
|
||||
return str(uuid_utils.uuid7())
|
||||
import uuid
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
def make_combined_slug(text: str, uid: str) -> str:
|
||||
@@ -156,164 +140,31 @@ 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 = list(dict.fromkeys(extract_mentions(content)))
|
||||
usernames = extract_mentions(content)
|
||||
if not usernames:
|
||||
return
|
||||
from devplacepy.templating import clear_unread_cache
|
||||
users = get_table("users")
|
||||
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)
|
||||
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"])
|
||||
|
||||
|
||||
def format_date(dt_str: str, include_time: bool = False) -> str:
|
||||
|
||||
+33
-199
@@ -23,7 +23,6 @@ NEWS_UIDS = []
|
||||
NEWS_SLUGS = []
|
||||
GIST_SLUGS = []
|
||||
GIST_UIDS = []
|
||||
BUG_UIDS = []
|
||||
NOTIFICATION_UIDS = []
|
||||
ADMIN_USER = {}
|
||||
ADMIN_TARGETS = {}
|
||||
@@ -31,6 +30,10 @@ 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"
|
||||
|
||||
@@ -72,10 +75,7 @@ def seed_data(environment, **kwargs):
|
||||
import http.cookiejar
|
||||
|
||||
created = 0
|
||||
attempts = 0
|
||||
max_attempts = 25
|
||||
while created < 5 and attempts < max_attempts:
|
||||
attempts += 1
|
||||
while created < 5:
|
||||
username = random_username()
|
||||
email = f"{username}@locust.devplace"
|
||||
body = urllib.parse.urlencode({
|
||||
@@ -91,13 +91,6 @@ 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):
|
||||
@@ -189,20 +182,6 @@ 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
|
||||
@@ -319,20 +298,11 @@ 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(BUG_UIDS)} bugs, {len(USER_UIDS)} uids"
|
||||
f"{len(USER_UIDS)} uids"
|
||||
)
|
||||
|
||||
|
||||
@@ -364,11 +334,7 @@ class DevPlaceUser(HttpUser):
|
||||
params = {"tab": tab}
|
||||
if topic:
|
||||
params["topic"] = topic
|
||||
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")
|
||||
self.client.get("/feed", params=params, name="feed")
|
||||
|
||||
@task(4)
|
||||
def view_post(self):
|
||||
@@ -384,19 +350,14 @@ class DevPlaceUser(HttpUser):
|
||||
if not ALL_USERNAMES:
|
||||
return
|
||||
self.client.get(
|
||||
f"/profile/{random.choice(ALL_USERNAMES)}",
|
||||
params={"tab": random.choice(["posts", "activity"])},
|
||||
name="profile/[username]",
|
||||
f"/profile/{random.choice(ALL_USERNAMES)}", name="profile/[username]"
|
||||
)
|
||||
|
||||
@task(3)
|
||||
def view_projects(self):
|
||||
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")
|
||||
self.client.get("/projects", params={
|
||||
"tab": random.choice(["recent", "released", "popular", "new"]),
|
||||
}, name="projects")
|
||||
|
||||
@task(1)
|
||||
def view_landing(self):
|
||||
@@ -435,23 +396,7 @@ class DevPlaceUser(HttpUser):
|
||||
|
||||
@task(2)
|
||||
def view_gists(self):
|
||||
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",
|
||||
)
|
||||
self.client.get("/gists", name="gists")
|
||||
|
||||
@task(2)
|
||||
def view_gist_detail(self):
|
||||
@@ -606,58 +551,45 @@ 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._vote("post", random.choice(POST_UIDS))
|
||||
self.client.post(
|
||||
f"/votes/post/{random.choice(POST_UIDS)}",
|
||||
data={"value": random.choice([1, -1])},
|
||||
name="votes/post",
|
||||
)
|
||||
|
||||
@task(1)
|
||||
def vote_on_project(self):
|
||||
if not PROJECT_UIDS:
|
||||
return
|
||||
self._vote("project", random.choice(PROJECT_UIDS))
|
||||
self.client.post(
|
||||
f"/votes/project/{random.choice(PROJECT_UIDS)}",
|
||||
data={"value": random.choice([1, -1])},
|
||||
name="votes/project",
|
||||
)
|
||||
|
||||
@task(1)
|
||||
def vote_on_comment(self):
|
||||
if not COMMENT_UIDS:
|
||||
return
|
||||
self._vote("comment", random.choice(COMMENT_UIDS))
|
||||
self.client.post(
|
||||
f"/votes/comment/{random.choice(COMMENT_UIDS)}",
|
||||
data={"value": random.choice([1, -1])},
|
||||
name="votes/comment",
|
||||
)
|
||||
|
||||
@task(1)
|
||||
def vote_on_gist(self):
|
||||
if not GIST_UIDS:
|
||||
return
|
||||
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))
|
||||
self.client.post(
|
||||
f"/votes/gist/{random.choice(GIST_UIDS)}",
|
||||
data={"value": random.choice([1, -1])},
|
||||
name="votes/gist",
|
||||
)
|
||||
|
||||
@task(1)
|
||||
def follow_user(self):
|
||||
@@ -700,15 +632,6 @@ 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")
|
||||
@@ -716,21 +639,6 @@ 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):
|
||||
@@ -833,45 +741,14 @@ 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"
|
||||
resp = self.client.get(
|
||||
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):
|
||||
@@ -883,49 +760,6 @@ 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
|
||||
|
||||
@@ -13,12 +13,9 @@ 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
Reference in New Issue
Block a user