Compare commits

..
Author SHA1 Message Date
blindxfishandClaude Opus 5 24a79177e8 Show a post's image on its card, and show it full size
DevPlace CI / test (pull_request) Has been cancelled
_attachment_display.html iterates a context variable named
`attachments`, so every caller binds it before the include.
_post_card.html was the one caller that did not: it guarded on
item.attachments but included the partial with nothing bound, so the
gallery looped over whatever `attachments` happened to be in the
surrounding page context and rendered empty. Every post with an image
looked image-less on the feed and on profiles, and on a project page -
where project_detail.html sets `attachments` at template scope for the
project's own files - a devlog card would have rendered the project's
files as its own.

With the image actually reaching the card, render a lone one properly:
a gallery holding exactly one item gets a `single` class and takes the
full content column (max-height 480px, object-fit contain, no hover
scale), matching the original DevPlace. That branch serves the stored
original rather than thumbnail_url, because a thumbnail is 200px on its
longest side and stretching it to the column width is visibly blurry.

Animated GIFs needed no change and now have a test proving it: they
never had a thumbnail to flatten, so they already took the original-file
path and simply render larger.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 23:12:39 +02:00
retoor e0672f896d Merge pull request 'feat: Implement links to the official iOS app in the web app' (#170) from typosaurus/169-implement-links-to-the-official-ios-app-in-the-web-app into master
DevPlace CI / test (push) Failing after 26m50s
Reviewed-on: #170
2026-08-16 06:37:41 +02:00
typosaurus c9802440d7 test(sveta): Write e2e tests for the iOS app badges
DevPlace CI / test (pull_request) Failing after 28m47s
Outcome: done
Changed: tests/e2e/iosapp.py:1-94 (new file)
Verified by: python -m pytest tests/e2e/iosapp.py -q -> 5 passed (3 consecutive clean runs, incl. verify()); full make test -> 3348 passed, 1 skipped, 11 failed (9 tests/api/projects/workspace.py FileNotFoundError 'docker' = missing docker binary in container, pre-existing per sibling run; 1 tests/e2e/game/farm.py steal flake, passes standalone; 1 was this file's pre-fix settings-propagation race, fixed)
Findings: tests/e2e/iosapp.py locks the badge behaviour: footer badge visible on landing (.landing-footer) and /feed (.site-footer), guest topnav badge next to Login/Sign Up, mobile-panel badge after opening the hamburger, every href equals the configured ios_app_url with target=_blank rel=noopener noreferrer, and all badges disappear when ios_app_url is cleared (restored in try/finally).
Settings-flip e2e tests must sleep CACHE_VERSION_PROPAGATION_SECONDS (1.5s) after set_setting before navigating: the server caches the cache_state version for 1s (core.py:56), and the first iosapp run hit that race (badge absent on a stale render).
On a fresh DB the operational_defaults block incl. ios_app_url is never seeded because it is gated on the pre-init db.tables snapshot (schema.py:34 vs :1705), so the badge renders only after an admin sets ios_app_url.
Open: re-run make test on a docker-capable host to clear the 9 workspace.py environment failures; decide the fresh-DB ios_app_url seeding gap (PM/implementation node); farm s

Typosaurus-Run: 2910ed1ba2604be09c649141af1baf44
Typosaurus-Node: e8e69f65a5fa40c2bf6a8b0206cfb2d4
Typosaurus-Agent: @sveta
Refs: #169
2026-08-16 03:59:11 +00:00
typosaurus 15f04e0d13 feat(nadia): Implement iOS app badges in footer, topnav, and mobile menu
Outcome: done
Changed: devplacepy/templates/base.html:126-132,189-195; devplacepy/templates/_footer_links.html:12-19; devplacepy/templates/admin_settings.html:33-38; devplacepy/routers/admin/settings.py:19; devplacepy/templating.py:197-199,218; devplacepy/static/css/base.css:720-731,785-793,1151-1161,1210-1212; devplacepy/database/CLAUDE.md:224,228
Verified by: import gate clean; py_compile clean; ruff/pyflakes no new findings; TestClient smoke (badges present with href=ios_app_url, target=_blank rel=noopener noreferrer, static_url-versioned src on /, /feed, /leaderboard; absent when setting empty; admin clear via /admin/settings POST verified); make test-fast: 2659 passed, 1 skipped, 10 failed - all environmental (9 missing docker binary, 1 missing libcairo.so.2), none caused by this change; Playwright e2e inapplicable (chromium cannot launch: libglib-2.0.so.0 missing)
Findings: Badges render only when ios_app_url site setting is non-empty and are editable/clearable via AdminSettingsForm (CLEARABLE_SETTINGS). Researched URL https://apps.apple.com/app/devplace/id6797215143 (trackId 6797215143, bundleId wok.DevPlaceIOS) confirmed via iTunes Lookup API and seeded in schema.py operational_defaults by sibling commit. Topnav badge hides at <=1024px where the mobile panel badge takes over, keeping the 360px layout overflow-free. Badge SVG is Apple's official white variant vendored at devplacepy/static/app-store-badge.svg with retoor header. Apple badge marketing guidance (40px min

Typosaurus-Run: 2910ed1ba2604be09c649141af1baf44
Typosaurus-Node: 2817fd803023406699936e88b76ee632
Typosaurus-Agent: @nadia
Refs: #169
2026-08-16 03:59:11 +00:00
typosaurus 551d540bc7 feat(zoya): Review the research findings against the node acceptance criteria
Outcome: done
Changed: none
Verified by: not verified - review-only node; repo cross-checks done (devplacepy/templates/docs/contact.html:5, devplacepy/templates/admin_settings.html:192, devplacepy/database/schema.py:1746, devplacepy/static/app-store-badge.svg)
Findings: Verdict: accept with findings - every parent acceptance criterion is addressed by sourced, tiered findings; none of the notes below is blocking.
The verified URL is already consumed in-flight: uncommitted ios_app_url default at devplacepy/database/schema.py:1746 plus AdminSettingsForm field (devplacepy/models.py) and ios_app_url() Jinja global (devplacepy/templating.py); later nodes must not re-introduce it and must review that diff separately.
Apple's official badge SVG is vendored at devplacepy/static/app-store-badge.svg (title Download_on_the_App_Store_Badge_US-UK_RGB_blk_4SVG_092917, 119.66407x40), matching leaf-2's source recommendation; it sits at the static root rather than the leaf's suggested static/vendors/, a consistency-review call, and the leaf's own commit declared "Changed: none" despite adding it.
"Confirmed live" evidence is DuckDuckGo-indexed storefront snapshots (/fr/, /th/, /il/) via the sanctioned aggregator rsearch.app.molodetz.nl, direct egress to apps.apple.com being blocked and disclosed; corroborated by github.com/WilhelmOks/DevPlaceIOS, so a manual click-through before release is advised.
Apple's badge terms (min 40px onscreen height, one badge per layout) vs the ticket's "small badg

Typosaurus-Run: 2910ed1ba2604be09c649141af1baf44
Typosaurus-Node: 0c6857c89c9448589c0d0141ee774948
Typosaurus-Agent: @zoya
Refs: #169
2026-08-16 03:59:11 +00:00
typosaurus fd409ceea7 feat(vera): Verify the official DevPlace iOS app listing on the App Store
Outcome: done
Changed: none
Verified by: not verified (no files changed; evidence cross-checked from three live storefront variants of the same listing id)
Findings: Official DevPlace iOS app verified live on the App Store, retrieved 16/08/2026: name "DevPlace" (subtitle "The Developer Social Network"), developer "Wilhelm Oks", id 6797215143, canonical URL https://apps.apple.com/app/devplace/id6797215143 (confirmed live via /fr/, /th/, /il/ storefront variants).
Listing confirmed as the official DevPlace client: feature list and description match the DevPlace web app and point users to "DevPlace on the web"; cross-checked against devplacepy/templates/docs/contact.html:5 and devplacepy/templates/admin_settings.html:192, which state DevPlace declares trader info in the app stores that carry a DevPlace client.
Publisher on the listing is the personal name "Wilhelm Oks" (Provider; Copyright © 2026 Wilhelm Oks); the mapping to repo identity retoor@molodetz.nl is an assumption, not provable from inside the repo.
No App Store URL exists anywhere in the repo (grep 16/08/2026); the verified URL must be introduced as new, e.g. as a shared constant.
Direct egress to apps.apple.com and itunes.apple.com is blocked in this run; listing content was retrieved via the sanctioned aggregator rsearch.app.molodetz.nl (timestamps 2026-08-16T01:44-01:45Z).
Corroboration: github.com/WilhelmOks/DevPlaceIOS ("A native iOS app for DevPlace") matches the App Store developer name.
Open: badge graphic ass

Typosaurus-Run: 2910ed1ba2604be09c649141af1baf44
Typosaurus-Node: 42c9a7671c2e4a0e960c2adf44323954
Typosaurus-Agent: @vera
Refs: #169
2026-08-16 03:59:11 +00:00
27 changed files with 388 additions and 127 deletions
+4 -1
View File
@@ -218,11 +218,12 @@ Site settings are seeded on startup (`site_settings` table):
| `moderation_mature_default_hidden` | `"1"` | Hide mature-labelled content behind an interstitial by default |
| `account_deletion_grace_hours` | `"24"` | Reversible window before a deleted account is purged |
| `contact_email` / `contact_phone` / `contact_address` | `""` | Published contact details, rendered on `/docs/contact.html` |
| `ios_app_url` | `"https://apps.apple.com/app/devplace/id6797215143"` | Official iOS app listing; the App Store badges in the footer, topnav and mobile menu link here and hide when the value is empty |
| `terms_version` / `privacy_version` / `guidelines_version` | `"1"` | Bumping `terms_version` forces re-acceptance before the next write. **Every reader uses `get_setting(key, "1") or "1"`** - an empty stored value must read as the default or the gate 403s every write |
| `ai_third_party_provider` | `""` | Named in the consent copy and the privacy policy |
| `extra_head` | `""` | Raw HTML emitted verbatim into every page `<head>` by `templating.extra_head_tag()`; site-wide trusted-admin input, not sanitized |
Besides the site/news/upload keys above, the **Operational** group is admin-editable at `/admin/settings`: `site_url` (public origin for absolute links incl. container ingress; resolved by `seo.public_base_url()` = setting -> `DEVPLACE_SITE_URL` env -> request origin), `rate_limit_per_minute`, `rate_limit_window_seconds`, `news_service_interval`, `session_max_age_days`, `session_remember_days`, `registration_open`, `maintenance_mode`, `maintenance_message`, `docs_search_mode` (`agent`|`bm25`, default `agent` - picks the `/docs/search.html` surface: the in-page Devii chat or the classic BM25 list; a viewer over their daily AI limit, or a guest when Devii is disabled, auto-falls-back to BM25 via `routers/docs/views.py` `_agent_search_state`), `outbound_proxy_url` (empty by default - when set, every `stealth.stealth_async_client`/`stealth_sync_client` call across the whole app routes through it via `stealth.configured_proxy_url()`; validated as `http(s)://`/`socks5(h)://` with a host in `AdminSettingsForm`; falls back to `DEVPLACE_OUTBOUND_PROXY_URL` when unset - see the "Outbound HTTP" note in the root `CLAUDE.md`). The **Custom Code** key `extra_head` is the sole key in the settings handler's `CLEARABLE_SETTINGS` set, so saving an empty textarea removes it (the default loop skips empty values).
Besides the site/news/upload keys above, the **Operational** group is admin-editable at `/admin/settings`: `site_url` (public origin for absolute links incl. container ingress; resolved by `seo.public_base_url()` = setting -> `DEVPLACE_SITE_URL` env -> request origin), `rate_limit_per_minute`, `rate_limit_window_seconds`, `news_service_interval`, `session_max_age_days`, `session_remember_days`, `registration_open`, `maintenance_mode`, `maintenance_message`, `docs_search_mode` (`agent`|`bm25`, default `agent` - picks the `/docs/search.html` surface: the in-page Devii chat or the classic BM25 list; a viewer over their daily AI limit, or a guest when Devii is disabled, auto-falls-back to BM25 via `routers/docs/views.py` `_agent_search_state`), `outbound_proxy_url` (empty by default - when set, every `stealth.stealth_async_client`/`stealth_sync_client` call across the whole app routes through it via `stealth.configured_proxy_url()`; validated as `http(s)://`/`socks5(h)://` with a host in `AdminSettingsForm`; falls back to `DEVPLACE_OUTBOUND_PROXY_URL` when unset - see the "Outbound HTTP" note in the root `CLAUDE.md`). The **Custom Code** key `extra_head` and the `ios_app_url` badge link are the keys in the settings handler's `CLEARABLE_SETTINGS` set, so saving an empty textarea/field removes them (the default loop skips empty values).
The seed block in `database.py` is guarded by `if "site_settings" in tables:` - on a brand-new DB the table does not exist yet (dataset creates tables lazily on first insert), so none of these rows are written until the table exists. Correct runtime behavior therefore relies on every consumer passing the production default to `get_setting`/`get_int_setting`, not on the seed.
@@ -240,3 +241,5 @@ Operational settings - read sites and rules:
| `registration_open` | `auth.py` `signup_page` (GET) and `signup` (POST) | POST returns before any DB write when closed |
**Booleans are `<select>`, never checkboxes.** The settings save handler (`admin.py`) skips empty form values so empty fields don't clobber existing rows. An unchecked checkbox submits nothing, so it could never be turned off - `registration_open` and `maintenance_mode` use `<option value="1">`/`<option value="0">` so a value is always submitted.
+2
View File
@@ -1743,6 +1743,7 @@ def init_db():
"statistics_tracking_enabled": "1",
"docs_search_mode": "agent",
"outbound_proxy_url": "",
"ios_app_url": "https://apps.apple.com/app/devplace/id6797215143",
"devii_lessons_max_per_owner": "500",
"devii_lessons_max_age_days": "90",
"moderation_sla_hours": "24",
@@ -2142,3 +2143,4 @@ def _backfill_gamification():
f"{len(checked)} with milestone-eligible activity"
)
+2
View File
@@ -657,6 +657,7 @@ class AdminSettingsForm(BaseModel):
site_description: str = Field(default="", max_length=500)
site_tagline: str = Field(default="", max_length=500)
site_url: str = Field(default="", max_length=300)
ios_app_url: str = Field(default="", max_length=300)
max_upload_size_mb: str = Field(default="", max_length=10)
allowed_file_types: str = Field(default="", max_length=1000)
max_attachments_per_resource: str = Field(default="", max_length=10)
@@ -1119,3 +1120,4 @@ class MaturePreferenceForm(BaseModel):
class AccountDeleteForm(BaseModel):
password: str = Field(min_length=1, max_length=128)
confirm_text: str = Field(default="", max_length=40)
+2 -1
View File
@@ -16,7 +16,7 @@ from devplacepy.dependencies import json_or_form
logger = logging.getLogger(__name__)
router = APIRouter()
CLEARABLE_SETTINGS = {"extra_head"}
CLEARABLE_SETTINGS = {"extra_head", "ios_app_url"}
@router.get("/settings", response_class=HTMLResponse)
async def admin_settings(request: Request):
@@ -85,3 +85,4 @@ async def admin_settings_save(
links=[audit.setting(key)],
)
return action_result(request, "/admin/settings")
+47
View File
@@ -0,0 +1,47 @@
<!-- retoor <retoor@molodetz.nl> -->
<svg id="livetype" xmlns="http://www.w3.org/2000/svg" width="119.66407" height="40" viewBox="0 0 119.66407 40">
<title>Download_on_the_App_Store_Badge_US-UK_RGB_blk_4SVG_092917</title>
<g>
<g>
<g>
<path d="M110.13477,0H9.53468c-.3667,0-.729,0-1.09473.002-.30615.002-.60986.00781-.91895.0127A13.21476,13.21476,0,0,0,5.5171.19141a6.66509,6.66509,0,0,0-1.90088.627A6.43779,6.43779,0,0,0,1.99757,1.99707,6.25844,6.25844,0,0,0,.81935,3.61816a6.60119,6.60119,0,0,0-.625,1.90332,12.993,12.993,0,0,0-.1792,2.002C.00587,7.83008.00489,8.1377,0,8.44434V31.5586c.00489.3105.00587.6113.01515.9219a12.99232,12.99232,0,0,0,.1792,2.0019,6.58756,6.58756,0,0,0,.625,1.9043A6.20778,6.20778,0,0,0,1.99757,38.001a6.27445,6.27445,0,0,0,1.61865,1.1787,6.70082,6.70082,0,0,0,1.90088.6308,13.45514,13.45514,0,0,0,2.0039.1768c.30909.0068.6128.0107.91895.0107C8.80567,40,9.168,40,9.53468,40H110.13477c.3594,0,.7246,0,1.084-.002.3047,0,.6172-.0039.9219-.0107a13.279,13.279,0,0,0,2-.1768,6.80432,6.80432,0,0,0,1.9082-.6308,6.27742,6.27742,0,0,0,1.6172-1.1787,6.39482,6.39482,0,0,0,1.1816-1.6143,6.60413,6.60413,0,0,0,.6191-1.9043,13.50643,13.50643,0,0,0,.1856-2.0019c.0039-.3106.0039-.6114.0039-.9219.0078-.3633.0078-.7246.0078-1.0938V9.53613c0-.36621,0-.72949-.0078-1.09179,0-.30664,0-.61426-.0039-.9209a13.5071,13.5071,0,0,0-.1856-2.002,6.6177,6.6177,0,0,0-.6191-1.90332,6.46619,6.46619,0,0,0-2.7988-2.7998,6.76754,6.76754,0,0,0-1.9082-.627,13.04394,13.04394,0,0,0-2-.17676c-.3047-.00488-.6172-.01074-.9219-.01269-.3594-.002-.7246-.002-1.084-.002Z" style="fill: #a6a6a6"/>
<path d="M8.44483,39.125c-.30468,0-.602-.0039-.90429-.0107a12.68714,12.68714,0,0,1-1.86914-.1631,5.88381,5.88381,0,0,1-1.65674-.5479,5.40573,5.40573,0,0,1-1.397-1.0166,5.32082,5.32082,0,0,1-1.02051-1.3965,5.72186,5.72186,0,0,1-.543-1.6572,12.41351,12.41351,0,0,1-.1665-1.875c-.00634-.2109-.01464-.9131-.01464-.9131V8.44434S.88185,7.75293.8877,7.5498a12.37039,12.37039,0,0,1,.16553-1.87207,5.7555,5.7555,0,0,1,.54346-1.6621A5.37349,5.37349,0,0,1,2.61183,2.61768,5.56543,5.56543,0,0,1,4.01417,1.59521a5.82309,5.82309,0,0,1,1.65332-.54394A12.58589,12.58589,0,0,1,7.543.88721L8.44532.875H111.21387l.9131.0127a12.38493,12.38493,0,0,1,1.8584.16259,5.93833,5.93833,0,0,1,1.6709.54785,5.59374,5.59374,0,0,1,2.415,2.41993,5.76267,5.76267,0,0,1,.5352,1.64892,12.995,12.995,0,0,1,.1738,1.88721c.0029.2832.0029.5874.0029.89014.0079.375.0079.73193.0079,1.09179V30.4648c0,.3633,0,.7178-.0079,1.0752,0,.3252,0,.6231-.0039.9297a12.73126,12.73126,0,0,1-.1709,1.8535,5.739,5.739,0,0,1-.54,1.67,5.48029,5.48029,0,0,1-1.0156,1.3857,5.4129,5.4129,0,0,1-1.3994,1.0225,5.86168,5.86168,0,0,1-1.668.5498,12.54218,12.54218,0,0,1-1.8692.1631c-.2929.0068-.5996.0107-.8974.0107l-1.084.002Z"/>
</g>
<g id="_Group_" data-name="&lt;Group&gt;">
<g id="_Group_2" data-name="&lt;Group&gt;">
<g id="_Group_3" data-name="&lt;Group&gt;">
<path id="_Path_" data-name="&lt;Path&gt;" d="M24.76888,20.30068a4.94881,4.94881,0,0,1,2.35656-4.15206,5.06566,5.06566,0,0,0-3.99116-2.15768c-1.67924-.17626-3.30719,1.00483-4.1629,1.00483-.87227,0-2.18977-.98733-3.6085-.95814a5.31529,5.31529,0,0,0-4.47292,2.72787c-1.934,3.34842-.49141,8.26947,1.3612,10.97608.9269,1.32535,2.01018,2.8058,3.42763,2.7533,1.38706-.05753,1.9051-.88448,3.5794-.88448,1.65876,0,2.14479.88448,3.591.8511,1.48838-.02416,2.42613-1.33124,3.32051-2.66914a10.962,10.962,0,0,0,1.51842-3.09251A4.78205,4.78205,0,0,1,24.76888,20.30068Z" style="fill: #fff"/>
<path id="_Path_2" data-name="&lt;Path&gt;" d="M22.03725,12.21089a4.87248,4.87248,0,0,0,1.11452-3.49062,4.95746,4.95746,0,0,0-3.20758,1.65961,4.63634,4.63634,0,0,0-1.14371,3.36139A4.09905,4.09905,0,0,0,22.03725,12.21089Z" style="fill: #fff"/>
</g>
</g>
<g>
<path d="M42.30227,27.13965h-4.7334l-1.13672,3.35645H34.42727l4.4834-12.418h2.083l4.4834,12.418H43.438ZM38.0591,25.59082h3.752l-1.84961-5.44727h-.05176Z" style="fill: #fff"/>
<path d="M55.15969,25.96973c0,2.81348-1.50586,4.62109-3.77832,4.62109a3.0693,3.0693,0,0,1-2.84863-1.584h-.043v4.48438h-1.8584V21.44238H48.4302v1.50586h.03418a3.21162,3.21162,0,0,1,2.88281-1.60059C53.645,21.34766,55.15969,23.16406,55.15969,25.96973Zm-1.91016,0c0-1.833-.94727-3.03809-2.39258-3.03809-1.41992,0-2.375,1.23047-2.375,3.03809,0,1.82422.95508,3.0459,2.375,3.0459C52.30227,29.01563,53.24953,27.81934,53.24953,25.96973Z" style="fill: #fff"/>
<path d="M65.12453,25.96973c0,2.81348-1.50586,4.62109-3.77832,4.62109a3.0693,3.0693,0,0,1-2.84863-1.584h-.043v4.48438h-1.8584V21.44238H58.395v1.50586h.03418A3.21162,3.21162,0,0,1,61.312,21.34766C63.60988,21.34766,65.12453,23.16406,65.12453,25.96973Zm-1.91016,0c0-1.833-.94727-3.03809-2.39258-3.03809-1.41992,0-2.375,1.23047-2.375,3.03809,0,1.82422.95508,3.0459,2.375,3.0459C62.26711,29.01563,63.21438,27.81934,63.21438,25.96973Z" style="fill: #fff"/>
<path d="M71.71047,27.03613c.1377,1.23145,1.334,2.04,2.96875,2.04,1.56641,0,2.69336-.80859,2.69336-1.91895,0-.96387-.67969-1.541-2.28906-1.93652l-1.60937-.3877c-2.28027-.55078-3.33887-1.61719-3.33887-3.34766,0-2.14258,1.86719-3.61426,4.51855-3.61426,2.624,0,4.42285,1.47168,4.4834,3.61426h-1.876c-.1123-1.23926-1.13672-1.9873-2.63379-1.9873s-2.52148.75684-2.52148,1.8584c0,.87793.6543,1.39453,2.25488,1.79l1.36816.33594c2.54785.60254,3.60645,1.626,3.60645,3.44238,0,2.32324-1.85059,3.77832-4.79395,3.77832-2.75391,0-4.61328-1.4209-4.7334-3.667Z" style="fill: #fff"/>
<path d="M83.34621,19.2998v2.14258h1.72168v1.47168H83.34621v4.99121c0,.77539.34473,1.13672,1.10156,1.13672a5.80752,5.80752,0,0,0,.61133-.043v1.46289a5.10351,5.10351,0,0,1-1.03223.08594c-1.833,0-2.54785-.68848-2.54785-2.44434V22.91406H80.16262V21.44238H81.479V19.2998Z" style="fill: #fff"/>
<path d="M86.065,25.96973c0-2.84863,1.67773-4.63867,4.29395-4.63867,2.625,0,4.29492,1.79,4.29492,4.63867,0,2.85645-1.66113,4.63867-4.29492,4.63867C87.72609,30.6084,86.065,28.82617,86.065,25.96973Zm6.69531,0c0-1.9541-.89551-3.10742-2.40137-3.10742s-2.40039,1.16211-2.40039,3.10742c0,1.96191.89453,3.10645,2.40039,3.10645S92.76027,27.93164,92.76027,25.96973Z" style="fill: #fff"/>
<path d="M96.18606,21.44238h1.77246v1.541h.043a2.1594,2.1594,0,0,1,2.17773-1.63574,2.86616,2.86616,0,0,1,.63672.06934v1.73828a2.59794,2.59794,0,0,0-.835-.1123,1.87264,1.87264,0,0,0-1.93652,2.083v5.37012h-1.8584Z" style="fill: #fff"/>
<path d="M109.3843,27.83691c-.25,1.64355-1.85059,2.77148-3.89844,2.77148-2.63379,0-4.26855-1.76465-4.26855-4.5957,0-2.83984,1.64355-4.68164,4.19043-4.68164,2.50488,0,4.08008,1.7207,4.08008,4.46582v.63672h-6.39453v.1123a2.358,2.358,0,0,0,2.43555,2.56445,2.04834,2.04834,0,0,0,2.09082-1.27344Zm-6.28223-2.70215h4.52637a2.1773,2.1773,0,0,0-2.2207-2.29785A2.292,2.292,0,0,0,103.10207,25.13477Z" style="fill: #fff"/>
</g>
</g>
</g>
<g id="_Group_4" data-name="&lt;Group&gt;">
<g>
<path d="M37.82619,8.731a2.63964,2.63964,0,0,1,2.80762,2.96484c0,1.90625-1.03027,3.002-2.80762,3.002H35.67092V8.731Zm-1.22852,5.123h1.125a1.87588,1.87588,0,0,0,1.96777-2.146,1.881,1.881,0,0,0-1.96777-2.13379h-1.125Z" style="fill: #fff"/>
<path d="M41.68068,12.44434a2.13323,2.13323,0,1,1,4.24707,0,2.13358,2.13358,0,1,1-4.24707,0Zm3.333,0c0-.97607-.43848-1.54687-1.208-1.54687-.77246,0-1.207.5708-1.207,1.54688,0,.98389.43457,1.55029,1.207,1.55029C44.57522,13.99463,45.01369,13.42432,45.01369,12.44434Z" style="fill: #fff"/>
<path d="M51.57326,14.69775h-.92187l-.93066-3.31641h-.07031l-.92676,3.31641h-.91309l-1.24121-4.50293h.90137l.80664,3.436h.06641l.92578-3.436h.85254l.92578,3.436h.07031l.80273-3.436h.88867Z" style="fill: #fff"/>
<path d="M53.85354,10.19482H54.709v.71533h.06641a1.348,1.348,0,0,1,1.34375-.80225,1.46456,1.46456,0,0,1,1.55859,1.6748v2.915h-.88867V12.00586c0-.72363-.31445-1.0835-.97168-1.0835a1.03294,1.03294,0,0,0-1.0752,1.14111v2.63428h-.88867Z" style="fill: #fff"/>
<path d="M59.09377,8.437h.88867v6.26074h-.88867Z" style="fill: #fff"/>
<path d="M61.21779,12.44434a2.13346,2.13346,0,1,1,4.24756,0,2.1338,2.1338,0,1,1-4.24756,0Zm3.333,0c0-.97607-.43848-1.54687-1.208-1.54687-.77246,0-1.207.5708-1.207,1.54688,0,.98389.43457,1.55029,1.207,1.55029C64.11232,13.99463,64.5508,13.42432,64.5508,12.44434Z" style="fill: #fff"/>
<path d="M66.4009,13.42432c0-.81055.60352-1.27783,1.6748-1.34424l1.21973-.07031v-.38867c0-.47559-.31445-.74414-.92187-.74414-.49609,0-.83984.18213-.93848.50049h-.86035c.09082-.77344.81836-1.26953,1.83984-1.26953,1.12891,0,1.76563.562,1.76563,1.51318v3.07666h-.85547v-.63281h-.07031a1.515,1.515,0,0,1-1.35254.707A1.36026,1.36026,0,0,1,66.4009,13.42432Zm2.89453-.38477v-.37646l-1.09961.07031c-.62012.0415-.90137.25244-.90137.64941,0,.40527.35156.64111.835.64111A1.0615,1.0615,0,0,0,69.29543,13.03955Z" style="fill: #fff"/>
<path d="M71.34816,12.44434c0-1.42285.73145-2.32422,1.86914-2.32422a1.484,1.484,0,0,1,1.38086.79h.06641V8.437h.88867v6.26074h-.85156v-.71143h-.07031a1.56284,1.56284,0,0,1-1.41406.78564C72.0718,14.772,71.34816,13.87061,71.34816,12.44434Zm.918,0c0,.95508.4502,1.52979,1.20313,1.52979.749,0,1.21191-.583,1.21191-1.52588,0-.93848-.46777-1.52979-1.21191-1.52979C72.72121,10.91846,72.26613,11.49707,72.26613,12.44434Z" style="fill: #fff"/>
<path d="M79.23,12.44434a2.13323,2.13323,0,1,1,4.24707,0,2.13358,2.13358,0,1,1-4.24707,0Zm3.333,0c0-.97607-.43848-1.54687-1.208-1.54687-.77246,0-1.207.5708-1.207,1.54688,0,.98389.43457,1.55029,1.207,1.55029C82.12453,13.99463,82.563,13.42432,82.563,12.44434Z" style="fill: #fff"/>
<path d="M84.66945,10.19482h.85547v.71533h.06641a1.348,1.348,0,0,1,1.34375-.80225,1.46456,1.46456,0,0,1,1.55859,1.6748v2.915H87.605V12.00586c0-.72363-.31445-1.0835-.97168-1.0835a1.03294,1.03294,0,0,0-1.0752,1.14111v2.63428h-.88867Z" style="fill: #fff"/>
<path d="M93.51516,9.07373v1.1416h.97559v.74854h-.97559V13.2793c0,.47168.19434.67822.63672.67822a2.96657,2.96657,0,0,0,.33887-.02051v.74023a2.9155,2.9155,0,0,1-.4834.04541c-.98828,0-1.38184-.34766-1.38184-1.21582v-2.543h-.71484v-.74854h.71484V9.07373Z" style="fill: #fff"/>
<path d="M95.70461,8.437h.88086v2.48145h.07031a1.3856,1.3856,0,0,1,1.373-.80664,1.48339,1.48339,0,0,1,1.55078,1.67871v2.90723H98.69v-2.688c0-.71924-.335-1.0835-.96289-1.0835a1.05194,1.05194,0,0,0-1.13379,1.1416v2.62988h-.88867Z" style="fill: #fff"/>
<path d="M104.76125,13.48193a1.828,1.828,0,0,1-1.95117,1.30273A2.04531,2.04531,0,0,1,100.73,12.46045a2.07685,2.07685,0,0,1,2.07617-2.35254c1.25293,0,2.00879.856,2.00879,2.27V12.688h-3.17969v.0498a1.1902,1.1902,0,0,0,1.19922,1.29,1.07934,1.07934,0,0,0,1.07129-.5459Zm-3.126-1.45117h2.27441a1.08647,1.08647,0,0,0-1.1084-1.1665A1.15162,1.15162,0,0,0,101.63527,12.03076Z" style="fill: #fff"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 11 KiB

+17
View File
@@ -29,6 +29,23 @@
max-height: 200px;
}
.attachment-gallery.single .attachment-gallery-item:has(.gallery-thumb) {
width: 100%;
max-width: 100%;
}
.attachment-gallery.single .attachment-gallery-item:has(.gallery-thumb):hover {
transform: none;
border-color: var(--border-light);
}
.attachment-gallery.single .gallery-thumb {
width: 100%;
max-width: 100%;
max-height: 480px;
object-fit: contain;
}
.attachment-gallery-item:has(.non-image) {
width: 120px;
height: 120px;
+33 -2
View File
@@ -717,6 +717,17 @@ img {
line-height: 1;
}
.app-store-nav-link {
display: inline-flex;
align-items: center;
line-height: 0;
}
.app-store-nav-badge {
width: 90px;
height: auto;
}
.topnav-mobile-overlay {
display: none;
position: fixed;
@@ -771,6 +782,15 @@ img {
background: var(--accent-light);
}
.app-store-mobile-link {
justify-content: flex-start;
}
.app-store-mobile-badge {
width: 120px;
height: auto;
}
.topnav-mobile-divider {
height: 1px;
background: var(--border);
@@ -1128,6 +1148,17 @@ body:has(.page-messages) {
color: var(--text-primary);
}
.app-store-link {
display: inline-flex;
align-items: center;
line-height: 0;
}
.app-store-badge {
width: 120px;
height: auto;
}
.sr-only {
position: absolute;
width: 1px;
@@ -1176,7 +1207,8 @@ body:has(.page-messages) {
}
.topnav-tools-dropdown,
.topnav-icon[href="/leaderboard"] {
.topnav-icon[href="/leaderboard"],
.app-store-nav-link {
display: none;
}
@@ -1392,4 +1424,3 @@ body:has(.page-messages) {
color: var(--text-secondary);
margin: 0 0 var(--space-lg);
}
-16
View File
@@ -190,14 +190,6 @@
color: var(--danger);
}
.post-action-btn.vote-up::before {
content: "+";
}
.post-action-btn.vote-down::before {
content: "\2212";
}
.post-action-btn.vote-up.voted {
color: var(--accent);
font-weight: 700;
@@ -208,14 +200,6 @@
font-weight: 700;
}
.post-action-btn.vote-up.voted::before {
content: "\2295";
}
.post-action-btn.vote-down.voted::before {
content: "\2296";
}
.post-votes {
display: inline-flex;
align-items: center;
-16
View File
@@ -138,14 +138,6 @@
color: var(--danger);
}
.comment-vote-btn.vote-up::before {
content: "+";
}
.comment-vote-btn.vote-down::before {
content: "-";
}
.comment-vote-btn.vote-up.voted {
color: var(--accent);
font-weight: 700;
@@ -156,14 +148,6 @@
font-weight: 700;
}
.comment-vote-btn.vote-up.voted::before {
content: "\2295";
}
.comment-vote-btn.vote-down.voted::before {
content: "\2296";
}
.comment-vote-count {
font-size: 0.75rem;
font-weight: 700;
+5 -1
View File
@@ -92,7 +92,11 @@ File validation: max 5MB, allowed extensions: `.png`, `.jpg`, `.jpeg`, `.gif`, `
**Ingesting a file from a URL.** `store_attachment_from_url(url, user_uid, filename=None)` (async, in `attachments.py`) is the remote counterpart to `store_attachment`: it downloads the URL on the server through `fetch_remote_file()` - SSRF-guarded (`_guard_public_url` resolves the host and refuses private/loopback/reserved/multicast addresses, mirroring the Devii fetch guard) and size-capped (streams, aborting once `_get_max_upload_bytes()` is exceeded) - resolves a filename from the URL path or the response `Content-Type` (`MIME_TO_EXT`), then calls `store_attachment()` so the bytes land in the **exact same** pipeline (validation, thumbnailing, DB row). It raises `RemoteFetchError(message, status)` which the route maps to an HTTP status. It is exposed at `POST /uploads/upload-url` (`UploadUrlForm{url, filename?}`, `require_user_api`) and as the Devii catalog action `attach_url` (handler `http`, `requires_auth=True`); both return the same record as `/uploads/upload`. The returned `uid` binds to a resource the same way as any upload - via `attachment_uids` at create/edit time - so attaching a remote image is just `attach_url` then `create_post`/`create_project`/etc. with that uid. Do not re-download remote files in a router; reuse this helper so the guard and size cap stay in one place.
`_row_to_attachment()` / `store_attachment()` expose `is_image` and `is_video` (derived from the mime prefix). The shared partial `templates/_attachment_display.html` branches image -> `<img>`, video -> `<video controls preload="metadata" class="gallery-video">`, else download link; rendering through this one partial is what makes video work across every feature at once. `AttachmentOut` (`schemas.py`) carries both flags - add new display keys there too or JSON drops them.
`_row_to_attachment()` / `store_attachment()` expose `is_image` and `is_video` (derived from the mime prefix). The shared partial `templates/_attachment_display.html` branches image -> `<img>`, video -> `<video controls preload="metadata" class="gallery-video">`, else download link; rendering through this one partial is what makes video work across every feature at once.
**Every caller MUST bind `attachments` before including the partial** - `{% set attachments = item.get('attachments', []) %}` or `{% with attachments=... %}`. The partial iterates the bare name `attachments`, so a caller that only guards on `{% if item.attachments %}` and includes without binding renders the gallery from whatever `attachments` happens to be in the surrounding page context. This is not theoretical: `_post_card.html` did exactly that, so **feed and profile cards silently rendered an empty gallery for every post that had an image**, and on a project page (where `project_detail.html` sets `attachments` at template scope for the project's own files) a devlog card would have rendered the *project's* attachments as if they were the post's. Guarded by `tests/e2e/feed.py::test_feed_card_shows_the_post_image`.
**A lone attachment is a hero, not a chip.** When the gallery holds exactly one item the partial adds a `single` class, and `attachments.css` widens that item to the full content column (`max-height: 480px`, `object-fit: contain`, no hover scale) instead of the 240x200 chip a multi-item gallery uses. **The `single` branch must serve `att['url']`, never `thumbnail_url`** - a thumbnail is 200px on its longest side, so blowing it up to the column width renders visibly blurry. That is the whole reason the src is a conditional rather than "thumbnail when one exists". Because the partial is shared, this applies everywhere at once: post cards, post detail, comments, gists, projects and chat bubbles. Animated GIFs never had a thumbnail to begin with (`THUMBNAIL_EXTENSIONS` excludes `.gif`, so animation survives), which means they already took the original-file path and simply render larger now. `AttachmentOut` (`schemas.py`) carries both flags - add new display keys there too or JSON drops them.
Media is served **inline** (not forced-download) for known-safe types only. The set `INLINE_MEDIA_EXTENSIONS` in `main.py` (`UploadStaticFiles`) and the matching `map $uri $upload_disposition` in `nginx/nginx.conf.template` must stay in sync: images/video/audio -> `inline` (so `<video>` plays and seeks via Range), everything else -> `attachment`. SVG is deliberately excluded from both (stored-XSS defense). `ContentRenderer.js` embeds direct video URLs typed into content via `videoExtRe`, mirroring its image handling.
+1 -3
View File
@@ -57,9 +57,7 @@ export class VoteManager extends OptimisticAction {
});
document.querySelectorAll(`form[action="${action}"] button[type="submit"]`).forEach((button) => {
const formValue = parseInt(button.closest("form").querySelector('input[name="value"]').value, 10);
const isVoted = result.value !== 0 && formValue === result.value;
button.classList.toggle("voted", isVoted);
button.setAttribute("aria-pressed", String(isVoted));
button.classList.toggle("voted", result.value !== 0 && formValue === result.value);
});
}
}
+1 -2
View File
@@ -66,7 +66,7 @@ Do NOT hand-write the overlay/header markup. Use the shared macro in `templates/
Reuse these via `{% set _x = ... %}{% include %}` (the `_avatar_link.html` convention) instead of copy-pasting markup:
- `_post_composer_form.html` - the create-post form (topic selector, content/title, project select, attachments, poll builder, footer). Locals: `_composer_topic` (preselected topic, default `random`), `_composer_project` (preselected project uid or `""`). Wrapped in the `modal()` macro by `feed.html` (Create New Post) and `project_detail.html` (owner-only Post an update, preset to `devlog` + the project). Never fork a second copy of this form.
- `_post_votes.html` - post +/- vote bar. Locals: `_uid`, `_my_vote`, `_count`. The +/- glyphs come from the vote CSS classes via `::before` (`feed.css` for `.post-action-btn`, `post.css` for `.comment-vote-btn`), swapping to `\u2295`/`\u2296` when `.voted` - do not put literal glyphs in 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`.
@@ -89,4 +89,3 @@ The three public listings (`/feed`, `/gists`, `/projects`) share one search box:
## Modal pattern
`Application.js` `initModals()` toggles a `.visible` CSS class on `.modal-overlay`; the CSS rule `.modal-overlay.visible { display: flex; }` handles visibility. Triggers usually have `href="#"`, so call `e.preventDefault()` in click handlers. `.modal-close` is wired generically - no inline JS needed.
@@ -1,10 +1,9 @@
<div class="attachment-gallery">
{% set _single = attachments|length == 1 %}
<div class="attachment-gallery{% if _single %} single{% endif %}">
{% for att in attachments %}
<div class="attachment-gallery-item">
{% if att.get('is_image') and att.get('thumbnail_url') %}
<img src="{{ att['thumbnail_url'] }}" alt="{{ att.get('original_filename', '') }}" loading="lazy" class="gallery-thumb" data-lightbox data-full="{{ att['url'] }}" data-mime="{{ att.get('mime_type', '') }}">
{% elif att.get('is_image') %}
<img src="{{ att['url'] }}" alt="{{ att.get('original_filename', '') }}" loading="lazy" class="gallery-thumb" data-lightbox data-full="{{ att['url'] }}" data-mime="{{ att.get('mime_type', '') }}">
{% if att.get('is_image') %}
<img src="{{ att['url'] if _single or not att.get('thumbnail_url') else att['thumbnail_url'] }}" alt="{{ att.get('original_filename', '') }}" loading="lazy" class="gallery-thumb" data-lightbox data-full="{{ att['url'] }}" data-mime="{{ att.get('mime_type', '') }}">
{% elif att.get('is_video') %}
<video src="{{ att['url'] }}" controls preload="metadata" class="gallery-video"></video>
{% elif att.get('is_audio') %}
+2 -3
View File
@@ -3,12 +3,12 @@
<div class="comment-votes" role="group" aria-label="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 %}" aria-label="Upvote" title="Upvote" aria-pressed="{% if item.my_vote == 1 %}true{% else %}false{% endif %}"{{ guest_disabled(user) }}></button>
<button type="submit" class="comment-vote-btn vote-up{% if item.my_vote == 1 %} voted{% endif %}" aria-label="Upvote" title="Upvote" aria-pressed="{% if item.my_vote == 1 %}true{% else %}false{% endif %}"{{ guest_disabled(user) }}>+</button>
</form>
<span class="comment-vote-count" data-vote-count="{{ item.comment['uid'] }}">{{ item.votes.up - item.votes.down }}</span>
<form method="POST" action="/votes/comment/{{ item.comment['uid'] }}">
<input type="hidden" name="value" value="-1">
<button type="submit" class="comment-vote-btn vote-down{% if item.my_vote == -1 %} voted{% endif %}" aria-label="Downvote" title="Downvote" aria-pressed="{% if item.my_vote == -1 %}true{% else %}false{% endif %}"{{ guest_disabled(user) }}></button>
<button type="submit" class="comment-vote-btn vote-down{% if item.my_vote == -1 %} voted{% endif %}" aria-label="Downvote" title="Downvote" aria-pressed="{% if item.my_vote == -1 %}true{% else %}false{% endif %}"{{ guest_disabled(user) }}>-</button>
</form>
</div>
@@ -70,4 +70,3 @@
{{ render_comment_flat(child, depth, item.author) }}
{% endfor %}
{% endmacro %}
+6
View File
@@ -8,4 +8,10 @@
<a href="/docs/privacy.html"><span class="icon">&#x1F512;</span> Privacy</a>
<a href="/docs/community-guidelines.html"><span class="icon">&#x1F91D;</span> Guidelines</a>
<a href="/docs/contact.html"><span class="icon">&#x2709;&#xFE0F;</span> Contact</a>
{% set _ios_url = ios_app_url() %}
{% if _ios_url %}
<a href="{{ _ios_url }}" class="app-store-link" target="_blank" rel="noopener noreferrer" aria-label="Download the DevPlace app on the App Store">
<img src="{{ static_url('/static/app-store-badge.svg') }}" alt="Download on the App Store" class="app-store-badge" width="120" height="40" loading="lazy">
</a>
{% endif %}
</nav>
+2 -1
View File
@@ -25,7 +25,8 @@
<a href="{{ item.project_link.url }}" class="project-link">Project: {{ item.project_link.name }}</a>
{% endif %}
{% if item.attachments %}
{% set attachments = item.get('attachments', []) %}
{% if attachments %}
{% include "_attachment_display.html" %}
{% endif %}
+2 -3
View File
@@ -1,12 +1,11 @@
<div class="post-votes" role="group" aria-label="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-down{% if _my_vote == -1 %} voted{% endif %}" aria-label="Downvote" title="Downvote" aria-pressed="{% if _my_vote == -1 %}true{% else %}false{% endif %}"{{ guest_disabled(user) }}></button>
<button type="submit" class="post-action-btn vote-down{% if _my_vote == -1 %} voted{% endif %}" aria-label="Downvote" title="Downvote" aria-pressed="{% if _my_vote == -1 %}true{% else %}false{% endif %}"{{ guest_disabled(user) }}></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-up{% if _my_vote == 1 %} voted{% endif %}" aria-label="Upvote" title="Upvote" aria-pressed="{% if _my_vote == 1 %}true{% else %}false{% endif %}"{{ guest_disabled(user) }}></button>
<button type="submit" class="post-action-btn vote-up{% if _my_vote == 1 %} voted{% endif %}" aria-label="Upvote" title="Upvote" aria-pressed="{% if _my_vote == 1 %}true{% else %}false{% endif %}"{{ guest_disabled(user) }}>+</button>
</form>
</div>
+6
View File
@@ -30,6 +30,12 @@
<small>Public origin used for absolute links (SEO, container ingress /p/&lt;slug&gt;). Empty = derive from the request or the DEVPLACE_SITE_URL env var.</small>
</div>
<div class="admin-field">
<label for="ios_app_url">iOS App Store URL</label>
<input type="text" id="ios_app_url" name="ios_app_url" value="{{ settings.get('ios_app_url', '') }}" maxlength="300" placeholder="https://apps.apple.com/app/devplace/id0000000000">
<small>Official iOS app listing. The App Store badge links here in the footer and the top navigation. Empty hides the badges.</small>
</div>
<hr class="admin-settings-divider">
<h3 class="admin-settings-group-title">Uploads & Attachments</h3>
+12
View File
@@ -123,6 +123,12 @@
{% else %}
<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>
{% set _ios_url = ios_app_url() %}
{% if _ios_url %}
<a href="{{ _ios_url }}" class="app-store-nav-link" target="_blank" rel="noopener noreferrer" aria-label="Download the DevPlace app on the App Store">
<img src="{{ static_url('/static/app-store-badge.svg') }}" alt="App Store" class="app-store-nav-badge" width="90" height="30" loading="lazy">
</a>
{% endif %}
{% endif %}
<button type="button" class="topnav-hamburger" id="hamburger-btn" aria-label="Toggle menu" aria-controls="mobile-panel" aria-expanded="false"></button>
</div>
@@ -180,6 +186,12 @@
<div class="topnav-mobile-divider"></div>
<a href="/auth/login" class="topnav-mobile-link"><span class="icon">🔑</span> Login</a>
<a href="/auth/signup" class="topnav-mobile-link"><span class="icon"></span> Sign Up</a>
{% set _ios_url = ios_app_url() %}
{% if _ios_url %}
<a href="{{ _ios_url }}" class="topnav-mobile-link app-store-mobile-link" target="_blank" rel="noopener noreferrer" aria-label="Download the DevPlace app on the App Store">
<img src="{{ static_url('/static/app-store-badge.svg') }}" alt="Download on the App Store" class="app-store-mobile-badge" width="120" height="40" loading="lazy">
</a>
{% endif %}
{% endif %}
</nav>
</div>
+5
View File
@@ -194,6 +194,10 @@ def contact_details() -> dict[str, str]:
}
def ios_app_url() -> str:
return get_setting("ios_app_url", "").strip()
def policy_version(kind: str) -> str:
return get_setting(f"{kind}_version", "1") or "1"
@@ -211,6 +215,7 @@ def ai_provider_name() -> str:
templates.env.globals["contact_details"] = contact_details
templates.env.globals["ios_app_url"] = ios_app_url
templates.env.globals["policy_version"] = policy_version
templates.env.globals["moderation_sla_hours"] = moderation_sla_hours
templates.env.globals["moderation_minimum_age"] = moderation_minimum_age
+17 -8
View File
@@ -319,6 +319,23 @@ def login_user(page, user):
page.wait_for_url("**/feed", timeout=10000, wait_until="domcontentloaded")
def create_post_with_files(page, content, files, expected_count=1):
from playwright.sync_api import expect
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
page.locator(".feed-fab").first.wait_for(state="visible", timeout=10000)
page.locator(".feed-fab").first.click()
page.fill("#post-content", content)
page.locator("#create-post-modal dp-upload .dp-upload-input").first.set_input_files(
files
)
expect(
page.locator("#create-post-modal dp-upload .dp-upload-count").first
).to_have_text(f"({expected_count})", timeout=15000)
page.locator("#create-post-modal button.btn-primary:has-text('Post')").click()
page.wait_for_url(f"{BASE_URL}/posts/*", wait_until="domcontentloaded")
def assert_share_copies(page, expected_fragment):
from playwright.sync_api import expect
@@ -340,14 +357,6 @@ def assert_share_copies(page, expected_fragment):
)
def vote_glyph(locator):
value = locator.evaluate("el => getComputedStyle(el, '::before').content")
value = value.strip().strip('"').strip("'")
if value.startswith("\\") and len(value) == 5 and value[1:].isdigit():
value = chr(int(value[1:], 16))
return value
def assert_no_horizontal_overflow(page, layout):
report = page.evaluate(
"""(selector) => {
+23 -1
View File
@@ -1,6 +1,6 @@
# retoor <retoor@molodetz.nl>
from tests.conftest import BASE_URL
from tests.conftest import BASE_URL, create_post_with_files
import time
import requests
from playwright.sync_api import expect
@@ -702,6 +702,28 @@ def test_feed_politics_topic(alice):
assert politics_link.is_visible()
def test_feed_card_shows_the_post_image(alice):
import io
from PIL import Image
page, _ = alice
buf = io.BytesIO()
Image.new("RGB", (600, 400), (28, 120, 200)).save(buf, "PNG")
create_post_with_files(
page,
"Feed card image rendering check",
[{"name": "card.png", "mimeType": "image/png", "buffer": buf.getvalue()}],
)
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
card = page.locator(
".post-card:has-text('Feed card image rendering check')"
).first
card.wait_for(state="visible", timeout=10000)
image = card.locator(".attachment-gallery.single .gallery-thumb").first
image.wait_for(state="visible", timeout=10000)
assert "_thumb" not in image.get_attribute("src")
def test_create_post_cancel_modal(alice):
page, _ = alice
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
+93
View File
@@ -0,0 +1,93 @@
# retoor <retoor@molodetz.nl>
import time
from devplacepy.database import get_setting, set_setting
from tests.conftest import BASE_URL, CACHE_VERSION_PROPAGATION_SECONDS
IOS_SETTING = "ios_app_url"
IOS_APP_URL = "https://apps.apple.com/app/devplace/id6797215143"
LANDING_FOOTER_BADGE = ".landing-footer .app-store-link"
SITE_FOOTER_BADGE = ".site-footer .app-store-link"
NAV_BADGE = ".topnav-right .app-store-nav-link"
MOBILE_BADGE = ".topnav-mobile-panel .app-store-mobile-link"
def _configure():
original = get_setting(IOS_SETTING, "")
set_setting(IOS_SETTING, IOS_APP_URL)
time.sleep(CACHE_VERSION_PROPAGATION_SECONDS)
return original
def _assert_badge(badge):
badge.wait_for(state="visible")
assert badge.get_attribute("href") == IOS_APP_URL
assert badge.get_attribute("target") == "_blank"
assert badge.get_attribute("rel") == "noopener noreferrer"
def test_footer_app_store_badge_on_landing_page(page, app_server):
original = _configure()
try:
page.goto(f"{BASE_URL}/", wait_until="domcontentloaded")
_assert_badge(page.locator(LANDING_FOOTER_BADGE))
finally:
set_setting(IOS_SETTING, original)
def test_footer_app_store_badge_on_regular_page(page, app_server):
original = _configure()
try:
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
_assert_badge(page.locator(SITE_FOOTER_BADGE))
finally:
set_setting(IOS_SETTING, original)
def test_topnav_app_store_badge_next_to_login_signup(page, app_server):
original = _configure()
try:
page.goto(f"{BASE_URL}/", wait_until="domcontentloaded")
_assert_badge(page.locator(NAV_BADGE))
assert page.locator(".topnav-right a[href='/auth/login']").is_visible()
assert page.locator(".topnav-right a[href='/auth/signup']").is_visible()
finally:
set_setting(IOS_SETTING, original)
def test_mobile_menu_app_store_badge(page, app_server):
original = _configure()
try:
page.set_viewport_size({"width": 390, "height": 844})
page.goto(f"{BASE_URL}/", wait_until="domcontentloaded")
hamburger = page.locator("#hamburger-btn")
hamburger.wait_for(state="visible")
hamburger.click()
page.locator("#mobile-panel.open").wait_for(state="visible")
_assert_badge(page.locator(MOBILE_BADGE))
finally:
set_setting(IOS_SETTING, original)
def test_badges_hide_when_ios_app_url_is_cleared(page, app_server):
original = _configure()
try:
page.goto(f"{BASE_URL}/", wait_until="domcontentloaded")
assert page.locator(LANDING_FOOTER_BADGE).count() == 1
set_setting(IOS_SETTING, "")
time.sleep(CACHE_VERSION_PROPAGATION_SECONDS)
page.goto(f"{BASE_URL}/", wait_until="domcontentloaded")
assert page.locator(LANDING_FOOTER_BADGE).count() == 0
assert page.locator(NAV_BADGE).count() == 0
assert page.locator(MOBILE_BADGE).count() == 0
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
assert page.locator(SITE_FOOTER_BADGE).count() == 0
finally:
set_setting(IOS_SETTING, original)
+1 -1
View File
@@ -39,7 +39,7 @@ def test_leaderboard_ranks_after_upvote(app_server, browser, seeded_db):
pa.goto(post_url, wait_until="domcontentloaded")
pa.wait_for_timeout(1000)
vote_btn = pa.locator("button.post-action-btn.vote-up").first
vote_btn = pa.locator("button.post-action-btn").filter(has_text="+").first
vote_btn.wait_for(state="visible", timeout=10000)
vote_btn.click()
pa.wait_for_timeout(1500)
+2 -2
View File
@@ -310,7 +310,7 @@ def test_vote_notification_on_post(app_server, browser, seeded_db):
pa.goto(post_url, wait_until="domcontentloaded")
pa.wait_for_timeout(1000)
vote_btn = pa.locator("button.post-action-btn.vote-up").first
vote_btn = pa.locator("button.post-action-btn").filter(has_text="+").first
vote_btn.wait_for(state="visible", timeout=10000)
vote_btn.click()
pa.wait_for_timeout(1500)
@@ -796,7 +796,7 @@ def test_vote_notification_click_opens_target(app_server, browser, seeded_db):
pa.goto(f"{BASE_URL}{post_path}", wait_until="domcontentloaded")
pa.wait_for_timeout(1000)
vote_btn = pa.locator("button.post-action-btn.vote-up").first
vote_btn = pa.locator("button.post-action-btn").filter(has_text="+").first
vote_btn.wait_for(state="visible", timeout=10000)
vote_btn.click()
pa.wait_for_timeout(1500)
+94 -52
View File
@@ -2,7 +2,7 @@
import re
from playwright.sync_api import expect
from tests.conftest import BASE_URL, assert_share_copies, vote_glyph
from tests.conftest import BASE_URL, assert_share_copies, create_post_with_files
def create_post(page, topic="random", content="Test post content", title=None):
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
page.locator(".feed-fab").first.wait_for(state="visible", timeout=10000)
@@ -13,6 +13,27 @@ def create_post(page, topic="random", content="Test post content", title=None):
page.fill("#post-title", title)
page.locator("#create-post-modal button.btn-primary:has-text('Post')").click()
page.wait_for_url(f"{BASE_URL}/posts/*", wait_until="domcontentloaded")
def _png_bytes(color=(0, 128, 255), size=(600, 400)):
import io
from PIL import Image
buf = io.BytesIO()
Image.new("RGB", size, color).save(buf, "PNG")
return buf.getvalue()
def _gif_bytes():
import io
from PIL import Image
frames = [Image.new("RGB", (40, 40), c) for c in ((255, 0, 0), (0, 0, 255))]
buf = io.BytesIO()
frames[0].save(
buf, "GIF", save_all=True, append_images=frames[1:], duration=120, loop=0
)
return buf.getvalue()
def _profile_stars(page, username):
page.goto(f"{BASE_URL}/profile/{username}", wait_until="domcontentloaded")
value = page.locator(
@@ -74,57 +95,35 @@ def test_post_vote_increment(alice):
def test_post_upvote_voted_state_persists(alice):
page, _ = alice
create_post(page, "showcase", "Upvote persistence test")
vote_up = page.locator(".post-action-btn.vote-up").first
assert vote_glyph(vote_up) == "+"
with page.expect_response(
lambda r: "/votes/" in r.url and r.request.method == "POST"
):
vote_up.click()
page.locator(".post-action-btn.vote-up").first.click()
expect(page.locator(".post-vote-count").first).to_have_text("1")
expect(vote_up).to_have_class(re.compile(r"\bvoted\b"))
assert vote_glyph(vote_up) == "\u2295"
page.reload(wait_until="domcontentloaded")
vote_up = page.locator(".post-action-btn.vote-up").first
expect(vote_up).to_have_class(re.compile(r"\bvoted\b"))
assert vote_glyph(vote_up) == "\u2295"
vote_down = page.locator(".post-action-btn.vote-down").first
assert "voted" not in (vote_down.get_attribute("class") or "")
assert vote_glyph(vote_down) == "\u2212"
with page.expect_response(
lambda r: "/votes/" in r.url and r.request.method == "POST"
):
vote_up.click()
expect(page.locator(".post-vote-count").first).to_have_text("0")
expect(vote_up).not_to_have_class(re.compile(r"\bvoted\b"))
assert vote_glyph(vote_up) == "+"
expect(page.locator(".post-action-btn.vote-up").first).to_have_class(
re.compile(r"\bvoted\b")
)
assert "voted" not in (
page.locator(".post-action-btn.vote-down").first.get_attribute("class") or ""
)
def test_post_downvote_voted_state_persists(alice):
page, _ = alice
create_post(page, "showcase", "Downvote persistence test")
vote_down = page.locator(".post-action-btn.vote-down").first
assert vote_glyph(vote_down) == "\u2212"
with page.expect_response(
lambda r: "/votes/" in r.url and r.request.method == "POST"
):
vote_down.click()
page.locator(".post-action-btn.vote-down").first.click()
expect(page.locator(".post-vote-count").first).to_have_text("-1")
expect(vote_down).to_have_class(re.compile(r"\bvoted\b"))
assert vote_glyph(vote_down) == "\u2296"
page.reload(wait_until="domcontentloaded")
vote_down = page.locator(".post-action-btn.vote-down").first
expect(vote_down).to_have_class(re.compile(r"\bvoted\b"))
assert vote_glyph(vote_down) == "\u2296"
vote_up = page.locator(".post-action-btn.vote-up").first
assert "voted" not in (vote_up.get_attribute("class") or "")
assert vote_glyph(vote_up) == "+"
with page.expect_response(
lambda r: "/votes/" in r.url and r.request.method == "POST"
):
vote_down.click()
expect(page.locator(".post-vote-count").first).to_have_text("0")
expect(vote_down).not_to_have_class(re.compile(r"\bvoted\b"))
assert vote_glyph(vote_down) == "\u2212"
expect(page.locator(".post-action-btn.vote-down").first).to_have_class(
re.compile(r"\bvoted\b")
)
assert "voted" not in (
page.locator(".post-action-btn.vote-up").first.get_attribute("class") or ""
)
def test_comment_voted_state_persists(alice):
@@ -136,25 +135,17 @@ def test_comment_voted_state_persists(alice):
expect(
page.locator(".comment-text:has-text('Comment whose vote should persist')")
).to_be_visible()
comment_vote = page.locator(".comment-vote-btn.vote-up").first
assert vote_glyph(comment_vote) == "+"
with page.expect_response(
lambda r: "/votes/" in r.url and r.request.method == "POST"
):
comment_vote.click()
expect(comment_vote).to_have_class(re.compile(r"\bvoted\b"))
assert vote_glyph(comment_vote) == "\u2295"
page.locator(".comment-vote-btn").first.click()
expect(page.locator(".comment-vote-btn").first).to_have_class(
re.compile(r"\bvoted\b")
)
page.reload(wait_until="domcontentloaded")
comment_vote = page.locator(".comment-vote-btn.vote-up").first
expect(comment_vote).to_have_class(re.compile(r"\bvoted\b"))
assert vote_glyph(comment_vote) == "\u2295"
with page.expect_response(
lambda r: "/votes/" in r.url and r.request.method == "POST"
):
comment_vote.click()
expect(page.locator(".comment-vote-count").first).to_have_text("0")
expect(comment_vote).not_to_have_class(re.compile(r"\bvoted\b"))
assert vote_glyph(comment_vote) == "+"
expect(page.locator(".comment-vote-btn").first).to_have_class(
re.compile(r"\bvoted\b")
)
def test_mention_first_match_highlighted(alice):
@@ -405,6 +396,57 @@ def test_emoji_picker_opens(alice):
assert page.locator("emoji-picker").first.is_visible()
def test_single_image_post_shows_the_original_full_size(alice):
page, _ = alice
create_post_with_files(
page,
"Post carrying exactly one image",
[{"name": "shot.png", "mimeType": "image/png", "buffer": _png_bytes()}],
)
gallery = page.locator(".attachment-gallery.single")
gallery.wait_for(state="visible", timeout=10000)
img = gallery.locator(".gallery-thumb").first
src = img.get_attribute("src")
assert "_thumb" not in src, f"hero image served the 200px thumbnail: {src}"
assert src == img.get_attribute("data-full")
def test_multiple_image_post_keeps_thumbnails(alice):
page, _ = alice
create_post_with_files(
page,
"Post carrying two images",
[
{"name": "one.png", "mimeType": "image/png", "buffer": _png_bytes()},
{
"name": "two.png",
"mimeType": "image/png",
"buffer": _png_bytes((200, 30, 90)),
},
],
expected_count=2,
)
page.locator(".attachment-gallery").first.wait_for(state="visible", timeout=10000)
assert page.locator(".attachment-gallery.single").count() == 0
thumbs = page.locator(".attachment-gallery .gallery-thumb")
assert thumbs.count() == 2
for i in range(thumbs.count()):
assert "_thumb" in thumbs.nth(i).get_attribute("src")
def test_animated_gif_post_serves_the_original_file(alice):
page, _ = alice
create_post_with_files(
page,
"Post carrying an animated gif",
[{"name": "loop.gif", "mimeType": "image/gif", "buffer": _gif_bytes()}],
)
img = page.locator(".attachment-gallery .gallery-thumb").first
img.wait_for(state="visible", timeout=10000)
src = img.get_attribute("src")
assert src.endswith(".gif"), f"animation lost, served {src}"
def test_attachment_upload_ui(alice):
import io
from PIL import Image
+5 -9
View File
@@ -14,7 +14,7 @@ def _create_plain_post_engagement_ui(page, content):
page.wait_for_url(f"{BASE_URL}/posts/*", wait_until="domcontentloaded")
from uuid import uuid4
from datetime import datetime, timedelta, timezone
from tests.conftest import BASE_URL, assert_share_copies, vote_glyph
from tests.conftest import BASE_URL, assert_share_copies
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
def _seed_posts(count):
@@ -404,19 +404,15 @@ def test_feed_vote_voted_state_persists(alice):
card = page.locator(
".post-card", has_text="Feed vote persistence test content"
).first
vote_up = card.locator(".post-action-btn.vote-up")
assert vote_glyph(vote_up) == "+"
vote_up.click()
card.locator(".post-action-btn.vote-up").click()
expect(card.locator(".post-vote-count")).to_have_text("1")
expect(vote_up).to_have_class(re.compile(r"\bvoted\b"))
assert vote_glyph(vote_up) == "\u2295"
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
card = page.locator(
".post-card", has_text="Feed vote persistence test content"
).first
vote_up = card.locator(".post-action-btn.vote-up")
expect(vote_up).to_have_class(re.compile(r"\bvoted\b"))
assert vote_glyph(vote_up) == "\u2295"
expect(card.locator(".post-action-btn.vote-up")).to_have_class(
re.compile(r"\bvoted\b")
)
def test_feed_card_share_button(alice):